feat(inbox): route requests to people and record personal outcomes (#20959)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The inbox collects requests that need a human decision.
> - Teams need to know who owns each request and who may act on it.
> - A shared close operation must differ from hiding a card for one
person.
> - This pull request adds personal routing and durable outcomes to the
existing attention service.
> - People can claim or transfer work and inspect what another person
did for them.
> - Domain permissions still decide who may resolve each request.

## Linked Issues or Issue Description

**Subsystem affected**

Server, shared contracts, and database. This is the routing foundation
for a stacked inbox UI change.

**Problem or motivation**

A company-wide attention feed does not give teams a clear personal
queue. Archive, withdrawal, transfer, and decisions by another person
need distinct scopes and records.

**Proposed solution**

Resolve a request audience from its addressee, task participants, and
configured owners. Provide personal, unassigned, company, all-assignee,
and archived views. Add claim, transfer, personal archive and snooze,
shared withdrawal, receipts, and personal history.

**Alternatives considered**

A single shared inbox leaves ownership and personal hiding ambiguous.
The service keeps one canonical request and projects its audience
instead of copying requests for each person.

**Roadmap alignment**

This extends the existing Multiple Human Users and Agent Reviews and
Approvals capabilities in ROADMAP.md.

**Additional context**

Refs #19363 and #19520 for the current inbox navigation and confirmation
replacement rules. Refs #20268 for related actionable-request work. That
PR filters actionability; this stack adds audience routing and personal
outcomes.

This is part 1 of a two-PR stack. The inbox UI PR #20961 targets this
branch.

## What Changed

- Add nullable company and project decision owners and a company budget
owner.
- Add audience views and personal badge counts to the canonical
attention projections.
- Add claim, transfer, withdrawal, personal archive, snooze, and restore
operations with permission checks and activity records. Lock addressed
transfers to the current addressee or routing administrators, and
validate the recipient against canonical resolution policies.
- Add receipts and scoped history for work handled by another person.
- Keep closed-task requests and replaced confirmations out of current
cards.
- Add an idempotent migration and a concurrent receipt-history index.
- Update typed fixtures so the existing UI remains compatible with the
new contracts.

## Verification

- Server layered typechecking passes, including all three test-tree
partitions.
- Seven routing and interaction-resolution suites pass all 249 tests
against a dedicated external PostgreSQL test database on `4caecc21d8`.
Six new regressions failed before the repair; they cover takeover
followed by approval, legitimate delegation, excluded creators/review
requesters, and unauthorized claims.
- Prior focused verification covers private approval history and
receipts, transfer visibility, audience escalation, withdrawal
governance, hidden-card counts, per-recipient confirmation replacement,
and SDK source contracts.
- A synthetic three-person company passed 37 routing and lifecycle
acceptance checks. The UI stack adds browser acceptance for the shared
controls.
- The complete [GitHub Actions CI
matrix](https://github.com/paperclipai/paperclip/actions/runs/43134997331)
passes on `4caecc21d891677323a7335f23b1a015cce24c13` (Linux/Node 24:
typecheck, build, general and serialized tests, Runner support, browser
shards, and aggregate gates). Greptile is 5/5 on that exact head with no
open findings. The fresh security scan passes after explicitly
rechecking the addressee-transfer repair.
- `pnpm verify` reports exit 2 because a CI-global script changed; full
CI supplies that broader coverage. Embedded PostgreSQL could not start
on this Mac because shared-memory slots were exhausted, so local
database checks use a separate external test database. Linux CI covers
the embedded setup.

## Risks

- Personal badge counts change to the viewer's assigned requests.
Accessible feed totals and task membership keep their existing scope.
- The migration adds nullable owner fields and a dismissal fingerprint.
Its index runs outside a transaction. The migration is classified and
rerunnable.
- Routing changes the addressee used by resolution authorization. The
current addressee or a routing administrator must authorize an addressed
transfer; the recipient must satisfy writer access, canonical resolver
exclusions, and task review policy under row locks.
- The shared validator export barrel crosses 1,000 lines because it
exports the new attention schemas. Schema implementation stays in the
dedicated attention validator module.

## Model Used

OpenAI GPT-6 through Codex. Used repository tools, code execution, and
browser checks. This session does not expose a more specific runtime
model ID or its context-window size.

## 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 described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
(OpenAPI contracts and docs/api/attention.md)
- [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: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-11-11 17:49:39 -08:00
1 parent 9dfdeaad86
commit 8818402e7a
87 files changed
+9600 -332

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ function makeCompany(overrides: Partial<Company>): Company {
logoAssetId: null,
logoUrl: null,
defaultResponsibleUserId: null,
defaultDecisionOwnerUserId: null,
budgetOwnerUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
+128
View File
@@ -0,0 +1,128 @@
---
title: Attention inbox
summary: Personal request routing, close actions, and decision history
---
The attention API projects pending requests and operational alerts into an inbox.
All routes require a board user with access to the company. Task and approval
privacy checks apply to cards, counts, history, and receipts. Agents cannot use
these board routes.
## Read the inbox
```text
GET /api/companies/{companyId}/attention
GET /api/companies/{companyId}/attention/summary
GET /api/companies/{companyId}/attention/tasks
```
The feed accepts these query parameters:
| Parameter | Meaning |
| --- | --- |
| `view` | `mine`, `unassigned`, `company`, `archived`, or `all`. The API default is `all`. |
| `includeDismissed` | Include personally hidden cards. Default: `false`. |
| `archived` | Read retained source archives instead of current sources. This differs from personal `view=archived`. |
| `all` | Return a complete snapshot instead of a page. |
| `limit`, `cursor` | Page size and opaque continuation cursor. Default size: 50; maximum: 100. |
| `sort` | `activity` or `decide`. |
| `activitySince`, `activityUntil` | Inclusive activity bounds in ISO date-time format. |
| `queue` | Filter by a decision queue key. |
The response includes `items`, `totalCount`, `countsBySourceKind`, `viewCounts`,
`deskBadgeCount`, and `nextCursor`. Related evidence shares one obligation lead
and counts once. `deskBadgeCount` counts requests assigned to the viewer.
Summary returns the same current counts without card bodies. Tasks also returns
unique `taskIds` for visible requests. Neither endpoint returns card bodies.
Routing uses explicit recipients, chat ownership, review participants, task
assignees, human instigators, and configured decision owners. A card with no
owner enters Unassigned. After seven days it moves to Company. A required
request that waits 30 days escalates to the configured decision owner or Company.
Task privacy still applies. Escalation keeps the request pending and its gate closed.
`view=mine` also includes receipts when someone else handles the viewer's request.
Receipts expire after seven days. They do not count as decisions or in the badge.
Every approval-linked task must remain readable to return an approval receipt.
## Claim or transfer
```text
POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/claim
POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/transfer
```
Claim has no body. Transfer takes `{ "userId": "<user ID>", "reason": "optional" }`.
These operations support `issue_thread_interaction` sources. Claim assigns an
ownerless request to the caller. Changing an addressed request requires its current
addressee or an active company owner/administrator (or an instance administrator).
Transfer assigns it to a writer who can read the task and satisfy the interaction
creator exclusions and the task review policy. Viewers cannot claim or transfer
requests. A chat question stays with its chat owner. A request to an agent that
can still answer stays with that agent.
The addressee is part of resolution authorization, so routing validates authority
and the recipient under task and interaction row locks before updating it. Inbox
view placement alone grants no authority. Concurrent claims and transfers retain
their compare-and-set checks. An identical repeated assignment returns
`alreadyClaimed` or `alreadyTransferred`. Changes record `attention.claimed` or
`attention.transferred` activity.
## Close a card
```text
POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/close
```
Use one of these bodies:
```json
{ "mode": "archive", "fingerprint": "<changeFingerprint from the card>" }
```
```json
{ "mode": "snooze", "until": "2099-01-01T12:00:00.000Z", "fingerprint": "<changeFingerprint>" }
```
```json
{ "mode": "withdraw", "reason": "The proposal is no longer needed" }
```
Archive and snooze affect only the caller. The source stays pending. Hiding
obligation evidence hides its lead and all related evidence. The returned
`itemKey` identifies the personal dismissal. Restore it through the existing
inbox-dismissal restore endpoint. A content change or a new assignment to the
viewer can make a hidden card return. A snoozed card also returns when time expires.
Withdrawal affects everyone. It cancels an interaction, approval, or decision
through that source's existing lifecycle. Review policy and execution checks
still apply. A hire request must be rejected through approvals instead.
The card's `closeModes` lists its supported actions. A successful close records
`attention.closed` activity and returns the source, mode, dismissal key, and time.
## Decision history
```text
GET /api/companies/{companyId}/attention/history
```
History includes resolved interactions, decisions, and approvals. `scope=mine` shows work handled by or for the caller. `scope=all`
shows accessible company history. `handledForMe=true` narrows it to work handled
for the caller. Filters include `search`, `outcome`, `agentId`, `projectId`,
`since`, `sort=newest|oldest`, `limit` (maximum 100), and `cursor`.
Withdrawn requests, expired requests, and requests closed with their task have
separate outcomes. Personal archive and snooze do not record consent.
## Errors
| Status | Meaning |
| --- | --- |
| `400` | Invalid source kind, UUID, filters, cursor, or request body; a snooze needs a future time. |
| `403` | No company access, no board user context, read-only membership, or a denied review policy. |
| `404` | Missing source or a source the caller cannot read. |
| `409` | Another person claimed, routed, or answered first; the request is already closed; or execution prevents withdrawal. |
| `422` | Unsupported operation, unavailable recipient, recipient without task access, or a chat ownership constraint. |
A closed-source conflict includes `code: attention_already_closed` and the
recorded resolver when known. Clients should refresh the feed and show that
outcome instead of retrying a decision automatically.
+1
View File
@@ -124,6 +124,7 @@
"api/agents",
"api/issues",
"api/approvals",
"api/attention",
"api/goals-and-projects",
"api/costs",
"api/secrets",
+1
View File
@@ -630,6 +630,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
"0459_environment_lease_backup_scope_index.sql",
"0460_workspace_activity_context_index.sql",
"0461_created_from_legacy_origin_run_index.sql",
"0469_zippy_photon.sql",
"0384_funny_adam_warlock.sql",
@@ -0,0 +1,5 @@
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "default_decision_owner_user_id" text;--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "budget_owner_user_id" text;--> statement-breakpoint
ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "fingerprint" text;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "decision_owner_user_id" text;--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "activity_log_company_handled_for_created_idx" ON "activity_log" USING btree ("company_id",("details" ->> 'handledForUserId'),"created_at") WHERE ("activity_log"."details" ->> 'handledForUserId') IS NOT NULL;
@@ -1,6 +1,6 @@
{
"id": "e2a678da-05cc-4316-a40e-d85a69be01e3",
"prevId": "aaf06ddb-56bd-4455-b2d1-fdb0254445e7",
"id": "ac25715a-74c6-4988-8525-63ad14781c74",
"prevId": "c06d4653-b3df-4e3e-bbf9-6de9b37cf806",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -1417,6 +1417,34 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"activity_log_company_handled_for_created_idx": {
"name": "activity_log_company_handled_for_created_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "(\"details\" ->> 'handledForUserId')",
"asc": true,
"isExpression": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "(\"activity_log\".\"details\" ->> 'handledForUserId') IS NOT NULL",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -6233,7 +6261,7 @@
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": false
},
"account_id": {
"name": "account_id",
@@ -6309,11 +6337,11 @@
}
},
"indexes": {
"account_issuer_account_id_uq": {
"name": "account_issuer_account_id_uq",
"account_provider_account_id_uq": {
"name": "account_provider_account_id_uq",
"columns": [
{
"expression": "issuer",
"expression": "provider_id",
"isExpression": false,
"asc": true,
"nulls": "last"
@@ -16340,6 +16368,18 @@
"primaryKey": false,
"notNull": false
},
"default_decision_owner_user_id": {
"name": "default_decision_owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"budget_owner_user_id": {
"name": "budget_owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"require_board_approval_for_new_agents": {
"name": "require_board_approval_for_new_agents",
"type": "boolean",
@@ -36235,6 +36275,12 @@
"primaryKey": false,
"notNull": false
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@@ -51034,6 +51080,18 @@
"primaryKey": false,
"notNull": false
},
"environment_id": {
"name": "environment_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"decision_owner_user_id": {
"name": "decision_owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"target_date": {
"name": "target_date",
"type": "date",
@@ -51058,12 +51116,6 @@
"primaryKey": false,
"notNull": false
},
"environment_id": {
"name": "environment_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"git_authorization_grant_id": {
"name": "git_authorization_grant_id",
"type": "uuid",
@@ -62996,6 +63048,12 @@
"notNull": true,
"default": false
},
"time_zone": {
"name": "time_zone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
+7
View File
@@ -3270,6 +3270,13 @@
"when": 1794421030996,
"tag": "0468_busy_wendell_rand",
"breakpoints": true
},
{
"idx": 469,
"version": "7",
"when": 1794428870590,
"tag": "0469_zippy_photon",
"breakpoints": true
}
]
}
+5
View File
@@ -43,5 +43,10 @@ export const activityLog = pgTable(
companyUserIssueActorIdx: index("activity_log_company_user_issue_actor_idx")
.on(table.companyId, table.actorId, table.entityId)
.where(sql`${table.actorType} = 'user' AND ${table.entityType} = 'issue'`),
// Receipts for the person a card was for (PAP-749 plan §6): the For you
// feed reads the last 7 days of entries that name `handledForUserId`.
companyHandledForCreatedIdx: index("activity_log_company_handled_for_created_idx")
.on(table.companyId, sql`(${table.details} ->> 'handledForUserId')`, table.createdAt)
.where(sql`(${table.details} ->> 'handledForUserId') IS NOT NULL`),
}),
);
+10
View File
@@ -16,6 +16,16 @@ export const companies = pgTable(
spendMonthUtc: text("spend_month_utc"),
spentMonthlyCents: numeric("spent_monthly_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
defaultResponsibleUserId: text("default_responsible_user_id"),
// PAP-749 task 1d-lite: the company's default decision owner. When set,
// ownerless "Needs You" cards route to this user's "For you" list instead of
// the claimable Unassigned pool. Attribution (`defaultResponsibleUserId`) is
// deliberately NOT reused for this — the plan forbids routing by
// responsibility, so decision ownership is its own setting.
defaultDecisionOwnerUserId: text("default_decision_owner_user_id"),
// PAP-749 task 1d: the budget owner. A budget hard stop also goes to this
// user's "For you" list (Raise limit / Keep paused) while the Company view
// keeps showing it. Placement only; it grants no authority.
budgetOwnerUserId: text("budget_owner_user_id"),
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
.notNull()
.default(false),
@@ -11,6 +11,10 @@ export const inboxDismissals = pgTable(
kind: text("kind").$type<"dismiss" | "snooze">().notNull().default("dismiss"),
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
snoozedUntil: timestamp("snoozed_until", { withTimezone: true }),
// Important-change fingerprint of the attention card the user hid
// (PAP-749 Rule 9). The card returns only when the current fingerprint
// differs; legacy rows without one keep the activity-timestamp rule.
fingerprint: text("fingerprint"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
+4
View File
@@ -21,6 +21,10 @@ export const projects = pgTable(
status: text("status").notNull().default("backlog"),
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
legacyEnvironmentId: uuid("environment_id").references(() => environments.id, { onDelete: "set null" }),
// PAP-749 task 1d: the project's decision owner. A card on this project's
// work that names no person goes to this user's "For you" list before the
// company's default decision owner. Placement only; it grants no authority.
decisionOwnerUserId: text("decision_owner_user_id"),
targetDate: date("target_date"),
color: text("color"),
icon: text("icon"),
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { attentionAudienceReasonText } from "./attention-audience.js";
describe("attention audience reason text", () => {
it("tells the default decision owner why the card came to them", () => {
expect(attentionAudienceReasonText("escalation_owner")).toBe("You are the default decision owner");
});
it("names the project and budget escalation owners", () => {
expect(attentionAudienceReasonText("project_owner")).toBe("You own this project's decisions");
expect(attentionAudienceReasonText("budget_owner")).toBe("You own budget escalation");
});
it("has no line for an unaddressed card", () => {
expect(attentionAudienceReasonText("none")).toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
import type { AttentionAudienceReason, AttentionReceiptOutcome, AttentionStaleReason } from "./types/attention.js";
/**
* The "Why me?" line for each audience reason (PAP-749 plan §1, Rule 4). The UI
* renders this from `AttentionItem.audience.reason`; `none` has no line because
* the card is not addressed to anyone. Exhaustive on purpose, so a new reason
* cannot ship without its copy.
*/
export const ATTENTION_AUDIENCE_REASON_TEXT: Record<AttentionAudienceReason, string | null> = {
addressee: "Sent to you",
conversation: "You started this chat",
reviewer: "You are the reviewer",
assignee: "Assigned to you",
instigator: "You created the original task",
requester: "You requested this",
// Escalation owners (Rule 2 step 5): the project's decision owner comes
// before the company's default decision owner; the budget owner gets budget
// hard stops.
escalation_owner: "You are the default decision owner",
project_owner: "You own this project's decisions",
budget_owner: "You own budget escalation",
none: null,
};
export function attentionAudienceReasonText(reason: AttentionAudienceReason): string | null {
return ATTENTION_AUDIENCE_REASON_TEXT[reason];
}
/**
* The Stale chip text for each stale reason (PAP-749 plan §2, Rule 10, and the
* 7-day Unassigned rule). Exhaustive on purpose, like the "Why me?" copy.
*/
export const ATTENTION_STALE_REASON_TEXT: Record<AttentionStaleReason, string> = {
issue_closed: "Task closed",
aged: "Waiting 30+ days",
decision_expiring: "Expires soon",
unowned: "No owner for 7+ days",
};
export function attentionStaleReasonText(reason: AttentionStaleReason): string {
return ATTENTION_STALE_REASON_TEXT[reason];
}
/**
* The verb of each action (PAP-749 plan §6, Rule 12). Receipts and Decision
* history rows both read it, so one action has one verb everywhere (PAP-767).
* The verbs follow the card's buttons: Approve, Request changes (Reject or
* Revise… on a confirmation), Keep paused, Raise limit. Exhaustive, like the
* copy above.
*/
export const ATTENTION_RECEIPT_OUTCOME_TEXT: Record<AttentionReceiptOutcome, string> = {
answered: "Answered",
approved: "Approved",
rejected: "Rejected",
changes_requested: "Changes requested",
revision_requested: "Revision requested",
decided: "Decided",
withdrawn: "Withdrawn",
skipped: "Skipped",
transferred: "Transferred",
kept_paused: "Kept paused",
limit_raised: "Limit raised",
};
/**
* "Answered by @scott", "Withdrawn by @scott", "Transferred to @riley by
* @scott". Labels are display names without the `@`.
*/
export function attentionReceiptText(input: {
outcome: AttentionReceiptOutcome;
actorLabel: string;
transferredToLabel?: string | null;
}): string {
const verb = ATTENTION_RECEIPT_OUTCOME_TEXT[input.outcome];
if (input.outcome === "transferred" && input.transferredToLabel) {
return `${verb} to @${input.transferredToLabel} by @${input.actorLabel}`;
}
return `${verb} by @${input.actorLabel}`;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { attentionCardType, type AttentionItem } from "./index.js";
const card = (sourceKind: AttentionItem["sourceKind"], metadata: Record<string, unknown> = {}) =>
({ sourceKind, subject: { kind: "issue", id: "x", companyId: "c", title: null, identifier: null, status: null, href: null, metadata } }) as Pick<AttentionItem, "sourceKind" | "subject">;
describe("attentionCardType (PAP-775)", () => {
it("picks the interaction design from its kind and what it confirms", () => {
expect(attentionCardType(card("issue_thread_interaction", { kind: "ask_user_questions" }))).toBe("answer_questions");
expect(attentionCardType(card("issue_thread_interaction", { kind: "suggest_tasks" }))).toBe("suggested_tasks");
expect(attentionCardType(card("issue_thread_interaction", { kind: "request_confirmation", isPlanTarget: true }))).toBe("review_plan");
expect(attentionCardType(card("issue_thread_interaction", { kind: "request_confirmation", requestKind: "deliverable" }))).toBe("review_deliverable");
expect(attentionCardType(card("issue_thread_interaction", { kind: "request_confirmation", requestKind: "tool_action" }))).toBe("tool_access");
expect(attentionCardType(card("issue_thread_interaction", { kind: "request_confirmation", requestKind: "connection" }))).toBe("connect_service");
expect(attentionCardType(card("issue_thread_interaction", { kind: "request_confirmation" }))).toBe("confirm");
});
it("maps every other source to its own design", () => {
expect(attentionCardType(card("approval", { type: "hire_agent" }))).toBe("board_approval");
expect(attentionCardType(card("join_request"))).toBe("join_request");
expect(attentionCardType(card("review"))).toBe("review_task");
expect(attentionCardType(card("review", { reviewAttentionState: "stalled" }))).toBe("blocked_task");
expect(attentionCardType(card("blocker_attention"))).toBe("blocked_task");
expect(attentionCardType(card("agent_error_alert"))).toBe("failed_run");
expect(attentionCardType(card("budget_alert"))).toBe("budget_stop");
expect(attentionCardType(card("recovery_action"))).toBe("recovery");
expect(attentionCardType(card("decision"))).toBe("make_choice");
});
});
+2
View File
@@ -16,6 +16,8 @@ const companySnapshotSchema: z.ZodType<Company> = z.object({
budgetMonthlyCents: z.number(),
spentMonthlyCents: z.number(),
defaultResponsibleUserId: z.string().nullable(),
defaultDecisionOwnerUserId: z.string().nullable().default(null),
budgetOwnerUserId: z.string().nullable().default(null),
requireBoardApprovalForNewAgents: z.boolean(),
interactionResolverGovernance: interactionResolverGovernanceSchema,
feedbackDataSharingEnabled: z.boolean(),
+46 -1
View File
@@ -167,30 +167,68 @@ export {
type ResponsibleUserDenialTone,
} from "./responsible-user-denial.js";
export type {
AttentionAudience,
AttentionAudienceKind,
AttentionAudienceReason,
AttentionAlreadyClosedDetails,
AttentionClaimableSourceKind,
AttentionClaimResult,
AttentionCloseInput,
AttentionCloseMode,
AttentionCloseResult,
AttentionDecisionVerb,
AttentionTransferInput,
AttentionTransferResult,
DecisionHistoryItem,
DecisionHistoryOutcomeFilter,
DecisionHistoryQuery,
DecisionHistoryScope,
DecisionHistoryPage,
DecisionHistoryResultOutcome,
DecisionHistoryAction,
AttentionDetailImage,
AttentionFeed,
AttentionSummary,
AttentionTaskIndex,
AttentionFeedView,
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
AttentionFeedQuery,
AttentionPersonalView,
AttentionProjectRef,
AttentionQueueRef,
AttentionResolverAudience,
AttentionSeverity,
AttentionSortMode,
AttentionSourceKind,
AttentionStale,
AttentionStaleReason,
AttentionHandledDetails,
AttentionReceipt,
AttentionRequester,
AttentionCardKind,
AttentionCardType,
AttentionRequestKind,
AttentionReceiptOutcome,
AttentionSubject,
AttentionSubjectKind,
AttentionTriageAttribution,
AttentionViewCounts,
AttentionWorkspaceRef,
} from "./types/attention.js";
export { ATTENTION_SOURCE_KINDS } from "./types/attention.js";
export {
ATTENTION_CLAIMABLE_SOURCE_KINDS,
attentionCardKind,
attentionCardType,
ATTENTION_CLOSE_MODES,
ATTENTION_PERSONAL_VIEWS,
ATTENTION_RECEIPT_DAYS,
ATTENTION_RECEIPT_OUTCOMES,
ATTENTION_SOURCE_KINDS,
DECISION_HISTORY_OUTCOME_FILTERS,
DECISION_HISTORY_SCOPES,
} from "./types/attention.js";
export { questionSetToAskUserQuestionsPayload } from "./question-set.js";
export type {
DecisionQueue,
@@ -2159,6 +2197,7 @@ export {
resolveBudgetIncidentSchema,
resolveApprovalSchema,
requestApprovalRevisionSchema,
cancelApprovalSchema,
resubmitApprovalSchema,
addApprovalCommentSchema,
type CreateApproval,
@@ -2166,8 +2205,13 @@ export {
type ResolveBudgetIncident,
type ResolveApproval,
type RequestApprovalRevision,
type CancelApproval,
type ResubmitApproval,
type AddApprovalComment,
attentionCloseSchema,
attentionTransferSchema,
type AttentionClose,
type AttentionTransfer,
envBindingPlainSchema,
envBindingSecretRefSchema,
envBindingUserSecretRefSchema,
@@ -2813,6 +2857,7 @@ export * from "./types/voice-sessions.js";
export * from "./validators/voice-sessions.js";
export * from "./agent-appearance.js";
export * from "./attention-audience.js";
export * from "./ai-connections.js";
export * from "./ai-connection-router.js";
export * from "./ai-connection-usage.js";
+471 -7
View File
@@ -85,6 +85,11 @@ export type AttentionSortMode = "activity" | "decide";
export interface AttentionFeedQuery {
includeDismissed?: boolean;
archived?: boolean;
/**
* Which audience slice to return (PAP-749 plan §1). Defaults to `all`, the
* pre-PAP-749 superset, so omitting it keeps the current user-visible feed.
*/
view?: AttentionFeedView;
/** Return the complete filtered snapshot in one response. */
all?: boolean;
activitySince?: string;
@@ -218,6 +223,344 @@ export interface AttentionResolverAudience {
createdByAgentName: string | null;
}
/**
* Where a card *shows* (PAP-749 plan §1, Rule 2) — distinct from
* {@link AttentionResolverAudience}, which says who may *act*. The server
* computes this once per item from participation facts (an explicit addressee,
* the chat user, the review participant or human assignee, or the instigator
* who created the originating human work) and never from `responsibleUserId`,
* the routine creator, a connected account, the agent owner, or a manual
* routine starter. `kind` drives the three-view switch: `user` belongs in that
* user's "For you" list, `pool` belongs in the claimable "Unassigned" pool, and
* `company` belongs in the operator "Company" view. Audience governs placement
* only; the resolver policy still gates every action.
*/
export type AttentionAudienceKind = "user" | "pool" | "company";
export type AttentionAudienceReason =
| "addressee"
| "conversation"
| "reviewer"
| "assignee"
| "instigator"
| "requester"
/** The company's default decision owner (task 1d-lite). */
| "escalation_owner"
/** The decision owner of the card's project (task 1d). */
| "project_owner"
/** The company's budget owner, on a budget hard stop (task 1d). */
| "budget_owner"
| "none";
export interface AttentionAudience {
kind: AttentionAudienceKind;
userId: string | null;
reason: AttentionAudienceReason;
/**
* A company alert that also has a personal owner (PAP-749 plan §1, Rule 3):
* the card shows in {@link userId}'s "For you" list and in the Company view.
* Only a budget hard stop with a budget owner sets it today.
*/
alsoInCompany?: boolean;
/**
* The person who transferred the card to {@link userId} ("Route to @x"),
* when the addressee came from a transfer (PAP-749 plan §6, Rule 12). The
* "Why me?" line reads "Transferred by @x".
*/
transferredByUserId?: string | null;
}
/**
* Which slice of the attention feed to return. `all` is the default and is a
* superset that preserves the pre-PAP-749 behaviour (no user-visible change);
* `mine`, `unassigned`, and `company` partition the feed by {@link
* AttentionAudience.kind}. `archived` is the requesting person's own hidden
* cards (PAP-749 plan §3): open cards they archived or snoozed, read from
* their `inbox_dismissals`, whatever the card's audience.
*/
export type AttentionFeedView = "mine" | "unassigned" | "company" | "archived" | "all";
/**
* The views the Decisions pages switch between (PAP-749 plan §1, Rule 3; §3):
* For you, Unassigned, Company, the person's own Archived view, and Everyone
* (`all`, every person's cards), which only board members see (PAP-768).
*/
export const ATTENTION_PERSONAL_VIEWS = ["mine", "unassigned", "company", "archived", "all"] as const;
export type AttentionPersonalView = (typeof ATTENTION_PERSONAL_VIEWS)[number];
/**
* Cards in each view for the requesting user (PAP-749 plan §1, Rule 5).
* Counted before the view, date and queue filters and pagination, so every
* surface reads the same numbers whatever it fetched. `mine`, `unassigned`,
* `company` and `all` count current cards without the ones this person
* archived or snoozed; `archived` counts exactly those hidden cards (archived
* plus snoozed). Only `mine` is the badge; the other counts stay on their tabs.
*/
export type AttentionViewCounts = Record<AttentionPersonalView, number>;
/**
* Source kinds whose card names a person through an addressee field, so Claim
* and Transfer can store the new owner there (PAP-749 plan §1, Rule 3; §2,
* Rule 6). Other kinds have no addressee to set yet.
*/
export const ATTENTION_CLAIMABLE_SOURCE_KINDS = ["issue_thread_interaction"] as const;
export type AttentionClaimableSourceKind = (typeof ATTENTION_CLAIMABLE_SOURCE_KINDS)[number];
/** Result of `POST /companies/:companyId/attention/:sourceKind/:sourceId/claim`. */
export interface AttentionClaimResult {
sourceKind: AttentionClaimableSourceKind;
sourceId: string;
claimedByUserId: string;
/** `true` when the caller already owned the card; nothing changed. */
alreadyClaimed: boolean;
}
/**
* The verbs of the Close… menu (PAP-749 plan §2, Rule 6). Each has one effect:
* - `withdraw`: closes the request for everyone because it is out of date. It
* is never an approval or consent, and it tells the agent.
* - `archive`: hides the card for you only, until it changes. The source stays
* open and teammates still see it.
* - `snooze`: hides the card for you only, until a time you pick. It does not
* change a shared deadline.
* Transfer is not a close mode; it has its own endpoint.
*/
export const ATTENTION_CLOSE_MODES = ["withdraw", "archive", "snooze"] as const;
export type AttentionCloseMode = (typeof ATTENTION_CLOSE_MODES)[number];
/** Body of `POST /companies/:companyId/attention/:sourceKind/:sourceId/close`. */
export interface AttentionCloseInput {
mode: AttentionCloseMode;
/** Why the request is out of date. Shown in history and sent to the agent. */
reason?: string | null;
/** Snooze end, ISO timestamp in the future. Required for `snooze` only. */
until?: string | null;
/**
* The card's `changeFingerprint` as the viewer saw it, for `archive` and
* `snooze`. The card returns when its content differs from this version.
* Omitted: the server uses the current version.
*/
fingerprint?: string | null;
}
/** Result of the close endpoint. */
export interface AttentionCloseResult {
sourceKind: AttentionSourceKind;
sourceId: string;
mode: AttentionCloseMode;
closedByUserId: string;
/** The task the card belongs to, when it has one. */
issueId: string | null;
/** Snooze end for `snooze`; `null` otherwise. */
until: string | null;
}
/** Body of `POST /companies/:companyId/attention/:sourceKind/:sourceId/transfer`. */
export interface AttentionTransferInput {
/** The person who gets the card ("Route to @x"). */
userId: string;
reason?: string | null;
}
/** Result of the transfer endpoint. */
export interface AttentionTransferResult {
sourceKind: AttentionClaimableSourceKind;
sourceId: string;
transferredToUserId: string;
/** The person the card was for before the transfer, if any. */
previousAddresseeUserId: string | null;
/** `true` when the card already went to that person; nothing changed. */
alreadyTransferred: boolean;
}
/**
* Details of the 409 that the close, claim and transfer endpoints return when
* a different person already answered or closed the source first (PAP-749
* plan §2, "Two persons answer at the same time"). The UI shows "Already
* answered by @x".
*/
export interface AttentionAlreadyClosedDetails {
code: "attention_already_closed";
sourceKind: AttentionSourceKind;
sourceId: string;
status: string;
resolvedByUserId: string | null;
resolvedByAgentId: string | null;
resolvedAt: string | null;
}
/**
* Why a card is stale (PAP-749 plan §2, Rule 10). A stale card stays open and
* answerable; the label only moves it to the Stale group. The server never
* withdraws a stale card and never treats one as approved.
* - `issue_closed`: its task is done or cancelled and the card is still pending.
* - `aged`: no important change for 30 days ("Waiting 30+ days").
* - `decision_expiring`: a decision with 80% or more of its time limit gone.
* - `unowned`: an Unassigned card with no owner for 7 days ("No owner for 7+
* days"); it moves to the Company view.
*/
export type AttentionStaleReason = "issue_closed" | "aged" | "decision_expiring" | "unowned";
export interface AttentionStale {
reason: AttentionStaleReason;
}
/**
* What a different person did with a card that was for you (PAP-749 plan §6,
* Rule 12). Archive and snooze are personal, so they never make a receipt;
* Claim takes only ownerless cards, so it has no audience person to tell.
* Decision history names the same action with the same value, so a receipt
* and its history row read the same verb (PAP-767).
*/
export const ATTENTION_RECEIPT_OUTCOMES = [
"answered",
"approved",
"rejected",
"changes_requested",
"revision_requested",
"decided",
"withdrawn",
"skipped",
"transferred",
"kept_paused",
"limit_raised",
] as const;
export type AttentionReceiptOutcome = (typeof ATTENTION_RECEIPT_OUTCOMES)[number];
/** A receipt stays at the top of For you this long, or until its person dismisses it. */
export const ATTENTION_RECEIPT_DAYS = 7;
/**
* Stored on the activity entry of the action as `details.receipt`, next to
* `details.handledForUserId` (the card's audience person at the time of the
* action). Present only when that person is not the actor. Receipts and the
* "Handled for me" history filter read these; there is no receipts table.
*/
export interface AttentionHandledDetails {
outcome: AttentionReceiptOutcome;
sourceKind: AttentionSourceKind;
sourceId: string;
issueId: string | null;
/** The card title when the person acted. */
title: string | null;
/** The person who acted. Decisions log as the executor, so it is kept here. */
actorUserId: string;
reason?: string | null;
/** For `transferred`: who got the card. */
transferredToUserId?: string | null;
}
/**
* One receipt at the top of For you: "Answered by @scott", "Withdrawn by
* @scott" with the reason, "Transferred to @riley by @scott". A receipt is not
* a card and is never in the badge count.
*/
export interface AttentionReceipt {
/** The activity entry the receipt comes from. */
id: string;
/** Inbox dismissal key; dismissing it hides the receipt for you. */
dismissalKey: string;
outcome: AttentionReceiptOutcome;
sourceKind: AttentionSourceKind;
sourceId: string;
title: string | null;
reason: string | null;
actorUserId: string;
transferredToUserId: string | null;
issue: { id: string; identifier: string | null; title: string; href: string } | null;
/** Where the source shows now: its task thread, or the approval page. */
href: string | null;
createdAt: string;
/** When the receipt leaves For you on its own. */
expiresAt: string;
}
export interface AttentionRequester {
kind: "agent" | "user";
id: string;
name: string | null;
}
/**
* The two kinds of Needs You card (PAP-775, IA7): a `decision` asks a person
* to choose, answer or approve; a `problem` reports something that broke (a
* failed run, an agent error, a budget stop, a stuck task). Each card is
* exactly one kind.
*/
export type AttentionCardKind = "decision" | "problem";
const ATTENTION_PROBLEM_SOURCE_KINDS: ReadonlySet<AttentionSourceKind> = new Set([
"failed_run",
"agent_error_alert",
"budget_alert",
"blocker_attention",
"recovery_action",
]);
export function attentionCardKind(item: Pick<AttentionItem, "sourceKind" | "subject">): AttentionCardKind {
if (ATTENTION_PROBLEM_SOURCE_KINDS.has(item.sourceKind)) return "problem";
// A review with no reviewer and no way forward is a stuck task.
if (item.sourceKind === "review" && item.subject.metadata?.reviewAttentionState === "stalled") return "problem";
return "decision";
}
/**
* Which of the Needs You card designs a card uses (PAP-775, Paper "A · Compact
* cards"): one design per type of request, each with its own context panel and
* actions. Receipts are the `update` type and never reach this from the feed.
*/
export type AttentionCardType =
| "answer_questions"
| "make_choice"
| "review_plan"
| "confirm"
| "suggested_tasks"
| "review_deliverable"
| "review_items"
| "board_approval"
| "join_request"
| "tool_access"
| "connect_service"
| "review_task"
| "failed_run"
| "budget_stop"
| "blocked_task"
| "recovery";
/**
* What an interaction confirmation is about, read from its payload so the feed
* can pick the card design before the interaction itself loads.
*/
export type AttentionRequestKind = "tool_action" | "secret" | "connection" | "access" | "deliverable";
export function attentionCardType(item: Pick<AttentionItem, "sourceKind" | "subject">): AttentionCardType {
const metadata = item.subject.metadata ?? {};
switch (item.sourceKind) {
case "issue_thread_interaction": {
const requestKind = metadata.requestKind as AttentionRequestKind | null | undefined;
if (requestKind === "tool_action" || requestKind === "secret" || requestKind === "access") return "tool_access";
if (requestKind === "connection") return "connect_service";
if (metadata.kind === "connection_intent") return "connect_service";
if (metadata.kind === "ask_user_questions") return "answer_questions";
if (metadata.kind === "suggest_tasks") return "suggested_tasks";
if (metadata.kind === "request_item_verdicts") return "review_items";
if (metadata.isPlanTarget === true) return "review_plan";
if (requestKind === "deliverable") return "review_deliverable";
return "confirm";
}
case "decision": return "make_choice";
case "approval": return "board_approval";
case "join_request": return "join_request";
case "review": return metadata.reviewAttentionState === "stalled" ? "blocked_task" : "review_task";
case "blocker_attention": return "blocked_task";
case "failed_run":
case "agent_error_alert": return "failed_run";
case "budget_alert": return "budget_stop";
case "recovery_action": return "recovery";
default: return "confirm";
}
}
export interface AttentionItem {
id: string;
companyId: string;
@@ -237,11 +580,57 @@ export interface AttentionItem {
createdAt: string;
updatedAt: string;
relatedIssue: AttentionSubject | null;
/** Where this card shows (PAP-749 plan §1, Rule 2). Placement only; never authority. */
audience: AttentionAudience;
/** Set when the card is stale (PAP-749 plan §2, Rule 10); `null` for a current card. */
stale: AttentionStale | null;
/**
* The card is a required gate: an approval or a governed request that keeps
* its gate until someone answers it (PAP-749 plan §2, Rule 10). A stale
* required card goes to a person; "Withdraw all stale" never closes it. A
* person can still withdraw one from its own Close… menu.
*/
required: boolean;
/**
* Opaque fingerprint of the card's important content for this viewer
* (PAP-749 plan §2, Rule 9). Send it with a dismiss or snooze; the hidden
* card returns only when the fingerprint changes, not on log entries,
* retries, unrelated comments or assignee changes.
*/
changeFingerprint: string;
/**
* The Close… menu verbs the server supports for this card (PAP-749 plan §2,
* Rule 6). `withdraw` is present only when the source has a real withdraw
* or cancel; budget, review, blocker and operator alerts use their own
* actions instead.
*/
closeModes: AttentionCloseMode[];
/**
* One card for each obligation (PAP-749 plan §2, Rule 11). Cards with the
* same key are one obligation: a pending approval with the in-review state of
* its task (`approval:<approvalId>`), a budget hard stop with its override
* approval (`approval:<approvalId>`, the stop leads; PAP-768), or a blocked
* task with the failed run that caused it (`blocked:<issueId>`). The UI shows the lead card and folds
* the others into it as evidence; the feed still returns every card, keeps
* the evidence right after its lead, and shows or hides it with the lead.
* `null` when the card is its own obligation. A configured human review
* stage is an independent quality gate, so it keeps its own card.
*/
obligationKey: string | null;
/** The `id` of the card that leads the obligation; set with `obligationKey`. */
obligationLeadId: string | null;
project: AttentionProjectRef | null;
workspace: AttentionWorkspaceRef | null;
expiresAt: string | null;
ruleKey: string | null;
originAgentName: string | null;
/**
* Who is asking (PAP-775, UI2): the agent or person that created the
* request, so a card can show their avatar and name. `name` is set for an
* agent; a person's name comes from the company directory. `null` for a
* system alert nobody asked for.
*/
requester?: AttentionRequester | null;
queues: AttentionQueueRef[];
shelf: boolean;
retentionDays: number;
@@ -265,15 +654,25 @@ export interface AttentionFeed {
generatedAt: string;
totalCount: number;
/**
* The sidebar badge: distinct items that either surfaced today ("new today")
* or carry an explicit decide-by deadline that is due today/past ("overdue").
* Computed before pagination so a small first page still reflects the
* company-wide load. The desk no longer editorializes about what "can wait".
* The one badge on the sidebar, mobile navigation and Decisions page
* (PAP-749 plan §1, Rule 5): current "For you" cards, the same number as
* `viewCounts.mine`. It does not reset at midnight; an open card stays in the
* count until a person answers it. Unassigned and Company are never in it.
*/
deskBadgeCount: number;
/**
* Per-view counts for the three-view switch and the Unassigned banner. Each
* obligation counts once, so a lead and its evidence are one (Rule 11).
*/
viewCounts: AttentionViewCounts;
nextCursor: string | null;
countsBySourceKind: Record<AttentionSourceKind, number>;
items: AttentionItem[];
/**
* Receipts for the requesting person, newest first (PAP-749 plan §6). Set
* for the For you view (`view=mine`) only; not counted in any badge.
*/
receipts?: AttentionReceipt[];
}
/** Canonical attention counts without downloading the decision cards. */
@@ -283,27 +682,80 @@ export interface AttentionTaskIndex extends AttentionSummary {
taskIds: string[];
}
/** Read-only projection of canonical task decisions; never a second write store. */
/**
* Read-only projection of canonical task decisions and approvals (PAP-749 plan
* §3); never a second write store.
*/
export interface DecisionHistoryItem {
id: string;
sourceKind: "issue_thread_interaction" | "decision";
sourceKind: "issue_thread_interaction" | "decision" | "approval";
sourceId: string;
issueId: string;
/** The task the decision belongs to. `null` for an approval with no linked task. */
issueId: string | null;
issueIdentifier: string | null;
issueTitle: string;
issueTitle: string | null;
agentId: string | null;
agentName: string | null;
projectId: string | null;
projectName: string | null;
title: string;
/** The stored status: `accepted`, `answered`, `cancelled`, `expired`, `approved`, … */
outcome: string;
/**
* The more specific recorded close, when the status alone hides it:
* `withdrawn` (a person or the agent withdrew the request), `skipped` or
* `issue_closed` ("Closed with task"). `null` for an ordinary answer.
*/
resultOutcome?: DecisionHistoryResultOutcome | null;
label: string;
/**
* What the person did, in the receipt's words: the UI renders "Approved by
* @scott" from this and `resolvedByUserId`, like the receipt for the same
* action (PAP-767). `detail` is what the verb leaves out: the chosen option
* of a decision, or the verdict counts of an item review. `null` when no
* person acted (expired, closed with its task, superseded).
*/
action?: DecisionHistoryAction | null;
/** Why the request was withdrawn or skipped, when the person gave a reason. */
reason?: string | null;
resolvedAt: string;
resolvedByUserId: string | null;
resolvedByAgentId: string | null;
/**
* The person the card was for, when a different person answered, withdrew
* or transferred it ("Answered by @x for @y"; PAP-749 plan §6).
*/
handledForUserId?: string | null;
href: string;
target: { kind: "issue_document"; issueId?: string; key: string; revisionId: string } | { kind: "issue_attachment"; attachmentId: string } | null;
}
export type DecisionHistoryResultOutcome = "withdrawn" | "skipped" | "issue_closed";
export interface DecisionHistoryAction {
outcome: AttentionReceiptOutcome;
detail: string | null;
}
/**
* Outcome filters of the Decision history panel (PAP-749 plan §3). Each value
* matches every source that can have it: `withdrawn` covers withdrawn
* interactions and the decisions and approvals a person withdrew; `expired`
* is a time-out or supersede, never a card that closed with its task;
* `issue_closed` is "Closed with task".
*/
export const DECISION_HISTORY_OUTCOME_FILTERS = [
"accepted",
"answered",
"rejected",
"decided",
"withdrawn",
"expired",
"issue_closed",
"cancelled",
] as const;
export type DecisionHistoryOutcomeFilter = (typeof DECISION_HISTORY_OUTCOME_FILTERS)[number];
export interface DecisionHistoryQuery {
cursor?: string;
limit?: number;
@@ -313,8 +765,20 @@ export interface DecisionHistoryQuery {
projectId?: string;
sort?: "newest" | "oldest";
since?: string;
/** "Handled for me": only cards for you that a different person handled (PAP-749 plan §6). */
handledForMe?: boolean;
/**
* Whose history (PAP-768). `mine` is "Handled by me or for me": what you
* answered, withdrew or transferred, what a different person handled for
* you, and the cards whose audience is you. `all` (the default) is every
* person's history. The outcome filters apply within either scope.
*/
scope?: DecisionHistoryScope;
}
export const DECISION_HISTORY_SCOPES = ["mine", "all"] as const;
export type DecisionHistoryScope = (typeof DECISION_HISTORY_SCOPES)[number];
export interface DecisionHistoryPage {
items: DecisionHistoryItem[];
nextCursor: string | null;
+16
View File
@@ -26,6 +26,22 @@ export interface Company {
budgetMonthlyCents: number;
spentMonthlyCents: number;
defaultResponsibleUserId: string | null;
/**
* PAP-749 task 1d-lite: the company's default decision owner. When set,
* ownerless attention cards route to this user's "For you" list; when null
* they stay in the claimable Unassigned pool. Placement only — it never
* grants authority, which the resolver policy still gates. Distinct from
* {@link defaultResponsibleUserId}, which is attribution and is never used
* to route cards.
*/
defaultDecisionOwnerUserId: string | null;
/**
* PAP-749 task 1d: the budget owner. A budget hard stop also goes to this
* user's "For you" list, with Raise limit / Keep paused, while the Company
* view still shows it. `null` leaves the hard stop in Company only.
* Placement only, like {@link defaultDecisionOwnerUserId}.
*/
budgetOwnerUserId: string | null;
requireBoardApprovalForNewAgents: boolean;
interactionResolverGovernance: InteractionResolverGovernance;
feedbackDataSharingEnabled: boolean;
@@ -8,6 +8,8 @@ export interface InboxDismissal {
kind: InboxDismissalKind;
dismissedAt: Date;
snoozedUntil: Date | null;
/** The attention card's `changeFingerprint` when it was hidden; `null` for other inbox items. */
fingerprint: string | null;
createdAt: Date;
updatedAt: Date;
}
+39 -1
View File
@@ -39,30 +39,68 @@ export type {
WriteSummarySlotResponse,
} from "./summary-slot.js";
export type {
AttentionAudience,
AttentionAudienceKind,
AttentionAudienceReason,
AttentionAlreadyClosedDetails,
AttentionClaimableSourceKind,
AttentionClaimResult,
AttentionCloseInput,
AttentionCloseMode,
AttentionCloseResult,
AttentionDecisionVerb,
AttentionTransferInput,
AttentionTransferResult,
DecisionHistoryItem,
DecisionHistoryOutcomeFilter,
DecisionHistoryQuery,
DecisionHistoryScope,
DecisionHistoryPage,
DecisionHistoryResultOutcome,
DecisionHistoryAction,
AttentionDetailImage,
AttentionFeed,
AttentionSummary,
AttentionTaskIndex,
AttentionFeedView,
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
AttentionFeedQuery,
AttentionPersonalView,
AttentionProjectRef,
AttentionQueueRef,
AttentionResolverAudience,
AttentionSeverity,
AttentionSortMode,
AttentionSourceKind,
AttentionStale,
AttentionStaleReason,
AttentionHandledDetails,
AttentionReceipt,
AttentionRequester,
AttentionCardKind,
AttentionCardType,
AttentionRequestKind,
AttentionReceiptOutcome,
AttentionSubject,
AttentionSubjectKind,
AttentionTriageAttribution,
AttentionViewCounts,
AttentionWorkspaceRef,
} from "./attention.js";
export { ATTENTION_SOURCE_KINDS } from "./attention.js";
export {
ATTENTION_CLAIMABLE_SOURCE_KINDS,
attentionCardKind,
attentionCardType,
ATTENTION_CLOSE_MODES,
ATTENTION_PERSONAL_VIEWS,
ATTENTION_RECEIPT_DAYS,
ATTENTION_RECEIPT_OUTCOMES,
ATTENTION_SOURCE_KINDS,
DECISION_HISTORY_OUTCOME_FILTERS,
DECISION_HISTORY_SCOPES,
} from "./attention.js";
export type {
DecisionQueue,
DecisionQueueItem,
+7
View File
@@ -143,6 +143,13 @@ export interface Project {
privacyOwnerUserId?: string | null;
status: ProjectStatus;
leadAgentId: string | null;
/**
* PAP-749 task 1d: the project's decision owner. A Needs You card on this
* project's work that names no person goes to this user's "For you" list,
* before the company's default decision owner. Placement only — the resolver
* policy still gates who may act. Omitted by older servers/fixtures.
*/
decisionOwnerUserId?: string | null;
targetDate: string | null;
color: string | null;
icon: string | null;
@@ -23,6 +23,13 @@ export const requestApprovalRevisionSchema = z.object({
export type RequestApprovalRevision = z.infer<typeof requestApprovalRevisionSchema>;
/** Withdraw an open approval because it is out of date. Never an approval. */
export const cancelApprovalSchema = z.object({
reason: z.string().trim().max(4000).optional().nullable(),
}).strict();
export type CancelApproval = z.infer<typeof cancelApprovalSchema>;
export const resubmitApprovalSchema = z.object({
payload: z.record(z.string(), z.unknown()).optional(),
});
@@ -0,0 +1,43 @@
import { z } from "zod";
import { ATTENTION_CLOSE_MODES } from "../types/attention.js";
const reasonSchema = z.string().trim().max(4000).nullish();
/**
* Body of the attention close endpoint (PAP-749 plan §2, Rule 6). Snooze needs
* a future `until`; the other modes must not send one. The fingerprint is
* personal presentation state only, so a wrong value just lets the card return.
*/
export const attentionCloseSchema = z.object({
mode: z.enum(ATTENTION_CLOSE_MODES),
reason: reasonSchema,
until: z.string().trim().datetime({ offset: true }).nullish(),
fingerprint: z.string().trim().min(1).max(200).nullish(),
}).strict().superRefine((value, ctx) => {
if (value.mode !== "snooze") {
if (value.until != null) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["until"], message: "Only snooze takes an until time" });
}
if (value.mode === "withdraw" && value.fingerprint != null) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["fingerprint"], message: "Withdraw does not take a fingerprint" });
}
return;
}
if (value.until == null) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["until"], message: "Snooze requires until" });
return;
}
if (new Date(value.until).getTime() <= Date.now()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["until"], message: "until must be in the future" });
}
});
export type AttentionClose = z.infer<typeof attentionCloseSchema>;
/** Body of the attention transfer endpoint ("Route to @x"). */
export const attentionTransferSchema = z.object({
userId: z.string().trim().min(1).max(200),
reason: reasonSchema,
}).strict();
export type AttentionTransfer = z.infer<typeof attentionTransferSchema>;
@@ -36,6 +36,12 @@ export const updateCompanySchema = objectWithoutDefaults(
.extend({
status: z.enum(COMPANY_STATUSES).optional(),
spentMonthlyCents: z.number().int().nonnegative().optional(),
// PAP-749 task 1d-lite: the default decision owner (a user). `null` clears
// it, sending ownerless cards back to Unassigned.
defaultDecisionOwnerUserId: z.string().min(1).nullable().optional(),
// PAP-749 task 1d: the budget owner (a user). `null` leaves budget hard
// stops in the Company view only.
budgetOwnerUserId: z.string().min(1).nullable().optional(),
requireBoardApprovalForNewAgents: z.boolean().optional(),
interactionResolverGovernance: interactionResolverGovernanceSchema.optional(),
feedbackDataSharingEnabled: z.boolean().optional(),
+9
View File
@@ -657,15 +657,24 @@ export {
createApprovalSchema,
resolveApprovalSchema,
requestApprovalRevisionSchema,
cancelApprovalSchema,
resubmitApprovalSchema,
addApprovalCommentSchema,
type CreateApproval,
type ResolveApproval,
type RequestApprovalRevision,
type CancelApproval,
type ResubmitApproval,
type AddApprovalComment,
} from "./approval.js";
export {
attentionCloseSchema,
attentionTransferSchema,
type AttentionClose,
type AttentionTransfer,
} from "./attention.js";
export {
envBindingPlainSchema,
envBindingSecretRefSchema,
@@ -115,6 +115,8 @@ const projectFields = {
visibility: z.enum(["open", "private"]).optional().default("open"),
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
leadAgentId: z.string().guid().optional().nullable(),
// PAP-749 task 1d: the project's decision owner (a user). `null` clears it.
decisionOwnerUserId: z.string().min(1).optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(),
+242
View File
@@ -0,0 +1,242 @@
/**
* scripts/count-needs-you-views.ts
*
* PAP-749 task 1a deliverable: "count how many local cards go into each view".
*
* It spins up a throwaway, migrated embedded Postgres, runs the Needs You demo
* seed (scripts/seed-needs-you-demo.ts) into it, then runs the attention feed
* for each demo user under every view= value and prints the card counts. The
* demo company is the canonical "local" card set for the integration branch's
* preview, so this quantifies the audience migration impact before task 1b
* flips the default view in the UI.
*
* Run from the repo root (resolves server deps via the server package):
* pnpm --filter @paperclipai/server exec tsx ../scripts/count-needs-you-views.ts
*
* Read-condition note: the feed is listed with no actor, i.e. full board read
* visibility, which matches the board operator the Needs You page runs as.
*/
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import {
createDb,
startEmbeddedPostgresTestDatabase,
authUsers,
companies as companiesTable,
} from "../packages/db/src/index.js";
import type { AttentionFeedView } from "../packages/shared/src/index.js";
import { attentionService } from "../server/src/services/attention.js";
import { attentionReceiptService } from "../server/src/services/attention-receipts.js";
import { decisionHistoryService } from "../server/src/services/decision-history.js";
import { issueService } from "../server/src/services/issues.js";
import { expirePendingInteractionsOnClosedIssues } from "../server/src/services/closed-issue-interaction-sweep.js";
// Repo-root scripts can't resolve server-only bare deps directly, so pull
// drizzle's helpers through the server package (see the seed script).
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
const { eq, inArray } = serverRequire("drizzle-orm") as typeof import("drizzle-orm");
const DEMO_COMPANY_NAME = "Needs You Demo";
const DEMO_EMAILS: Record<string, string> = {
Scott: "scott@needs-you-demo.test",
Dotta: "dotta@needs-you-demo.test",
Riley: "riley@needs-you-demo.test",
};
const VIEWS: AttentionFeedView[] = ["all", "mine", "unassigned", "company"];
async function main() {
const tempDb = await startEmbeddedPostgresTestDatabase("needs-you-counts-");
try {
const seedScript = fileURLToPath(new URL("./seed-needs-you-demo.ts", import.meta.url));
const tsxBin = fileURLToPath(new URL("../server/node_modules/tsx/dist/cli.mjs", import.meta.url));
const seedRun = spawnSync(process.execPath, [tsxBin, seedScript], {
env: { ...process.env, DATABASE_URL: tempDb.connectionString },
stdio: "inherit",
});
if (seedRun.status !== 0) throw new Error(`seed exited with status ${seedRun.status}`);
const db = createDb(tempDb.connectionString);
const [company] = await db
.select({ id: companiesTable.id, prefix: companiesTable.issuePrefix })
.from(companiesTable)
.where(eq(companiesTable.name, DEMO_COMPANY_NAME));
if (!company) throw new Error("demo company not found after seed");
const userRows = await db
.select({ id: authUsers.id, email: authUsers.email })
.from(authUsers)
.where(inArray(authUsers.email, Object.values(DEMO_EMAILS)));
const idByEmail = new Map(userRows.map((row) => [row.email, row.id]));
const svc = attentionService(db);
// Task 2a: stale cards by reason, for Scott's full feed.
const printStale = async (label: string) => {
const scott = idByEmail.get(DEMO_EMAILS.Scott);
if (!scott) return;
const feed = await svc.list(company.id, { userId: scott, view: "all" });
const stale = feed.items.filter((item) => item.stale);
console.log(`\n[${label}] Scott view=all: ${feed.totalCount} card(s), ${stale.length} stale`);
for (const item of stale) {
console.log(` - ${item.stale!.reason.padEnd(17)} ${item.audience.kind.padEnd(7)} ${item.sourceKind}: ${item.subject.title}`);
}
};
await printStale("before startup sweep");
// Task 2a Rule 9: which of Scott's personally hidden cards stay hidden.
const scottForHidden = idByEmail.get(DEMO_EMAILS.Scott);
if (scottForHidden) {
const withHidden = await svc.list(company.id, { userId: scottForHidden, view: "all", includeDismissed: true });
console.log("\n[hidden] Scott's archived/snoozed cards:");
for (const item of withHidden.items.filter((entry) => entry.dismissal)) {
console.log(` - ${item.dismissal!.isActive ? "hidden " : "returned"} ${item.dismissal!.kind.padEnd(7)} ${item.subject.title}`);
}
}
// A server start runs the closed-task sweep once; run it twice to show it is idempotent.
console.log(`\n[sweep] first run: ${JSON.stringify(await expirePendingInteractionsOnClosedIssues(db))}`);
console.log(`[sweep] second run: ${JSON.stringify(await expirePendingInteractionsOnClosedIssues(db))}`);
await printStale("after startup sweep");
console.log(`\n=== Needs You demo card counts by view (company ${company.id}, prefix ${company.prefix}) ===`);
// `badge` is the shared For you badge (task 1b). The columns count cards;
// the badge counts obligations, so it is `mine` minus folded evidence (task 2d).
console.log("user | all | mine | unassigned | company | badge");
console.log("-------+-----+------+------------+---------+------");
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
const userId = idByEmail.get(email);
if (!userId) {
console.log(`${name.padEnd(6)} | (user not found)`);
continue;
}
const counts: Record<string, number> = {};
let badge = 0;
for (const view of VIEWS) {
const feed = await svc.list(company.id, { userId, view });
counts[view] = feed.totalCount;
badge = feed.deskBadgeCount;
}
console.log(
`${name.padEnd(6)} | ${String(counts.all).padStart(3)} | ${String(counts.mine).padStart(4)} | ` +
`${String(counts.unassigned).padStart(10)} | ${String(counts.company).padStart(7)} | ${String(badge).padStart(5)}`,
);
}
// Task 2c: the collapsed Stale (N) group in each view, and what "Withdraw
// all stale" would close: cards whose closeModes include withdraw and that
// are not required gates (required approvals and governed requests stay).
console.log("\n=== Stale group per view (task 2c) ===");
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
const userId = idByEmail.get(email);
if (!userId) continue;
for (const view of ["mine", "unassigned", "company"] as const) {
const feed = await svc.list(company.id, { userId, view });
const stale = feed.items.filter((item) => item.stale);
if (!stale.length) continue;
const bulk = (item: (typeof stale)[number]) => item.closeModes.includes("withdraw") && !item.required;
console.log(`${name} view=${view}: Stale (${stale.length}), Withdraw all stale closes ${stale.filter(bulk).length}`);
for (const item of stale) {
const verb = bulk(item) ? "withdraw" : item.required ? "required" : "stays ";
console.log(` - ${verb} ${item.stale!.reason.padEnd(17)} ${item.sourceKind}: ${item.subject.title}`);
}
}
}
// Receipts at the top of For you, and tasks unread in Mine (task 2f).
console.log("\n=== Receipts (task 2f) ===");
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
const userId = idByEmail.get(email);
if (!userId) continue;
const actor = { type: "board", source: "session", userId, companyIds: [company.id], isInstanceAdmin: true } as never;
const receipts = await attentionReceiptService(db).list(company.id, { userId, actor });
const unread = await issueService(db).list(company.id, { unreadForUserId: userId });
console.log(`${name}: ${receipts.length} receipt(s); unread in Mine: ${unread.map((issue) => issue.title).join(", ") || "none"}`);
for (const receipt of receipts) {
console.log(` - ${receipt.outcome.padEnd(9)} by ${receipt.actorUserId === idByEmail.get(DEMO_EMAILS.Scott) ? "Scott" : receipt.actorUserId}: ${receipt.title}${receipt.reason ? ` (reason: ${receipt.reason})` : ""}`);
}
}
// Task 2e: each person's Archived view (archived first, then the Snoozed
// group) and its tab count, which is never in the badge; then the Decision
// history outcome filters, approvals included.
console.log("\n=== Archived view (task 2e) ===");
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
const userId = idByEmail.get(email);
if (!userId) continue;
const feed = await svc.list(company.id, { userId, view: "archived" });
console.log(`${name}: Archived tab ${feed.viewCounts.archived}, badge ${feed.deskBadgeCount} (= For you ${feed.viewCounts.mine})`);
for (const item of feed.items) {
const when = item.dismissal!.kind === "snooze" ? `snoozed, back ${item.dismissal!.snoozedUntil}` : `archived ${item.dismissal!.dismissedAt}`;
console.log(` - ${when}${item.stale ? ` · stale ${item.stale.reason}` : ""}: ${item.subject.title}`);
}
}
if (scottForHidden) {
const actor = { type: "board", source: "session", userId: scottForHidden, companyIds: [company.id], isInstanceAdmin: true } as never;
const history = decisionHistoryService(db);
console.log("\n=== Decision history filters for Scott (task 2e) ===");
for (const outcome of [undefined, "withdrawn", "expired", "issue_closed", "accepted", "rejected"]) {
const page = await history.list(company.id, { actor, userId: scottForHidden, limit: 100, ...(outcome ? { outcome } : {}) });
const approvals = page.items.filter((item) => item.sourceKind === "approval").length;
console.log(`${(outcome ?? "all").padEnd(12)} ${String(page.items.length).padStart(3)} entr${page.items.length === 1 ? "y" : "ies"} (${approvals} approval${approvals === 1 ? "" : "s"})`);
if (outcome) for (const item of page.items) console.log(` - ${item.sourceKind.padEnd(24)} ${item.label}${item.reason ? ` (reason: ${item.reason})` : ""}: ${item.title}`);
}
}
// Task 2d: one card for each obligation. The feed keeps every card; the
// page folds evidence into its lead, and the view counts and badge count
// each obligation once.
console.log("\n=== One card for each obligation (task 2d) ===");
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
const userId = idByEmail.get(email);
if (!userId) continue;
const shown: string[] = [];
for (const view of ["mine", "unassigned", "company"] as const) {
const feed = await svc.list(company.id, { userId, view, all: true, allowUnscopedAll: true });
const leads = feed.items.filter((item) => !item.obligationLeadId || item.obligationLeadId === item.id);
shown.push(`${view} ${feed.items.length} cards -> ${leads.length} shown (count ${feed.viewCounts[view]})`);
}
console.log(`${name}: ${shown.join("; ")}`);
}
if (scottForHidden) {
const feed = await svc.list(company.id, { userId: scottForHidden, view: "all", all: true, allowUnscopedAll: true });
for (const lead of feed.items.filter((item) => item.obligationKey && item.obligationLeadId === item.id)) {
const place = lead.audience.kind === "user" ? (lead.audience.userId === scottForHidden ? "Scott" : lead.audience.userId) : lead.audience.kind;
console.log(` ${lead.obligationKey} -> ${place}: ${lead.sourceKind} "${lead.subject.title}"`);
for (const evidence of feed.items.filter((item) => item.obligationLeadId === lead.id && item !== lead)) {
console.log(` + ${evidence.sourceKind} "${evidence.subject.title}"`);
}
}
const gated = feed.items.filter((item) => item.subject.title?.startsWith("Independent gate") || item.subject.title === "Approve the reviewed task");
for (const item of gated) console.log(` separate gate: ${item.sourceKind} "${item.subject.title}" (obligationKey ${item.obligationKey})`);
}
// Task 1d: escalation owners. Cards whose "Why me?" is a project or budget
// owner, and where the budget hard stop shows for each person.
console.log("\n=== Escalation owners (task 1d) ===");
const nameById = new Map([...idByEmail].map(([email, id]) => [id, Object.keys(DEMO_EMAILS).find((key) => DEMO_EMAILS[key] === email)!]));
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
const userId = idByEmail.get(email);
if (!userId) continue;
const mine = await svc.list(company.id, { userId, view: "mine", all: true, allowUnscopedAll: true });
const company_ = await svc.list(company.id, { userId, view: "company", all: true, allowUnscopedAll: true });
const owned = mine.items.filter((item) => ["project_owner", "budget_owner", "escalation_owner"].includes(item.audience.reason));
const hardStop = company_.items.find((item) => item.sourceKind === "budget_alert" && item.subject.metadata?.thresholdType === "hard");
console.log(`${name}: ${owned.length} escalation card(s) in For you; Company shows the hard stop: ${hardStop ? `yes (for ${hardStop.audience.userId ? `@${nameById.get(hardStop.audience.userId)}` : "operators"})` : "no"}`);
for (const item of owned) console.log(` - ${item.audience.reason.padEnd(16)} ${item.sourceKind}: ${item.subject.title} [${item.decisionVerbs.map((verb) => verb.label).join(" / ")}]`);
}
// Per-source-kind breakdown for Scott's mine view, to show the composition.
const scottId = idByEmail.get(DEMO_EMAILS.Scott);
if (scottId) {
for (const view of VIEWS) {
const feed = await svc.list(company.id, { userId: scottId, view });
const nonZero = Object.entries(feed.countsBySourceKind).filter(([, n]) => n > 0);
console.log(`\n[Scott view=${view}] ${feed.totalCount} card(s): ${nonZero.map(([k, n]) => `${k}=${n}`).join(", ") || "none"}`);
}
}
await (db as unknown as { $client: { end: (o?: { timeout?: number }) => Promise<void> } }).$client.end({ timeout: 5 });
} finally {
await tempDb.cleanup();
}
}
await main();
+927
View File
@@ -0,0 +1,927 @@
/**
* scripts/seed-needs-you-demo.ts
*
* Needs You demo seed for PAP-749 ("Plan fixes for Needs You"), task 0 (PAP-752),
* plan section 7. It builds one synthetic demo company with three human board
* members and every "Needs You" / attention card group listed in the plan's
* section 7 table, so the multi-user authenticated preview can exercise the full
* flow end to end.
*
* Design rules (from AGENTS.md + the plan):
* - Uses the server services (not raw SQL) wherever a service exists; a few
* attention sources have no public create service (failed runs, agent error,
* join requests, escalated recovery, closed history) and are written directly
* to their tables, matching how the product itself produces that state.
* - Re-runnable: it deletes and recreates ONLY the demo company and its three
* demo users. It never touches any other company.
* - Stale cases are backdated by updating the source rows' timestamps.
* - No product behavior changes here: this script lives under scripts/ and is
* never imported by the app.
*
* Run it against the preview's OWN database (never this instance's data):
* DATABASE_URL=postgres://... \
* pnpm --filter @paperclipai/server exec tsx ../scripts/seed-needs-you-demo.ts
*
* Logins: the three demo users share one password, the DEMO_PASSWORD constant
* below (override with SEED_DEMO_PASSWORD). Emails are listed in DEMO_USERS.
*
* Current cards (PAP-749 task 2a): archived/snoozed cards store the card's
* `changeFingerprint`, read from the live feed exactly as the UI sends it, so
* they return only on an important change. The pending cards on the done and
* cancelled tasks are expired by the closed-task sweep at the next server
* start; the approval on a done task stays (approvals are never auto-closed)
* and shows the "Task closed" stale reason.
*
* Default decision owner (PAP-749 task 1d-lite): set SEED_DEFAULT_DECISION_OWNER
* to a demo user key (scott|dotta|riley) to configure the company's default
* decision owner. When set, the Unassigned-group cards route to that user's
* "For you" list; when unset (the default) they stay in Unassigned. Re-run the
* seed with and without it to compare the two behaviors.
*
* Views and Claim (PAP-749 task 1b): one ownerless card is already claimed by
* Dotta through the claim service, with the same `attention.claimed` activity
* entry the claim route writes. It shows in Dotta's "For you" as "Sent to you"
* and in nobody's Unassigned pool.
*
* Close API (PAP-749 task 2b): the "Closed history" group holds an interaction
* Dotta withdrew, a decision Scott withdrew and an approval Scott withdrew, made
* through the same services the close endpoint calls, with matching
* `attention.closed` entries. Riley still starts with no cards, so "transfer a
* card to Riley" is a live step in the checklist.
*
* Receipts (PAP-749 task 2f): Dotta's For you opens with three receipts, made
* the way the routes make them (the card's audience is read before the action
* and written as `handledForUserId` + `receipt` on the activity entry): Scott
* answered a confirmation on her task, withdrew her question with a reason
* (the task shows unread in her Mine), and approved the approval she
* requested. Transfer stays a live step, so Riley still starts with no cards.
*
* Archived view and history filters (PAP-749 task 2e): Scott's Archived tab
* holds the archived cards (one of them also stale, 40 days without a change)
* and, in its Snoozed group, the card snoozed until tomorrow. Decision history
* now includes approvals: Dotta's approval Scott approved, the approval Scott
* withdrew, and a strategy approval with no task that Scott rejected. The
* Withdrawn, Expired and Closed with task filters each match their own rows;
* the closed-with-task card is stored as `expired` like the product stores it,
* and the Expired filter leaves it out.
*
* One card for each obligation (PAP-749 task 2d): Scott's For you shows the
* approval with its in-review task as one card, and the failed run with the
* task it blocked (blocked by exhausted retries, board-owned next action) as
* one card with Retry; the run moves from Company to Scott with the task. A
* task with a configured human review stage plus an approval stays two cards,
* because the review is an independent quality gate.
*
* Project and budget escalation owners (PAP-749 task 1d): the "Platform"
* project names Dotta as its decision owner, so its ownerless card goes to
* Dotta's "For you" ("You own this project's decisions") before the company's
* default decision owner. Scott is the budget owner, so the budget hard stop is
* also in Scott's "For you" ("You own budget escalation", with Raise limit /
* Keep paused) while every person's Company view still shows it; the 90%
* warning stays in Company only. Toggle with SEED_PROJECT_DECISION_OWNER and
* SEED_BUDGET_OWNER (scott|dotta|riley|none). Riley still starts with no cards.
*/
import { randomUUID, createHash } from "node:crypto";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import {
createDb,
authUsers,
authAccounts,
authSessions,
instanceUserRoles,
companyMemberships,
inboxDismissals,
heartbeatRuns,
heartbeatRunEvents,
invites,
joinRequests,
issueRecoveryActions,
issueThreadInteractions,
decisions,
decisionTargetIssues,
issueApprovals,
agents,
approvals as approvalsTable,
projects,
issues as issuesTable,
companies as companiesTable,
} from "../packages/db/src/index.js";
import { loadConfig } from "../server/src/config.js";
import { companyService } from "../server/src/services/companies.js";
import { accessService } from "../server/src/services/access.js";
import { issueService } from "../server/src/services/issues.js";
import { issueThreadInteractionService } from "../server/src/services/issue-thread-interactions.js";
import { documentService } from "../server/src/services/documents.js";
import { approvalService } from "../server/src/services/approvals.js";
import { decisionService } from "../server/src/services/decisions.js";
import { budgetService, flushBudgetEvaluationWork } from "../server/src/services/budgets.js";
import { costService } from "../server/src/services/costs.js";
import { issueRecoveryActionService } from "../server/src/services/issue-recovery-actions.js";
import { inboxDismissalService } from "../server/src/services/inbox-dismissals.js";
import { attentionService } from "../server/src/services/attention.js";
import { attentionHandledDetails, attentionHandledFor } from "../server/src/services/attention-receipts.js";
import { logActivity } from "../server/src/services/activity-log.js";
import { createAgentLifecycle } from "../server/src/services/agent-lifecycle.js";
// drizzle-orm and better-auth are server-package dependencies; from a repo-root
// script they only resolve through the server package's require graph.
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
const { eq, and, inArray, ne } = serverRequire("drizzle-orm") as typeof import("drizzle-orm");
const { hashPassword } = (await import(
pathToFileURL(serverRequire.resolve("better-auth/crypto")).href
)) as { hashPassword: (password: string) => Promise<string> };
type Db = ReturnType<typeof createDb>;
const DEMO_COMPANY_NAME = "Needs You Demo";
const DEMO_PASSWORD = process.env.SEED_DEMO_PASSWORD ?? "needs-you-demo-2026";
const DEMO_USERS = [
{ key: "scott", name: "Scott", email: "scott@needs-you-demo.test", role: "owner" },
{ key: "dotta", name: "Dotta", email: "dotta@needs-you-demo.test", role: "admin" },
{ key: "riley", name: "Riley", email: "riley@needs-you-demo.test", role: "admin" },
] as const;
const DAY_MS = 24 * 60 * 60 * 1000;
const now = () => new Date();
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
const hoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000);
const daysFromNow = (days: number) => new Date(Date.now() + days * DAY_MS);
function resolveDatabaseUrl(): string {
const fromEnv = process.env.DATABASE_URL?.trim();
if (fromEnv) return fromEnv;
const config = loadConfig();
if (config.databaseUrl) return config.databaseUrl;
return `postgres://paperclip:paperclip@127.0.0.1:${config.embeddedPostgresPort}/paperclip`;
}
async function main() {
const url = resolveDatabaseUrl();
const db: Db = createDb(url);
console.log(`[seed] database: ${url.replace(/:[^:@/]*@/, ":***@")}`);
await teardown(db);
const summary = await seed(db);
console.log("\n[seed] done. Demo company is ready.");
console.log(`[seed] company: ${summary.companyName} (${summary.companyId}) prefix ${summary.prefix}`);
console.log("[seed] users (shared password in DEMO_PASSWORD / SEED_DEMO_PASSWORD):");
for (const u of summary.users) console.log(` - ${u.name} <${u.email}> [${u.role}]`);
console.log(`[seed] cards created: ${summary.cardCount} (see group breakdown above)`);
console.log(
`[seed] default decision owner: ${summary.defaultDecisionOwner ?? "cleared (Unassigned)"} — toggle with SEED_DEFAULT_DECISION_OWNER`,
);
console.log(
`[seed] budget owner: ${summary.budgetOwner ?? "cleared (Company only)"} — toggle with SEED_BUDGET_OWNER`,
);
console.log(
`[seed] Platform project decision owner: ${summary.projectDecisionOwner ?? "cleared"} — toggle with SEED_PROJECT_DECISION_OWNER`,
);
await (db as unknown as { $client: { end: (o?: { timeout?: number }) => Promise<void> } }).$client.end({ timeout: 5 });
process.exit(0);
}
async function teardown(db: Db) {
console.log("[seed] teardown: removing any existing demo company + users...");
const existing = await db
.select({ id: companiesTable.id })
.from(companiesTable)
.where(eq(companiesTable.name, DEMO_COMPANY_NAME));
for (const company of existing) {
// company-deletion.ts does not delete these tables, and decisions.origin_run_id
// keeps a FK onto heartbeat_runs that blocks the standard delete. Remove the
// demo rows first (interactions/approvals/recovery also cascade from issues,
// but deleting them here keeps teardown independent of that ordering).
await db.delete(decisionTargetIssues).where(eq(decisionTargetIssues.companyId, company.id));
await db.delete(decisions).where(eq(decisions.companyId, company.id));
await db.delete(inboxDismissals).where(eq(inboxDismissals.companyId, company.id));
await db.delete(issueApprovals).where(eq(issueApprovals.companyId, company.id));
await db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.companyId, company.id));
await db.delete(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, company.id));
// company-deletion refuses to purge agents that are not terminated/rejected.
await db.update(agents).set({ lifecycleState: "terminated", updatedAt: now() }).where(eq(agents.companyId, company.id));
await companyService(db).remove(company.id);
console.log(`[seed] removed company ${company.id}`);
}
const emails = DEMO_USERS.map((u) => u.email);
const demoUserRows = await db
.select({ id: authUsers.id })
.from(authUsers)
.where(inArray(authUsers.email, emails));
const demoUserIds = demoUserRows.map((r) => r.id);
if (demoUserIds.length > 0) {
await db.delete(companyMemberships).where(inArray(companyMemberships.principalId, demoUserIds));
await db.delete(instanceUserRoles).where(inArray(instanceUserRoles.userId, demoUserIds));
await db.delete(inboxDismissals).where(inArray(inboxDismissals.userId, demoUserIds));
await db.delete(authSessions).where(inArray(authSessions.userId, demoUserIds));
await db.delete(authAccounts).where(inArray(authAccounts.userId, demoUserIds));
await db.delete(authUsers).where(inArray(authUsers.id, demoUserIds));
console.log(`[seed] removed ${demoUserIds.length} demo user(s)`);
}
}
async function seed(db: Db) {
const access = accessService(db);
const interactions = issueThreadInteractionService(db);
const approvalSvc = approvalService(db);
const decisionsSvc = decisionService(db, { wakeOriginAgent: async () => {} });
const issuesSvc = issueService(db);
const recoverySvc = issueRecoveryActionService(db);
const dismissals = inboxDismissalService(db);
let cardCount = 0;
const card = (group: string, note: string) => {
cardCount += 1;
console.log(`[seed] + [${group}] ${note}`);
};
// --- company -------------------------------------------------------------
const company = await companyService(db).create({
name: DEMO_COMPANY_NAME,
description: "Synthetic demo for the Needs You multiplayer + stale-card plan (PAP-749).",
status: "active",
budgetMonthlyCents: 500_00,
});
const companyId = company.id;
console.log(`[seed] created company ${companyId} (prefix ${company.issuePrefix})`);
// --- users ---------------------------------------------------------------
const userIds: Record<string, string> = {};
for (const u of DEMO_USERS) {
const id = randomUUID();
const ts = now();
await db.insert(authUsers).values({
id,
name: u.name,
email: u.email,
emailVerified: true,
image: null,
createdAt: ts,
updatedAt: ts,
});
await db.insert(authAccounts).values({
id: randomUUID(),
issuer: "local:credential",
accountId: id,
providerId: "credential",
userId: id,
password: await hashPassword(DEMO_PASSWORD),
createdAt: ts,
updatedAt: ts,
});
if (u.role === "owner") {
await db.insert(instanceUserRoles).values({ userId: id, role: "instance_admin" }).onConflictDoNothing();
}
await access.ensureMembership(companyId, "user", id, u.role, "active");
await access.ensureRoleDefaultGrants(companyId, id, u.role, id);
userIds[u.key] = id;
console.log(`[seed] created user ${u.name} <${u.email}> [${u.role}] ${id}`);
}
const scott = userIds.scott!;
const dotta = userIds.dotta!;
const riley = userIds.riley!;
// --- default decision owner (PAP-749 task 1d-lite) -----------------------
// Toggle for the reviewer: SEED_DEFAULT_DECISION_OWNER accepts a demo user key
// (scott|dotta|riley) to route ownerless cards to that person's "For you"
// list; anything else (or unset / "none") leaves it cleared, so ownerless
// cards stay in the claimable Unassigned pool. Re-run the seed with and
// without it to compare set vs cleared.
const defaultDecisionOwnerKey = (process.env.SEED_DEFAULT_DECISION_OWNER ?? "").trim().toLowerCase();
const defaultDecisionOwnerId =
defaultDecisionOwnerKey && defaultDecisionOwnerKey !== "none" ? userIds[defaultDecisionOwnerKey] ?? null : null;
if (defaultDecisionOwnerId) {
await companyService(db).update(companyId, { defaultDecisionOwnerUserId: defaultDecisionOwnerId });
console.log(`[seed] default decision owner: ${defaultDecisionOwnerKey} (${defaultDecisionOwnerId}) — ownerless cards route to this user's For you`);
} else {
console.log('[seed] default decision owner: cleared — ownerless cards go to Unassigned. Set SEED_DEFAULT_DECISION_OWNER=dotta to compare.');
}
// --- budget owner and project decision owner (PAP-749 task 1d) -----------
// Defaults: Scott owns budget escalation and Dotta owns the Platform
// project's decisions. "none" clears either one, so the hard stop stays in
// Company only and the Platform card falls back to the default decision
// owner or Unassigned.
const ownerFromEnv = (name: string, fallback: string) => {
const key = (process.env[name] ?? fallback).trim().toLowerCase();
return { key, id: key && key !== "none" ? userIds[key] ?? null : null };
};
const budgetOwner = ownerFromEnv("SEED_BUDGET_OWNER", "scott");
const projectDecisionOwner = ownerFromEnv("SEED_PROJECT_DECISION_OWNER", "dotta");
if (budgetOwner.id) {
await companyService(db).update(companyId, { budgetOwnerUserId: budgetOwner.id });
console.log(`[seed] budget owner: ${budgetOwner.key} (${budgetOwner.id}) — the budget hard stop is also in this user's For you`);
} else {
console.log("[seed] budget owner: cleared — the budget hard stop is in Company only");
}
// --- agents --------------------------------------------------------------
const hire = (name: string, role: string, extra: Record<string, unknown> = {}) =>
createAgentLifecycle(db).requestHire(companyId, {
name,
role,
title: name,
status: "idle",
adapterType: "process",
adapterConfig: { command: "echo", args: [name] },
budgetMonthlyCents: 100_00,
...extra,
});
const worker = await hire("Dottabot", "engineer");
const pausedAgent = await hire("Napping Agent", "engineer");
const failingAgent = await hire("Flaky Runner", "engineer");
const erroredAgent = await hire("Broken Agent", "engineer");
const hardStopAgent = await hire("Spendy Agent", "engineer");
const warnAgent = await hire("Thrifty Agent", "engineer");
// project for the demo issues
const projectId = await createProject(db, companyId, worker!.id);
// Helper: create an issue via the service. in_progress / in_review issues
// require an assignee; default to the worker agent when none is specified.
const makeIssue = async (input: Record<string, unknown>) => {
const needsAssignee = input.status === "in_progress" || input.status === "in_review";
const hasAssignee = Boolean(input.assigneeAgentId || input.assigneeUserId);
const assigneeDefault = needsAssignee && !hasAssignee ? { assigneeAgentId: worker!.id } : {};
const issue = await issuesSvc.create(companyId, { priority: "medium", projectId, ...assigneeDefault, ...input } as never);
return issue as { id: string; title: string };
};
// Helper: create an interaction. Returns the created row (with id).
const makeInteraction = async (
issue: { id: string },
kind: string,
payload: Record<string, unknown>,
opts: { title?: string; summary?: string; addresseeUserId?: string | null; actorUserId?: string; actorAgentId?: string } = {},
) => {
const actor = opts.actorAgentId ? { agentId: opts.actorAgentId } : { userId: opts.actorUserId ?? scott };
const created = await interactions.create(
{ id: issue.id, companyId },
{
kind,
payload: { version: 1, ...payload },
title: opts.title,
summary: opts.summary,
addresseeUserId: opts.addresseeUserId ?? null,
idempotencyKey: `seed:${issue.id}:${kind}:${randomUUID()}`,
} as never,
actor as never,
);
return created as { id: string };
};
const reviewExecutionState = (userId: string) => ({
status: "pending" as const,
currentStageId: null,
currentStageIndex: null,
currentStageType: "review" as const,
currentParticipant: { type: "user" as const, userId },
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
});
// =========================================================================
// GROUP: For you (Scott) — a question, a confirmation on a Scott task,
// a review where Scott is reviewer, an approval Scott requested.
// =========================================================================
for (const [who, userId] of [["Scott", scott], ["Dotta", dotta]] as const) {
const qIssue = await makeIssue({ title: `${who}: question needing an answer`, status: "todo", createdByUserId: userId, responsibleUserId: userId });
await makeInteraction(qIssue, "ask_user_questions", {
questions: [{ id: "pick", prompt: `${who}, which option should we ship?`, selectionMode: "single", required: true, options: [{ id: "a", label: "Option A" }, { id: "b", label: "Option B" }] }],
}, { title: `A question for ${who}`, addresseeUserId: userId });
card(`For you / ${who}`, "ask_user_questions sent to the user");
const cIssue = await makeIssue({ title: `${who}: task you created`, status: "in_progress", createdByUserId: userId, responsibleUserId: userId, assigneeAgentId: worker!.id });
await makeInteraction(cIssue, "request_confirmation", { prompt: `${who}, confirm the plan for a task you created.` }, { title: `Confirm: ${who}'s task` });
card(`For you / ${who}`, "request_confirmation on a task the user created (instigator)");
const rIssue = await makeIssue({ title: `${who}: review the launch checklist`, status: "todo", createdByUserId: userId });
await issuesSvc.update(rIssue.id, { status: "in_review", assigneeUserId: userId, responsibleUserId: userId, executionState: reviewExecutionState(userId) } as never);
card(`For you / ${who}`, "review with the user as the execution participant");
const aIssue = await makeIssue({ title: `${who}: spend approval`, status: "in_progress", createdByUserId: userId, responsibleUserId: userId });
const approval = await approvalSvc.create(companyId, {
type: "request_board_approval",
status: "pending",
requestedByUserId: userId,
payload: { title: `Approve ${who}'s request`, summary: `Release demo budget for ${who}.` },
} as never);
await db.insert(issueApprovals).values({ companyId, issueId: aIssue.id, approvalId: (approval as { id: string }).id });
card(`For you / ${who}`, "request_board_approval requested by the user");
}
// A plan approval whose ask mentions a second document (PAP-775): the plan
// and the mentioned design notes each open as a side-panel tab.
{
const docs = documentService(db);
const planIssue = await makeIssue({ title: "Scott: approve the onboarding plan", status: "in_progress", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id }) as { id: string; identifier?: string };
const plan = await docs.upsertIssueDocument({ issueId: planIssue.id, key: "plan", title: "Onboarding plan", format: "markdown",
body: "# Onboarding plan\n\n1. Send the welcome email on day one.\n2. Pair each new member with a buddy.\n3. Review progress after two weeks.", createdByAgentId: worker!.id });
await docs.upsertIssueDocument({ issueId: planIssue.id, key: "design", title: "Design notes", format: "markdown",
body: "# Design notes\n\nThe welcome email reuses the existing template. The buddy list comes from the team directory.", createdByAgentId: worker!.id });
await makeInteraction(planIssue, "request_confirmation", {
prompt: `Approve the onboarding plan? It follows the [design notes](/NEE/issues/${planIssue.identifier}#document-design).`,
acceptLabel: "Approve plan", rejectLabel: "Request changes", rejectRequiresReason: true,
target: { type: "issue_document", issueId: planIssue.id, key: "plan", revisionId: plan.document.latestRevisionId },
}, { title: "Approve the onboarding plan", actorAgentId: worker!.id, addresseeUserId: scott });
card("For you / Scott", "plan approval that mentions a second document");
}
// =========================================================================
// GROUP: Card designs (PAP-775, Paper "A · Compact cards") — one card for
// each design the groups above don't already cover. Demo only:
// approving the tool or connection card runs nothing real.
// =========================================================================
{
const docs = documentService(db);
const emailIssue = await makeIssue({ title: "Welcome email", status: "in_review", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
const draft = await docs.upsertIssueDocument({ issueId: emailIssue.id, key: "email-draft", title: "Welcome to the team", format: "markdown",
body: "# Welcome to the team\n\nHi {first name}, we're glad you're here. Your first step: say hello to your buddy.", createdByAgentId: worker!.id });
await makeInteraction(emailIssue, "request_confirmation", {
prompt: "A shorter intro and a clear first step, ready for the pilot. Approval marks the draft ready; sending is a separate decision.",
acceptLabel: "Approve draft", rejectLabel: "Request changes",
target: { type: "issue_document", issueId: emailIssue.id, key: "email-draft", revisionId: draft.document.latestRevisionId },
}, { title: "Review the welcome email draft", actorAgentId: worker!.id, addresseeUserId: scott });
card("Card designs / Scott", "deliverable review (a document that is not the plan)");
const followUps = await makeIssue({ title: "Pilot follow-ups", status: "in_progress", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
await makeInteraction(followUps, "suggest_tasks", {
tasks: [
{ clientKey: "email", title: "Write the welcome email", description: "Communications" },
{ clientKey: "buddy", title: "Create the buddy matching checklist", description: "Operations" },
{ clientKey: "survey", title: "Draft the two-week survey", description: "Research" },
],
}, { title: "Choose tasks to create", summary: "Choose which follow-ups to create in the Onboarding project.", actorAgentId: worker!.id, addresseeUserId: scott });
card("Card designs / Scott", "suggested tasks for Scott");
const releaseIssue = await makeIssue({ title: "Publish pilot assets", status: "in_progress", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
await makeInteraction(releaseIssue, "request_confirmation", {
prompt: "The agent needs the protected GitHub release tool and its saved credential.",
toolAction: { version: 1, actionRequestId: randomUUID(), invocationId: randomUUID(), toolName: "github.create_release", toolDisplayName: "Create a release",
connectionId: null, applicationId: null, appDisplayName: "GitHub", risk: "write", previewMarkdown: "acme/onboarding · Tag: pilot-v1",
argumentsSummaryJson: JSON.stringify({ repo: "acme/onboarding", tag: "pilot-v1" }), argumentsHash: randomUUID(), expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() },
}, { title: "Allow the worker to publish the pilot release?", actorAgentId: worker!.id, addresseeUserId: scott });
card("Card designs / Scott", "tool access approval");
const sessionsIssue = await makeIssue({ title: "Schedule pilot sessions", status: "blocked", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
const connect = await makeInteraction(sessionsIssue, "request_confirmation", { prompt: "Connect Google Calendar" }, { title: "Connect Google Calendar", actorAgentId: worker!.id, addresseeUserId: scott });
// Only the server authors a connection request, so write it as the server would.
await db.update(issueThreadInteractions).set({ kind: "connection_intent",
summary: "Connect an account to schedule the onboarding sessions. You'll sign in with Google and review the requested permissions.",
payload: { version: 1, serviceSlug: "google-calendar", serviceName: "Google Calendar", requestingAgentId: worker!.id, requestingAgentName: "Dottabot", phase: "requested" } })
.where(eq(issueThreadInteractions.id, connect.id));
card("Card designs / Scott", "connect a service");
await approvalSvc.create(companyId, {
type: "hire_agent", status: "pending", requestedByAgentId: worker!.id,
payload: { name: "Researcher", role: "researcher", title: "Research role", capabilities: "Prepare and summarize the onboarding interviews.", adapterType: "claude_local", budgetMonthlyCents: 5000 },
} as never);
card("Card designs / Company", "agent hire approval");
}
// =========================================================================
// GROUP: Child-task lineage — a child of a Scott task, created by an agent,
// with a review for Scott.
// =========================================================================
const parentIssue = await makeIssue({ title: "Scott: parent initiative", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const childIssue = await makeIssue({ title: "Agent-built child task (review for Scott)", status: "todo", parentId: parentIssue.id, createdByAgentId: worker!.id });
await issuesSvc.update(childIssue.id, { status: "in_review", assigneeUserId: scott, responsibleUserId: scott, executionState: reviewExecutionState(scott) } as never);
card("Child-task lineage", "agent-created child of a Scott task, review routed to Scott");
// =========================================================================
// GROUP: Unassigned — routine/agent-created work with no addressee, plus a
// card for an agent that cannot run (paused).
// =========================================================================
const routineIssue = await makeIssue({ title: "Routine-created work (no human owner)", status: "todo", createdByAgentId: worker!.id, originKind: "routine" });
await makeInteraction(routineIssue, "request_confirmation", { prompt: "Confirm the routine output. No explicit human owner." }, { title: "Ownerless: routine output", actorAgentId: worker!.id });
card("Unassigned", "agent/routine-created confirmation with no addressee");
const agentIssue = await makeIssue({ title: "Agent-created work (no addressee)", status: "todo", createdByAgentId: worker!.id });
await makeInteraction(agentIssue, "suggest_tasks", { tasks: [{ clientKey: "s1", title: "Follow-up task suggested by an agent" }] }, { title: "Ownerless: suggested tasks", actorAgentId: worker!.id });
card("Unassigned", "agent-created suggest_tasks with no addressee");
const cannotRunIssue = await makeIssue({ title: "Request to an agent that cannot run", status: "in_progress", assigneeAgentId: worker!.id, createdByAgentId: worker!.id });
const cannotRun = await makeInteraction(cannotRunIssue, "request_confirmation", { prompt: "Agent cannot run; a human must pick this up." }, { title: "Agent paused — needs a human", actorAgentId: worker!.id });
// The create path refuses a non-invokable addressee, and the feed re-evaluates
// invokability at read time, so set the (paused) agent addressee directly.
await db.update(agents).set({ status: "paused", updatedAt: now() }).where(eq(agents.id, pausedAgent!.id));
await db.update(issueThreadInteractions).set({ addresseeAgentId: pausedAgent!.id }).where(eq(issueThreadInteractions.id, cannotRun.id));
card("Unassigned", "request addressed to a paused (non-invokable) agent");
// Claimed (task 1b): an ownerless card Dotta took from Unassigned. The claim
// makes Dotta the addressee, so it leaves every other person's pool.
const claimedIssue = await makeIssue({ title: "Agent-created work Dotta claimed", status: "todo", createdByAgentId: worker!.id });
const claimedCard = await makeInteraction(claimedIssue, "request_confirmation", { prompt: "Dotta claimed this from Unassigned." }, { title: "Claimed by Dotta", actorAgentId: worker!.id });
const claim = await attentionService(db).claim(companyId, {
sourceKind: "issue_thread_interaction", sourceId: claimedCard.id, userId: dotta, actor: await boardActorFor(db, companyId, dotta),
});
await logActivity(db, {
companyId, actorType: "user", actorId: dotta, action: "attention.claimed", entityType: "issue", entityId: claim.issueId, issueId: claim.issueId,
details: { sourceKind: claim.sourceKind, sourceId: claim.sourceId, interactionId: claim.sourceId, claimedByUserId: dotta, previousAddresseeAgentId: null },
});
card("Claimed", "ownerless card Dotta claimed (in Dotta's For you, not in Unassigned)");
// =========================================================================
// GROUP: Project owner (task 1d) — agent-created work with no human signal
// in a project that names a decision owner.
// =========================================================================
const platformProjectId = await createProject(db, companyId, worker!.id, {
name: "Platform",
description: "Its decision owner gets the project's ownerless cards.",
decisionOwnerUserId: projectDecisionOwner.id,
});
const platformIssue = await issuesSvc.create(companyId, {
title: "Agent-created Platform work (no addressee)", status: "todo", priority: "medium",
projectId: platformProjectId, createdByAgentId: worker!.id,
} as never) as { id: string };
await makeInteraction(platformIssue, "request_confirmation", { prompt: "Confirm the Platform migration plan. No explicit human owner." }, {
title: "Platform: confirm the migration plan", actorAgentId: worker!.id,
});
card(
"Project owner",
projectDecisionOwner.id
? `ownerless Platform card in ${projectDecisionOwner.key}'s For you ("You own this project's decisions")`
: "ownerless Platform card (no project decision owner: default owner or Unassigned)",
);
// =========================================================================
// GROUP: Company — failed run after retries, agent error, budget hard stop,
// budget warning at 90%, join request, recovery that stopped.
// =========================================================================
// failed run (after bounded-retry exhaustion)
{
const [run] = await db.insert(heartbeatRuns).values({
companyId, agentId: failingAgent!.id, scopeKind: "company", status: "failed",
invocationSource: "on_demand", error: "Task failed after all retries.", startedAt: hoursAgo(3), finishedAt: hoursAgo(2),
}).returning();
await db.insert(heartbeatRunEvents).values({
companyId, runId: run!.id, agentId: failingAgent!.id, seq: 1, eventType: "lifecycle",
message: "Bounded retry exhausted after 3 scheduled attempts; no further retries will run.", createdAt: hoursAgo(2),
});
card("Company", "failed run after bounded-retry exhaustion");
}
// agent error
await db.update(agents).set({ status: "error", errorReason: "Adapter crashed during startup.", updatedAt: now() }).where(eq(agents.id, erroredAgent!.id));
card("Company", "agent in error state");
// budget hard stop (agent-scoped so it does not pause the whole demo company)
await budgetService(db).upsertPolicy(companyId, { scopeType: "agent", scopeId: hardStopAgent!.id, amount: 100_00, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true } as never, scott);
await costService(db).createEvent(companyId, { agentId: hardStopAgent!.id, provider: "anthropic", model: "claude-opus-4-8", usageKind: "agent", costCents: 100_00, occurredAt: now() } as never);
card("Company", budgetOwner.id
? `budget hard stop (100%) incident — also in ${budgetOwner.key}'s For you as the budget owner`
: "budget hard stop (100%) incident");
// budget warning at ~90%
await budgetService(db).upsertPolicy(companyId, { scopeType: "agent", scopeId: warnAgent!.id, amount: 100_00, warnPercent: 80, hardStopEnabled: false, notifyEnabled: true } as never, scott);
await costService(db).createEvent(companyId, { agentId: warnAgent!.id, provider: "anthropic", model: "claude-opus-4-8", usageKind: "agent", costCents: 90_00, occurredAt: now() } as never);
// This independent seed process must finish both hard-stop and warning work
// before handing the demo to a server whose notifier did not write the costs.
await flushBudgetEvaluationWork(db, { companyId });
card("Company", "budget warning (~90%) incident");
// join request (pending approval)
{
const [invite] = await db.insert(invites).values({
companyId, inviteType: "company_join", tokenHash: createHash("sha256").update(`seed-invite-${randomUUID()}`).digest("hex"),
allowedJoinTypes: "both", expiresAt: daysFromNow(7), acceptedAt: now(),
}).returning();
await db.insert(joinRequests).values({
inviteId: invite!.id, companyId, requestType: "human", status: "pending_approval", requestIp: "127.0.0.1",
requestEmailSnapshot: "newcomer@needs-you-demo.test",
});
card("Company", "pending join request");
}
// recovery that stopped (escalated to a human owner)
{
const recoveryIssue = await makeIssue({ title: "Run recovery escalated to the board", status: "blocked", createdByAgentId: worker!.id });
await db.insert(issueRecoveryActions).values({
companyId, sourceIssueId: recoveryIssue.id, kind: "stalled_run", status: "escalated", ownerType: "board",
cause: "run_stalled", fingerprint: `seed-${randomUUID()}`, evidence: { note: "Recovery stopped; a human must decide next steps." },
nextAction: "A human must decide how to recover this stalled run.",
});
card("Company", "recovery action escalated to the board");
}
// =========================================================================
// GROUP: Stale — 31 & 60 days, done task, cancelled task, decision at ~85%
// of its TTL, and a stale approval that must escalate.
// =========================================================================
for (const age of [31, 60]) {
const staleIssue = await makeIssue({ title: `Stale ${age}-day confirmation`, status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const stale = await makeInteraction(staleIssue, "request_confirmation", { prompt: `This has waited ${age} days.` }, { title: `Stale (${age}d)` });
await backdateInteraction(db, stale.id, daysAgo(age));
card("Stale", `${age}-day-old pending confirmation`);
}
{
const doneIssue = await makeIssue({ title: "Confirmation on a done task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const onDone = await makeInteraction(doneIssue, "request_confirmation", { prompt: "The task is done but this card lingers." }, { title: "Lingering on a done task" });
await db.update(issuesTable).set({ status: "done", updatedAt: daysAgo(5) }).where(eq(issuesTable.id, doneIssue.id));
await backdateInteraction(db, onDone.id, daysAgo(5));
card("Stale", "pending card on a done task (expired by the sweep at the next server start)");
}
{
const cancelledIssue = await makeIssue({ title: "Confirmation on a cancelled task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const onCancelled = await makeInteraction(cancelledIssue, "request_confirmation", { prompt: "The task is cancelled but this card lingers." }, { title: "Lingering on a cancelled task" });
await db.update(issuesTable).set({ status: "cancelled", updatedAt: daysAgo(5) }).where(eq(issuesTable.id, cancelledIssue.id));
await backdateInteraction(db, onCancelled.id, daysAgo(5));
card("Stale", "pending card on a cancelled task (expired by the sweep at the next server start)");
}
{
// The sweep never closes approvals, so this one keeps the "Task closed" reason.
const doneApprovalIssue = await makeIssue({ title: "Approval left on a done task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const doneApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Approval on a finished task", summary: "The task is done; the approval still waits for a person." } } as never);
await db.insert(issueApprovals).values({ companyId, issueId: doneApprovalIssue.id, approvalId: (doneApproval as { id: string }).id });
await db.update(issuesTable).set({ status: "done", updatedAt: daysAgo(3) }).where(eq(issuesTable.id, doneApprovalIssue.id));
card("Stale", "approval on a done task (stale: Task closed; never auto-withdrawn)");
}
{
// Unassigned for 8 days: moves to Company with "No owner for 7+ days".
const unownedIssue = await makeIssue({ title: "Agent work with no owner for 8 days", status: "todo", createdByAgentId: worker!.id });
const unowned = await makeInteraction(unownedIssue, "request_confirmation", { prompt: "Nobody claimed this for over a week." }, { title: "Ownerless for 8 days", actorAgentId: worker!.id });
await backdateInteraction(db, unowned.id, daysAgo(8));
card("Stale", "Unassigned card older than 7 days (moves to Company: No owner for 7+ days)");
}
// decision at ~85% of a 7-day TTL (created 6 days ago, expires ~1 day out)
const decisionIssue = await makeIssue({ title: "Decision nearing its deadline", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const boardActor = await boardActorFor(db, companyId, scott);
const decisionRun = await makeOriginRun(db, companyId, worker!.id, decisionIssue.id, scott);
const nearDeadline = await decisionsSvc.create({
companyId, actor: boardActor, agentId: worker!.id, runId: decisionRun,
title: "Choose the rollout plan (deadline soon)", body: "This decision is ~85% through its time limit.",
options: [{ id: "staged", label: "Staged rollout", effects: [] }, { id: "bigbang", label: "All at once", style: "destructive", effects: [] }],
expiresAt: daysFromNow(1),
} as never);
await db.update(decisions).set({ createdAt: daysAgo(6), updatedAt: daysAgo(6) }).where(eq(decisions.id, (nearDeadline as { id: string }).id));
card("Stale", "open decision at ~85% of its TTL");
// stale approval that must escalate (35 days old)
{
const staleApprovalIssue = await makeIssue({ title: "Approval waiting 35+ days", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const staleApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Stale approval (35 days)", summary: "This approval has waited over 30 days and must escalate." } } as never);
await db.insert(issueApprovals).values({ companyId, issueId: staleApprovalIssue.id, approvalId: (staleApproval as { id: string }).id });
await db.update(approvalsTable).set({ createdAt: daysAgo(35), updatedAt: daysAgo(35) }).where(eq(approvalsTable.id, (staleApproval as { id: string }).id));
card("Stale", "approval older than 30 days (must escalate, never auto-withdrawn)");
}
// =========================================================================
// GROUP: Archived & snoozed (Scott) — two archived, one snoozed until
// tomorrow, one archived card with a newer change that must come back.
// =========================================================================
// Hide each card with the fingerprint Scott's feed shows for it, exactly as
// the UI sends it (PAP-749 Rule 9).
const hiddenCards: Array<{ id: string; mode: "archive" | "snooze" }> = [];
for (const i of [1, 2]) {
const archIssue = await makeIssue({ title: `Archived by Scott #${i}`, status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const arch = await makeInteraction(archIssue, "request_confirmation", { prompt: `Scott archived this card (#${i}).` }, { title: `Archived #${i}`, addresseeUserId: scott });
hiddenCards.push({ id: arch.id, mode: "archive" });
card("Archived/Snoozed", `card archived by Scott (#${i})`);
}
const snoozeIssue = await makeIssue({ title: "Snoozed by Scott until tomorrow", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const snoozed = await makeInteraction(snoozeIssue, "request_confirmation", { prompt: "Scott snoozed this until tomorrow." }, { title: "Snoozed", addresseeUserId: scott });
hiddenCards.push({ id: snoozed.id, mode: "snooze" });
card("Archived/Snoozed", "card snoozed by Scott until tomorrow");
const comebackIssue = await makeIssue({ title: "Archived, then changed — must come back", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const comeback = await makeInteraction(comebackIssue, "request_confirmation", { prompt: "Archived earlier, then the agent changed the proposal." }, { title: "Archived with a new change", addresseeUserId: scott });
hiddenCards.push({ id: comeback.id, mode: "archive" });
card("Archived/Snoozed", "archived card whose proposal changed (comes back)");
const logOnlyIssue = await makeIssue({ title: "Archived, then only log activity — stays hidden", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const logOnly = await makeInteraction(logOnlyIssue, "request_confirmation", { prompt: "Archived; later activity is only log noise." }, { title: "Archived with log-only activity", addresseeUserId: scott });
hiddenCards.push({ id: logOnly.id, mode: "archive" });
card("Archived/Snoozed", "archived card with only log activity (stays hidden)");
// Task 2e: the Archived view shows the archive date and whether the card is also stale.
const archivedStaleIssue = await makeIssue({ title: "Archived and stale (40 days)", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const archivedStale = await makeInteraction(archivedStaleIssue, "request_confirmation", { prompt: "Archived a while ago; nothing changed for 40 days." }, { title: "Archived and stale", addresseeUserId: scott });
await backdateInteraction(db, archivedStale.id, daysAgo(40));
hiddenCards.push({ id: archivedStale.id, mode: "archive" });
card("Archived/Snoozed", "archived card that is also stale (Archived view: archive date + Stale chip)");
const scottFeed = await attentionService(db).list(companyId, { userId: scott, all: true, allowUnscopedAll: true });
const feedBySourceId = new Map(scottFeed.items.map((item) => [item.subject.id, item]));
for (const hidden of hiddenCards) {
const item = feedBySourceId.get(hidden.id);
if (!item) throw new Error(`[seed] card ${hidden.id} missing from Scott's feed; cannot archive it`);
if (hidden.mode === "snooze") {
await dismissals.snooze(companyId, scott, item.dismissalKey, daysFromNow(1), hoursAgo(2), item.changeFingerprint);
} else {
await dismissals.dismiss(companyId, scott, item.dismissalKey, hoursAgo(2), item.changeFingerprint);
}
}
// After archiving: a new proposal is an important change, so the card returns.
await db.update(issueThreadInteractions)
.set({ payload: { version: 1, prompt: "Revised proposal: ship behind a flag first?" }, updatedAt: now() })
.where(eq(issueThreadInteractions.id, comeback.id));
// Activity alone (log entries, retries, comments) is not, so this card stays hidden.
await db.update(issueThreadInteractions).set({ updatedAt: now() }).where(eq(issueThreadInteractions.id, logOnly.id));
// =========================================================================
// GROUP: Closed (history) — answered, withdrawn, expired, closed-with-task.
// =========================================================================
{
const histAnswered = await makeIssue({ title: "History: answered", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const a = await makeInteraction(histAnswered, "request_confirmation", { prompt: "Answered in the past." }, { title: "Answered" });
await db.update(issueThreadInteractions).set({ status: "answered", resolvedAt: daysAgo(3), resolvedByUserId: scott, result: { outcome: "resolved", decision: "accept" }, updatedAt: daysAgo(3) }).where(eq(issueThreadInteractions.id, a.id));
card("Closed history", "answered interaction");
// Withdrawn through the same services the close endpoint calls (task 2b),
// each with the `attention.closed` entry the route writes. History shows
// "Withdrawn by user" and the reason.
const closedEntry = async (userId: string, sourceKind: string, sourceId: string, issueId: string, reason: string) =>
logActivity(db, {
companyId, actorType: "user", actorId: userId, action: "attention.closed", entityType: "issue", entityId: issueId, issueId,
details: { sourceKind, sourceId, mode: "withdraw", reason, until: null, closedByUserId: userId },
});
const histWithdrawn = await makeIssue({ title: "History: withdrawn by Dotta", status: "todo", createdByUserId: scott, responsibleUserId: scott });
const w = await makeInteraction(histWithdrawn, "request_confirmation", { prompt: "Withdrawn in the past." }, { title: "Withdrawn" });
await interactions.withdrawInteraction({ id: histWithdrawn.id, companyId }, w.id, { reason: "No longer needed." }, { userId: dotta });
await db.update(issueThreadInteractions).set({ resolvedAt: daysAgo(2), updatedAt: daysAgo(2) }).where(eq(issueThreadInteractions.id, w.id));
await closedEntry(dotta, "issue_thread_interaction", w.id, histWithdrawn.id, "No longer needed.");
card("Closed history", "interaction Dotta withdrew (Withdrawn by user)");
const histDecisionIssue = await makeIssue({ title: "History: decision withdrawn by Scott", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const histDecisionRun = await makeOriginRun(db, companyId, worker!.id, histDecisionIssue.id, scott);
const histDecision = await decisionsSvc.create({
companyId, actor: await boardActorFor(db, companyId, scott), agentId: worker!.id, runId: histDecisionRun,
title: "Pick a launch date (withdrawn)", body: "Scott withdrew this because the launch moved.",
options: [{ id: "monday", label: "Monday", effects: [] }, { id: "friday", label: "Friday", effects: [] }],
} as never) as { id: string };
await decisionsSvc.cancel(histDecision.id, { actorType: "user", actorId: scott }, { reason: "The launch moved; this is out of date." });
await closedEntry(scott, "decision", histDecision.id, histDecisionIssue.id, "The launch moved; this is out of date.");
card("Closed history", "decision Scott withdrew (Withdrawn by user)");
const histApprovalIssue = await makeIssue({ title: "History: approval withdrawn by Scott", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const histApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: dotta, payload: { title: "Approve the old vendor (withdrawn)", summary: "Withdrawn as out of date; never approved." } } as never) as { id: string };
await db.insert(issueApprovals).values({ companyId, issueId: histApprovalIssue.id, approvalId: histApproval.id });
await approvalSvc.cancel(histApproval.id, "We picked a different vendor.", { cancelledByUserId: scott });
await closedEntry(scott, "approval", histApproval.id, histApprovalIssue.id, "We picked a different vendor.");
card("Closed history", "approval Scott withdrew (history: \"Withdrawn by @Scott\" with the reason)");
// Task 2e: approvals are in history. One with no task, rejected by Scott.
const histRejected = await approvalSvc.create(companyId, { type: "approve_ceo_strategy", status: "pending", requestedByUserId: dotta, payload: { title: "Adopt the 2027 pricing strategy", summary: "Rejected; the board kept current pricing." } } as never) as { id: string };
await approvalSvc.reject(histRejected.id, scott, "Keep current pricing for now.");
await db.update(approvalsTable).set({ decidedAt: daysAgo(4), updatedAt: daysAgo(4) }).where(eq(approvalsTable.id, histRejected.id));
card("Closed history", "approval with no task that Scott rejected (history: \"Approval · …\")");
const histExpired = await makeIssue({ title: "History: expired", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const e = await makeInteraction(histExpired, "request_confirmation", { prompt: "Expired in the past." }, { title: "Expired" });
await db.update(issueThreadInteractions).set({ status: "expired", resolvedAt: daysAgo(1), result: { outcome: "expired" }, updatedAt: daysAgo(1) }).where(eq(issueThreadInteractions.id, e.id));
card("Closed history", "expired interaction");
const histClosedWithTask = await makeIssue({ title: "History: closed with its task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
const c = await makeInteraction(histClosedWithTask, "request_confirmation", { prompt: "Closed when its task closed." }, { title: "Closed with task" });
// Stored as `expired` with outcome `issue_closed`, the shape the product
// writes when a task closes; the Expired filter leaves it out (task 2e).
await db.update(issueThreadInteractions).set({ status: "expired", resolvedAt: daysAgo(1), result: { version: 1, outcome: "issue_closed" }, updatedAt: daysAgo(1) }).where(eq(issueThreadInteractions.id, c.id));
await db.update(issuesTable).set({ status: "done", updatedAt: daysAgo(1) }).where(eq(issuesTable.id, histClosedWithTask.id));
card("Closed history", "interaction closed with its task");
}
// =========================================================================
// GROUP: Receipts for Dotta (task 2f) — what Scott did with Dotta's cards.
// =========================================================================
{
const handledFor = (sourceKind: "issue_thread_interaction" | "approval", sourceId: string) =>
attentionHandledFor(db, companyId, { sourceKind, sourceId }, scott);
const answeredIssue = await makeIssue({ title: "Dotta: launch checklist", status: "todo", createdByUserId: dotta, responsibleUserId: dotta });
const answered = await makeInteraction(answeredIssue, "request_confirmation", { prompt: "Is the launch checklist complete?" }, { title: "Confirm the launch checklist" });
const answeredFor = await handledFor("issue_thread_interaction", answered.id);
await db.update(issueThreadInteractions).set({ status: "accepted", resolvedAt: now(), resolvedByUserId: scott, result: { outcome: "accepted" }, updatedAt: now() }).where(eq(issueThreadInteractions.id, answered.id));
await logActivity(db, {
companyId, actorType: "user", actorId: scott, action: "issue.thread_interaction_accepted", entityType: "issue", entityId: answeredIssue.id,
details: { interactionId: answered.id, interactionKind: "request_confirmation", interactionStatus: "accepted", ...attentionHandledDetails(answeredFor, "approved") },
});
card("Receipts", "Dotta: \"Approved by @Scott\" (confirmation on her task)");
const withdrawnIssue = await makeIssue({ title: "Dotta: vendor shortlist", status: "todo", createdByUserId: dotta, responsibleUserId: dotta });
const withdrawn = await makeInteraction(withdrawnIssue, "ask_user_questions", {
questions: [{ id: "vendor", prompt: "Which vendor should we shortlist?", selectionMode: "single", options: [{ id: "a", label: "Acme" }, { id: "b", label: "Globex" }] }],
}, { title: "Pick a vendor to shortlist" });
// Dotta read the task first, so the withdraw makes it unread in her Mine.
await issuesSvc.markRead(companyId, withdrawnIssue.id, dotta, now());
const withdrawnFor = await handledFor("issue_thread_interaction", withdrawn.id);
const reason = "We already signed with Acme.";
await interactions.withdrawInteraction({ id: withdrawnIssue.id, companyId }, withdrawn.id, { reason }, { userId: scott });
for (const action of ["issue.thread_interaction_withdrawn", "attention.closed"]) {
await logActivity(db, {
companyId, actorType: "user", actorId: scott, action, entityType: "issue", entityId: withdrawnIssue.id, issueId: withdrawnIssue.id,
details: action === "attention.closed"
? { sourceKind: "issue_thread_interaction", sourceId: withdrawn.id, mode: "withdraw", reason, until: null, closedByUserId: scott, ...attentionHandledDetails(withdrawnFor, "withdrawn", { reason }) }
: { interactionId: withdrawn.id, interactionKind: "ask_user_questions", interactionStatus: "cancelled", reason, ...attentionHandledDetails(withdrawnFor, "withdrawn", { reason }) },
});
}
card("Receipts", "Dotta: \"Withdrawn by @Scott\" with the reason; task unread in her Mine");
const approvedRequest = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: dotta, payload: { title: "Approve the Q4 hiring plan", summary: "Dotta asked; Scott approved." } } as never) as { id: string };
const approvedFor = await handledFor("approval", approvedRequest.id);
await approvalSvc.approve(approvedRequest.id, scott, null);
await logActivity(db, {
companyId, actorType: "user", actorId: scott, action: "approval.approved", entityType: "approval", entityId: approvedRequest.id,
details: { type: "request_board_approval", requestedByAgentId: null, linkedIssueIds: [], ...attentionHandledDetails(approvedFor, "approved") },
});
card("Receipts", "Dotta: \"Approved by @Scott\" (approval she requested)");
}
// =========================================================================
// GROUP: One obligation (task 2d, Rule 11) — an approval with its in-review
// task, and a blocked task with the failed run that caused it, each
// shown as one card. A configured human review stage is an
// independent quality gate, so that pair stays two cards.
// =========================================================================
{
// The task is in review only because it waits on the approval: one card.
const obIssue = await makeIssue({ title: "Obligation: approval + in-review task", status: "in_review", createdByUserId: scott, responsibleUserId: scott });
const obApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Approve the in-review task", summary: "One obligation: the approval and the in-review task are the same ask." } } as never);
await db.insert(issueApprovals).values({ companyId, issueId: obIssue.id, approvalId: (obApproval as { id: string }).id });
card("One obligation", "approval paired with its in-review task (one card: the approval, with the task as evidence)");
}
{
// Scott is also the configured reviewer: the review verdict and the
// approval are separate gates, so they stay two cards.
const gateIssue = await makeIssue({ title: "Independent gate: human review + approval", status: "todo", createdByUserId: scott, responsibleUserId: scott });
await issuesSvc.update(gateIssue.id, { status: "in_review", assigneeAgentId: null, assigneeUserId: scott, responsibleUserId: scott, executionState: reviewExecutionState(scott) } as never);
const gateApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Approve the reviewed task", summary: "A separate gate from the human review stage on the same task." } } as never);
await db.insert(issueApprovals).values({ companyId, issueId: gateIssue.id, approvalId: (gateApproval as { id: string }).id });
card("One obligation", "independent quality gate: a human review stage and an approval stay two cards");
}
{
const blockedIssue = await makeIssue({ title: "Obligation: blocked task + failed run", status: "blocked", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: failingAgent!.id });
// What exhausted retries leave on the task: blocked, with a board-owned next action.
await db.update(issuesTable).set({
unblockDescriptor: { owner: "board", action: "Automatic retries exhausted. Retry the run or reassign the task." },
blockedTransitionAt: hoursAgo(4),
}).where(eq(issuesTable.id, blockedIssue.id));
const [brun] = await db.insert(heartbeatRuns).values({
companyId, agentId: failingAgent!.id, scopeKind: "issue", issueId: blockedIssue.id, status: "failed",
invocationSource: "on_demand", responsibleUserId: scott, error: "Run failed and blocked the task.",
contextSnapshot: { issueId: blockedIssue.id }, startedAt: hoursAgo(5), finishedAt: hoursAgo(4),
}).returning();
await db.insert(heartbeatRunEvents).values({
companyId, runId: brun!.id, agentId: failingAgent!.id, seq: 1, eventType: "lifecycle",
message: "Bounded retry exhausted after 3 scheduled attempts; the task is blocked.", createdAt: hoursAgo(4),
});
card("One obligation", "blocked task paired with its failed run (one card: the run with Retry, with the blocked task as evidence)");
}
// Keep the demo deterministic (plan section 7: "Agents in the demo company do
// not run. The seed makes their state directly."). Pausing every non-errored
// agent stops the heartbeat scheduler from starting a new run that would, for
// example, supersede the seeded failed_run card.
await db.update(agents).set({ status: "paused", updatedAt: now() })
.where(and(eq(agents.companyId, companyId), ne(agents.status, "error")));
return {
companyId,
companyName: company.name,
prefix: company.issuePrefix,
users: DEMO_USERS.map((u) => ({ name: u.name, email: u.email, role: u.role })),
cardCount,
defaultDecisionOwner: defaultDecisionOwnerId ? defaultDecisionOwnerKey : null,
budgetOwner: budgetOwner.id ? budgetOwner.key : null,
projectDecisionOwner: projectDecisionOwner.id ? projectDecisionOwner.key : null,
};
}
// --- small helpers ---------------------------------------------------------
async function createProject(
db: Db,
companyId: string,
leadAgentId: string,
overrides: { name?: string; description?: string; decisionOwnerUserId?: string | null } = {},
): Promise<string> {
const [project] = await db.insert(projects).values({
companyId, name: "Needs You Demo", description: "Holds the demo issues.", status: "in_progress", leadAgentId, ...overrides,
}).returning();
return (project as { id: string }).id;
}
async function backdateInteraction(db: Db, interactionId: string, when: Date) {
await db.update(issueThreadInteractions).set({ createdAt: when, updatedAt: when }).where(eq(issueThreadInteractions.id, interactionId));
}
async function makeOriginRun(db: Db, companyId: string, agentId: string, issueId: string, responsibleUserId: string): Promise<string> {
const [run] = await db.insert(heartbeatRuns).values({
companyId, agentId, scopeKind: "issue", issueId, status: "succeeded", invocationSource: "on_demand",
responsibleUserId, contextSnapshot: { issueId }, startedAt: hoursAgo(1), finishedAt: hoursAgo(1),
}).returning();
return (run as { id: string }).id;
}
async function boardActorFor(db: Db, companyId: string, userId: string) {
const membership = await db
.select({ companyId: companyMemberships.companyId, membershipRole: companyMemberships.membershipRole, status: companyMemberships.status })
.from(companyMemberships)
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active")))
.then((rows) => rows[0] ?? null);
return { type: "board", userId, companyIds: [companyId], memberships: membership ? [membership] : [], source: "session" } as never;
}
await main();
+322
View File
@@ -0,0 +1,322 @@
/**
* scripts/verify-needs-you-checklist.ts
*
* PAP-749 final check: walks the plan's section 7 "things to try" checklist,
* plus the task 1d escalation owners and the PAP-768 additions (P1 Everyone,
* P2 personal history, P3 one budget-stop card), through the real HTTP API as each demo
* user (Scott, Dotta, Riley). It signs in with the demo password, so it needs a
* running server in `authenticated` mode whose database holds a FRESH run of
* scripts/seed-needs-you-demo.ts (default seed options).
*
* It CHANGES the demo data (withdraw, transfer, claim, archive/restore,
* withdraw all stale, keep a budget stop paused). Re-run the seed afterwards
* to give a reviewer the untouched demo.
*
* BASE_URL=http://127.0.0.1:3200 \
* pnpm --filter @paperclipai/server exec tsx ../scripts/verify-needs-you-checklist.ts
*
* What it cannot prove: anything a browser shows (the live update without a
* reload, the confirmation dialogs, the classic page's layout). Those steps are
* reported as API-level checks only.
*/
import type { AttentionFeed, AttentionItem, AttentionReceipt, DecisionHistoryItem } from "../packages/shared/src/index.js";
const BASE_URL = (process.env.BASE_URL ?? "http://127.0.0.1:3200").replace(/\/$/, "");
const PASSWORD = process.env.SEED_DEMO_PASSWORD ?? "needs-you-demo-2026";
const DEMO_COMPANY_NAME = "Needs You Demo";
const USERS = {
scott: "scott@needs-you-demo.test",
dotta: "dotta@needs-you-demo.test",
riley: "riley@needs-you-demo.test",
} as const;
type UserKey = keyof typeof USERS;
type Session = { key: UserKey; userId: string; cookie: string };
type View = "mine" | "unassigned" | "company" | "archived" | "all";
const results: Array<{ step: string; ok: boolean; detail: string }> = [];
function check(step: string, ok: boolean, detail: string) {
results.push({ step, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${step} — ${detail}`);
}
async function http<T>(session: Session | null, method: string, path: string, body?: unknown): Promise<{ status: number; json: T }> {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
Accept: "application/json",
Origin: BASE_URL,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(session ? { Cookie: session.cookie } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: unknown = null;
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
return { status: res.status, json: json as T };
}
async function signIn(key: UserKey): Promise<Session> {
const res = await fetch(`${BASE_URL}/api/auth/sign-in/email`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Origin: BASE_URL },
body: JSON.stringify({ email: USERS[key], password: PASSWORD }),
});
if (!res.ok) throw new Error(`sign-in for ${key} failed: ${res.status} ${await res.text()}`);
const cookie = res.headers.getSetCookie().map((value) => value.split(";")[0]).join("; ");
const session = await http<{ user?: { id: string } }>({ key, userId: "", cookie }, "GET", "/api/auth/get-session");
const userId = session.json?.user?.id;
if (!userId) throw new Error(`no session user for ${key}`);
return { key, userId, cookie };
}
let companyId = "";
async function feed(session: Session, view: View): Promise<AttentionFeed> {
const res = await http<AttentionFeed>(session, "GET", `/api/companies/${companyId}/attention?all=true&view=${view}`);
if (res.status !== 200) throw new Error(`feed ${view} for ${session.key}: ${res.status} ${JSON.stringify(res.json)}`);
return res.json;
}
/** The cards a page shows: obligation evidence folds into its lead (task 2d). */
function leads(items: AttentionItem[]) {
return items.filter((item) => !item.obligationLeadId || item.obligationLeadId === item.id);
}
const has = (items: AttentionItem[], id: string) => items.some((item) => item.id === id);
const titleOf = (item: AttentionItem | undefined) => item?.subject.title ?? "(none)";
async function close(session: Session, item: AttentionItem, body: Record<string, unknown>) {
return http<unknown>(session, "POST", `/api/companies/${companyId}/attention/${item.sourceKind}/${item.subject.id}/close`, body);
}
async function main() {
console.log(`[verify] ${BASE_URL}`);
const scott = await signIn("scott");
const dotta = await signIn("dotta");
const riley = await signIn("riley");
const name = (userId: string | null | undefined) =>
[scott, dotta, riley].find((session) => session.userId === userId)?.key ?? userId ?? "nobody";
const companies = await http<Array<{ id: string; name: string }>>(scott, "GET", "/api/companies");
const company = companies.json.find((entry) => entry.name === DEMO_COMPANY_NAME);
if (!company) throw new Error("demo company not found; run the seed first");
companyId = company.id;
console.log(`[verify] company ${companyId}`);
// 1. Each For you list is different, and the badge agrees with the list.
const mine = { scott: await feed(scott, "mine"), dotta: await feed(dotta, "mine"), riley: await feed(riley, "mine") };
const scottIds = new Set(mine.scott.items.map((item) => item.id));
const overlap = mine.dotta.items.filter((item) => scottIds.has(item.id));
check("1 For you differs", mine.scott.items.length > 0 && mine.dotta.items.length > 0 && overlap.length === 0,
`Scott ${mine.scott.items.length} cards, Dotta ${mine.dotta.items.length}, shared ${overlap.length}; Riley ${mine.riley.items.length}`);
for (const [key, data] of Object.entries(mine)) {
const shown = leads(data.items).length;
check(`1 badge = list (${key})`, data.deskBadgeCount === data.viewCounts.mine && data.viewCounts.mine === shown,
`badge ${data.deskBadgeCount}, For you tab ${data.viewCounts.mine}, cards shown ${shown}`);
}
check("1 every For you card is the viewer's", (["scott", "dotta", "riley"] as const).every((key) =>
mine[key].items.every((item) => item.audience.userId === { scott, dotta, riley }[key].userId)), "audience.userId matches the viewer");
// Task 1d: escalation owners.
const hardStop = mine.scott.items.find((item) => item.sourceKind === "budget_alert");
check("1d budget owner card", hardStop?.audience.reason === "budget_owner" && hardStop.audience.alsoInCompany === true
&& hardStop.decisionVerbs.map((verb) => verb.label).join("/") === "Raise limit/Keep paused",
`Scott For you: "${titleOf(hardStop)}" (${hardStop?.audience.reason}; ${hardStop?.decisionVerbs.map((verb) => verb.label).join(" / ")})`);
const companyViews = { scott: await feed(scott, "company"), dotta: await feed(dotta, "company"), riley: await feed(riley, "company") };
check("1d Company still shows the hard stop", Boolean(hardStop) && Object.values(companyViews).every((data) => has(data.items, hardStop!.id)),
"in Company for Scott, Dotta and Riley (as For @scott for the other two)");
check("1d hard stop only in the owner's For you", Boolean(hardStop) && !has(mine.dotta.items, hardStop!.id) && !has(mine.riley.items, hardStop!.id),
"not in Dotta's or Riley's For you");
const warning = companyViews.scott.items.find((item) => item.sourceKind === "budget_alert" && item.subject.metadata?.thresholdType === "soft");
check("1d budget warning stays Company only", warning?.audience.kind === "company", `"${titleOf(warning)}" audience ${warning?.audience.kind}`);
// PAP-768 part 3: the hard stop and its override approval are one card. The
// stop leads; the approval is its evidence and is no longer an ownerless
// card in Unassigned.
const overrideApprovalId = (hardStop?.subject.metadata as { approvalId?: string | null } | null)?.approvalId ?? null;
const overrideEvidence = mine.scott.items.find((item) => item.sourceKind === "approval" && item.subject.id === overrideApprovalId);
check("P3 one card for the budget stop", Boolean(hardStop && overrideEvidence) && overrideEvidence!.obligationLeadId === hardStop!.id
&& has(leads(mine.scott.items), hardStop!.id) && !has(leads(mine.scott.items), overrideEvidence!.id),
`Scott For you: "${titleOf(hardStop)}" leads; "${titleOf(overrideEvidence)}" folded in (lead ${overrideEvidence?.obligationLeadId === hardStop?.id ? "the stop" : overrideEvidence?.obligationLeadId ?? "none"})`);
const pools = { scott: await feed(scott, "unassigned"), dotta: await feed(dotta, "unassigned"), riley: await feed(riley, "unassigned") };
check("P3 override approval not in Unassigned", Boolean(overrideApprovalId)
&& Object.values(pools).every((data) => !data.items.some((item) => item.subject.id === overrideApprovalId)),
`not in Scott's, Dotta's or Riley's Unassigned (${Object.values(pools).map((data) => data.viewCounts.unassigned).join("/")} cards)`);
check("P3 the stop counts once", Object.values(companyViews).every((data) => data.items.filter((item) => item.subject.id === overrideApprovalId)
.every((item) => item.obligationLeadId === hardStop?.id)) && mine.scott.viewCounts.mine === leads(mine.scott.items).length,
"in Company the approval is evidence of the stop; For you counts the stop once");
const projectCard = mine.dotta.items.find((item) => item.audience.reason === "project_owner");
check("1d project decision owner", Boolean(projectCard), `Dotta For you: "${titleOf(projectCard)}" (project_owner)`);
// PAP-768 part 1: Everyone (view=all) for board members, with its own count.
const scottAll = await feed(scott, "all");
const access = await http<{ memberships?: Array<{ companyId: string; membershipRole: string | null; status: string }> }>(scott, "GET", "/api/cli-auth/me");
const scottMembership = access.json?.memberships?.find((entry) => entry.companyId === companyId && entry.status === "active");
check("P1 Scott is a board member who sees Everyone", Boolean(scottMembership?.membershipRole && scottMembership.membershipRole !== "viewer"),
`role ${scottMembership?.membershipRole ?? "none"}`);
check("P1 Everyone count is its own, not the badge", scottAll.viewCounts.all === leads(scottAll.items).length
&& scottAll.deskBadgeCount === scottAll.viewCounts.mine && scottAll.viewCounts.all > scottAll.viewCounts.mine,
`Everyone tab ${scottAll.viewCounts.all}, cards ${leads(scottAll.items).length}, badge ${scottAll.deskBadgeCount} (For you ${scottAll.viewCounts.mine})`);
const othersInEveryone = leads(scottAll.items).filter((item) => item.audience.kind === "user" && item.audience.userId && item.audience.userId !== scott.userId);
check("P1 Everyone shows other people's cards", othersInEveryone.some((item) => item.audience.userId === dotta.userId),
`${othersInEveryone.length} cards for others (${[...new Set(othersInEveryone.map((item) => name(item.audience.userId)))].join(", ")}); the UI shows each as "For @name"`);
// 2. As Scott, withdraw a card for Dotta from Everyone; Dotta gets the
// receipt. Cards addressed to Dotta stay out of Scott's feed (the
// pre-PAP-749 addressee filter), so these are her instigator and
// project-owner cards; the project-owner card goes last so the task 1d demo
// card survives step 2.
const dottaCards = scottAll.items
.filter((item) => item.audience.userId === dotta.userId
&& item.sourceKind === "issue_thread_interaction" && item.closeModes.includes("withdraw") && !item.stale)
.sort((left, right) => Number(left.audience.reason === "project_owner") - Number(right.audience.reason === "project_owner"));
const toWithdraw = dottaCards[0];
if (!toWithdraw) throw new Error("no withdrawable card for Dotta");
const withdraw = await close(scott, toWithdraw, { mode: "withdraw", reason: "Out of date (checklist step 2)" });
const dottaAfterWithdraw = await feed(dotta, "mine");
const withdrawReceipt = (dottaAfterWithdraw.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === toWithdraw.subject.id);
check("2 withdraw for Dotta (from Everyone)", withdraw.status === 200 && !has(dottaAfterWithdraw.items, toWithdraw.id)
&& withdrawReceipt?.outcome === "withdrawn" && withdrawReceipt.actorUserId === scott.userId,
`"${titleOf(toWithdraw)}" -> ${withdraw.status}; Dotta receipt: ${withdrawReceipt ? `${withdrawReceipt.outcome} by ${name(withdrawReceipt.actorUserId)} (reason: ${withdrawReceipt.reason})` : "none"}`);
// 3. As Scott, transfer a card to Riley; it shows in Riley's For you.
const toTransfer = dottaCards.find((item) => item.id !== toWithdraw.id);
if (!toTransfer) throw new Error("no second card for Dotta to transfer");
const transfer = await http<unknown>(scott, "POST", `/api/companies/${companyId}/attention/${toTransfer.sourceKind}/${toTransfer.subject.id}/transfer`, {
userId: riley.userId, reason: "Riley owns this now (checklist step 3)",
});
const rileyAfter = await feed(riley, "mine");
const transferred = rileyAfter.items.find((item) => item.subject.id === toTransfer.subject.id);
const dottaAfterTransfer = await feed(dotta, "mine");
const transferReceipt = (dottaAfterTransfer.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === toTransfer.subject.id);
check("3 transfer to Riley", transfer.status === 200 && transferred?.audience.transferredByUserId === scott.userId
&& !has(dottaAfterTransfer.items, toTransfer.id) && transferReceipt?.outcome === "transferred",
`"${titleOf(toTransfer)}" -> ${transfer.status}; Riley For you: ${transferred ? `yes, "Transferred by @${name(transferred.audience.transferredByUserId)}"` : "no"}; Dotta receipt: ${transferReceipt?.outcome ?? "none"}`);
// 4. As Scott, claim an Unassigned card; it leaves Dotta's Unassigned.
const scottPool = await feed(scott, "unassigned");
const toClaim = scottPool.items.find((item) => item.sourceKind === "issue_thread_interaction");
if (!toClaim) throw new Error("no claimable Unassigned card");
const dottaPoolBefore = await feed(dotta, "unassigned");
const claim = await http<unknown>(scott, "POST", `/api/companies/${companyId}/attention/${toClaim.sourceKind}/${toClaim.subject.id}/claim`, {});
const dottaPoolAfter = await feed(dotta, "unassigned");
const scottMineAfterClaim = await feed(scott, "mine");
check("4 claim from Unassigned", claim.status === 200 && has(dottaPoolBefore.items, toClaim.id) && !has(dottaPoolAfter.items, toClaim.id)
&& has(scottMineAfterClaim.items, toClaim.id),
`"${titleOf(toClaim)}" -> ${claim.status}; Dotta Unassigned ${dottaPoolBefore.viewCounts.unassigned} -> ${dottaPoolAfter.viewCounts.unassigned}; now in Scott's For you`);
// 5. Archive a card; it shows in Archived; Restore works; Dotta still sees it.
const scottCompany = await feed(scott, "company");
const toArchive = scottCompany.items.find((item) => item.sourceKind === "agent_error_alert") ?? scottCompany.items[0];
if (!toArchive) throw new Error("no Company card to archive");
const archive = await close(scott, toArchive, { mode: "archive", fingerprint: toArchive.changeFingerprint });
const scottArchived = await feed(scott, "archived");
const scottCompanyAfter = await feed(scott, "company");
const dottaCompanyAfter = await feed(dotta, "company");
check("5 archive for me", archive.status === 200 && has(scottArchived.items, toArchive.id) && !has(scottCompanyAfter.items, toArchive.id)
&& has(dottaCompanyAfter.items, toArchive.id),
`"${titleOf(toArchive)}" -> ${archive.status}; in Scott's Archived (tab ${scottArchived.viewCounts.archived}); Dotta still sees it`);
const restore = await http<unknown>(scott, "DELETE", `/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent(toArchive.dismissalKey)}`);
const scottCompanyRestored = await feed(scott, "company");
const scottArchivedRestored = await feed(scott, "archived");
check("5 restore", restore.status >= 200 && restore.status < 300 && has(scottCompanyRestored.items, toArchive.id) && !has(scottArchivedRestored.items, toArchive.id),
`restore -> ${restore.status}; back in Scott's Company view`);
// 6. Withdraw all stale: only non-required cards with a real withdraw close.
const scottMine = await feed(scott, "mine");
const stale = leads(scottMine.items).filter((item) => item.stale);
const withdrawable = stale.filter((item) => item.closeModes.includes("withdraw") && !item.required);
const kept = stale.filter((item) => !withdrawable.includes(item));
const statuses: number[] = [];
for (const item of withdrawable) statuses.push((await close(scott, item, { mode: "withdraw", reason: "Withdraw all stale (checklist step 6)" })).status);
const scottMineAfterStale = await feed(scott, "mine");
check("6 withdraw all stale", stale.length > 0 && statuses.every((status) => status === 200)
&& withdrawable.every((item) => !has(scottMineAfterStale.items, item.id)) && kept.every((item) => has(scottMineAfterStale.items, item.id)),
`Stale (${stale.length}): withdrew ${withdrawable.length} [${statuses.join(",")}], kept ${kept.length} (${kept.map((item) => item.required ? "required" : "no withdraw").join(", ") || "none"})`);
// 7. The stale approval is in Company with "Waiting 30+ days", still pending.
const companyNow = await feed(dotta, "company");
const staleApproval = companyNow.items.find((item) => item.sourceKind === "approval" && item.stale?.reason === "aged");
check("7 stale approval escalated, not withdrawn", staleApproval?.subject.status === "pending" && staleApproval.required === true,
`"${titleOf(staleApproval)}" in Company, stale ${staleApproval?.stale?.reason}, status ${staleApproval?.subject.status}`);
// 8. Decision history filters.
type History = { items: Array<Pick<DecisionHistoryItem, "sourceKind" | "sourceId" | "label" | "reason" | "action" | "resolvedByUserId" | "handledForUserId">> };
const history = async (session: Session, query: string) =>
(await http<History>(session, "GET", `/api/companies/${companyId}/attention/history?limit=100&${query}`)).json.items;
const withdrawn = await history(scott, "outcome=withdrawn");
const expired = await history(scott, "outcome=expired");
const closedWithTask = await history(scott, "outcome=issue_closed");
const handledForDotta = await history(dotta, "handledForMe=true");
check("8 history Withdrawn", withdrawn.some((item) => item.sourceId === toWithdraw.subject.id), `${withdrawn.length} entries, includes step 2`);
check("8 history Expired", expired.length > 0, `${expired.length} entries`);
check("8 history Closed with task", closedWithTask.length > 0, `${closedWithTask.length} entries`);
check("8 history Handled for me (Dotta)", handledForDotta.some((item) => item.sourceId === toWithdraw.subject.id), `${handledForDotta.length} entries, includes step 2`);
// PAP-768 part 2: history opens on "Handled by me or for me" (scope=mine,
// what both pages ask for first); Everyone (scope=all) is every person's.
// The board's case: Scott answers the confirmation on "Scott: task you
// created"; it must stay out of Dotta's default history.
const ownTaskCard = (await feed(scott, "mine")).items.find((item) => item.sourceKind === "issue_thread_interaction"
&& item.relatedIssue?.title === "Scott: task you created");
if (!ownTaskCard?.relatedIssue) throw new Error("no confirmation on \"Scott: task you created\" in Scott's For you");
const accept = await http<unknown>(scott, "POST", `/api/issues/${ownTaskCard.relatedIssue.id}/interactions/${ownTaskCard.subject.id}/accept`, {});
const ownTaskRow = (items: History["items"]) => items.some((item) => item.sourceId === ownTaskCard.subject.id);
const scottMineHistory = await history(scott, "scope=mine");
const dottaMineHistory = await history(dotta, "scope=mine");
const dottaEveryoneHistory = await history(dotta, "scope=all");
check("P2 Scott's own answer is in his history", accept.status === 200 && ownTaskRow(scottMineHistory),
`accept "${ownTaskCard.relatedIssue.identifier} · ${ownTaskCard.relatedIssue.title}" -> ${accept.status}; in Scott's Handled by me or for me`);
check("P2 Dotta's default history leaves it out", !ownTaskRow(dottaMineHistory),
`Dotta Handled by me or for me: ${dottaMineHistory.length} entries, none on ${ownTaskCard.relatedIssue.identifier}`);
check("P2 Dotta's Everyone history shows it", ownTaskRow(dottaEveryoneHistory),
`Dotta Everyone: ${dottaEveryoneHistory.length} entries, includes ${ownTaskCard.relatedIssue.identifier} · Scott: task you created`);
check("P2 Dotta's history is hers", dottaMineHistory.length > 0 && dottaMineHistory.length < dottaEveryoneHistory.length,
`${dottaMineHistory.length} of ${dottaEveryoneHistory.length} entries`);
const dottaMineWithdrawn = await history(dotta, "scope=mine&outcome=withdrawn");
const dottaMineHandled = await history(dotta, "scope=mine&handledForMe=true");
const scottMineExpired = await history(scott, "scope=mine&outcome=expired");
check("P2 outcome filters work inside each scope", dottaMineWithdrawn.some((item) => item.sourceId === toWithdraw.subject.id)
&& dottaMineHandled.some((item) => item.sourceId === toWithdraw.subject.id) && scottMineExpired.length > 0,
`Dotta mine + Withdrawn ${dottaMineWithdrawn.length} (step 2 withdraw for her), mine + Handled for me ${dottaMineHandled.length}; Scott mine + Expired ${scottMineExpired.length}`);
// 9. The classic Decisions page reads the same per-person feed (`all=true&view=`).
const classic = await feed(dotta, "mine");
check("9 classic page feed (API only)", classic.deskBadgeCount === classic.viewCounts.mine,
"same endpoint and query as Needs You; its layout needs a browser");
// Task 1d: Dotta keeps Scott's hard stop paused; Scott gets the receipt.
if (hardStop) {
const resolve = await http<unknown>(dotta, "POST", `/api/companies/${companyId}/budget-incidents/${hardStop.subject.id}/resolve`, { action: "keep_paused" });
const scottAfterBudget = await feed(scott, "mine");
const budgetReceipt = (scottAfterBudget.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === hardStop.subject.id);
const companyAfterBudget = await feed(riley, "company");
check("1d receipt for the budget owner", resolve.status === 200 && budgetReceipt?.actorUserId === dotta.userId
&& budgetReceipt.outcome === "kept_paused"
&& !has(scottAfterBudget.items, hardStop.id) && !has(companyAfterBudget.items, hardStop.id),
`Keep paused by Dotta -> ${resolve.status}; Scott receipt: ${budgetReceipt ? `${budgetReceipt.outcome} by ${name(budgetReceipt.actorUserId)} -> ${budgetReceipt.href}` : "none"}`);
// PAP-767: the same action is in Scott's "Handled for me" history as the
// hard stop's approval, kept paused by Dotta for Scott.
const approvalId = overrideApprovalId;
const overrideAfter = approvalId ? await http<{ status?: string; decidedByUserId?: string | null }>(scott, "GET", `/api/approvals/${approvalId}`) : null;
check("P3 Keep paused rejects the folded approval", overrideAfter?.json?.status === "rejected" && overrideAfter.json.decidedByUserId === dotta.userId,
`approval ${overrideAfter?.json?.status ?? "missing"} by ${name(overrideAfter?.json?.decidedByUserId)}`);
const scottHandled = await history(scott, "handledForMe=true");
const keptPaused = scottHandled.find((item) => item.sourceKind === "approval" && item.sourceId === approvalId);
check("1d history: kept paused by Dotta for Scott", Boolean(approvalId) && keptPaused?.action?.outcome === "kept_paused"
&& keptPaused.resolvedByUserId === dotta.userId && keptPaused.handledForUserId === scott.userId,
keptPaused ? `${keptPaused.action?.outcome} by ${name(keptPaused.resolvedByUserId)} for ${name(keptPaused.handledForUserId)}` : `not in Scott's Handled for me (${scottHandled.length} entries)`);
// The same row in both people's own history (PAP-768 parts 2 and 3).
const inOwnHistory = async (session: Session) => (await history(session, "scope=mine")).find((item) => item.sourceId === approvalId);
const [scottOwn, dottaOwn, rileyOwn] = [await inOwnHistory(scott), await inOwnHistory(dotta), await inOwnHistory(riley)];
check("P3 history: Kept paused by @Dotta · for @Scott in both histories", scottOwn?.action?.outcome === "kept_paused" && dottaOwn?.handledForUserId === scott.userId
&& !rileyOwn,
`Scott ${scottOwn ? "yes" : "no"}, Dotta ${dottaOwn ? "yes" : "no"}, Riley ${rileyOwn ? "yes" : "no"}`);
}
const failed = results.filter((result) => !result.ok);
console.log(`\n[verify] ${results.length - failed.length}/${results.length} checks passed`);
process.exit(failed.length > 0 ? 1 : 0);
}
await main();
@@ -0,0 +1,580 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
authUsers,
companies,
companyMemberships,
createDb,
inboxDismissals,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import type { AttentionAudience, AttentionFeedView } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { attentionRoutes } from "../routes/attention.js";
import { attentionService } from "../services/attention.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres attention audience tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
// PAP-749 task 1a: two-user service tests for resolveAttentionAudience and the
// view= partition. The audience controls where a card shows; it never changes
// who may act, and it is never derived from responsibleUserId.
describeEmbeddedPostgres("attention audience service (PAP-749 task 1a)", () => {
const SCOTT = "scott";
const DOTTA = "dotta";
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-audience-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
await db.delete(authUsers);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
async function seedCompany(prefix = "AUD", seedWriters = false) {
const companyId = randomUUID();
const workerId = randomUUID();
const errorAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `${prefix} Co`,
issuePrefix: prefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: workerId, companyId, name: "Worker", role: "engineer", status: "idle",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
},
{
id: errorAgentId, companyId, name: "Broken Agent", role: "engineer", status: "error",
errorReason: "adapter config missing",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
},
]);
if (seedWriters) {
for (const userId of [SCOTT, DOTTA]) {
await db.insert(authUsers).values({ id: userId, name: userId, email: `${userId}@audience.example.test`, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
}
}
return { companyId, workerId, errorAgentId, prefix };
}
let issueSeq = 0;
async function insertIssue(input: {
companyId: string;
id?: string;
status?: string;
parentId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
responsibleUserId?: string | null;
originKind?: string;
conversationAgentId?: string | null;
conversationUserId?: string | null;
conversationState?: typeof issues.$inferInsert.conversationState;
executionState?: Record<string, unknown> | null;
}) {
const id = input.id ?? randomUUID();
issueSeq += 1;
await db.insert(issues).values({
id,
companyId: input.companyId,
identifier: `${issueSeq}`,
title: `Issue ${issueSeq}`,
status: input.status ?? "in_progress",
priority: "medium",
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: input.assigneeUserId ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
responsibleUserId: input.responsibleUserId ?? null,
originKind: input.originKind ?? "manual",
originFingerprint: `fp-${issueSeq}`,
conversationAgentId: input.conversationAgentId ?? null,
conversationUserId: input.conversationUserId ?? null,
conversationState: input.conversationState ?? null,
executionState: input.executionState ?? null,
});
return id;
}
async function insertConfirmation(input: {
companyId: string;
issueId: string;
workerId: string;
addresseeUserId?: string | null;
addresseeAgentId?: string | null;
}) {
const id = randomUUID();
await db.insert(issueThreadInteractions).values({
id,
companyId: input.companyId,
issueId: input.issueId,
kind: "request_confirmation",
status: "pending",
title: "Approve the plan",
createdByAgentId: input.workerId,
addresseeUserId: input.addresseeUserId ?? null,
addresseeAgentId: input.addresseeAgentId ?? null,
payload: { version: 1, prompt: "Approve this plan?" },
});
return id;
}
function list(companyId: string, userId: string, view?: AttentionFeedView) {
return attentionService(db).list(companyId, { userId, view });
}
function audienceOf(items: Array<{ subject: { id: string }; audience: AttentionAudience }>, sourceId: string) {
return items.find((item) => item.subject.id === sourceId)?.audience ?? null;
}
it("resolves the audience for each source kind and step without a user-visible change in view=all", async () => {
const { companyId, workerId } = await seedCompany();
// Step 4 (instigator): a human-created task by Scott, with a no-addressee confirmation.
const scottTask = await insertIssue({ companyId, createdByUserId: SCOTT, originKind: "manual" });
const scottInstigated = await insertConfirmation({ companyId, issueId: scottTask, workerId });
// Step 4 for Dotta, to prove Scott still sees it in `all` (pre-PAP-749 behaviour).
const dottaTask = await insertIssue({ companyId, createdByUserId: DOTTA, originKind: "manual" });
const dottaInstigated = await insertConfirmation({ companyId, issueId: dottaTask, workerId });
// Step 1 (addressee): confirmation explicitly addressed to Scott.
const addressedTask = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "task_watchdog" });
const addressedToScott = await insertConfirmation({ companyId, issueId: addressedTask, workerId, addresseeUserId: SCOTT });
// Step 2 (conversation): a chat container whose user is Scott.
const chatTask = await insertIssue({
companyId, status: "in_review", assigneeAgentId: workerId,
conversationAgentId: workerId, conversationUserId: SCOTT, conversationState: "waiting",
});
const chatConfirmation = await insertConfirmation({ companyId, issueId: chatTask, workerId });
// "requester": an approval requested by Scott.
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending",
requestedByUserId: SCOTT, payload: { title: "Ship it" },
});
// Pool (step 6) + responsibleUserId guard: agent/routine-created work, no addressee,
// responsibleUserId = Scott. It must NOT route to Scott.
const routineTask = await insertIssue({
companyId, createdByAgentId: workerId, originKind: "routine_execution", responsibleUserId: SCOTT,
});
const poolConfirmation = await insertConfirmation({ companyId, issueId: routineTask, workerId });
const feed = await list(companyId, SCOTT, "all");
const items = feed.items;
// Each source kind / step resolves to the expected audience.
expect(audienceOf(items, scottInstigated)).toEqual({ kind: "user", userId: SCOTT, reason: "instigator" });
expect(audienceOf(items, addressedToScott)).toEqual({ kind: "user", userId: SCOTT, reason: "addressee" });
expect(audienceOf(items, chatConfirmation)).toEqual({ kind: "user", userId: SCOTT, reason: "conversation" });
expect(audienceOf(items, approvalId)).toEqual({ kind: "user", userId: SCOTT, reason: "requester" });
// The routine/agent-created card is a pool card; responsibleUserId did NOT route it to Scott.
expect(audienceOf(items, poolConfirmation)).toEqual({ kind: "pool", userId: null, reason: "none" });
// The company operations alert (the errored agent) is a Company card.
const errorCard = items.find((item) => item.sourceKind === "agent_error_alert");
expect(errorCard?.audience).toEqual({ kind: "company", userId: null, reason: "none" });
// No user-visible change in `all`: Scott still sees Dotta's instigated card (it has no addressee),
// exactly as before PAP-749. Only an interaction addressed to another user stays hidden.
expect(audienceOf(items, dottaInstigated)).toEqual({ kind: "user", userId: DOTTA, reason: "instigator" });
});
it("keeps an interaction addressed to another user hidden in every view (pre-PAP-749 behaviour)", async () => {
const { companyId, workerId } = await seedCompany("ADX");
const task = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "task_watchdog" });
const addressedToDotta = await insertConfirmation({ companyId, issueId: task, workerId, addresseeUserId: DOTTA });
const scottAll = await list(companyId, SCOTT, "all");
expect(scottAll.items.some((item) => item.subject.id === addressedToDotta)).toBe(false);
const dottaAll = await list(companyId, DOTTA, "all");
expect(audienceOf(dottaAll.items, addressedToDotta)).toEqual({ kind: "user", userId: DOTTA, reason: "addressee" });
});
it("partitions the feed by view: mine, unassigned, and company", async () => {
const { companyId, workerId } = await seedCompany("PRT");
const scottTask = await insertIssue({ companyId, createdByUserId: SCOTT, originKind: "manual" });
const scottCard = await insertConfirmation({ companyId, issueId: scottTask, workerId });
const dottaTask = await insertIssue({ companyId, createdByUserId: DOTTA, originKind: "manual" });
const dottaCard = await insertConfirmation({ companyId, issueId: dottaTask, workerId });
const routineTask = await insertIssue({
companyId, createdByAgentId: workerId, originKind: "routine_execution", responsibleUserId: SCOTT,
});
const poolCard = await insertConfirmation({ companyId, issueId: routineTask, workerId });
const scottMine = await list(companyId, SCOTT, "mine");
const scottMineIds = scottMine.items.map((item) => item.subject.id);
expect(scottMineIds).toContain(scottCard);
expect(scottMineIds).not.toContain(dottaCard);
expect(scottMineIds).not.toContain(poolCard);
expect(scottMine.items.every((item) => item.audience.kind === "user" && item.audience.userId === SCOTT)).toBe(true);
const dottaMine = await list(companyId, DOTTA, "mine");
const dottaMineIds = dottaMine.items.map((item) => item.subject.id);
expect(dottaMineIds).toContain(dottaCard);
expect(dottaMineIds).not.toContain(scottCard);
const unassigned = await list(companyId, SCOTT, "unassigned");
const unassignedIds = unassigned.items.map((item) => item.subject.id);
expect(unassignedIds).toContain(poolCard);
expect(unassignedIds).not.toContain(scottCard);
expect(unassigned.items.every((item) => item.audience.kind === "pool")).toBe(true);
const company = await list(companyId, SCOTT, "company");
expect(company.items.length).toBeGreaterThan(0);
expect(company.items.every((item) => item.audience.kind === "company")).toBe(true);
expect(company.items.some((item) => item.sourceKind === "agent_error_alert")).toBe(true);
});
it("routes ownerless cards to the company default decision owner, and to Unassigned once cleared (PAP-749 task 1d-lite)", async () => {
const { companyId, workerId } = await seedCompany("DDO");
// An agent/routine card with no human signal — a pool card by default.
const routineTask = await insertIssue({
companyId, createdByAgentId: workerId, originKind: "routine_execution", responsibleUserId: SCOTT,
});
const ownerlessCard = await insertConfirmation({ companyId, issueId: routineTask, workerId });
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: DOTTA, status: "active", membershipRole: "admin",
});
// With the default decision owner set to Dotta, the ownerless card routes to
// Dotta's "For you" list, with reason escalation_owner — not the pool.
await db.update(companies).set({ defaultDecisionOwnerUserId: DOTTA }).where(eq(companies.id, companyId));
const dottaMineSet = await list(companyId, DOTTA, "mine");
expect(audienceOf(dottaMineSet.items, ownerlessCard)).toEqual({ kind: "user", userId: DOTTA, reason: "escalation_owner" });
const unassignedSet = await list(companyId, SCOTT, "unassigned");
expect(unassignedSet.items.some((item) => item.subject.id === ownerlessCard)).toBe(false);
// responsibleUserId is still never a routing signal: Scott does not get it.
const scottMineSet = await list(companyId, SCOTT, "mine");
expect(scottMineSet.items.some((item) => item.subject.id === ownerlessCard)).toBe(false);
// Clearing the owner sends the same card back to the claimable pool.
await db.update(companies).set({ defaultDecisionOwnerUserId: null }).where(eq(companies.id, companyId));
const unassignedCleared = await list(companyId, SCOTT, "unassigned");
expect(audienceOf(unassignedCleared.items, ownerlessCard)).toEqual({ kind: "pool", userId: null, reason: "none" });
const dottaMineCleared = await list(companyId, DOTTA, "mine");
expect(dottaMineCleared.items.some((item) => item.subject.id === ownerlessCard)).toBe(false);
});
it("sends ownerless cards to Unassigned when the default decision owner is no longer an active member", async () => {
const { companyId, workerId } = await seedCompany("DDI");
const routineTask = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "routine_execution" });
const ownerlessCard = await insertConfirmation({ companyId, issueId: routineTask, workerId });
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: DOTTA, status: "suspended", membershipRole: "admin",
});
await db.update(companies).set({ defaultDecisionOwnerUserId: DOTTA }).where(eq(companies.id, companyId));
const unassigned = await list(companyId, SCOTT, "unassigned");
expect(audienceOf(unassigned.items, ownerlessCard)).toEqual({ kind: "pool", userId: null, reason: "none" });
const dottaMine = await list(companyId, DOTTA, "mine");
expect(dottaMine.items.some((item) => item.subject.id === ownerlessCard)).toBe(false);
});
it("routes a delegated child card to the instigator of its parent", async () => {
const { companyId, workerId } = await seedCompany("CHN");
// Scott creates the parent human task; an agent spins up a child under it.
const parentTask = await insertIssue({ companyId, createdByUserId: SCOTT, originKind: "manual" });
const childTask = await insertIssue({
companyId, parentId: parentTask, createdByAgentId: workerId, originKind: "manual",
});
const childCard = await insertConfirmation({ companyId, issueId: childTask, workerId });
const scottMine = await list(companyId, SCOTT, "mine");
expect(audienceOf(scottMine.items, childCard)).toEqual({ kind: "user", userId: SCOTT, reason: "instigator" });
const dottaMine = await list(companyId, DOTTA, "mine");
expect(dottaMine.items.some((item) => item.subject.id === childCard)).toBe(false);
});
// PAP-749 task 1b: one badge (For you only, no day boundary) and Claim.
it("counts only current For you cards in the badge, with the same per-view counts in every view", async () => {
const { companyId, workerId } = await seedCompany("BDG");
const scottTask = await insertIssue({ companyId, createdByUserId: SCOTT, originKind: "manual" });
const scottCard = await insertConfirmation({ companyId, issueId: scottTask, workerId });
const olderScottTask = await insertIssue({ companyId, createdByUserId: SCOTT, originKind: "manual" });
const olderScottCard = await insertConfirmation({ companyId, issueId: olderScottTask, workerId });
// Three days old: the old "new today" badge left it out; an open card now stays counted.
const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000);
await db.update(issueThreadInteractions)
.set({ createdAt: threeDaysAgo, updatedAt: threeDaysAgo })
.where(eq(issueThreadInteractions.id, olderScottCard));
const dottaTask = await insertIssue({ companyId, createdByUserId: DOTTA, originKind: "manual" });
await insertConfirmation({ companyId, issueId: dottaTask, workerId });
const routineTask = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "routine_execution" });
await insertConfirmation({ companyId, issueId: routineTask, workerId });
const all = await list(companyId, SCOTT, "all");
expect(all.viewCounts.mine).toBe(2);
expect(all.deskBadgeCount).toBe(2);
expect(all.viewCounts.unassigned).toBe(1);
expect(all.viewCounts.company).toBeGreaterThanOrEqual(1);
// Everyone (PAP-768) counts every person's current cards; it is never the badge.
expect(all.viewCounts.all).toBe(all.items.length);
expect(all.viewCounts.all).toBeGreaterThan(all.viewCounts.mine);
expect(all.items.some((item) => item.audience.userId === DOTTA)).toBe(true);
// Counts ignore the requested view, so the tabs, banner and badge agree.
for (const view of ["mine", "unassigned", "company"] as const) {
expect((await list(companyId, SCOTT, view)).viewCounts).toEqual(all.viewCounts);
}
expect((await list(companyId, DOTTA, "all")).deskBadgeCount).toBe(1);
// A personally hidden card leaves the badge; the other person's count is unchanged.
const scottItem = (await list(companyId, SCOTT, "mine")).items.find((item) => item.subject.id === scottCard)!;
await db.insert(inboxDismissals).values({
companyId, userId: SCOTT, itemKey: scottItem.dismissalKey, kind: "dismiss", fingerprint: scottItem.changeFingerprint,
});
const afterDismiss = await attentionService(db).list(companyId, { userId: SCOTT, includeDismissed: true });
expect(afterDismiss.deskBadgeCount).toBe(1);
expect(afterDismiss.viewCounts.mine).toBe(1);
});
// PAP-749 task 2e: the Archived view reads the viewer's own inbox_dismissals.
it("returns only your archived and snoozed cards in view=archived, counted on their tab and never in the badge", async () => {
const { companyId, workerId } = await seedCompany("ARV");
const scottCard = async () => insertConfirmation({ companyId, issueId: await insertIssue({ companyId, createdByUserId: SCOTT, originKind: "manual" }), workerId });
const archivedCard = await scottCard();
const snoozedCard = await scottCard();
const currentCard = await scottCard();
const expiredSnoozeCard = await scottCard();
const changedCard = await scottCard();
const routineTask = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "routine_execution" });
const archivedPoolCard = await insertConfirmation({ companyId, issueId: routineTask, workerId });
const before = await list(companyId, SCOTT, "all");
const itemOf = (sourceId: string) => before.items.find((item) => item.subject.id === sourceId)!;
const tomorrow = new Date(Date.now() + 86_400_000);
await db.insert(inboxDismissals).values([
{ companyId, userId: SCOTT, itemKey: itemOf(archivedCard).dismissalKey, kind: "dismiss", fingerprint: itemOf(archivedCard).changeFingerprint },
{ companyId, userId: SCOTT, itemKey: itemOf(snoozedCard).dismissalKey, kind: "snooze", snoozedUntil: tomorrow, fingerprint: itemOf(snoozedCard).changeFingerprint },
// A card of any audience can be archived for yourself, also an Unassigned one.
{ companyId, userId: SCOTT, itemKey: itemOf(archivedPoolCard).dismissalKey, kind: "dismiss", fingerprint: itemOf(archivedPoolCard).changeFingerprint },
// These two are back: the snooze ended, and the card changed since it was archived.
{ companyId, userId: SCOTT, itemKey: itemOf(expiredSnoozeCard).dismissalKey, kind: "snooze", snoozedUntil: new Date(Date.now() - 60_000), fingerprint: itemOf(expiredSnoozeCard).changeFingerprint },
{ companyId, userId: SCOTT, itemKey: itemOf(changedCard).dismissalKey, kind: "dismiss", fingerprint: "an-older-version.me" },
]);
const archived = await list(companyId, SCOTT, "archived");
expect(new Set(archived.items.map((item) => item.subject.id))).toEqual(new Set([archivedCard, snoozedCard, archivedPoolCard]));
expect(archived.items.every((item) => item.dismissal?.isActive)).toBe(true);
const snoozed = archived.items.find((item) => item.subject.id === snoozedCard)!;
expect(snoozed.dismissal).toMatchObject({ kind: "snooze", snoozedUntil: tomorrow.toISOString(), isActive: true });
expect(archived.items.find((item) => item.subject.id === archivedPoolCard)?.audience.kind).toBe("pool");
expect(archived.totalCount).toBe(3);
expect(archived.receipts).toBeUndefined();
// The tab count is the Archived list; the badge is still only current For you cards.
expect(archived.viewCounts).toMatchObject({ mine: 3, unassigned: 0, archived: 3 });
expect(archived.deskBadgeCount).toBe(3);
for (const view of ["mine", "unassigned", "company", "all"] as const) {
expect((await list(companyId, SCOTT, view)).viewCounts).toEqual(archived.viewCounts);
}
// Everyone leaves out the cards you hid, like the other views (PAP-768).
const everyone = await list(companyId, SCOTT, "all");
expect(archived.viewCounts.all).toBe(everyone.items.length);
expect(everyone.items.map((item) => item.subject.id)).not.toContain(archivedPoolCard);
const mineIds = (await list(companyId, SCOTT, "mine")).items.map((item) => item.subject.id);
expect(mineIds).toEqual(expect.arrayContaining([currentCard, expiredSnoozeCard, changedCard]));
expect(mineIds).not.toContain(archivedCard);
expect(mineIds).not.toContain(snoozedCard);
expect((await list(companyId, SCOTT, "unassigned")).items.map((item) => item.subject.id)).not.toContain(archivedPoolCard);
// Archive is personal: Dotta's Archived view is empty and her pool still has the card.
const dottaArchived = await list(companyId, DOTTA, "archived");
expect(dottaArchived.items).toEqual([]);
expect(dottaArchived.viewCounts.archived).toBe(0);
expect((await list(companyId, DOTTA, "unassigned")).items.map((item) => item.subject.id)).toContain(archivedPoolCard);
// Restore (deleting the dismissal) moves the card back to For you.
await db.delete(inboxDismissals).where(and(eq(inboxDismissals.userId, SCOTT), eq(inboxDismissals.itemKey, itemOf(archivedCard).dismissalKey)));
const restored = await list(companyId, SCOTT, "archived");
expect(restored.items.map((item) => item.subject.id)).not.toContain(archivedCard);
expect(restored.viewCounts).toMatchObject({ mine: 4, archived: 2 });
});
it("accepts view=archived on the route and still rejects unknown views", async () => {
const { companyId } = await seedCompany("ARR");
const testApp = express();
testApp.use((req, _res, next) => {
(req as any).actor = { type: "board", source: "session", userId: SCOTT, companyIds: [companyId], isInstanceAdmin: false };
next();
});
testApp.use("/api", attentionRoutes(db));
testApp.use(errorHandler);
const feed = await request(testApp).get(`/api/companies/${companyId}/attention?view=archived&all=true`).expect(200);
expect(feed.body).toMatchObject({ items: [], viewCounts: { archived: 0 } });
expect(feed.body.receipts).toBeUndefined();
await request(testApp).get(`/api/companies/${companyId}/attention?view=hidden`).expect(400);
});
it("scopes decision history on the route: mine is handled by me or for me, all is everyone (PAP-768)", async () => {
const { companyId, workerId } = await seedCompany("HSR");
const scottCard = await insertConfirmation({ companyId, issueId: await insertIssue({ companyId, createdByUserId: SCOTT, originKind: "manual" }), workerId });
await db.update(issueThreadInteractions)
.set({ status: "accepted", resolvedAt: new Date(), resolvedByUserId: SCOTT })
.where(eq(issueThreadInteractions.id, scottCard));
const app = (userId: string) => {
const testApp = express();
testApp.use((req, _res, next) => {
(req as any).actor = { type: "board", source: "session", userId, companyIds: [companyId], isInstanceAdmin: false };
next();
});
testApp.use("/api", attentionRoutes(db));
testApp.use(errorHandler);
return testApp;
};
const history = async (userId: string, query: string) =>
(await request(app(userId)).get(`/api/companies/${companyId}/attention/history?${query}`).expect(200)).body.items.map((item: { sourceId: string }) => item.sourceId);
expect(await history(SCOTT, "scope=mine")).toEqual([scottCard]);
expect(await history(DOTTA, "scope=mine")).toEqual([]);
expect(await history(DOTTA, "scope=all")).toEqual([scottCard]);
expect(await history(DOTTA, "")).toEqual([scottCard]);
await request(app(DOTTA)).get(`/api/companies/${companyId}/attention/history?scope=team`).expect(400);
});
it("claims a pool card for one person and removes it from every other person's pool", async () => {
const { companyId, workerId } = await seedCompany("CLM", true);
const routineTask = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "routine_execution" });
const poolCard = await insertConfirmation({ companyId, issueId: routineTask, workerId });
const svc = attentionService(db);
const board = (userId: string) => ({ type: "board" as const, source: "session" as const, userId, companyIds: [companyId], isInstanceAdmin: false });
expect((await list(companyId, DOTTA, "unassigned")).items.some((item) => item.subject.id === poolCard)).toBe(true);
const claim = await svc.claim(companyId, {
sourceKind: "issue_thread_interaction", sourceId: poolCard, userId: SCOTT, actor: board(SCOTT),
});
expect(claim).toMatchObject({ sourceId: poolCard, claimedByUserId: SCOTT, alreadyClaimed: false, issueId: routineTask });
const scottMine = await list(companyId, SCOTT, "mine");
expect(audienceOf(scottMine.items, poolCard)).toEqual({ kind: "user", userId: SCOTT, reason: "addressee" });
expect((await list(companyId, DOTTA, "unassigned")).items.some((item) => item.subject.id === poolCard)).toBe(false);
expect((await list(companyId, SCOTT, "unassigned")).items.some((item) => item.subject.id === poolCard)).toBe(false);
// Claiming again is a no-op; a different person gets a conflict, not a takeover.
await expect(svc.claim(companyId, {
sourceKind: "issue_thread_interaction", sourceId: poolCard, userId: SCOTT, actor: board(SCOTT),
})).resolves.toMatchObject({ alreadyClaimed: true });
await expect(svc.claim(companyId, {
sourceKind: "issue_thread_interaction", sourceId: poolCard, userId: DOTTA, actor: board(DOTTA),
})).rejects.toMatchObject({ status: 409 });
// The claim moves placement only: the resolver policy is unchanged.
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, poolCard));
expect(row?.addresseeUserId).toBe(SCOTT);
expect(row?.effectiveResolverPolicy).toBe("anyone");
});
it("claims a stuck agent's request but leaves a runnable agent's request with the agent", async () => {
const { companyId, workerId, errorAgentId } = await seedCompany("CLA", true);
const svc = attentionService(db);
const board = { type: "board" as const, source: "session" as const, userId: SCOTT, companyIds: [companyId], isInstanceAdmin: false };
// A paused agent cannot run, so its request reaches the pool; an agent in error still can.
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, errorAgentId));
const task = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "routine_execution" });
const stuck = await insertConfirmation({ companyId, issueId: task, workerId, addresseeAgentId: errorAgentId });
const runnable = await insertConfirmation({ companyId, issueId: task, workerId, addresseeAgentId: workerId });
await svc.claim(companyId, { sourceKind: "issue_thread_interaction", sourceId: stuck, userId: SCOTT, actor: board });
const [claimed] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, stuck));
expect(claimed).toMatchObject({ addresseeUserId: SCOTT, addresseeAgentId: null });
await expect(svc.claim(companyId, { sourceKind: "issue_thread_interaction", sourceId: runnable, userId: SCOTT, actor: board }))
.rejects.toMatchObject({ status: 409 });
});
it("serves the claim route to board users only, validates input and writes one activity entry", async () => {
const { companyId, workerId } = await seedCompany("CLR", true);
const routineTask = await insertIssue({ companyId, createdByAgentId: workerId, originKind: "routine_execution" });
const poolCard = await insertConfirmation({ companyId, issueId: routineTask, workerId });
const approvalId = randomUUID();
await db.insert(approvals).values({ id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: {} });
function app(actor: Record<string, unknown>) {
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
testApp.use("/api", attentionRoutes(db));
testApp.use(errorHandler);
return testApp;
}
const scott = { type: "board", source: "session", userId: SCOTT, companyIds: [companyId], isInstanceAdmin: false };
const outsider = { type: "board", source: "session", userId: "outsider", companyIds: [], isInstanceAdmin: false };
const agent = { type: "agent", source: "agent_key", companyId, agentId: workerId, runId: null };
const claimUrl = (kind: string, id: string) => `/api/companies/${companyId}/attention/${kind}/${id}/claim`;
await request(app(agent)).post(claimUrl("issue_thread_interaction", poolCard)).expect(403);
await request(app(outsider)).post(claimUrl("issue_thread_interaction", poolCard)).expect(403);
await request(app(scott)).post(claimUrl("nonsense", poolCard)).expect(400);
await request(app(scott)).post(claimUrl("issue_thread_interaction", "not-a-uuid")).expect(400);
await request(app(scott)).post(claimUrl("issue_thread_interaction", randomUUID())).expect(404);
await request(app(scott)).post(claimUrl("approval", approvalId)).expect(422);
const claimed = await request(app(scott)).post(claimUrl("issue_thread_interaction", poolCard)).expect(200);
expect(claimed.body).toEqual({
sourceKind: "issue_thread_interaction", sourceId: poolCard, claimedByUserId: SCOTT, alreadyClaimed: false,
});
await request(app(scott)).post(claimUrl("issue_thread_interaction", poolCard)).expect(200);
const entries = await db.select().from(activityLog)
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "attention.claimed")));
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ actorType: "user", actorId: SCOTT, entityType: "issue", entityId: routineTask });
expect(entries[0]?.details).toMatchObject({ sourceKind: "issue_thread_interaction", sourceId: poolCard, claimedByUserId: SCOTT });
});
});
@@ -0,0 +1,509 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
authUsers,
companies,
companyMemberships,
createDb,
decisions,
heartbeatRuns,
inboxDismissals,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { attentionRoutes } from "../routes/attention.js";
import { approvalRoutes } from "../routes/approvals.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { attentionCloseModes, attentionService } from "../services/attention.js";
import { decisionHistoryService, interactionHistoryLabel } from "../services/decision-history.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres attention close tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("attentionCloseModes (PAP-749 task 2b)", () => {
it("offers withdraw only where the source has a real withdraw or cancel", () => {
const none = { metadata: undefined };
expect(attentionCloseModes("issue_thread_interaction", none)).toEqual(["withdraw", "archive", "snooze"]);
expect(attentionCloseModes("decision", none)).toEqual(["withdraw", "archive", "snooze"]);
expect(attentionCloseModes("approval", { metadata: { type: "request_board_approval" } })).toEqual(["withdraw", "archive", "snooze"]);
expect(attentionCloseModes("approval", { metadata: { type: "hire_agent" } })).toEqual(["archive", "snooze"]);
for (const kind of ["budget_alert", "recovery_action", "failed_run", "agent_error_alert", "review", "blocker_attention", "join_request"] as const) {
expect(attentionCloseModes(kind, none)).toEqual(["archive", "snooze"]);
}
expect(attentionCloseModes("productivity_review", none)).toEqual([]);
});
it("labels withdrawn, skipped and task-closed history rows by their result outcome", () => {
const row = (result: unknown, resolvedByUserId: string | null = null, status = "cancelled") =>
interactionHistoryLabel({ kind: "request_confirmation", status, result, resolvedByUserId });
expect(row({ outcome: "withdrawn" }, "scott")).toBe("Withdrawn by user");
expect(row({ outcome: "withdrawn" })).toBe("Withdrawn");
expect(row({ outcome: "skipped" }, "scott")).toBe("Skipped");
expect(row({ outcome: "issue_closed" }, null, "expired")).toBe("Closed with task");
expect(row(null, null, "accepted")).toBe("Approved");
});
});
// PAP-749 task 2b: one close API. Withdraw closes the request for everyone and
// tells the agent; archive and snooze hide it for one person; transfer routes it
// to another person. Each returns 400/403/404/409/422 consistently.
describeEmbeddedPostgres("attention close, transfer and approval cancel (PAP-749 task 2b)", () => {
const SCOTT = "scott";
const DOTTA = "dotta";
const RILEY = "riley";
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-close-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
await db.delete(authUsers);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
let seq = 0;
async function seedCompany(prefix: string) {
const companyId = randomUUID();
const workerId = randomUUID();
await db.insert(companies).values({ id: companyId, name: `${prefix} Co`, issuePrefix: prefix, requireBoardApprovalForNewAgents: false });
await db.insert(agents).values({
id: workerId, companyId, name: "Worker", role: "engineer", status: "idle",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
});
for (const userId of [SCOTT, DOTTA, RILEY]) {
await db.insert(authUsers).values({ id: userId, name: userId, email: `${userId}-${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "admin" });
}
return { companyId, workerId };
}
async function insertIssue(companyId: string, input: { status?: string; createdByUserId?: string | null; conversation?: { agentId: string; userId: string } } = {}) {
const id = randomUUID();
seq += 1;
await db.insert(issues).values({
id, companyId, identifier: `${seq}`, title: `Issue ${seq}`, status: input.status ?? "todo", priority: "medium",
createdByUserId: input.createdByUserId ?? null, originKind: "manual", originFingerprint: `fp-${seq}`,
conversationAgentId: input.conversation?.agentId ?? null, conversationUserId: input.conversation?.userId ?? null,
conversationState: input.conversation ? "active" : null, assigneeAgentId: input.conversation?.agentId ?? null,
});
return id;
}
const PAYLOADS: Record<string, Record<string, unknown>> = {
request_confirmation: { version: 1, prompt: "Approve this plan?" },
ask_user_questions: { version: 1, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "a", label: "A" }] }] },
suggest_tasks: { version: 1, tasks: [{ clientKey: "a", title: "Do a thing" }] },
request_checkbox_confirmation: { version: 1, prompt: "Pick files", options: [{ id: "a", label: "A" }] },
request_item_verdicts: { version: 1, prompt: "Review items", items: [{ id: "a", label: "A" }], verdicts: ["approve", "reject"] },
};
async function insertInteraction(companyId: string, issueId: string, workerId: string, input: { kind?: string; addresseeUserId?: string | null } = {}) {
const id = randomUUID();
const kind = input.kind ?? "request_confirmation";
await db.insert(issueThreadInteractions).values({
id, companyId, issueId, kind, status: "pending", title: `A ${kind}`, createdByAgentId: workerId,
addresseeUserId: input.addresseeUserId ?? null, payload: PAYLOADS[kind] as never,
});
return id;
}
async function insertDecision(companyId: string, workerId: string, originIssueId: string) {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: workerId, status: "succeeded", contextSnapshot: { issueId: originIssueId } });
const id = randomUUID();
await db.insert(decisions).values({
id, companyId, originAgentId: workerId, originIssueId, originRunId: runId, title: "Pick a vendor", body: "Pick one",
options: [], status: "open", expiresAt: new Date(Date.now() + 86_400_000), signedSpec: "test", targetSnapshots: {},
});
return id;
}
const board = (companyId: string, userId: string, extra: Record<string, unknown> = {}) =>
({ type: "board", source: "session", userId, companyIds: [companyId], isInstanceAdmin: false, ...extra });
function app(actor: Record<string, unknown>) {
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
testApp.use("/api", attentionRoutes(db));
testApp.use("/api", approvalRoutes(db));
testApp.use(errorHandler);
return testApp;
}
const closeUrl = (companyId: string, kind: string, id: string) => `/api/companies/${companyId}/attention/${kind}/${id}/close`;
const transferUrl = (companyId: string, kind: string, id: string) => `/api/companies/${companyId}/attention/${kind}/${id}/transfer`;
const activity = (companyId: string, action: string) =>
db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, action)));
it("withdraws every interaction kind for a board user, never as consent, and logs both entries", async () => {
const { companyId, workerId } = await seedCompany("CLW");
const issueId = await insertIssue(companyId, { createdByUserId: DOTTA });
const scott = app(board(companyId, SCOTT));
for (const kind of Object.keys(PAYLOADS)) {
const interactionId = await insertInteraction(companyId, issueId, workerId, { kind });
const response = await request(scott).post(closeUrl(companyId, "issue_thread_interaction", interactionId))
.send({ mode: "withdraw", reason: "Out of date" });
expect(response.status, `${kind}: ${JSON.stringify(response.body)}`).toBe(200);
expect(response.body).toEqual({
sourceKind: "issue_thread_interaction", sourceId: interactionId, mode: "withdraw", closedByUserId: SCOTT, issueId, until: null,
});
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
expect(row).toMatchObject({ status: "cancelled", resolvedByUserId: SCOTT });
expect(row?.result).toMatchObject({ outcome: "withdrawn", reason: "Out of date" });
}
expect(await activity(companyId, "issue.thread_interaction_withdrawn")).toHaveLength(5);
const closed = await activity(companyId, "attention.closed");
expect(closed).toHaveLength(5);
expect(closed[0]).toMatchObject({ actorType: "user", actorId: SCOTT, entityType: "issue", entityId: issueId });
expect(closed[0]?.details).toMatchObject({ sourceKind: "issue_thread_interaction", mode: "withdraw", reason: "Out of date", closedByUserId: SCOTT });
// Decision history names the withdrawal, who did it and why.
const history = await decisionHistoryService(db).list(companyId, {
actor: board(companyId, SCOTT) as never, userId: SCOTT, outcome: "withdrawn_by_user",
});
expect(history.items).toHaveLength(5);
expect(history.items[0]).toMatchObject({ label: "Withdrawn by user", reason: "Out of date", resolvedByUserId: SCOTT });
});
it("keeps personally hidden content out of feed, summary, and task membership", async () => {
const { companyId, workerId } = await seedCompany("HID");
const issueId = await insertIssue(companyId, { createdByUserId: SCOTT });
const interactionId = await insertInteraction(companyId, issueId, workerId);
const decisionId = await insertDecision(companyId, workerId, issueId);
const approvalId = randomUUID();
await db.insert(approvals).values({ id: approvalId, companyId, type: "request_board_approval", requestedByUserId: SCOTT, payload: { title: "Ship", details: "Important proposal" } });
const svc = attentionService(db);
const actor = board(companyId, SCOTT) as never;
const before = await svc.list(companyId, { userId: SCOTT, actor, all: true, allowUnscopedAll: true });
expect(new Set(before.items.map(item => item.subject.id))).toEqual(new Set([interactionId, decisionId, approvalId]));
for (const item of before.items) {
await svc.hideForUser(companyId, { sourceKind: item.sourceKind, sourceId: item.subject.id, userId: SCOTT, actor,
mode: item.sourceKind === "decision" ? "snooze" : "archive", until: new Date(Date.now() + 86_400_000).toISOString(), fingerprint: item.changeFingerprint });
}
expect((await svc.list(companyId, { userId: SCOTT, actor })).items).toEqual([]);
expect(await svc.summary(companyId, { userId: SCOTT, actor })).toMatchObject({ totalCount: 0, deskBadgeCount: 0, viewCounts: { mine: 0, archived: 3 } });
expect(await svc.taskIndex(companyId, { userId: SCOTT, actor })).toMatchObject({ totalCount: 0, taskIds: [] });
});
it.each(["instigator", "assignee", "conversation", "default"])("does not claim work already routed by %s", async (owner) => {
const { companyId, workerId } = await seedCompany("OWN");
const issueId = await insertIssue(companyId, owner === "instigator" ? { createdByUserId: DOTTA } : {});
if (owner === "assignee") await db.update(issues).set({ assigneeUserId: DOTTA }).where(eq(issues.id, issueId));
if (owner === "conversation") await db.update(issues).set({ conversationAgentId: workerId, conversationUserId: DOTTA, conversationState: "active", assigneeAgentId: workerId, createdByUserId: DOTTA }).where(eq(issues.id, issueId));
if (owner === "default") await db.update(companies).set({ defaultDecisionOwnerUserId: DOTTA }).where(eq(companies.id, companyId));
const sourceId = await insertInteraction(companyId, issueId, workerId);
await expect(attentionService(db).claim(companyId, { sourceKind: "issue_thread_interaction", sourceId, userId: RILEY, actor: board(companyId, RILEY) as never })).rejects.toMatchObject({ status: 409 });
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, sourceId)))[0]?.addresseeUserId).toBeNull();
});
it("rejects a transfer to a person who cannot read the task", async () => {
const { companyId, workerId } = await seedCompany("PRI");
const issueId = await insertIssue(companyId, { createdByUserId: SCOTT });
await db.update(issues).set({ visibility: "private" }).where(eq(issues.id, issueId));
const sourceId = await insertInteraction(companyId, issueId, workerId, { addresseeUserId: SCOTT });
const previous = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
try {
await request(app(board(companyId, SCOTT))).post(transferUrl(companyId, "issue_thread_interaction", sourceId)).send({ userId: DOTTA }).expect(422);
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, sourceId)))[0]?.addresseeUserId).toBe(SCOTT);
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previous;
}
});
it("enforces the review requester rule when withdrawing through attention", async () => {
const { companyId, workerId } = await seedCompany("GOV");
const issueId = await insertIssue(companyId, { status: "in_review", createdByUserId: SCOTT });
await db.update(issues).set({ reviewPolicy: "not_creator" }).where(eq(issues.id, issueId));
const sourceId = await insertInteraction(companyId, issueId, workerId);
await db.update(issueThreadInteractions).set({ createdByAgentId: null, createdByUserId: SCOTT }).where(eq(issueThreadInteractions.id, sourceId));
const url = closeUrl(companyId, "issue_thread_interaction", sourceId);
const denied = await request(app(board(companyId, SCOTT))).post(url).send({ mode: "withdraw" }).expect(403);
expect(denied.body.details.code).toBe("review_policy_denied");
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, sourceId)))[0]?.status).toBe("pending");
await request(app(board(companyId, DOTTA))).post(url).send({ mode: "withdraw" }).expect(200);
});
it("returns the recorded outcome when the original addressee claims a closed request", async () => {
const { companyId, workerId } = await seedCompany("RAC");
const issueId = await insertIssue(companyId);
const sourceId = await insertInteraction(companyId, issueId, workerId, { addresseeUserId: SCOTT });
const boardApp = app(board(companyId, SCOTT));
await request(boardApp).post(closeUrl(companyId, "issue_thread_interaction", sourceId)).send({ mode: "withdraw" }).expect(200);
const response = await request(boardApp).post(`/api/companies/${companyId}/attention/issue_thread_interaction/${sourceId}/claim`).expect(409);
expect(response.body.details).toMatchObject({ code: "attention_already_closed", status: "cancelled", resolvedByUserId: SCOTT });
});
it("lets a person withdraw a stale card on a closed task", async () => {
const { companyId, workerId } = await seedCompany("CLD");
const doneTask = await insertIssue(companyId, { status: "done", createdByUserId: SCOTT });
const interactionId = await insertInteraction(companyId, doneTask, workerId);
await request(app(board(companyId, SCOTT))).post(closeUrl(companyId, "issue_thread_interaction", interactionId))
.send({ mode: "withdraw" }).expect(200);
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
expect(row).toMatchObject({ status: "cancelled", resolvedByUserId: SCOTT });
});
it("returns 400, 403, 404, 409 and 422 for close", async () => {
const { companyId, workerId } = await seedCompany("CLE");
const issueId = await insertIssue(companyId, { createdByUserId: SCOTT });
const interactionId = await insertInteraction(companyId, issueId, workerId);
const hireId = randomUUID();
await db.insert(approvals).values({ id: hireId, companyId, type: "hire_agent", status: "pending", requestedByUserId: SCOTT, payload: {} });
const scott = app(board(companyId, SCOTT));
const url = closeUrl(companyId, "issue_thread_interaction", interactionId);
// 400: bad source kind, id or body.
await request(scott).post(closeUrl(companyId, "nonsense", interactionId)).send({ mode: "archive" }).expect(400);
await request(scott).post(closeUrl(companyId, "issue_thread_interaction", "not-a-uuid")).send({ mode: "archive" }).expect(400);
await request(scott).post(url).send({ mode: "delete" }).expect(400);
await request(scott).post(url).send({ mode: "snooze" }).expect(400);
await request(scott).post(url).send({ mode: "snooze", until: new Date(Date.now() - 60_000).toISOString() }).expect(400);
await request(scott).post(url).send({ mode: "withdraw", until: new Date(Date.now() + 60_000).toISOString() }).expect(400);
// 403: agents, outsiders and read-only viewers.
await request(app({ type: "agent", source: "agent_key", companyId, agentId: workerId, runId: null })).post(url).send({ mode: "withdraw" }).expect(403);
await request(app(board(companyId, "outsider", { companyIds: [] }))).post(url).send({ mode: "withdraw" }).expect(403);
await request(app(board(companyId, RILEY, { memberships: [{ companyId, status: "active", membershipRole: "viewer" }] })))
.post(url).send({ mode: "withdraw" }).expect(403);
// 422: nothing to withdraw, or a hire request that must be rejected instead.
const unsupported = await request(scott).post(closeUrl(companyId, "budget_alert", randomUUID())).send({ mode: "withdraw" }).expect(422);
expect(unsupported.body.details).toMatchObject({ code: "attention_close_mode_unsupported", closeModes: ["archive", "snooze"] });
await request(scott).post(closeUrl(companyId, "approval", hireId)).send({ mode: "withdraw" }).expect(422);
// 404: no such card for this person.
await request(scott).post(closeUrl(companyId, "issue_thread_interaction", randomUUID())).send({ mode: "withdraw" }).expect(404);
await request(scott).post(closeUrl(companyId, "issue_thread_interaction", randomUUID())).send({ mode: "archive" }).expect(404);
// 409: the first answer wins; the second person learns who answered.
await request(scott).post(url).send({ mode: "withdraw" }).expect(200);
for (const mode of ["withdraw", "archive"]) {
const late = await request(app(board(companyId, DOTTA))).post(url).send({ mode }).expect(409);
expect(late.body.details).toMatchObject({
code: "attention_already_closed", sourceKind: "issue_thread_interaction", sourceId: interactionId,
status: "cancelled", resolvedByUserId: SCOTT,
});
}
expect(await activity(companyId, "attention.closed")).toHaveLength(1);
});
it("withdraws approvals and decisions through their own cancel, recording who and why", async () => {
const { companyId, workerId } = await seedCompany("CLA");
const issueId = await insertIssue(companyId, { createdByUserId: SCOTT });
const approvalId = randomUUID();
await db.insert(approvals).values({ id: approvalId, companyId, type: "request_board_approval", status: "pending", requestedByUserId: DOTTA, payload: { title: "Ship" } });
const decisionId = await insertDecision(companyId, workerId, issueId);
const scott = app(board(companyId, SCOTT));
await request(scott).post(closeUrl(companyId, "approval", approvalId)).send({ mode: "withdraw", reason: "Plan changed" }).expect(200);
const [approval] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
expect(approval).toMatchObject({ status: "cancelled", decidedByUserId: SCOTT, decisionNote: "Plan changed" });
expect(await activity(companyId, "approval.cancelled")).toHaveLength(1);
const late = await request(app(board(companyId, DOTTA))).post(closeUrl(companyId, "approval", approvalId)).send({ mode: "withdraw" }).expect(409);
expect(late.body.details).toMatchObject({ code: "attention_already_closed", status: "cancelled", resolvedByUserId: SCOTT });
const closedDecision = await request(scott).post(closeUrl(companyId, "decision", decisionId)).send({ mode: "withdraw", reason: "Vendor chosen elsewhere" }).expect(200);
expect(closedDecision.body).toMatchObject({ sourceKind: "decision", issueId });
const [decision] = await db.select().from(decisions).where(eq(decisions.id, decisionId));
expect(decision).toMatchObject({ status: "cancelled", decidedByUserId: SCOTT });
expect(decision?.metadata).toMatchObject({ cancelReason: "Vendor chosen elsewhere" });
await request(scott).post(closeUrl(companyId, "decision", decisionId)).send({ mode: "withdraw" }).expect(409);
const history = await decisionHistoryService(db).list(companyId, { actor: board(companyId, SCOTT) as never, userId: SCOTT, outcome: "withdrawn" });
// Approvals are in history too (PAP-749 task 2e), so the withdrawn approval shows next to the decision.
expect(history.items).toHaveLength(2);
expect(history.items).toEqual(expect.arrayContaining([
expect.objectContaining({ sourceKind: "decision", label: "Withdrawn by user", reason: "Vendor chosen elsewhere" }),
expect.objectContaining({ sourceKind: "approval", label: "Withdrawn by user", reason: "Plan changed", resultOutcome: "withdrawn" }),
]));
});
it("serves the approval cancel route to board users only", async () => {
const { companyId, workerId } = await seedCompany("CAP");
const approvalId = randomUUID();
const hireId = randomUUID();
await db.insert(approvals).values([
{ id: approvalId, companyId, type: "request_board_approval", status: "pending", requestedByUserId: DOTTA, payload: {} },
{ id: hireId, companyId, type: "hire_agent", status: "pending", requestedByUserId: DOTTA, payload: {} },
]);
const cancelUrl = (id: string) => `/api/approvals/${id}/cancel`;
await request(app({ type: "agent", source: "agent_key", companyId, agentId: workerId, runId: null })).post(cancelUrl(approvalId)).send({}).expect(403);
await request(app(board(companyId, SCOTT))).post(cancelUrl(approvalId)).send({ reason: 7 }).expect(400);
await request(app(board(companyId, SCOTT))).post(cancelUrl(randomUUID())).send({}).expect(404);
await request(app(board(companyId, SCOTT))).post(cancelUrl(hireId)).send({}).expect(422);
const cancelled = await request(app(board(companyId, SCOTT))).post(cancelUrl(approvalId)).send({ reason: "Stale" }).expect(200);
expect(cancelled.body).toMatchObject({ id: approvalId, status: "cancelled", decidedByUserId: SCOTT });
await request(app(board(companyId, SCOTT))).post(cancelUrl(approvalId)).send({}).expect(409);
});
it("archives and snoozes a card for one person only and keeps the source open", async () => {
const { companyId, workerId } = await seedCompany("CLH");
const issueId = await insertIssue(companyId, { createdByUserId: SCOTT });
const archived = await insertInteraction(companyId, issueId, workerId);
const snoozed = await insertInteraction(companyId, issueId, workerId, { kind: "ask_user_questions" });
const scott = app(board(companyId, SCOTT));
const visible = async (userId: string, id: string) =>
(await attentionService(db).list(companyId, { userId, actor: board(companyId, userId) as never })).items.some((item) => item.subject.id === id);
const archiveResponse = await request(scott).post(closeUrl(companyId, "issue_thread_interaction", archived)).send({ mode: "archive" }).expect(200);
expect(archiveResponse.body).toMatchObject({ mode: "archive", issueId, until: null });
const until = new Date(Date.now() + 3_600_000).toISOString();
const snoozeResponse = await request(scott).post(closeUrl(companyId, "issue_thread_interaction", snoozed)).send({ mode: "snooze", until }).expect(200);
expect(snoozeResponse.body).toMatchObject({ mode: "snooze", until });
expect(await visible(SCOTT, archived)).toBe(false);
expect(await visible(SCOTT, snoozed)).toBe(false);
expect(await visible(DOTTA, archived)).toBe(true);
expect(await visible(DOTTA, snoozed)).toBe(true);
const rows = await db.select().from(inboxDismissals).where(eq(inboxDismissals.companyId, companyId));
expect(rows.map((row) => row.kind).sort()).toEqual(["dismiss", "snooze"]);
expect(rows.every((row) => row.userId === SCOTT && row.fingerprint)).toBe(true);
const sources = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, companyId));
expect(sources.every((row) => row.status === "pending")).toBe(true);
const closed = await activity(companyId, "attention.closed");
expect(closed.map((entry) => (entry.details as Record<string, unknown>).mode).sort()).toEqual(["archive", "snooze"]);
});
it("does not let a company writer take another person's addressed request and approve it", async () => {
const { companyId, workerId } = await seedCompany("CTA");
await db.update(companyMemberships).set({ membershipRole: "operator" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, SCOTT)));
const issueId = await insertIssue(companyId, { createdByUserId: DOTTA });
const sourceId = await insertInteraction(companyId, issueId, workerId, { addresseeUserId: DOTTA });
await request(app(board(companyId, SCOTT))).post(transferUrl(companyId, "issue_thread_interaction", sourceId)).send({ userId: SCOTT }).expect(403);
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, sourceId));
expect(row).toMatchObject({ addresseeUserId: DOTTA, status: "pending" });
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
await expect(issueThreadInteractionService(db).acceptInteraction(
issue, sourceId, {}, { userId: SCOTT },
)).rejects.toMatchObject({ status: 403, details: { code: "interaction_addressee_mismatch" } });
expect(await activity(companyId, "attention.transferred")).toEqual([]);
});
it("lets the addressed writer delegate without giving other writers routing administration", async () => {
const { companyId, workerId } = await seedCompany("CTO");
await db.update(companyMemberships).set({ membershipRole: "operator" }).where(eq(companyMemberships.companyId, companyId));
const issueId = await insertIssue(companyId);
const sourceId = await insertInteraction(companyId, issueId, workerId, { addresseeUserId: DOTTA });
const url = transferUrl(companyId, "issue_thread_interaction", sourceId);
await request(app(board(companyId, DOTTA))).post(url).send({ userId: RILEY }).expect(200);
await request(app(board(companyId, DOTTA))).post(url).send({ userId: RILEY }).expect(200);
await request(app(board(companyId, SCOTT))).post(url).send({ userId: SCOTT }).expect(403);
});
it.each(["interaction", "review"] as const)("never routes a request to its excluded %s creator, even for an administrator", async (restriction) => {
const { companyId, workerId } = await seedCompany(restriction === "review" ? "CTR" : "CTI");
const issueId = await insertIssue(companyId, { status: restriction === "review" ? "in_review" : "todo", createdByUserId: RILEY });
const sourceId = await insertInteraction(companyId, issueId, workerId, { addresseeUserId: DOTTA });
await db.update(issueThreadInteractions).set({ createdByAgentId: null, createdByUserId: RILEY,
effectiveResolverPolicy: restriction === "interaction" ? "not_creator" : "anyone", resolverPolicyProvenance: "explicit" }).where(eq(issueThreadInteractions.id, sourceId));
if (restriction === "review") await db.update(issues).set({ reviewPolicy: "not_creator" }).where(eq(issues.id, issueId));
await request(app(board(companyId, SCOTT))).post(transferUrl(companyId, "issue_thread_interaction", sourceId)).send({ userId: RILEY }).expect(403);
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, sourceId)))[0].addresseeUserId).toBe(DOTTA);
expect(await activity(companyId, "attention.transferred")).toEqual([]);
});
it("does not let a viewer claim or route an ownerless request", async () => {
const { companyId, workerId } = await seedCompany("CTV");
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, SCOTT)));
const issueId = await insertIssue(companyId);
const sourceId = await insertInteraction(companyId, issueId, workerId);
const viewer = app(board(companyId, SCOTT));
await request(viewer).post(transferUrl(companyId, "issue_thread_interaction", sourceId)).send({ userId: RILEY }).expect(403);
await request(viewer).post(`/api/companies/${companyId}/attention/issue_thread_interaction/${sourceId}/claim`).expect(403);
});
it("does not let an excluded creator claim an ownerless request", async () => {
const { companyId, workerId } = await seedCompany("CTC");
const issueId = await insertIssue(companyId);
const sourceId = await insertInteraction(companyId, issueId, workerId);
await db.update(issueThreadInteractions).set({ createdByAgentId: null, createdByUserId: SCOTT, effectiveResolverPolicy: "not_creator", resolverPolicyProvenance: "explicit" }).where(eq(issueThreadInteractions.id, sourceId));
await request(app(board(companyId, SCOTT))).post(`/api/companies/${companyId}/attention/issue_thread_interaction/${sourceId}/claim`).expect(403);
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, sourceId)))[0].addresseeUserId).toBeNull();
});
it("transfers a card to another person who can answer it, with 400/403/404/409/422", async () => {
const { companyId, workerId } = await seedCompany("CLT");
const issueId = await insertIssue(companyId, { createdByUserId: DOTTA });
const forDotta = await insertInteraction(companyId, issueId, workerId, { addresseeUserId: DOTTA });
const approvalId = randomUUID();
await db.insert(approvals).values({ id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: {} });
const chatIssue = await insertIssue(companyId, { conversation: { agentId: workerId, userId: DOTTA } });
const chatQuestion = await insertInteraction(companyId, chatIssue, workerId, { kind: "ask_user_questions", addresseeUserId: DOTTA });
const scott = app(board(companyId, SCOTT));
const url = transferUrl(companyId, "issue_thread_interaction", forDotta);
await request(scott).post(url).send({}).expect(400);
await request(scott).post(url).send({ userId: RILEY, extra: true }).expect(400);
await request(app({ type: "agent", source: "agent_key", companyId, agentId: workerId, runId: null })).post(url).send({ userId: RILEY }).expect(403);
await request(scott).post(transferUrl(companyId, "issue_thread_interaction", randomUUID())).send({ userId: RILEY }).expect(404);
await request(scott).post(transferUrl(companyId, "approval", approvalId)).send({ userId: RILEY }).expect(422);
await request(scott).post(url).send({ userId: "not-a-member" }).expect(422);
await request(scott).post(transferUrl(companyId, "issue_thread_interaction", chatQuestion)).send({ userId: RILEY }).expect(422);
const moved = await request(scott).post(url).send({ userId: RILEY, reason: "Riley owns billing" }).expect(200);
expect(moved.body).toEqual({
sourceKind: "issue_thread_interaction", sourceId: forDotta, transferredToUserId: RILEY,
previousAddresseeUserId: DOTTA, alreadyTransferred: false,
});
await request(scott).post(url).send({ userId: RILEY }).expect(200).then((response) => expect(response.body.alreadyTransferred).toBe(true));
const rileyMine = await attentionService(db).list(companyId, { userId: RILEY, view: "mine" });
expect(rileyMine.items.find((item) => item.subject.id === forDotta)?.audience).toEqual({ kind: "user", userId: RILEY, reason: "addressee", transferredByUserId: SCOTT });
const dottaMine = await attentionService(db).list(companyId, { userId: DOTTA, view: "mine" });
expect(dottaMine.items.some((item) => item.subject.id === forDotta)).toBe(false);
const entries = await activity(companyId, "attention.transferred");
expect(entries).toHaveLength(1);
expect(entries[0]?.details).toMatchObject({ transferredToUserId: RILEY, transferredByUserId: SCOTT, previousAddresseeUserId: DOTTA, reason: "Riley owns billing" });
// Riley answers first; a late transfer and a late claim get "already answered".
await db.update(issueThreadInteractions).set({ status: "accepted", resolvedByUserId: RILEY, resolvedAt: new Date() })
.where(eq(issueThreadInteractions.id, forDotta));
const late = await request(scott).post(url).send({ userId: DOTTA }).expect(409);
expect(late.body.details).toMatchObject({ code: "attention_already_closed", status: "accepted", resolvedByUserId: RILEY });
const lateClaim = await request(app(board(companyId, DOTTA))).post(`/api/companies/${companyId}/attention/issue_thread_interaction/${forDotta}/claim`).expect(409);
expect(lateClaim.body.details).toMatchObject({ code: "attention_already_closed", resolvedByUserId: RILEY });
});
it("puts closeModes on feed items", async () => {
const { companyId, workerId } = await seedCompany("CLM");
const issueId = await insertIssue(companyId, { createdByUserId: SCOTT });
const interactionId = await insertInteraction(companyId, issueId, workerId);
const hireId = randomUUID();
await db.insert(approvals).values({ id: hireId, companyId, type: "hire_agent", status: "pending", requestedByUserId: SCOTT, payload: {} });
const feed = await attentionService(db).list(companyId, { userId: SCOTT });
expect(feed.items.find((item) => item.subject.id === interactionId)?.closeModes).toEqual(["withdraw", "archive", "snooze"]);
expect(feed.items.find((item) => item.subject.id === hireId)?.closeModes).toEqual(["archive", "snooze"]);
});
});
@@ -0,0 +1,506 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
companies,
companyMemberships,
createDb,
issueApprovals,
issueComments,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import type { AttentionAudience, AttentionFeedView, AttentionItem, RequestConfirmationToolActionPayload } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
attentionChangeFingerprint,
attentionService,
dismissalCoversChange,
escalateStaleAudience,
resolveAttentionStale,
} from "../services/attention.js";
import { expirePendingInteractionsOnClosedIssues } from "../services/closed-issue-interaction-sweep.js";
import { inboxDismissalService } from "../services/inbox-dismissals.js";
import { pendingUserRequestRowCondition } from "../services/issue-participation.js";
const TOOL_ACTION = {
version: 1,
actionRequestId: "tool-approval",
invocationId: "invocation",
toolName: "calendar.create_event",
toolDisplayName: "Create event",
connectionId: null,
applicationId: "google-calendar",
appDisplayName: "Google Calendar",
risk: "write",
previewMarkdown: "Create the calendar event.",
argumentsSummaryJson: "{}",
argumentsHash: "fixture-arguments",
expiresAt: null,
} satisfies RequestConfirmationToolActionPayload;
const DAY = 86_400_000;
const SCOTT = "scott";
const DOTTA = "dotta";
const POOL: AttentionAudience = { kind: "pool", userId: null, reason: "none" };
const FOR_SCOTT: AttentionAudience = { kind: "user", userId: SCOTT, reason: "addressee" };
// PAP-749 task 2a: the pure stale, escalation and fingerprint rules.
describe("attention stale and change rules (PAP-749 task 2a)", () => {
const now = Date.parse("2026-10-29T12:00:00.000Z");
const ago = (days: number) => new Date(now - days * DAY);
const stale = (input: Partial<Parameters<typeof resolveAttentionStale>[0]>) => resolveAttentionStale({
sourceKind: "issue_thread_interaction",
issueStatus: "in_progress",
since: ago(1),
createdAt: ago(1),
audience: FOR_SCOTT,
now,
...input,
});
it("applies the closed-task, decision, 30-day and 7-day rules in order", () => {
expect(stale({})).toBeNull();
expect(stale({ issueStatus: "done" })).toEqual({ reason: "issue_closed" });
expect(stale({ issueStatus: "cancelled", since: ago(60) })).toEqual({ reason: "issue_closed" });
expect(stale({ since: ago(29) })).toBeNull();
expect(stale({ since: ago(30) })).toEqual({ reason: "aged" });
// The clock is the last important change, not the creation date.
expect(stale({ createdAt: ago(60), since: ago(2) })).toBeNull();
expect(stale({ audience: POOL, since: ago(6) })).toBeNull();
expect(stale({ audience: POOL, since: ago(7) })).toEqual({ reason: "unowned" });
expect(stale({ audience: POOL, since: ago(31) })).toEqual({ reason: "aged" });
// 7-day decision window: 85% gone is stale, 50% is not.
const decision = { sourceKind: "decision" as const, createdAt: ago(6), expiresAt: new Date(now + DAY) };
expect(stale({ ...decision, createdAt: ago(5.95), since: ago(5.95) })).toEqual({ reason: "decision_expiring" });
expect(stale({ ...decision, createdAt: ago(3.5), since: ago(3.5), expiresAt: new Date(now + 3.5 * DAY) })).toBeNull();
});
it("escalates an old Unassigned card to Company and an old required card to the owner or Company", () => {
expect(escalateStaleAudience({ audience: POOL, since: ago(6), required: false, now })).toEqual(POOL);
expect(escalateStaleAudience({ audience: POOL, since: ago(8), required: false, now }))
.toEqual({ kind: "company", userId: null, reason: "none" });
// A non-required card keeps its person; only required gates escalate.
expect(escalateStaleAudience({ audience: FOR_SCOTT, since: ago(45), required: false, now })).toEqual(FOR_SCOTT);
expect(escalateStaleAudience({ audience: FOR_SCOTT, since: ago(29), required: true, now })).toEqual(FOR_SCOTT);
expect(escalateStaleAudience({ audience: FOR_SCOTT, since: ago(31), required: true, now }))
.toEqual({ kind: "company", userId: null, reason: "none" });
expect(escalateStaleAudience({
audience: FOR_SCOTT, since: ago(31), required: true, now,
companyConfig: { defaultDecisionOwnerUserId: DOTTA },
})).toEqual({ kind: "user", userId: DOTTA, reason: "escalation_owner" });
const company = { kind: "company" as const, userId: null, reason: "none" as const };
expect(escalateStaleAudience({ audience: company, since: ago(90), required: true, now })).toEqual(company);
});
it("returns a hidden card only when its content changes or it comes to the viewer", () => {
const content = { kind: "request_confirmation", payload: { prompt: "Ship?" } };
const mine = attentionChangeFingerprint({ sourceKind: "issue_thread_interaction", content, forViewer: true });
const other = attentionChangeFingerprint({ sourceKind: "issue_thread_interaction", content, forViewer: false });
const edited = attentionChangeFingerprint({
sourceKind: "issue_thread_interaction",
content: { ...content, payload: { prompt: "Ship today?" } },
forViewer: true,
});
// Key order does not matter; the hash is over canonical JSON.
expect(attentionChangeFingerprint({
sourceKind: "issue_thread_interaction",
content: { payload: { prompt: "Ship?" }, kind: "request_confirmation" },
forViewer: true,
})).toBe(mine);
expect(dismissalCoversChange(null, mine)).toBeNull();
expect(dismissalCoversChange(mine, mine)).toBe(true);
expect(dismissalCoversChange(mine, edited)).toBe(false);
// The card comes to you again: hidden while it was someone else's.
expect(dismissalCoversChange(other, mine)).toBe(false);
// A hand-off away from you is an assignee change, not a reason to return.
expect(dismissalCoversChange(mine, other)).toBe(true);
});
});
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres attention current-card tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("attention current cards (PAP-749 task 2a)", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-current-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
async function seedCompany(prefix = "CUR") {
const companyId = randomUUID();
const workerId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `${prefix} Co`,
issuePrefix: prefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: workerId, companyId, name: "Worker", role: "engineer", status: "idle",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
});
return { companyId, workerId };
}
let issueSeq = 0;
async function insertIssue(input: {
companyId: string;
status?: string;
createdByUserId?: string | null;
createdByAgentId?: string | null;
assigneeAgentId?: string | null;
}) {
const id = randomUUID();
issueSeq += 1;
await db.insert(issues).values({
id,
companyId: input.companyId,
identifier: `${issueSeq}`,
title: `Issue ${issueSeq}`,
status: input.status ?? "in_progress",
priority: "medium",
assigneeAgentId: input.assigneeAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdByAgentId: input.createdByAgentId ?? null,
originKind: input.createdByAgentId ? "task_watchdog" : "manual",
originFingerprint: `cur-${issueSeq}`,
});
return id;
}
async function insertInteraction(input: {
companyId: string;
issueId: string;
workerId: string;
kind?: string;
addresseeUserId?: string | null;
payload?: typeof issueThreadInteractions.$inferInsert.payload;
ageDays?: number;
dismissedAt?: Date | null;
}) {
const id = randomUUID();
const at = new Date(Date.now() - (input.ageDays ?? 0) * DAY);
await db.insert(issueThreadInteractions).values({
id,
companyId: input.companyId,
issueId: input.issueId,
kind: input.kind ?? "request_confirmation",
status: "pending",
title: "Approve the plan",
createdByAgentId: input.workerId,
addresseeUserId: input.addresseeUserId ?? null,
payload: input.payload ?? { version: 1, prompt: "Approve this plan?" },
dismissedAt: input.dismissedAt ?? null,
createdAt: at,
updatedAt: at,
});
return id;
}
async function list(companyId: string, userId: string, view?: AttentionFeedView) {
return (await attentionService(db).list(companyId, { userId, view })).items;
}
function find(items: AttentionItem[], sourceId: string) {
return items.find((item) => item.subject.id === sourceId) ?? null;
}
it("lists only current interactions, with the same active-request predicate as Mine", async () => {
const { companyId, workerId } = await seedCompany();
const task = await insertIssue({ companyId, createdByUserId: SCOTT, assigneeAgentId: workerId });
const current = await insertInteraction({ companyId, issueId: task, workerId, addresseeUserId: SCOTT });
// A person deferred an ordinary request: it leaves the required list.
const deferred = await insertInteraction({
companyId, issueId: task, workerId, addresseeUserId: SCOTT, kind: "request_checkbox_confirmation",
payload: { version: 1, prompt: "Pick some", options: [{ id: "a", label: "A" }] },
dismissedAt: new Date(),
});
// A governed request keeps its gate even after a dismiss.
const governed = await insertInteraction({
companyId, issueId: task, workerId, addresseeUserId: SCOTT, kind: "request_confirmation",
payload: { version: 1, prompt: "Allow the tool?", toolAction: TOOL_ACTION },
dismissedAt: new Date(),
});
// An ordinary question answered by a later human comment is historical.
const historical = await insertInteraction({
companyId, issueId: task, workerId, addresseeUserId: SCOTT, kind: "ask_user_questions", ageDays: 1,
payload: { version: 1, questions: [{ id: "q", prompt: "Which one?", selectionMode: "single", options: [{ id: "a", label: "A" }] }] },
});
await db.insert(issueComments).values({ companyId, issueId: task, authorUserId: SCOTT, body: "Use the first one." });
const listed = (await list(companyId, SCOTT))
.filter((item) => item.sourceKind === "issue_thread_interaction")
.map((item) => item.subject.id)
.sort();
expect(listed).toEqual([current, governed].sort());
expect(listed).not.toContain(deferred);
expect(listed).not.toContain(historical);
// Mine (`pendingUserRequestRowCondition`) selects exactly the same cards.
const mine = await db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.innerJoin(issues, eq(issues.id, issueThreadInteractions.issueId))
.where(pendingUserRequestRowCondition(companyId, SCOTT));
expect(mine.map((row) => row.id).sort()).toEqual(listed);
});
it("labels 30-day-old cards stale and escalates a stale approval without withdrawing it", async () => {
const { companyId, workerId } = await seedCompany();
const task = await insertIssue({ companyId, createdByUserId: SCOTT, assigneeAgentId: workerId });
const fresh = await insertInteraction({ companyId, issueId: task, workerId, ageDays: 10 });
const old = await insertInteraction({ companyId, issueId: task, workerId, kind: "suggest_tasks", ageDays: 31,
payload: { version: 1, tasks: [{ clientKey: "a", title: "Follow up" }] } });
const governed = await insertInteraction({ companyId, issueId: task, workerId, kind: "request_confirmation",
addresseeUserId: SCOTT, ageDays: 40, payload: { version: 1, prompt: "Allow?", toolAction: TOOL_ACTION } });
const approvalId = randomUUID();
const approvalAt = new Date(Date.now() - 31 * DAY);
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending",
requestedByUserId: SCOTT, payload: { title: "Ship it" }, createdAt: approvalAt, updatedAt: approvalAt,
});
let items = await list(companyId, SCOTT, "all");
expect(find(items, fresh)?.stale).toBeNull();
expect(find(items, fresh)?.audience).toEqual({ kind: "user", userId: SCOTT, reason: "instigator" });
// An ordinary stale card stays with its person, in the Stale group.
expect(find(items, old)?.stale).toEqual({ reason: "aged" });
expect(find(items, old)?.audience).toEqual({ kind: "user", userId: SCOTT, reason: "instigator" });
// Required cards go to Company when no escalation owner is set.
expect(find(items, approvalId)?.stale).toEqual({ reason: "aged" });
expect(find(items, approvalId)?.audience).toEqual({ kind: "company", userId: null, reason: "none" });
expect(find(items, governed)?.audience).toEqual({ kind: "company", userId: null, reason: "none" });
// The UI reads `required` so "Withdraw all stale" leaves these with a person (task 2c).
expect(find(items, approvalId)?.required).toBe(true);
expect(find(items, governed)?.required).toBe(true);
expect(find(items, old)?.required).toBe(false);
expect(find(await list(companyId, SCOTT, "company"), approvalId)).not.toBeNull();
expect(find(await list(companyId, SCOTT, "mine"), approvalId)).toBeNull();
// With a default decision owner, the stale approval goes to that person.
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: DOTTA, status: "active", membershipRole: "admin",
});
await db.update(companies).set({ defaultDecisionOwnerUserId: DOTTA }).where(eq(companies.id, companyId));
items = await list(companyId, DOTTA, "mine");
expect(find(items, approvalId)?.audience).toEqual({ kind: "user", userId: DOTTA, reason: "escalation_owner" });
// Listing never withdraws, expires or approves anything.
const [approval] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
expect(approval?.status).toBe("pending");
const statuses = await db.select({ status: issueThreadInteractions.status }).from(issueThreadInteractions)
.where(inArray(issueThreadInteractions.id, [old, governed]));
expect(statuses.map((row) => row.status)).toEqual(["pending", "pending"]);
});
it.each([DOTTA, null])("collects an addressed stale required request for its escalation queue (%s)", async (owner) => {
const { companyId, workerId } = await seedCompany();
if (owner) await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: owner, status: "active", membershipRole: "admin" });
await db.update(companies).set({ defaultDecisionOwnerUserId: owner }).where(eq(companies.id, companyId));
const issueId = await insertIssue({ companyId, createdByUserId: SCOTT });
const sourceId = await insertInteraction({ companyId, issueId, workerId, addresseeUserId: SCOTT, ageDays: 31,
kind: "request_confirmation", payload: { version: 1, prompt: "Review", toolAction: TOOL_ACTION } });
const incoming = await list(companyId, DOTTA, owner ? "mine" : "company");
expect(find(incoming, sourceId)).toMatchObject({ required: true, audience: owner ? { kind: "user", userId: owner } : { kind: "company" } });
expect(find(await list(companyId, SCOTT, "mine"), sourceId)).toBeNull();
});
it("labels cards on closed tasks stale", async () => {
const { companyId, workerId } = await seedCompany();
const doneTask = await insertIssue({ companyId, status: "done", createdByUserId: SCOTT });
const cancelledTask = await insertIssue({ companyId, status: "cancelled", createdByUserId: SCOTT });
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending",
requestedByUserId: SCOTT, payload: { title: "Approve the launch" },
});
await db.insert(issueApprovals).values({ companyId, issueId: doneTask, approvalId });
// Older code left this card pending when the task was cancelled.
const leftover = await insertInteraction({ companyId, issueId: cancelledTask, workerId });
const items = await list(companyId, SCOTT);
expect(find(items, approvalId)?.stale).toEqual({ reason: "issue_closed" });
expect(find(items, leftover)?.stale).toEqual({ reason: "issue_closed" });
});
it("moves an Unassigned card with no owner for 7 days to Company", async () => {
const { companyId, workerId } = await seedCompany();
const agentTask = await insertIssue({ companyId, createdByAgentId: workerId, assigneeAgentId: workerId });
const recent = await insertInteraction({ companyId, issueId: agentTask, workerId, ageDays: 3 });
const unowned = await insertInteraction({ companyId, issueId: agentTask, workerId, kind: "suggest_tasks",
ageDays: 8, payload: { version: 1, tasks: [{ clientKey: "a", title: "Triage" }] } });
const all = await list(companyId, SCOTT, "all");
expect(find(all, recent)?.audience).toEqual(POOL);
expect(find(all, recent)?.stale).toBeNull();
expect(find(all, unowned)?.audience).toEqual({ kind: "company", userId: null, reason: "none" });
expect(find(all, unowned)?.stale).toEqual({ reason: "unowned" });
expect(find(await list(companyId, SCOTT, "unassigned"), unowned)).toBeNull();
expect(find(await list(companyId, SCOTT, "company"), unowned)).not.toBeNull();
});
it("brings a hidden card back only on an important change", async () => {
const { companyId, workerId } = await seedCompany();
const otherAgent = randomUUID();
await db.insert(agents).values({
id: otherAgent, companyId, name: "Other", role: "engineer", status: "idle",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
});
const task = await insertIssue({ companyId, createdByUserId: SCOTT, assigneeAgentId: workerId });
const dismissed = await insertInteraction({ companyId, issueId: task, workerId, ageDays: 2 });
const snoozed = await insertInteraction({ companyId, issueId: task, workerId, kind: "suggest_tasks", ageDays: 2,
payload: { version: 1, tasks: [{ clientKey: "a", title: "One" }] } });
const legacy = await insertInteraction({ companyId, issueId: task, workerId, kind: "request_checkbox_confirmation",
ageDays: 2, payload: { version: 1, prompt: "Pick", options: [] } });
const agentTask = await insertIssue({ companyId, createdByAgentId: workerId, assigneeAgentId: workerId });
const pooled = await insertInteraction({ companyId, issueId: agentTask, workerId, ageDays: 1 });
const dismissals = inboxDismissalService(db);
const before = await list(companyId, SCOTT);
const hide = (id: string) => find(before, id)!;
await dismissals.dismiss(companyId, SCOTT, hide(dismissed).dismissalKey, new Date(), hide(dismissed).changeFingerprint);
await dismissals.snooze(companyId, SCOTT, hide(snoozed).dismissalKey, new Date(Date.now() + 7 * DAY), new Date(),
hide(snoozed).changeFingerprint);
await dismissals.dismiss(companyId, SCOTT, hide(pooled).dismissalKey, new Date(), hide(pooled).changeFingerprint);
// A row saved before fingerprints existed keeps the activity-time rule.
await dismissals.dismiss(companyId, SCOTT, hide(legacy).dismissalKey, new Date());
const visible = async () => (await list(companyId, SCOTT)).map((item) => item.subject.id);
const hidden = await visible();
for (const id of [dismissed, snoozed, pooled, legacy]) expect(hidden).not.toContain(id);
// Log-only activity and an assignee change do not bring the cards back.
const later = new Date(Date.now() + 60_000);
await db.update(issueThreadInteractions).set({ updatedAt: later })
.where(inArray(issueThreadInteractions.id, [dismissed, snoozed, pooled, legacy]));
await db.update(issues).set({ assigneeAgentId: otherAgent, updatedAt: later }).where(eq(issues.id, task));
let ids = await visible();
expect(ids).not.toContain(dismissed);
expect(ids).not.toContain(snoozed);
expect(ids).not.toContain(pooled);
// The legacy row still uses the old rule, so the newer activity shows it.
expect(ids).toContain(legacy);
// A changed proposal brings the card back, even mid-snooze.
await db.update(issueThreadInteractions)
.set({ payload: { version: 1, prompt: "Approve the revised plan?" } })
.where(eq(issueThreadInteractions.id, dismissed));
await db.update(issueThreadInteractions)
.set({ payload: { version: 1, tasks: [{ clientKey: "a", title: "One" }, { clientKey: "b", title: "Two" }] } })
.where(eq(issueThreadInteractions.id, snoozed));
// The pooled card comes to Scott: that is also a reason to return.
await db.update(issueThreadInteractions).set({ addresseeUserId: SCOTT })
.where(eq(issueThreadInteractions.id, pooled));
ids = await visible();
expect(ids).toContain(dismissed);
expect(ids).toContain(snoozed);
expect(ids).toContain(pooled);
});
it("expires pending cards on closed tasks once, and a second sweep changes nothing", async () => {
const { companyId, workerId } = await seedCompany();
const cancelledTask = await insertIssue({ companyId, status: "cancelled", createdByUserId: SCOTT });
const doneTask = await insertIssue({ companyId, status: "done", createdByUserId: SCOTT });
const answeredTask = await insertIssue({ companyId, status: "done", createdByUserId: SCOTT });
const openTask = await insertIssue({ companyId, createdByUserId: SCOTT, assigneeAgentId: workerId });
const onCancelled = [
await insertInteraction({ companyId, issueId: cancelledTask, workerId }),
await insertInteraction({ companyId, issueId: cancelledTask, workerId, kind: "ask_user_questions",
payload: { version: 1, questions: [{ id: "q", prompt: "Why?", selectionMode: "single", options: [{ id: "a", label: "A" }] }] } }),
];
const onDone = await insertInteraction({ companyId, issueId: doneTask, workerId });
// A done task keeps an ordinary historical question answerable.
const historical = await insertInteraction({ companyId, issueId: answeredTask, workerId, kind: "ask_user_questions",
ageDays: 1, payload: { version: 1, questions: [{ id: "q", prompt: "Which?", selectionMode: "single", options: [{ id: "a", label: "A" }] }] } });
await db.insert(issueComments).values({ companyId, issueId: answeredTask, authorUserId: SCOTT, body: "Answered here." });
const onOpen = await insertInteraction({ companyId, issueId: openTask, workerId });
const first = await expirePendingInteractionsOnClosedIssues(db);
expect(first).toEqual({ issues: 2, expired: 3, failed: 0 });
const statusOf = async () => new Map((await db
.select({ id: issueThreadInteractions.id, status: issueThreadInteractions.status, result: issueThreadInteractions.result })
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.companyId, companyId)))
.map((row) => [row.id, row]));
const afterFirst = await statusOf();
for (const id of [...onCancelled, onDone]) {
expect(afterFirst.get(id)?.status).toBe("expired");
expect(afterFirst.get(id)?.result).toMatchObject({ outcome: "issue_closed" });
}
expect(afterFirst.get(historical)?.status).toBe("pending");
expect(afterFirst.get(onOpen)?.status).toBe("pending");
const sweepEntries = () => db.select().from(activityLog).where(and(
eq(activityLog.companyId, companyId),
eq(activityLog.action, "issue.thread_interaction_expired"),
eq(activityLog.actorId, "attention.closed_issue_sweep"),
));
const entries = await sweepEntries();
expect(entries).toHaveLength(3);
expect(entries.every((entry) => entry.actorType === "system")).toBe(true);
const second = await expirePendingInteractionsOnClosedIssues(db);
expect(second).toEqual({ issues: 0, expired: 0, failed: 0 });
expect(await statusOf()).toEqual(afterFirst);
expect(await sweepEntries()).toHaveLength(3);
});
it("keeps sweeping and logs what closed when one card on an issue cannot expire", async () => {
const { companyId, workerId } = await seedCompany();
const cancelledTask = await insertIssue({ companyId, status: "cancelled", createdByUserId: SCOTT });
const confirmation = await insertInteraction({ companyId, issueId: cancelledTask, workerId, ageDays: 2 });
// A legacy payload that no longer passes validation.
const broken = await insertInteraction({ companyId, issueId: cancelledTask, workerId, kind: "ask_user_questions",
ageDays: 1, payload: { version: 1, questions: [{ id: "q", prompt: "Why?", selectionMode: "single", options: [] }] } });
const otherTask = await insertIssue({ companyId, status: "cancelled", createdByUserId: SCOTT });
const other = await insertInteraction({ companyId, issueId: otherTask, workerId });
const result = await expirePendingInteractionsOnClosedIssues(db);
expect(result.failed).toBe(1);
const rows = new Map((await db.select().from(issueThreadInteractions)
.where(eq(issueThreadInteractions.companyId, companyId))).map((row) => [row.id, row.status]));
expect(rows.get(other)).toBe("expired");
expect(rows.get(broken)).toBe("pending");
const logged = (await db.select().from(activityLog).where(and(
eq(activityLog.companyId, companyId),
eq(activityLog.actorId, "attention.closed_issue_sweep"),
))).map((entry) => (entry.details as { interactionId?: string } | null)?.interactionId);
// Every card that did close is in the activity log, including the one
// that closed before its sibling failed.
for (const id of [other, ...(rows.get(confirmation) === "expired" ? [confirmation] : [])]) {
expect(logged).toContain(id);
}
expect(result.expired).toBe(logged.length);
});
});
@@ -0,0 +1,372 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
budgetIncidents,
budgetPolicies,
companies,
companyMemberships,
createDb,
issueThreadInteractions,
issues,
projects,
} from "@paperclipai/db";
import type { AttentionFeedView } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { attentionService } from "../services/attention.js";
import {
attentionHandledDetails,
attentionHandledFor,
attentionReceiptService,
} from "../services/attention-receipts.js";
import { budgetService } from "../services/budgets.js";
import { decisionHistoryService } from "../services/decision-history.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres attention escalation owner tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
// PAP-749 task 1d: project decision owners and the budget owner as escalation
// destinations. Placement only: these tests check where a card shows, never
// who may act.
describeEmbeddedPostgres("attention escalation owners (PAP-749 task 1d)", () => {
const SCOTT = "scott";
const DOTTA = "dotta";
const RILEY = "riley";
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-escalation-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
async function seedCompany(prefix: string) {
const companyId = randomUUID();
const workerId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `${prefix} Co`,
issuePrefix: prefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: workerId, companyId, name: "Worker", role: "engineer", status: "idle",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
});
await db.insert(companyMemberships).values([SCOTT, DOTTA, RILEY].map((principalId) => ({
companyId, principalType: "user", principalId, status: "active", membershipRole: "admin",
})));
return { companyId, workerId };
}
async function insertProject(companyId: string, decisionOwnerUserId: string | null) {
const id = randomUUID();
await db.insert(projects).values({ id, companyId, name: `Project ${id.slice(0, 4)}`, decisionOwnerUserId });
return id;
}
let issueSeq = 0;
/** An agent-created task with no human signal: an ownerless card's home. */
async function insertAgentTask(companyId: string, workerId: string, projectId: string | null) {
const id = randomUUID();
issueSeq += 1;
await db.insert(issues).values({
id,
companyId,
projectId,
identifier: `${issueSeq}`,
title: `Issue ${issueSeq}`,
status: "todo",
priority: "medium",
createdByAgentId: workerId,
originKind: "routine_execution",
originFingerprint: `fp-${issueSeq}`,
});
return id;
}
async function insertConfirmation(companyId: string, issueId: string, workerId: string) {
const id = randomUUID();
await db.insert(issueThreadInteractions).values({
id,
companyId,
issueId,
kind: "request_confirmation",
status: "pending",
title: "Approve the plan",
createdByAgentId: workerId,
payload: { version: 1, prompt: "Approve this plan?" },
});
return id;
}
async function insertBudgetIncident(companyId: string, thresholdType: "hard" | "soft") {
const policyId = randomUUID();
await db.insert(budgetPolicies).values({
id: policyId,
companyId,
scopeType: "company",
scopeId: companyId,
metric: "billed_cents",
windowKind: thresholdType === "hard" ? "calendar_month_utc" : "lifetime",
amount: 100,
});
const [incident] = await db.insert(budgetIncidents).values({
companyId,
policyId,
scopeType: "company",
scopeId: companyId,
metric: "billed_cents",
windowKind: thresholdType === "hard" ? "calendar_month_utc" : "lifetime",
windowStart: new Date("2026-10-01T00:00:00.000Z"),
windowEnd: new Date("2026-11-01T00:00:00.000Z"),
thresholdType,
amountLimit: 100,
amountObserved: thresholdType === "hard" ? 100 : 90,
status: "open",
}).returning({ id: budgetIncidents.id });
return incident!.id;
}
function list(companyId: string, userId: string, view?: AttentionFeedView) {
return attentionService(db).list(companyId, { userId, view });
}
function find<T extends { subject: { id: string } }>(items: T[], sourceId: string) {
return items.find((item) => item.subject.id === sourceId) ?? null;
}
it("routes an ownerless card to its project's decision owner before the company default", async () => {
const { companyId, workerId } = await seedCompany("PRJ");
await db.update(companies).set({ defaultDecisionOwnerUserId: DOTTA }).where(eq(companies.id, companyId));
const ownedProject = await insertProject(companyId, RILEY);
const plainProject = await insertProject(companyId, null);
const ownedCard = await insertConfirmation(companyId, await insertAgentTask(companyId, workerId, ownedProject), workerId);
const plainCard = await insertConfirmation(companyId, await insertAgentTask(companyId, workerId, plainProject), workerId);
const rileyMine = await list(companyId, RILEY, "mine");
expect(find(rileyMine.items, ownedCard)?.audience).toEqual({ kind: "user", userId: RILEY, reason: "project_owner" });
expect(find(rileyMine.items, plainCard)).toBeNull();
const dottaMine = await list(companyId, DOTTA, "mine");
expect(find(dottaMine.items, plainCard)?.audience).toEqual({ kind: "user", userId: DOTTA, reason: "escalation_owner" });
expect(find(dottaMine.items, ownedCard)).toBeNull();
// Nothing ownerless is left in the pool.
const unassigned = await list(companyId, SCOTT, "unassigned");
expect(unassigned.items.filter((item) => [ownedCard, plainCard].some((id) => id === item.subject.id))).toEqual([]);
});
it("skips a project decision owner who is no longer an active member", async () => {
const { companyId, workerId } = await seedCompany("PRS");
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, RILEY));
const project = await insertProject(companyId, RILEY);
const card = await insertConfirmation(companyId, await insertAgentTask(companyId, workerId, project), workerId);
const unassigned = await list(companyId, SCOTT, "unassigned");
expect(find(unassigned.items, card)?.audience).toEqual({ kind: "pool", userId: null, reason: "none" });
const rileyMine = await list(companyId, RILEY, "mine");
expect(find(rileyMine.items, card)).toBeNull();
});
it("gives a budget hard stop to the budget owner while Company still shows it", async () => {
const { companyId } = await seedCompany("BGT");
await db.update(companies).set({ budgetOwnerUserId: SCOTT }).where(eq(companies.id, companyId));
const hardStop = await insertBudgetIncident(companyId, "hard");
const warning = await insertBudgetIncident(companyId, "soft");
const scottMine = await list(companyId, SCOTT, "mine");
const scottCard = find(scottMine.items, hardStop);
expect(scottCard?.audience).toEqual({ kind: "user", userId: SCOTT, reason: "budget_owner", alsoInCompany: true });
expect(scottCard?.decisionVerbs.map((verb) => verb.label)).toEqual(["Raise limit", "Keep paused"]);
expect(find(scottMine.items, warning)).toBeNull();
// The personal card is in the badge; it is one card, not two.
expect(scottMine.viewCounts.mine).toBe(1);
expect(scottMine.deskBadgeCount).toBe(1);
for (const userId of [SCOTT, DOTTA]) {
const company = await list(companyId, userId, "company");
expect(find(company.items, hardStop)).not.toBeNull();
expect(find(company.items, warning)?.audience).toEqual({ kind: "company", userId: null, reason: "none" });
expect(company.viewCounts.company).toBe(2);
}
const dottaMine = await list(companyId, DOTTA, "mine");
expect(find(dottaMine.items, hardStop)).toBeNull();
expect(dottaMine.viewCounts.mine).toBe(0);
});
it("keeps a budget hard stop in Company only when no budget owner is set", async () => {
const { companyId } = await seedCompany("BGN");
const hardStop = await insertBudgetIncident(companyId, "hard");
const company = await list(companyId, SCOTT, "company");
expect(find(company.items, hardStop)?.audience).toEqual({ kind: "company", userId: null, reason: "none" });
const scottMine = await list(companyId, SCOTT, "mine");
expect(find(scottMine.items, hardStop)).toBeNull();
});
// PAP-768: the override approval is part of the hard stop's card.
async function insertOverrideApproval(companyId: string, hardStop: string) {
const [approval] = await db.insert(approvals).values({
companyId, type: "budget_override_required", status: "pending", payload: { scopeName: "Budget Co" },
}).returning({ id: approvals.id });
await db.update(budgetIncidents).set({ approvalId: approval!.id }).where(eq(budgetIncidents.id, hardStop));
return approval!.id;
}
it("folds a hard stop's override approval into the stop's card, out of Unassigned (PAP-768)", async () => {
const { companyId } = await seedCompany("BGF");
await db.update(companies).set({ budgetOwnerUserId: SCOTT }).where(eq(companies.id, companyId));
const hardStop = await insertBudgetIncident(companyId, "hard");
const approvalId = await insertOverrideApproval(companyId, hardStop);
const scottMine = await list(companyId, SCOTT, "mine");
const stopCard = find(scottMine.items, hardStop)!;
const approvalCard = find(scottMine.items, approvalId)!;
expect(stopCard.obligationLeadId).toBe(stopCard.id);
expect(approvalCard).toMatchObject({ obligationKey: `approval:${approvalId}`, obligationLeadId: stopCard.id, audience: stopCard.audience });
// Evidence comes right after its lead, and the obligation counts once.
expect(scottMine.items.indexOf(approvalCard)).toBe(scottMine.items.indexOf(stopCard) + 1);
expect(scottMine.viewCounts.mine).toBe(1);
expect(scottMine.deskBadgeCount).toBe(1);
for (const userId of [SCOTT, DOTTA]) {
const unassigned = await list(companyId, userId, "unassigned");
expect(find(unassigned.items, approvalId)).toBeNull();
expect(unassigned.viewCounts.unassigned).toBe(0);
const company = await list(companyId, userId, "company");
expect(find(company.items, approvalId)?.obligationLeadId).toBe(stopCard.id);
expect(company.viewCounts.company).toBe(1);
}
// Keep paused by Dotta rejects the approval, for Scott.
const handledFor = await attentionHandledFor(db, companyId, { sourceKind: "budget_alert", sourceId: hardStop }, DOTTA);
await budgetService(db).resolveIncident(companyId, hardStop, { action: "keep_paused" }, DOTTA, null, {
activityDetails: attentionHandledDetails(handledFor, "kept_paused"),
});
const [approval] = await db.select({ status: approvals.status, decidedByUserId: approvals.decidedByUserId }).from(approvals).where(eq(approvals.id, approvalId));
expect(approval).toEqual({ status: "rejected", decidedByUserId: DOTTA });
const afterKeep = await list(companyId, SCOTT, "mine");
expect(find(afterKeep.items, hardStop)).toBeNull();
expect(find(afterKeep.items, approvalId)).toBeNull();
const history = async (userId: string) => (await decisionHistoryService(db).list(companyId, {
actor: { type: "board", userId, isInstanceAdmin: true, companyIds: [companyId] }, userId, scope: "mine",
})).items.filter((item) => item.sourceId === approvalId);
// "Kept paused by @Dotta · for @Scott" in both of their histories.
for (const userId of [SCOTT, DOTTA]) {
expect(await history(userId)).toEqual([expect.objectContaining({ sourceKind: "approval", action: { outcome: "kept_paused", detail: null },
resolvedByUserId: DOTTA, handledForUserId: SCOTT })]);
}
expect(await history(RILEY)).toEqual([]);
});
it("raises the limit through the folded card and approves its approval (PAP-768)", async () => {
const { companyId } = await seedCompany("BGL");
// No budget owner: the stop and its approval are one Company card, not a
// default decision owner's card.
await db.update(companies).set({ defaultDecisionOwnerUserId: DOTTA }).where(eq(companies.id, companyId));
const hardStop = await insertBudgetIncident(companyId, "hard");
const approvalId = await insertOverrideApproval(companyId, hardStop);
const company = await list(companyId, SCOTT, "company");
const stopCard = find(company.items, hardStop)!;
expect(stopCard.audience).toEqual({ kind: "company", userId: null, reason: "none" });
expect(find(company.items, approvalId)).toMatchObject({ obligationLeadId: stopCard.id, audience: stopCard.audience });
expect(find((await list(companyId, DOTTA, "mine")).items, approvalId)).toBeNull();
expect(company.viewCounts.company).toBe(1);
await budgetService(db).resolveIncident(companyId, hardStop, { action: "raise_budget_and_resume", amount: 500 }, SCOTT);
const [approval] = await db.select({ status: approvals.status, decidedByUserId: approvals.decidedByUserId }).from(approvals).where(eq(approvals.id, approvalId));
expect(approval).toEqual({ status: "approved", decidedByUserId: SCOTT });
const after = await list(companyId, SCOTT, "company");
expect(find(after.items, hardStop)).toBeNull();
expect(find(after.items, approvalId)).toBeNull();
const scottHistory = await decisionHistoryService(db).list(companyId, {
actor: { type: "board", userId: SCOTT, isInstanceAdmin: true, companyIds: [companyId] }, userId: SCOTT, scope: "mine",
});
expect(scottHistory.items.find((item) => item.sourceId === approvalId)).toMatchObject({ action: { outcome: "limit_raised", detail: null }, resolvedByUserId: SCOTT });
});
it("gives the budget owner a receipt when someone else keeps their hard stop paused", async () => {
const { companyId } = await seedCompany("BGR");
await db.update(companies).set({ budgetOwnerUserId: SCOTT }).where(eq(companies.id, companyId));
const hardStop = await insertBudgetIncident(companyId, "hard");
// A hard stop opens a budget override approval; Keep paused rejects it.
const [approval] = await db.insert(approvals).values({
companyId, type: "budget_override_required", status: "pending", payload: { scopeName: "BGR Co" },
}).returning({ id: approvals.id });
await db.update(budgetIncidents).set({ approvalId: approval!.id }).where(eq(budgetIncidents.id, hardStop));
const warning = await insertBudgetIncident(companyId, "soft");
// The owner acting on their own card, or anyone on a warning, is no receipt.
expect(await attentionHandledFor(db, companyId, { sourceKind: "budget_alert", sourceId: hardStop }, SCOTT)).toBeNull();
expect(await attentionHandledFor(db, companyId, { sourceKind: "budget_alert", sourceId: warning }, DOTTA)).toBeNull();
const handledFor = await attentionHandledFor(db, companyId, { sourceKind: "budget_alert", sourceId: hardStop }, DOTTA);
expect(handledFor).toMatchObject({ userId: SCOTT, actorUserId: DOTTA, title: "BGR Co budget hard stop" });
// Same details as the resolve route (routes/costs.ts).
await budgetService(db).resolveIncident(companyId, hardStop, { action: "keep_paused" }, DOTTA, null, {
activityDetails: attentionHandledDetails(handledFor, "kept_paused"),
});
const [entry] = await db
.select({ details: activityLog.details })
.from(activityLog)
.where(eq(activityLog.action, "budget.incident_resolved"));
expect(entry?.details).toMatchObject({ action: "keep_paused", handledForUserId: SCOTT });
const receipts = await attentionReceiptService(db).list(companyId, {
userId: SCOTT,
actor: { type: "board", userId: SCOTT, isInstanceAdmin: true, companyIds: [companyId] },
});
expect(receipts).toHaveLength(1);
expect(receipts[0]).toMatchObject({
outcome: "kept_paused",
sourceKind: "budget_alert",
sourceId: hardStop,
actorUserId: DOTTA,
reason: null,
href: "/BGR/costs",
});
// PAP-767: Scott's "Handled for me" history has the same action, kept
// paused by Dotta for him, on the hard stop's approval.
const scottActor = { type: "board" as const, userId: SCOTT, isInstanceAdmin: true, companyIds: [companyId] };
const handled = await decisionHistoryService(db).list(companyId, { actor: scottActor, userId: SCOTT, handledForMe: true });
expect(handled.items).toEqual([expect.objectContaining({
sourceKind: "approval",
sourceId: approval!.id,
label: "Kept paused",
action: { outcome: "kept_paused", detail: null },
resolvedByUserId: DOTTA,
handledForUserId: SCOTT,
})]);
// The card is gone for everyone once the incident closes.
const company = await list(companyId, DOTTA, "company");
expect(find(company.items, hardStop)).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { stripMarkdown } from "../services/attention.js";
// Card excerpts drop markdown syntax, not the characters people type inside
// words (PAP-767: "Scott archived this card ( 2)." on the classic page).
describe("attention card excerpts", () => {
it("keeps #, - and _ inside words and numbers", () => {
expect(stripMarkdown("Scott archived this card (#2).")).toBe("Scott archived this card (#2).");
expect(stripMarkdown("Obligation: approval + in-review task")).toBe("Obligation: approval + in-review task");
expect(stripMarkdown("newcomer@needs-you-demo.test")).toBe("newcomer@needs-you-demo.test");
expect(stripMarkdown("use snake_case_name and 3 * 4 = 12")).toBe("use snake_case_name and 3 * 4 = 12");
});
it("still removes headings, quotes, bullets, rules, emphasis and code", () => {
expect(stripMarkdown("## Plan\n- **Ship** the _new_ `thing`\n> quoted ~~old~~\n---\n* item two"))
.toBe("Plan Ship the new thing quoted old item two");
expect(stripMarkdown("See [the doc](https://example.test) ![shot](a.png)")).toBe("See the doc");
});
});
@@ -0,0 +1,385 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
approvals,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueApprovals,
issues,
} from "@paperclipai/db";
import type { AttentionAudience, AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
assignAttentionObligations,
attentionService,
type AttentionObligationHint,
} from "../services/attention.js";
import { inboxDismissalService } from "../services/inbox-dismissals.js";
import { ROUTABLE_BLOCKED_ROLLOUT_AT } from "../services/routable-blocked.js";
const SCOTT = "scott";
const DOTTA = "dotta";
const COMPANY: AttentionAudience = { kind: "company", userId: null, reason: "none" };
const POOL: AttentionAudience = { kind: "pool", userId: null, reason: "none" };
const forUser = (userId: string, reason: AttentionAudience["reason"] = "instigator"): AttentionAudience =>
({ kind: "user", userId, reason });
// PAP-749 task 2d: the pure obligation rules (plan §2, Rule 11).
describe("one card for each obligation (PAP-749 task 2d)", () => {
function card(
sourceKind: AttentionSourceKind,
dedupKey: string,
input: { audience?: AttentionAudience; required?: boolean; activityAt?: string } = {},
): AttentionItem {
return {
id: `${sourceKind}:${dedupKey}`,
sourceKind,
dedupKey,
audience: input.audience ?? forUser(SCOTT),
required: input.required ?? false,
activityAt: input.activityAt ?? "2026-10-30T10:00:00.000Z",
severity: "medium",
obligationKey: null,
obligationLeadId: null,
} as AttentionItem;
}
const hints = (entries: Array<[AttentionItem, AttentionObligationHint]>) =>
new Map(entries.map(([item, hint]) => [item.dedupKey, hint]));
it("joins a pending approval with the in-review state of its task, led by the approval", () => {
const approval = card("approval", "approval:a1", { audience: forUser(DOTTA, "requester"), required: true });
const review = card("review", "review:i1", { audience: forUser(SCOTT, "assignee"), activityAt: "2026-10-30T11:00:00.000Z" });
assignAttentionObligations([review, approval], hints([
[approval, { key: "approval:a1", anchor: true }],
[review, { key: "approval:a1", anchor: false }],
]));
for (const member of [approval, review]) {
expect(member.obligationKey).toBe("approval:a1");
expect(member.obligationLeadId).toBe(approval.id);
// One placement: the approval's person, so the obligation shows once.
expect(member.audience).toEqual(forUser(DOTTA, "requester"));
}
});
it("joins a blocked task with the failed run that caused it, led by the run, placed with the task's person", () => {
const blocked = card("blocker_attention", "blocked-owner:t1:2026-10-30", { audience: forUser(SCOTT) });
const terminal = card("blocker_attention", "blocker:t1", { audience: forUser(SCOTT) });
const run = card("failed_run", "run:r1", { audience: COMPANY, activityAt: "2026-10-30T09:00:00.000Z" });
assignAttentionObligations([blocked, terminal, run], hints([
[blocked, { key: "blocked:t1", anchor: true }],
[terminal, { key: "blocked:t1", anchor: true }],
[run, { key: "blocked:t1", anchor: false }],
]));
for (const member of [blocked, terminal, run]) {
expect(member.obligationKey).toBe("blocked:t1");
// The run carries the one action (Retry); the blocked task is the effect.
expect(member.obligationLeadId).toBe(run.id);
expect(member.audience).toEqual(forUser(SCOTT));
}
});
it("keeps a blocked obligation in Company when no person owns the blocked work", () => {
const blocked = card("blocker_attention", "blocker:t2", { audience: POOL });
const run = card("failed_run", "run:r2", { audience: COMPANY });
assignAttentionObligations([blocked, run], hints([
[blocked, { key: "blocked:t2", anchor: true }],
[run, { key: "blocked:t2", anchor: false }],
]));
expect(run.obligationLeadId).toBe(run.id);
expect(blocked.audience).toEqual(COMPANY);
});
it("leaves a required lead where escalation put it", () => {
const approval = card("approval", "approval:a3", { audience: COMPANY, required: true });
const review = card("review", "review:i3", { audience: forUser(SCOTT, "assignee") });
assignAttentionObligations([approval, review], hints([
[approval, { key: "approval:a3", anchor: true }],
[review, { key: "approval:a3", anchor: false }],
]));
// "Waiting 30+ days" stays in Company; a review does not pull it back.
expect(review.audience).toEqual(COMPANY);
});
it("leads a budget hard stop over its override approval and keeps the stop's placement (PAP-768)", () => {
const owned = { kind: "user", userId: SCOTT, reason: "budget_owner", alsoInCompany: true } as AttentionAudience;
const stop = card("budget_alert", "budget:p1:2026-11-01:hard", { audience: owned });
const approval = card("approval", "approval:b1", { audience: POOL, required: true });
assignAttentionObligations([approval, stop], hints([
[approval, { key: "approval:b1", anchor: true }],
[stop, { key: "approval:b1", anchor: true }],
]));
for (const member of [stop, approval]) {
expect(member.obligationKey).toBe("approval:b1");
// Raise limit and Keep paused answer the approval, so the stop leads.
expect(member.obligationLeadId).toBe(stop.id);
// The approval leaves Unassigned and goes with the stop to its owner.
expect(member.audience).toEqual(owned);
}
// Without a budget owner the stop stays in Company, even when the
// approval alone would have gone to the default decision owner.
const companyStop = card("budget_alert", "budget:p2:2026-11-01:hard", { audience: COMPANY });
const ownersApproval = card("approval", "approval:b2", { audience: forUser(DOTTA, "escalation_owner"), required: true });
assignAttentionObligations([companyStop, ownersApproval], hints([
[companyStop, { key: "approval:b2", anchor: true }],
[ownersApproval, { key: "approval:b2", anchor: true }],
]));
expect(ownersApproval.obligationLeadId).toBe(companyStop.id);
expect(ownersApproval.audience).toEqual(COMPANY);
expect(companyStop.audience).toEqual(COMPANY);
});
it("forms no obligation without an anchor card, or for a card on its own", () => {
const runA = card("failed_run", "run:ra", { audience: COMPANY });
const runB = card("failed_run", "run:rb", { audience: COMPANY });
const lonelyReview = card("review", "review:i4");
const lonelyApproval = card("approval", "approval:a5", { required: true });
assignAttentionObligations([runA, runB, lonelyReview, lonelyApproval], hints([
[runA, { key: "blocked:t4", anchor: false }],
[runB, { key: "blocked:t4", anchor: false }],
[lonelyReview, { key: "approval:a4", anchor: false }],
[lonelyApproval, { key: "approval:a5", anchor: true }],
]));
for (const item of [runA, runB, lonelyReview, lonelyApproval]) {
expect(item.obligationKey).toBeNull();
expect(item.obligationLeadId).toBeNull();
}
expect(runA.audience).toEqual(COMPANY);
});
});
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres attention obligation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("attention obligations in the feed (PAP-749 task 2d)", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-obligations-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
let issueSeq = 0;
async function insertIssue(companyId: string, input: Partial<typeof issues.$inferInsert> = {}) {
const id = randomUUID();
issueSeq += 1;
await db.insert(issues).values({
id,
companyId,
identifier: `OBL-${issueSeq}`,
title: `Obligation issue ${issueSeq}`,
status: "in_progress",
priority: "medium",
originKind: "manual",
originFingerprint: `obl-${issueSeq}`,
createdByUserId: SCOTT,
...input,
});
return id;
}
async function insertApproval(companyId: string, issueId: string, requestedByUserId = SCOTT) {
const id = randomUUID();
await db.insert(approvals).values({
id, companyId, type: "request_board_approval", status: "pending",
requestedByUserId, payload: { title: "Approve the in-review work" },
});
await db.insert(issueApprovals).values({ companyId, issueId, approvalId: id });
return id;
}
async function insertExhaustedRun(companyId: string, agentId: string, issueId: string) {
const id = randomUUID();
const at = new Date(Date.now() - 60_000);
await db.insert(heartbeatRuns).values({
id, companyId, agentId, invocationSource: "automation", status: "failed",
error: "adapter failed", errorCode: "adapter_failed", contextSnapshot: { issueId },
createdAt: at, updatedAt: at, finishedAt: at,
});
await db.insert(heartbeatRunEvents).values({
companyId, runId: id, agentId, seq: 1, eventType: "lifecycle",
message: "Bounded retry exhausted after 3 scheduled attempts; no further automatic retry will be queued",
});
return id;
}
async function seed() {
const companyId = randomUUID();
const workerId = randomUUID();
await db.insert(companies).values({
id: companyId, name: "Obligation Co", issuePrefix: "OBL", requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: workerId, companyId, name: "Flaky Runner", role: "engineer", status: "idle",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
});
// An approval and the in-review state of its task: one obligation.
const reviewTask = await insertIssue(companyId, { status: "in_review", assigneeAgentId: workerId });
const approvalId = await insertApproval(companyId, reviewTask);
// A configured human review stage is an independent quality gate.
const gatedTask = await insertIssue(companyId, {
status: "in_review",
assigneeAgentId: workerId,
executionState: {
status: "pending", currentStageId: null, currentStageIndex: null, currentStageType: "review",
currentParticipant: { type: "user", userId: DOTTA }, returnAssignee: null, reviewRequest: null,
completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, monitor: null,
},
});
const gatedApprovalId = await insertApproval(companyId, gatedTask);
// A task blocked by its exhausted run, and a failed run that blocks nothing.
const blockedTask = await insertIssue(companyId, {
status: "blocked",
assigneeAgentId: workerId,
unblockDescriptor: { owner: "board", action: "Automatic retries exhausted. Retry or reassign the task." },
blockedTransitionAt: new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 60_000),
});
const blockingRunId = await insertExhaustedRun(companyId, workerId, blockedTask);
const otherTask = await insertIssue(companyId, { assigneeAgentId: workerId });
const loneRunId = await insertExhaustedRun(companyId, workerId, otherTask);
return { companyId, reviewTask, approvalId, gatedTask, gatedApprovalId, blockedTask, blockingRunId, loneRunId };
}
const list = (companyId: string, view?: "mine" | "company" | "archived" | "all") =>
attentionService(db).list(companyId, { userId: SCOTT, view });
const bySource = (items: AttentionItem[], sourceKind: AttentionSourceKind, subjectId: string) =>
items.find((item) => item.sourceKind === sourceKind && item.subject.id === subjectId) ?? null;
it("keeps every card, joins each obligation under one lead, and counts it once", async () => {
const f = await seed();
const feed = await list(f.companyId, "all");
const approval = bySource(feed.items, "approval", f.approvalId)!;
const review = bySource(feed.items, "review", f.reviewTask)!;
expect(approval.obligationKey).toBe(`approval:${f.approvalId}`);
expect(approval.obligationLeadId).toBe(approval.id);
expect(review.obligationKey).toBe(`approval:${f.approvalId}`);
expect(review.obligationLeadId).toBe(approval.id);
expect(review.audience).toEqual(approval.audience);
// Independent quality gate: the human review stage and its approval stay separate.
const gatedReview = bySource(feed.items, "review", f.gatedTask)!;
expect(gatedReview.obligationKey).toBeNull();
expect(gatedReview.audience).toEqual(forUser(DOTTA, "reviewer"));
expect(bySource(feed.items, "approval", f.gatedApprovalId)!.obligationKey).toBeNull();
const run = bySource(feed.items, "failed_run", f.blockingRunId)!;
const blockerCards = feed.items.filter((item) => item.sourceKind === "blocker_attention" && item.subject.id === f.blockedTask);
expect(blockerCards.length).toBeGreaterThan(0);
expect(run.obligationKey).toBe(`blocked:${f.blockedTask}`);
expect(run.obligationLeadId).toBe(run.id);
for (const blocker of blockerCards) {
expect(blocker.obligationKey).toBe(`blocked:${f.blockedTask}`);
expect(blocker.obligationLeadId).toBe(run.id);
}
// The failed run goes with the work it blocks, to the person who started it.
expect(run.audience).toEqual(forUser(SCOTT));
// A failed run that blocks nothing stays a Company alert of its own.
const loneRun = bySource(feed.items, "failed_run", f.loneRunId)!;
expect(loneRun.obligationKey).toBeNull();
expect(loneRun.audience).toEqual(COMPANY);
// Evidence sits right after its lead.
const ids = feed.items.map((item) => item.id);
expect(ids.indexOf(review.id)).toBe(ids.indexOf(approval.id) + 1);
const runIndex = ids.indexOf(run.id);
expect(ids.slice(runIndex + 1, runIndex + 1 + blockerCards.length).sort())
.toEqual(blockerCards.map((item) => item.id).sort());
// Scott's For you: the joined approval, the gated approval and the blocked
// work, each counted once; the gated review is Dotta's.
const mine = await list(f.companyId, "mine");
expect(bySource(mine.items, "failed_run", f.blockingRunId)).not.toBeNull();
expect(bySource(mine.items, "review", f.reviewTask)).not.toBeNull();
expect(mine.viewCounts.mine).toBe(3);
expect(mine.deskBadgeCount).toBe(3);
const company = await list(f.companyId, "company");
expect(company.items.map((item) => item.subject.id)).toEqual([f.loneRunId]);
expect(company.viewCounts.company).toBe(1);
}, 120_000);
it("normalizes a personal hide of evidence to its obligation lead", async () => {
const f = await seed();
const before = await list(f.companyId, "mine");
const review = bySource(before.items, "review", f.reviewTask)!;
const approval = bySource(before.items, "approval", f.approvalId)!;
const hidden = await attentionService(db).hideForUser(f.companyId, { sourceKind: "review", sourceId: f.reviewTask, userId: SCOTT,
actor: { type: "board", userId: SCOTT }, mode: "archive", fingerprint: review.changeFingerprint });
expect(hidden.itemKey).toBe(approval.dismissalKey);
const mine = await list(f.companyId, "mine");
expect(bySource(mine.items, "approval", f.approvalId)).toBeNull();
expect(bySource(mine.items, "review", f.reviewTask)).toBeNull();
const archived = await list(f.companyId, "archived");
expect(archived.items.map(item => item.sourceKind).sort()).toEqual(["approval", "review"]);
}, 120_000);
it("hides and restores the evidence with its lead", async () => {
const f = await seed();
const before = await list(f.companyId, "mine");
const approval = bySource(before.items, "approval", f.approvalId)!;
const dismissals = inboxDismissalService(db);
await dismissals.dismiss(f.companyId, SCOTT, approval.dismissalKey, new Date(), approval.changeFingerprint);
const mine = await list(f.companyId, "mine");
expect(bySource(mine.items, "approval", f.approvalId)).toBeNull();
expect(bySource(mine.items, "review", f.reviewTask)).toBeNull();
expect(mine.viewCounts).toMatchObject({ mine: 2, archived: 1 });
const archived = await list(f.companyId, "archived");
expect(archived.items.map((item) => item.sourceKind).sort()).toEqual(["approval", "review"]);
await dismissals.restore(f.companyId, SCOTT, approval.dismissalKey);
const restored = await list(f.companyId, "mine");
expect(bySource(restored.items, "review", f.reviewTask)).not.toBeNull();
expect(restored.viewCounts).toMatchObject({ mine: 3, archived: 0 });
}, 120_000);
it("splits the obligation again once the approval is answered", async () => {
const f = await seed();
// Dotta's task in review, waiting on an approval Scott requested.
const dottaTask = await insertIssue(f.companyId, { status: "in_review", assigneeUserId: DOTTA });
const approvalId = await insertApproval(f.companyId, dottaTask);
let feed = await list(f.companyId, "all");
const review = bySource(feed.items, "review", dottaTask)!;
expect(review.obligationLeadId).toBe(`approval:approval:${approvalId}`);
expect(review.audience).toEqual(forUser(SCOTT, "requester"));
await db.update(approvals).set({ status: "approved" }).where(sql`${approvals.id} = ${approvalId}`);
feed = await list(f.companyId, "all");
expect(bySource(feed.items, "approval", approvalId)).toBeNull();
// The assignee's own review card is back on its own, with its own person.
const alone = bySource(feed.items, "review", dottaTask)!;
expect(alone.obligationKey).toBeNull();
expect(alone.obligationLeadId).toBeNull();
expect(alone.audience).toEqual(forUser(DOTTA, "assignee"));
}, 120_000);
});
@@ -0,0 +1,315 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
authUsers,
companies,
companyMemberships,
createDb,
inboxDismissals,
issueApprovals,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import { attentionReceiptText } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { attentionRoutes } from "../routes/attention.js";
import { approvalRoutes } from "../routes/approvals.js";
import {
attentionHandledDetails,
attentionReceiptService,
readAttentionHandledDetails,
type AttentionHandledFor,
} from "../services/attention-receipts.js";
import { decisionHistoryService } from "../services/decision-history.js";
import { issueService } from "../services/issues.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres attention receipt tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("attention receipt details (PAP-749 task 2f)", () => {
const handledFor: AttentionHandledFor = {
userId: "dotta",
actorUserId: "scott",
sourceKind: "issue_thread_interaction",
sourceId: "11111111-1111-4111-8111-111111111111",
issueId: "22222222-2222-4222-8222-222222222222",
title: "Approve this plan?",
};
it("writes handledForUserId and the receipt only when the card was for a different person", () => {
expect(attentionHandledDetails(null, "answered")).toEqual({});
expect(attentionHandledDetails(handledFor, "withdrawn", { reason: " Out of date " })).toEqual({
handledForUserId: "dotta",
receipt: {
outcome: "withdrawn",
sourceKind: "issue_thread_interaction",
sourceId: handledFor.sourceId,
issueId: handledFor.issueId,
title: "Approve this plan?",
actorUserId: "scott",
reason: "Out of date",
},
});
const transferred = attentionHandledDetails(handledFor, "transferred", { transferredToUserId: "riley", reason: " " });
expect(transferred).toMatchObject({ receipt: { outcome: "transferred", transferredToUserId: "riley" } });
expect("reason" in (transferred as { receipt: object }).receipt).toBe(false);
});
it("reads back stored receipts and ignores malformed ones", () => {
const details = attentionHandledDetails(handledFor, "answered");
expect(readAttentionHandledDetails(details)).toMatchObject({ outcome: "answered", actorUserId: "scott", reason: null });
expect(readAttentionHandledDetails(null)).toBeNull();
expect(readAttentionHandledDetails({ receipt: { ...((details as { receipt: object }).receipt), outcome: "archived" } })).toBeNull();
expect(readAttentionHandledDetails({ receipt: { ...((details as { receipt: object }).receipt), actorUserId: "" } })).toBeNull();
});
it("names the actor and, for a transfer, the new person", () => {
expect(attentionReceiptText({ outcome: "answered", actorLabel: "scott" })).toBe("Answered by @scott");
expect(attentionReceiptText({ outcome: "withdrawn", actorLabel: "scott" })).toBe("Withdrawn by @scott");
expect(attentionReceiptText({ outcome: "transferred", actorLabel: "scott", transferredToLabel: "riley" }))
.toBe("Transferred to @riley by @scott");
// The verbs of the card's buttons and of Decision history (PAP-767).
expect(attentionReceiptText({ outcome: "approved", actorLabel: "scott" })).toBe("Approved by @scott");
expect(attentionReceiptText({ outcome: "changes_requested", actorLabel: "scott" })).toBe("Changes requested by @scott");
expect(attentionReceiptText({ outcome: "decided", actorLabel: "scott" })).toBe("Decided by @scott");
expect(attentionReceiptText({ outcome: "kept_paused", actorLabel: "dotta" })).toBe("Kept paused by @dotta");
expect(attentionReceiptText({ outcome: "limit_raised", actorLabel: "dotta" })).toBe("Limit raised by @dotta");
});
});
// PAP-749 task 2f (plan §6, Rule 12): the person a card was for always sees
// what a different person did with it.
describeEmbeddedPostgres("receipts for the other person (PAP-749 task 2f)", () => {
const SCOTT = "scott";
const DOTTA = "dotta";
const RILEY = "riley";
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-receipts-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
await db.delete(authUsers);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
let seq = 0;
async function seedCompany(prefix: string) {
const companyId = randomUUID();
const workerId = randomUUID();
await db.insert(companies).values({ id: companyId, name: `${prefix} Co`, issuePrefix: prefix, requireBoardApprovalForNewAgents: false });
await db.insert(agents).values({
id: workerId, companyId, name: "Worker", role: "engineer", status: "idle",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
});
for (const userId of [SCOTT, DOTTA, RILEY]) {
await db.insert(authUsers).values({ id: userId, name: userId, email: `${userId}-${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "admin" });
}
return { companyId, workerId };
}
async function insertIssue(companyId: string, createdByUserId: string) {
const id = randomUUID();
seq += 1;
await db.insert(issues).values({
id, companyId, identifier: `${seq}`, title: `Issue ${seq}`, status: "todo", priority: "medium",
createdByUserId, originKind: "manual", originFingerprint: `fp-${seq}`,
});
return id;
}
it.each(["linked", "payload"])("checks every approval task before returning history or receipts (%s)", async (reference) => {
const { companyId, workerId } = await seedCompany("SEC");
const publicId = await insertIssue(companyId, SCOTT);
const privateId = await insertIssue(companyId, SCOTT);
await db.update(issues).set({ visibility: "private" }).where(eq(issues.id, privateId));
const sourceId = randomUUID();
await db.insert(approvals).values({ id: sourceId, companyId, type: "request_board_approval", status: "cancelled", requestedByAgentId: workerId,
requestedByUserId: DOTTA, decidedByUserId: SCOTT, decidedAt: new Date(), payload: { title: "Private result", ...(reference === "payload" ? { sourceIssueId: privateId } : {}) } });
await db.insert(issueApprovals).values([{ companyId, issueId: publicId, approvalId: sourceId }, ...(reference === "linked" ? [{ companyId, issueId: privateId, approvalId: sourceId }] : [])]);
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: SCOTT, action: "approval.cancelled", entityType: "approval", entityId: sourceId,
details: attentionHandledDetails({ userId: DOTTA, actorUserId: SCOTT, sourceKind: "approval", sourceId, issueId: publicId, title: "Private result" }, "withdrawn", { reason: "Private reason" }) });
const previous = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
try {
const actor = board(companyId, DOTTA) as never;
const receipts = await attentionReceiptService(db).list(companyId, { userId: DOTTA, actor });
expect(receipts).toEqual([]);
const history = await decisionHistoryService(db).list(companyId, { userId: DOTTA, actor, scope: "all" });
expect(history.items).toEqual([]);
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previous;
}
});
async function insertInteraction(companyId: string, issueId: string, workerId: string, addresseeUserId: string | null = null) {
const id = randomUUID();
await db.insert(issueThreadInteractions).values({
id, companyId, issueId, kind: "request_confirmation", status: "pending", title: "Approve the launch plan?",
createdByAgentId: workerId, addresseeUserId, payload: { version: 1, prompt: "Approve the launch plan?" } as never,
});
return id;
}
const board = (companyId: string, userId: string) =>
({ type: "board", source: "session", userId, companyIds: [companyId], isInstanceAdmin: false });
function app(companyId: string, userId: string) {
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
(req as any).actor = board(companyId, userId);
next();
});
testApp.use("/api", attentionRoutes(db));
testApp.use("/api", approvalRoutes(db));
testApp.use(errorHandler);
return testApp;
}
const forYou = async (companyId: string, userId: string) => {
const response = await request(app(companyId, userId)).get(`/api/companies/${companyId}/attention?all=true&view=mine`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
return response.body as { receipts?: Array<Record<string, unknown>>; deskBadgeCount: number; items: Array<Record<string, any>> };
};
it("tells Dotta when Scott withdraws her card: one receipt with the reason, the task unread in Mine, Handled for me", async () => {
const { companyId, workerId } = await seedCompany("RCW");
const issueId = await insertIssue(companyId, DOTTA);
const interactionId = await insertInteraction(companyId, issueId, workerId);
const otherIssueId = await insertIssue(companyId, DOTTA);
const otherInteractionId = await insertInteraction(companyId, otherIssueId, workerId);
// Dotta read both tasks, cards included, before anything happened.
const readAt = new Date();
await new Promise((resolve) => setTimeout(resolve, 20));
await db.execute(sql`insert into issue_read_states (company_id, issue_id, user_id, last_read_at)
values (${companyId}, ${issueId}, ${DOTTA}, ${readAt.toISOString()}::timestamptz),
(${companyId}, ${otherIssueId}, ${DOTTA}, ${readAt.toISOString()}::timestamptz)`);
expect((await forYou(companyId, DOTTA)).deskBadgeCount).toBe(2);
expect(await issueService(db).list(companyId, { unreadForUserId: DOTTA })).toEqual([]);
await request(app(companyId, SCOTT))
.post(`/api/companies/${companyId}/attention/issue_thread_interaction/${interactionId}/close`)
.send({ mode: "withdraw", reason: "Plan changed" })
.expect(200);
// Both entries name Dotta; she sees one receipt.
const handled = await db.select().from(activityLog)
.where(and(eq(activityLog.companyId, companyId), sql`${activityLog.details} ->> 'handledForUserId' = ${DOTTA}`));
expect(handled.map((row) => row.action).sort()).toEqual(["attention.closed", "issue.thread_interaction_withdrawn"]);
const dotta = await forYou(companyId, DOTTA);
expect(dotta.items.map((item) => item.subject.id)).toEqual([otherInteractionId]);
// A receipt is not a card and is not in the badge.
expect(dotta.deskBadgeCount).toBe(1);
expect(dotta.receipts).toHaveLength(1);
expect(dotta.receipts![0]).toMatchObject({
outcome: "withdrawn", sourceKind: "issue_thread_interaction", sourceId: interactionId,
actorUserId: SCOTT, reason: "Plan changed", title: "Approve the launch plan?",
issue: { id: issueId, href: expect.stringContaining("/RCW/issues/") },
href: expect.stringContaining(`#interaction-${interactionId}`),
});
// The actor gets no receipt.
expect((await forYou(companyId, SCOTT)).receipts).toEqual([]);
// Mine: the withdrawn task is unread for Dotta; the untouched one is not.
const unread = await issueService(db).list(companyId, { unreadForUserId: DOTTA });
expect(unread.map((issue) => issue.id)).toEqual([issueId]);
// Decision history: "Handled for me" is Dotta's, not Scott's.
const mine = await decisionHistoryService(db).list(companyId, { actor: board(companyId, DOTTA) as never, userId: DOTTA, handledForMe: true });
expect(mine.items.map((item) => item.sourceId)).toEqual([interactionId]);
expect(mine.items[0]).toMatchObject({ handledForUserId: DOTTA, resolvedByUserId: SCOTT });
const scotts = await decisionHistoryService(db).list(companyId, { actor: board(companyId, SCOTT) as never, userId: SCOTT, handledForMe: true });
expect(scotts.items).toEqual([]);
// Dismissed or older than 7 days: gone.
await db.insert(inboxDismissals).values({
companyId, userId: DOTTA, itemKey: dotta.receipts![0]!.dismissalKey as string, kind: "dismiss", dismissedAt: new Date(),
});
expect((await forYou(companyId, DOTTA)).receipts).toEqual([]);
await db.delete(inboxDismissals);
await db.update(activityLog).set({ createdAt: new Date(Date.now() - 8 * 86_400_000) })
.where(sql`${activityLog.details} ->> 'handledForUserId' = ${DOTTA}`);
expect((await forYou(companyId, DOTTA)).receipts).toEqual([]);
});
it("makes no receipt when the person closes their own card", async () => {
const { companyId, workerId } = await seedCompany("RCS");
const issueId = await insertIssue(companyId, DOTTA);
const interactionId = await insertInteraction(companyId, issueId, workerId);
await request(app(companyId, DOTTA))
.post(`/api/companies/${companyId}/attention/issue_thread_interaction/${interactionId}/close`)
.send({ mode: "withdraw" })
.expect(200);
expect((await forYou(companyId, DOTTA)).receipts).toEqual([]);
const handled = await db.select().from(activityLog).where(sql`(${activityLog.details} ->> 'handledForUserId') is not null`);
expect(handled).toEqual([]);
});
it("moves a transferred card to Riley with \"Transferred by @scott\" and tells Dotta", async () => {
const { companyId, workerId } = await seedCompany("RCT");
const issueId = await insertIssue(companyId, SCOTT);
const interactionId = await insertInteraction(companyId, issueId, workerId, DOTTA);
await request(app(companyId, SCOTT))
.post(`/api/companies/${companyId}/attention/issue_thread_interaction/${interactionId}/transfer`)
.send({ userId: RILEY })
.expect(200);
const dotta = await forYou(companyId, DOTTA);
expect(dotta.items).toEqual([]);
expect(dotta.receipts).toHaveLength(1);
expect(dotta.receipts![0]).toMatchObject({ outcome: "transferred", actorUserId: SCOTT, transferredToUserId: RILEY });
const riley = await forYou(companyId, RILEY);
expect(riley.receipts).toEqual([]);
expect(riley.items).toHaveLength(1);
expect(riley.items[0]!.audience).toEqual({ kind: "user", userId: RILEY, reason: "addressee", transferredByUserId: SCOTT });
});
it("tells Dotta when Scott approves the approval she requested", async () => {
const { companyId } = await seedCompany("RCA");
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending", requestedByUserId: DOTTA, payload: { title: "Ship v2" },
});
await request(app(companyId, SCOTT)).post(`/api/approvals/${approvalId}/approve`).send({}).expect(200);
const dotta = await forYou(companyId, DOTTA);
expect(dotta.receipts).toHaveLength(1);
expect(dotta.receipts![0]).toMatchObject({
outcome: "approved", sourceKind: "approval", sourceId: approvalId, actorUserId: SCOTT, title: "Ship v2", issue: null,
href: `/RCA/approvals/${approvalId}`,
});
});
});
+28 -11
View File
@@ -771,6 +771,15 @@ describeEmbeddedPostgres("attention service", () => {
agentName: "Broken Agent",
failureReasonExcerpt: "adapter config missing",
});
// PAP-749 Rule 7: cards offer only actions they can complete; hiding a
// card is the Close… menu, and withdraw only where a request exists.
const verbIds = (kind: string) => feed.items.find((item) => item.sourceKind === kind)?.decisionVerbs.map((verb) => verb.id);
expect(verbIds("failed_run")).toEqual(["retry", "inspect"]);
expect(verbIds("recovery_action")).toEqual(["open_recovery"]);
expect(verbIds("agent_error_alert")).toEqual(["inspect"]);
expect(verbIds("budget_alert")).toEqual(["raise_budget_and_resume", "keep_paused"]);
expect(feed.items.find((item) => item.sourceKind === "budget_alert")?.closeModes).toEqual(["archive", "snooze"]);
expect(feed.items.find((item) => item.sourceKind === "issue_thread_interaction")?.closeModes).toEqual(["withdraw", "archive", "snooze"]);
});
it("returns addressed interactions to board attention after addressee pause or termination", async () => {
@@ -1075,6 +1084,11 @@ describeEmbeddedPostgres("attention service", () => {
// Check a forced generic plan too: prepared statements reuse it, and only
// literal predicates (not bound parameters) let it use a partial index.
const query = listAttentionExhaustedRuns(db, companyId).toSQL();
// Shared disposable databases retain statistics from earlier fixtures after
// TRUNCATE. Plan this fixture's actual rows, including the generic plan.
await db.$client.unsafe("ANALYZE heartbeat_run_events");
await db.$client.unsafe("ANALYZE heartbeat_runs");
await db.$client.unsafe("ANALYZE agents");
const literal = (value: unknown) => `'${String(value).replaceAll("'", "''")}'`;
const plans = await db.$client.begin(async (tx) => {
await tx.unsafe("SET LOCAL enable_seqscan = off");
@@ -2090,6 +2104,7 @@ describeEmbeddedPostgres("attention service", () => {
type: "request_board_approval",
status: "pending",
payload: { title: "Approve rollout" },
requestedByUserId: "dotta-user",
});
await db.insert(issueApprovals).values([
{ companyId, issueId: secondIssueId, approvalId },
@@ -2101,6 +2116,10 @@ describeEmbeddedPostgres("attention service", () => {
expect(approvalItems).toHaveLength(1);
expect(approvalItems[0]?.subject.metadata?.issueId).toBe(firstIssueId);
// A person who asked is the requester; their name comes from the directory (PAP-775, UI2).
expect(approvalItems[0]?.requester).toEqual({ kind: "user", id: "dotta-user", name: null });
// A board approval is approved on the card like any other approval (PAP-775).
expect(approvalItems[0]?.inlineResolvable).toBe(true);
});
it("hides snoozed attention rows until snoozedUntil passes, then returns them unconditionally", async () => {
@@ -2245,19 +2264,13 @@ describeEmbeddedPostgres("attention service", () => {
sort: "decide",
limit: 20,
});
// Desk badge = distinct items surfaced today OR with a due decide-by
// Everything here was seeded ~now, so every visible row
// counts; the whole page fits under limit:20 so items == rankedItems.
const startOfUtcDay = Date.UTC(
new Date(now).getUTCFullYear(),
new Date(now).getUTCMonth(),
new Date(now).getUTCDate(),
);
// The badge is this person's "For you" count (PAP-749 Rule 5), with no day
// boundary; the whole page fits under limit:20 so items == rankedItems.
const expectedBadge = feed.items.filter(
(item) => new Date(item.createdAt).getTime() >= startOfUtcDay || item.decideBy === "today",
(item) => item.audience.kind === "user" && item.audience.userId === "board-user",
).length;
expect(expectedBadge).toBeGreaterThanOrEqual(2);
expect(feed.deskBadgeCount).toBe(expectedBadge);
expect(feed.viewCounts.mine).toBe(expectedBadge);
expect(feed.items.some((item) => item.subject.id === snoozedId)).toBe(false);
expect(feed.items.slice(0, 3).map((item) => item.subject.id)).toEqual([
expiringSoonId,
@@ -2274,6 +2287,8 @@ describeEmbeddedPostgres("attention service", () => {
expiresAt: decisionValues[0]!.expiresAt.toISOString(),
ruleKey: "release.soon",
originAgentName: "Worker",
// Who is asking (PAP-775, UI2).
requester: { kind: "agent", id: workerId, name: "Worker" },
queues: [{ key: "urgent-releases", title: "Urgent releases" }],
decideBy: "today",
decideByAttribution: {
@@ -2290,7 +2305,8 @@ describeEmbeddedPostgres("attention service", () => {
sort: "decide",
limit: 1,
});
expect(firstPage).toMatchObject({ totalCount: 2, deskBadgeCount: 2 });
// A queue filter narrows the page, never the shared badge.
expect(firstPage).toMatchObject({ totalCount: 2, deskBadgeCount: feed.deskBadgeCount, viewCounts: feed.viewCounts });
expect(firstPage.items.map((item) => item.subject.id)).toEqual([expiringSoonId]);
expect(firstPage.nextCursor).toBeTruthy();
const secondPage = await attentionService(db).list(companyId, {
@@ -2389,6 +2405,7 @@ describeEmbeddedPostgres("attention service", () => {
expect(summary).toEqual({
companyId, generatedAt: expect.any(String), totalCount: internalSnapshot.totalCount,
deskBadgeCount: internalSnapshot.deskBadgeCount, countsBySourceKind: internalSnapshot.countsBySourceKind,
viewCounts: internalSnapshot.viewCounts,
});
expect(JSON.stringify(summary).length).toBeLessThan(JSON.stringify(internalSnapshot).length / 10);
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { collapsePendingConfirmationsToNewest } from "../services/attention-source-eligibility.js";
function confirmation(id: string, at: number, fields: {
addresseeUserId?: string | null;
addresseeAgentId?: string | null;
payload?: unknown;
} = {}) {
return { id, issueId: "task", kind: "request_confirmation", createdAt: new Date(at),
payload: { version: 1, prompt: "Approve?" }, ...fields };
}
describe("pending confirmation selection", () => {
it("replaces only an ordinary confirmation for the same recipient", () => {
const rows = [
confirmation("old", 0), confirmation("new", 1),
confirmation("scott", 2, { addresseeUserId: "scott" }),
confirmation("dotta", 3, { addresseeUserId: "dotta" }),
confirmation("agent", 4, { addresseeAgentId: "agent" }),
];
expect(collapsePendingConfirmationsToNewest(rows).map(row => row.id))
.toEqual(["new", "scott", "dotta", "agent"]);
});
it.each(["toolAction", "secretProposal", "connectionAuthorization", "qualityReview"])(
"keeps each pending %s gate independent of other requests", (key) => {
const rows = [confirmation("ordinary", 0),
confirmation("gate-a", 1, { payload: { [key]: { id: "a" } } }),
confirmation("gate-b", 2, { payload: { [key]: { id: "b" } } })];
expect(collapsePendingConfirmationsToNewest(rows).map(row => row.id))
.toEqual(["ordinary", "gate-a", "gate-b"]);
},
);
});
@@ -0,0 +1,246 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
ensureMembership: vi.fn(),
getMembership: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => ({ upsertPolicy: vi.fn() }),
companyArtifactsService: () => ({ list: vi.fn() }),
companyPortabilityService: () => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}),
companyService: () => mockCompanyService,
feedbackService: () => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(),
}),
logActivity: mockLogActivity,
}));
function createCompany() {
const now = new Date("2026-10-29T02:00:00.000Z");
return {
id: "company-1",
name: "Paperclip",
description: null,
status: "active",
issuePrefix: "PAP",
issueCounter: 1,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
defaultDecisionOwnerUserId: null,
budgetOwnerUserId: null,
requireBoardApprovalForNewAgents: false,
createdAt: now,
updatedAt: now,
};
}
async function createApp(actor: Record<string, unknown>) {
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api/companies", companyRoutes({} as any));
app.use(errorHandler);
return app;
}
const BOARD = { type: "board", userId: "user-1", source: "local_implicit" };
// PAP-749 task 1d-lite: the default decision owner setting on PATCH /companies/:id.
describe("PATCH /api/companies/:companyId defaultDecisionOwnerUserId", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/companies.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.clearAllMocks();
mockCompanyService.getById.mockResolvedValue(createCompany());
});
it("sets an active member as the default decision owner and logs the change", async () => {
mockAccessService.getMembership.mockResolvedValue({ status: "active" });
mockCompanyService.update.mockResolvedValue({ ...createCompany(), defaultDecisionOwnerUserId: "user-2" });
const app = await createApp(BOARD);
const res = await request(app)
.patch("/api/companies/company-1")
.send({ defaultDecisionOwnerUserId: "user-2" });
expect(res.status).toBe(200);
expect(res.body.defaultDecisionOwnerUserId).toBe("user-2");
expect(mockAccessService.getMembership).toHaveBeenCalledWith("company-1", "user", "user-2");
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.updated",
entityId: "company-1",
details: { defaultDecisionOwnerUserId: "user-2" },
}));
});
it("clears the default decision owner without a membership lookup", async () => {
mockCompanyService.update.mockResolvedValue(createCompany());
const app = await createApp(BOARD);
const res = await request(app)
.patch("/api/companies/company-1")
.send({ defaultDecisionOwnerUserId: null });
expect(res.status).toBe(200);
expect(mockAccessService.getMembership).not.toHaveBeenCalled();
expect(mockCompanyService.update).toHaveBeenCalledWith(
"company-1",
{ defaultDecisionOwnerUserId: null },
expect.anything(),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.updated",
details: { defaultDecisionOwnerUserId: null },
}));
});
it.each([
["not a member", null],
["a suspended member", { status: "suspended" }],
])("rejects a user who is %s with 422", async (_label, membership) => {
mockAccessService.getMembership.mockResolvedValue(membership);
const app = await createApp(BOARD);
const res = await request(app)
.patch("/api/companies/company-1")
.send({ defaultDecisionOwnerUserId: "outsider" });
expect(res.status).toBe(422);
expect(res.body.error).toContain("active member");
expect(mockCompanyService.update).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("does not let agents change the default decision owner", async () => {
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", role: "ceo" });
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1")
.send({ defaultDecisionOwnerUserId: "user-2" });
expect(res.status).toBe(400);
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});
// PAP-749 task 1d: the budget owner setting follows the same rules.
describe("PATCH /api/companies/:companyId budgetOwnerUserId", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/companies.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.clearAllMocks();
mockCompanyService.getById.mockResolvedValue(createCompany());
});
it("sets an active member as the budget owner and logs the change", async () => {
mockAccessService.getMembership.mockResolvedValue({ status: "active" });
mockCompanyService.update.mockResolvedValue({ ...createCompany(), budgetOwnerUserId: "user-2" });
const app = await createApp(BOARD);
const res = await request(app)
.patch("/api/companies/company-1")
.send({ budgetOwnerUserId: "user-2" });
expect(res.status).toBe(200);
expect(res.body.budgetOwnerUserId).toBe("user-2");
expect(mockAccessService.getMembership).toHaveBeenCalledWith("company-1", "user", "user-2");
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.updated",
details: { budgetOwnerUserId: "user-2" },
}));
});
it("clears the budget owner without a membership lookup", async () => {
mockCompanyService.update.mockResolvedValue(createCompany());
const app = await createApp(BOARD);
const res = await request(app)
.patch("/api/companies/company-1")
.send({ budgetOwnerUserId: null });
expect(res.status).toBe(200);
expect(mockAccessService.getMembership).not.toHaveBeenCalled();
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", { budgetOwnerUserId: null }, expect.anything());
});
it("rejects a budget owner who is not an active member with 422", async () => {
mockAccessService.getMembership.mockResolvedValue({ status: "suspended" });
const app = await createApp(BOARD);
const res = await request(app)
.patch("/api/companies/company-1")
.send({ budgetOwnerUserId: "outsider" });
expect(res.status).toBe(422);
expect(res.body.error).toContain("Budget owner must be an active member");
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
it("does not let agents change the budget owner", async () => {
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", role: "ceo" });
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1")
.send({ budgetOwnerUserId: "user-2" });
expect(res.status).toBe(400);
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});
+224 -7
View File
@@ -1,10 +1,11 @@
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, decisions, decisionTargetIssues, heartbeatRuns, issues, issueThreadInteractions } from "@paperclipai/db";
import { decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget, itemVerdictsHistoryLabel } from "../services/decision-history.js";
import { activityLog, agents, approvals, budgetIncidents, budgetPolicies, companies, createDb, decisions, decisionTargetIssues, heartbeatRuns, issueApprovals, issues, issueThreadInteractions, projects } from "@paperclipai/db";
import { approvalHistoryAction, decisionHistoryAction, decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget, interactionHistoryAction, itemVerdictsHistoryLabel } from "../services/decision-history.js";
import { documentService } from "../services/documents.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
describe("decision receipt navigation", () => {
it("preserves all verdict outcomes in a mixed review summary", () => {
@@ -22,6 +23,23 @@ describe("decision receipt navigation", () => {
expect(() => decodeDecisionHistoryCursor("nope", "newest")).toThrow("Invalid decision history cursor");
expect(() => decodeDecisionHistoryCursor(Buffer.from(JSON.stringify({ at: "today", id: "x", sort: "oldest" })).toString("base64url"), "newest")).toThrow();
});
it("names each action with the receipt's verb (PAP-767)", () => {
const by = { resolvedByUserId: "scott" };
expect(interactionHistoryAction({ ...by, kind: "request_confirmation", status: "accepted", result: { outcome: "accepted" } })).toEqual({ outcome: "approved", detail: null });
expect(interactionHistoryAction({ ...by, kind: "request_confirmation", status: "rejected", result: { outcome: "rejected" } })).toEqual({ outcome: "changes_requested", detail: null });
expect(interactionHistoryAction({ ...by, kind: "ask_user_questions", status: "answered", result: {} })).toEqual({ outcome: "answered", detail: null });
expect(interactionHistoryAction({ ...by, kind: "request_item_verdicts", status: "answered", result: { outcome: "resolved", items: [{ verdict: "approve" }] } }))
.toEqual({ outcome: "answered", detail: "1 approved" });
expect(interactionHistoryAction({ ...by, kind: "ask_user_questions", status: "cancelled", result: { outcome: "withdrawn" } })).toEqual({ outcome: "withdrawn", detail: null });
// Nobody acted on an expired or task-closed card, or the agent withdrew it.
expect(interactionHistoryAction({ ...by, kind: "request_confirmation", status: "expired", result: { outcome: "issue_closed" } })).toBeNull();
expect(interactionHistoryAction({ resolvedByUserId: null, kind: "ask_user_questions", status: "cancelled", result: { outcome: "withdrawn" } })).toBeNull();
expect(approvalHistoryAction({ status: "approved", decidedByUserId: "scott", type: "request_board_approval" })).toEqual({ outcome: "approved", detail: null });
expect(approvalHistoryAction({ status: "rejected", decidedByUserId: "dotta", type: "budget_override_required" })).toEqual({ outcome: "kept_paused", detail: null });
expect(approvalHistoryAction({ status: "approved", decidedByUserId: "dotta", type: "budget_override_required" })).toEqual({ outcome: "limit_raised", detail: null });
expect(approvalHistoryAction({ status: "cancelled", decidedByUserId: null, type: "budget_override_required" })).toBeNull();
expect(decisionHistoryAction({ status: "decided", resolvedByUserId: "scott", chosenLabel: "Ship it" })).toEqual({ outcome: "decided", detail: "Ship it" });
});
it("preserves the owning task while the receipt points to its origin interaction", () => {
const target = decisionHistoryTarget({ target: { type: "issue_document", issueId: "task-b", key: "plan", revisionId: "b-v1" } });
expect(target).toEqual({ kind: "issue_document", issueId: "task-b", key: "plan", revisionId: "b-v1" });
@@ -29,15 +47,22 @@ describe("decision receipt navigation", () => {
});
});
const support = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("canonical decision history database projection", () => {
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
temp = await startEmbeddedPostgresTestDatabase("paperclip-decision-history-");
db = createDb(temp.connectionString);
if (!externalUrl) temp = await startEmbeddedPostgresTestDatabase("paperclip-decision-history-");
db = createDb(externalUrl ?? temp!.connectionString);
if (externalUrl) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
}, 30_000);
afterAll(async () => { vi.unstubAllEnvs(); await temp?.cleanup(); });
afterAll(async () => {
vi.unstubAllEnvs();
if (externalUrl && db) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
await db?.$client.end({ timeout: 1 });
await temp?.cleanup();
});
it("projects mixed review verdicts into history instead of generic answers", async () => {
const companyId = randomUUID(), issueId = randomUUID();
@@ -113,6 +138,198 @@ const support = await getEmbeddedPostgresTestSupport();
expect((await service.list(companyId, options)).items).toHaveLength(7);
});
// PAP-749 task 2e: approvals in history; Withdrawn, Expired and Closed with task filters.
it("adds approvals and keeps Withdrawn, Expired and Closed with task apart across every source", async () => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
const companyId = randomUUID(), agentId = randomUUID(), projectId = randomUUID(), issueId = randomUUID(), privateId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Approval history", issuePrefix: "APH" });
await db.insert(agents).values({ id: agentId, companyId, name: "Requester", role: "engineer", adapterType: "codex_local" });
await db.insert(projects).values({ id: projectId, companyId, name: "Launch" });
await db.insert(issues).values([
{ id: issueId, companyId, identifier: "APH-1", title: "Launch plan", projectId },
{ id: privateId, companyId, identifier: "APH-2", title: "Private plan", visibility: "private", createdByUserId: "somebody-else" },
]);
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId });
const at = (minutes: number) => new Date(Date.parse("2026-10-29T12:00:00.000Z") + minutes * 60_000);
const approved = randomUUID(), withdrawn = randomUUID(), cancelled = randomUUID(), rejected = randomUUID(), pending = randomUUID(), hidden = randomUUID();
await db.insert(approvals).values([
{ id: approved, companyId, type: "request_board_approval", status: "approved", requestedByAgentId: agentId, payload: { title: "Ship the launch" }, decidedByUserId: "scott", decidedAt: at(1) },
{ id: withdrawn, companyId, type: "approve_ceo_strategy", status: "cancelled", payload: { summary: "Old strategy" }, decisionNote: "Plan changed", decidedByUserId: "scott", decidedAt: at(2) },
{ id: cancelled, companyId, type: "budget_override_required", status: "cancelled", payload: {}, decidedAt: at(3) },
{ id: rejected, companyId, type: "request_board_approval", status: "rejected", payload: { title: "Raise the price" }, decisionNote: "No", decidedByUserId: "dotta", decidedAt: at(4) },
{ id: pending, companyId, type: "request_board_approval", status: "pending", payload: { title: "Still open" } },
{ id: hidden, companyId, type: "request_board_approval", status: "approved", payload: { title: "Private approval" }, decidedAt: at(5) },
]);
await db.insert(issueApprovals).values([
{ companyId, issueId, approvalId: approved },
{ companyId, issueId: privateId, approvalId: hidden },
]);
const expiredQ = randomUUID(), closedQ = randomUUID(), withdrawnQ = randomUUID();
await db.insert(issueThreadInteractions).values([
{ id: expiredQ, companyId, issueId, kind: "request_confirmation", status: "expired", title: "Timed out", resolvedAt: at(6), payload: { version: 1, prompt: "?" }, result: { version: 1, outcome: "stale_target" } },
{ id: closedQ, companyId, issueId, kind: "request_confirmation", status: "expired", title: "Closed with task", resolvedAt: at(7), payload: { version: 1, prompt: "?" }, result: { version: 1, outcome: "issue_closed" } },
{ id: withdrawnQ, companyId, issueId, kind: "ask_user_questions", status: "cancelled", title: "Withdrawn question", resolvedAt: at(8), resolvedByUserId: "scott", payload: { version: 1, questions: [] }, result: { version: 1, outcome: "withdrawn", reason: "Out of date" } },
]);
const decisionExpired = randomUUID(), decisionWithdrawn = randomUUID();
const decisionBase = { companyId, originIssueId: issueId, originAgentId: agentId, originRunId: runId, body: "Choose", options: [{ id: "yes", label: "Yes", effects: [] }], expiresAt: at(0), signedSpec: "test", targetSnapshots: {} };
await db.insert(decisions).values([
{ ...decisionBase, id: decisionExpired, title: "Expired decision", status: "expired", decidedAt: at(9) },
{ ...decisionBase, id: decisionWithdrawn, title: "Withdrawn decision", status: "cancelled", decidedAt: at(10), decidedByUserId: "scott", metadata: { cancelReason: "Not needed" } },
]);
// Dotta's approval that Scott withdrew for her shows in her "Handled for me".
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: "scott", action: "approval.cancelled", entityType: "approval", entityId: withdrawn,
details: { handledForUserId: "dotta", receipt: { outcome: "withdrawn", sourceKind: "approval", sourceId: withdrawn, issueId: null, title: "Old strategy", actorUserId: "scott" } } });
const service = decisionHistoryService(db);
const options = { actor: { type: "board" as const, userId: "scott", ignoreInstanceAdmin: true, authorizingUserId: "scott" }, userId: "scott" };
const ids = async (extra: Record<string, unknown> = {}) => (await service.list(companyId, { ...options, limit: 100, ...extra })).items.map((item) => item.sourceId);
const all = await service.list(companyId, { ...options, limit: 100 });
// Pending and private-task approvals stay out.
expect(new Set(all.items.filter((item) => item.sourceKind === "approval").map((item) => item.sourceId))).toEqual(new Set([approved, withdrawn, cancelled, rejected]));
const approvedItem = all.items.find((item) => item.sourceId === approved)!;
expect(approvedItem).toMatchObject({ id: `approval:${approved}`, title: "Ship the launch", label: "Approved", outcome: "approved", resultOutcome: null,
issueId, issueIdentifier: "APH-1", projectId, agentId, agentName: "Requester", resolvedByUserId: "scott", href: `/APH/approvals/${approved}` });
expect(all.items.find((item) => item.sourceId === withdrawn)).toMatchObject({ title: "Old strategy", label: "Withdrawn by user", resultOutcome: "withdrawn",
reason: "Plan changed", issueId: null, issueTitle: null });
expect(all.items.find((item) => item.sourceId === cancelled)).toMatchObject({ title: "budget override required", label: "Cancelled", resultOutcome: null, reason: null });
expect(all.items.find((item) => item.sourceId === rejected)).toMatchObject({ label: "Rejected", reason: null });
expect(all.items.find((item) => item.sourceId === withdrawnQ)).toMatchObject({ resultOutcome: "withdrawn", reason: "Out of date" });
expect(all.items.find((item) => item.sourceId === closedQ)).toMatchObject({ label: "Closed with task", resultOutcome: "issue_closed" });
expect(all.items.find((item) => item.sourceId === decisionWithdrawn)).toMatchObject({ resultOutcome: "withdrawn", reason: "Not needed" });
expect(new Set(await ids({ outcome: "withdrawn" }))).toEqual(new Set([withdrawn, withdrawnQ, decisionWithdrawn]));
expect(new Set(await ids({ outcome: "expired" }))).toEqual(new Set([expiredQ, decisionExpired]));
expect(await ids({ outcome: "issue_closed" })).toEqual([closedQ]);
expect(new Set(await ids({ outcome: "accepted" }))).toEqual(new Set([approved]));
expect(new Set(await ids({ outcome: "rejected" }))).toEqual(new Set([rejected]));
expect(new Set(await ids({ outcome: "cancelled" }))).toEqual(new Set([withdrawn, cancelled, withdrawnQ, decisionWithdrawn]));
expect(await ids({ outcome: "answered" })).toEqual([]);
expect(await ids({ search: "launch" })).toEqual(expect.arrayContaining([approved]));
expect(await ids({ search: "Old strategy" })).toEqual([withdrawn]);
expect(new Set(await ids({ agentId }))).toEqual(new Set([approved, decisionExpired, decisionWithdrawn]));
expect(await ids({ projectId })).toEqual(expect.arrayContaining([approved]));
expect(await ids({ projectId })).not.toContain(withdrawn);
expect(await ids({ issueIds: [issueId] })).toContain(approved);
expect(await ids({ issueIds: [issueId] })).not.toContain(withdrawn);
expect(await service.list(companyId, { ...options, userId: "dotta", limit: 100, handledForMe: true }).then((page) => page.items.map((item) => item.sourceId))).toEqual([withdrawn]);
expect((await service.list(companyId, { ...options, limit: 100 })).items.find((item) => item.sourceId === withdrawn)?.handledForUserId).toBe("dotta");
// One keyset cursor pages through all three sources without gaps.
const paged: string[] = []; let cursor: string | undefined;
do {
const page = await service.list(companyId, { ...options, limit: 2, cursor });
paged.push(...page.items.map((item) => item.id));
cursor = page.nextCursor ?? undefined;
} while (cursor);
expect(paged).toEqual(all.items.map((item) => item.id));
});
it("shows Keep paused on a budget hard stop as kept paused by the actor, for the budget owner (PAP-767)", async () => {
const companyId = randomUUID(), agentId = randomUUID(), policyId = randomUUID(), incidentId = randomUUID(), approvalId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Budget history", issuePrefix: "BDH" });
await db.insert(agents).values({ id: agentId, companyId, name: "Spendy Agent", role: "engineer", adapterType: "codex_local" });
await db.insert(budgetPolicies).values({ id: policyId, companyId, scopeType: "agent", scopeId: agentId, windowKind: "calendar_month_utc", amount: 10_000 });
// `budgets.resolveIncident` rejects the approval as the person who kept it paused.
await db.insert(approvals).values({ id: approvalId, companyId, type: "budget_override_required", status: "rejected",
payload: { scopeName: "Spendy Agent" }, decidedByUserId: "dotta", decidedAt: new Date() });
await db.insert(budgetIncidents).values({ id: incidentId, companyId, policyId, scopeType: "agent", scopeId: agentId, metric: "billed_cents",
windowKind: "calendar_month_utc", windowStart: new Date("2026-11-01T00:00:00.000Z"), windowEnd: new Date("2026-12-01T00:00:00.000Z"),
thresholdType: "hard", amountLimit: 10_000, amountObserved: 10_000, status: "dismissed", approvalId });
// The receipt names the budget card (the incident), not its approval.
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: "dotta", action: "budget.incident_resolved", entityType: "budget_incident", entityId: incidentId,
details: { action: "keep_paused", handledForUserId: "scott", receipt: { outcome: "kept_paused", sourceKind: "budget_alert", sourceId: incidentId, issueId: null, title: "Spendy Agent budget hard stop", actorUserId: "dotta" } } });
const service = decisionHistoryService(db);
const scott = { actor: { type: "board" as const, userId: "scott", ignoreInstanceAdmin: true, authorizingUserId: "scott" }, userId: "scott", limit: 100 };
const handled = await service.list(companyId, { ...scott, handledForMe: true });
expect(handled.items).toEqual([expect.objectContaining({ sourceKind: "approval", sourceId: approvalId, label: "Kept paused",
action: { outcome: "kept_paused", detail: null }, resolvedByUserId: "dotta", handledForUserId: "scott" })]);
// Dotta acted for Scott, so it is not in her own "Handled for me".
expect((await service.list(companyId, { ...scott, userId: "dotta", handledForMe: true })).items).toEqual([]);
expect((await service.list(companyId, scott)).items.find((item) => item.sourceId === approvalId)?.handledForUserId).toBe("scott");
});
// PAP-768: "Handled by me or for me" is the default history; Everyone is a scope.
it("scopes history to what I handled, what was handled for me and cards whose audience is me", async () => {
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Personal history", issuePrefix: "PHS" });
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", role: "engineer", adapterType: "codex_local" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId });
const [scottTask, childTask, dottaTask, assignedTask, agentTask] = [randomUUID(), randomUUID(), randomUUID(), randomUUID(), randomUUID()];
await db.insert(issues).values([
{ id: scottTask, companyId, identifier: "PHS-1", title: "Scott: task you created", createdByUserId: "scott", originKind: "manual" },
// Agent work under Scott's task is still his (the instigator walk).
{ id: childTask, companyId, identifier: "PHS-2", title: "Agent child", parentId: scottTask, createdByAgentId: agentId, originKind: "manual" },
{ id: dottaTask, companyId, identifier: "PHS-3", title: "Dotta's task", createdByUserId: "dotta", originKind: "manual" },
// A human assignee comes before the instigator.
{ id: assignedTask, companyId, identifier: "PHS-4", title: "Dotta's task for Scott", createdByUserId: "dotta", assigneeUserId: "scott", originKind: "manual" },
{ id: agentTask, companyId, identifier: "PHS-5", title: "Routine work", createdByAgentId: agentId, originKind: "routine_execution" },
]);
const at = (minutes: number) => new Date(Date.parse("2026-11-02T12:00:00.000Z") + minutes * 60_000);
const payload = { version: 1 as const, prompt: "?" };
const [answeredOwn, expiredOwn, expiredChild, answeredForDotta, forRiley, expiredAssigned, transferred, ownerless] =
Array.from({ length: 8 }, () => randomUUID());
await db.insert(issueThreadInteractions).values([
{ id: answeredOwn, companyId, issueId: scottTask, kind: "request_confirmation", status: "accepted", resolvedAt: at(1), resolvedByUserId: "scott", payload },
{ id: expiredOwn, companyId, issueId: scottTask, kind: "request_confirmation", status: "expired", resolvedAt: at(2), payload },
{ id: expiredChild, companyId, issueId: childTask, kind: "request_confirmation", status: "expired", resolvedAt: at(3), payload },
{ id: answeredForDotta, companyId, issueId: dottaTask, kind: "request_confirmation", status: "accepted", resolvedAt: at(4), resolvedByUserId: "scott", payload },
// An explicit addressee wins over the task's person.
{ id: forRiley, companyId, issueId: scottTask, addresseeUserId: "riley", kind: "request_confirmation", status: "expired", resolvedAt: at(5), payload },
{ id: expiredAssigned, companyId, issueId: assignedTask, kind: "request_confirmation", status: "expired", resolvedAt: at(6), payload },
{ id: transferred, companyId, issueId: agentTask, addresseeUserId: "riley", kind: "request_confirmation", status: "accepted", resolvedAt: at(7), resolvedByUserId: "riley", payload },
{ id: ownerless, companyId, issueId: agentTask, kind: "request_confirmation", status: "expired", resolvedAt: at(8), payload },
]);
// Scott moved Dotta's card to Riley; the receipt names him and her.
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: "scott", action: "attention.transferred", entityType: "issue", entityId: agentTask,
details: { handledForUserId: "dotta", receipt: { outcome: "transferred", sourceKind: "issue_thread_interaction", sourceId: transferred, issueId: agentTask, title: "Moved", actorUserId: "scott", transferredToUserId: "riley" } } });
const [requestedByDotta, onScottTask, systemApproval] = [randomUUID(), randomUUID(), randomUUID()];
await db.insert(approvals).values([
{ id: requestedByDotta, companyId, type: "request_board_approval", status: "rejected", requestedByUserId: "dotta", payload: { title: "Dotta asks" }, decidedByUserId: "scott", decidedAt: at(9) },
{ id: onScottTask, companyId, type: "request_board_approval", status: "approved", requestedByAgentId: agentId, payload: { title: "On Scott's task" }, decidedByUserId: "riley", decidedAt: at(10) },
{ id: systemApproval, companyId, type: "approve_ceo_strategy", status: "approved", payload: { title: "Nobody's" }, decidedAt: at(11) },
]);
await db.insert(issueApprovals).values({ companyId, issueId: scottTask, approvalId: onScottTask });
const [decisionOnScottTask, decisionWithdrawnByDotta] = [randomUUID(), randomUUID()];
const decisionBase = { companyId, originAgentId: agentId, originRunId: runId, body: "Choose", options: [{ id: "yes", label: "Yes", effects: [] }], expiresAt: at(0), signedSpec: "test", targetSnapshots: {} };
await db.insert(decisions).values([
{ ...decisionBase, id: decisionOnScottTask, originIssueId: scottTask, title: "Scott's decision", status: "expired", decidedAt: at(12) },
{ ...decisionBase, id: decisionWithdrawnByDotta, originIssueId: agentTask, title: "Withdrawn by Dotta", status: "cancelled", decidedAt: at(13), decidedByUserId: "dotta" },
]);
const service = decisionHistoryService(db);
const ids = async (userId: string, extra: Record<string, unknown> = {}) => new Set((await service.list(companyId, {
actor: { type: "board", userId, ignoreInstanceAdmin: true, authorizingUserId: userId }, userId, limit: 100, ...extra,
})).items.map((item) => item.sourceId));
expect(await ids("scott", { scope: "mine" })).toEqual(new Set([answeredOwn, expiredOwn, expiredChild, answeredForDotta, expiredAssigned, transferred,
requestedByDotta, onScottTask, decisionOnScottTask]));
// Dotta no longer sees "PHS-1 · Scott: task you created", which Scott answered on his own task.
expect(await ids("dotta", { scope: "mine" })).toEqual(new Set([answeredForDotta, transferred, requestedByDotta, decisionWithdrawnByDotta]));
expect(await ids("riley", { scope: "mine" })).toEqual(new Set([forRiley, transferred, onScottTask]));
// Everyone (and no scope, the API default) is every person's history.
const everyone = await ids("dotta", { scope: "all" });
expect(everyone).toEqual(await ids("dotta"));
expect(everyone.size).toBe(13);
expect(everyone.has(answeredOwn)).toBe(true);
// Outcome filters and Handled for me keep working inside each scope.
expect(await ids("scott", { scope: "mine", outcome: "expired" })).toEqual(new Set([expiredOwn, expiredChild, expiredAssigned, decisionOnScottTask]));
expect(await ids("dotta", { scope: "mine", outcome: "expired" })).toEqual(new Set());
expect(await ids("dotta", { scope: "all", outcome: "expired" })).toEqual(new Set([expiredOwn, expiredChild, forRiley, expiredAssigned, ownerless, decisionOnScottTask]));
expect(await ids("dotta", { scope: "mine", handledForMe: true })).toEqual(new Set([transferred]));
expect(await ids("dotta", { scope: "mine", outcome: "accepted" })).toEqual(new Set([answeredForDotta, transferred]));
// One keyset cursor still pages through the scoped sources without gaps.
const paged: string[] = []; let cursor: string | undefined;
do {
const page = await service.list(companyId, { actor: { type: "board", userId: "scott", ignoreInstanceAdmin: true, authorizingUserId: "scott" },
userId: "scott", scope: "mine", limit: 2, cursor });
paged.push(...page.items.map((item) => item.sourceId));
cursor = page.nextCursor ?? undefined;
} while (cursor);
expect(new Set(paged)).toEqual(await ids("scott", { scope: "mine" }));
expect(paged).toHaveLength(9);
});
it("paginates both sources without losing answered questions; filters before limiting; isolates companies and private tasks", async () => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), hiddenId = randomUUID(), otherIssueId = randomUUID(), runId = randomUUID();
@@ -301,6 +301,13 @@ describeEmbeddedPostgres("decision queue routes", () => {
metadata: input.metadata,
},
whyNow: "test",
audience: { kind: "pool", userId: null, reason: "none" },
stale: null,
required: false,
changeFingerprint: "fixture.candidate",
closeModes: ["archive", "snooze"],
obligationKey: null,
obligationLeadId: null,
decisionVerbs: [],
inlineResolvable: true,
entryRule: "test",
@@ -32,6 +32,8 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const mockAdapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
exitCode: 0,
@@ -79,7 +81,10 @@ import {
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
} from "../services/issue-dependency-wakeups.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl
? { supported: true, reason: undefined }
: await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -93,8 +98,10 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-issue-liveness-");
db = createDb(tempDb.connectionString);
if (!externalUrl) {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-issue-liveness-");
}
db = createDb(externalUrl ?? tempDb!.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(async () => {
@@ -134,6 +141,7 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
}, 30_000);
@@ -482,9 +490,10 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
const feed = await attentionService(db).list(companyId, { userId: "responsible-user" });
expect(feed.items.find((item) => item.subject.id === actions[0].id)).toMatchObject({
sourceKind: "recovery_action",
inlineResolvable: false,
relatedIssue: expect.objectContaining({ id: issueId }),
decisionVerbs: expect.arrayContaining([
expect.objectContaining({ id: "resolve", label: "Resolve" }),
expect.objectContaining({ id: "open_recovery", label: "Open recovery" }),
]),
});
});
@@ -594,7 +603,7 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
}
// Only the terminal run and completed wake were persisted before the crash.
// A new service must restore the missing action without invoking the agent.
const singleConnectionDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
const singleConnectionDb = createDb(externalUrl ?? tempDb!.connectionString, { maxConnections: 1 });
try {
if (gap === "transaction_failure") {
await db.execute(sql`alter table issue_comments add constraint review_repair_notice_test check (false) not valid`);
+66 -3
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { asc, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
@@ -26,8 +27,10 @@ import { inboxDismissalService } from "../services/inbox-dismissals.ts";
import { sidebarBadgeRoutes } from "../routes/sidebar-badges.js";
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -43,8 +46,9 @@ describeEmbeddedPostgres("inbox dismissals", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
db = createDb(tempDb.connectionString);
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
db = createDb(externalUrl ?? tempDb!.connectionString);
if (externalUrl) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
dismissalsSvc = inboxDismissalService(db);
badgesSvc = sidebarBadgeService(db);
}, 20_000);
@@ -64,6 +68,7 @@ describeEmbeddedPostgres("inbox dismissals", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
@@ -227,6 +232,64 @@ describeEmbeddedPostgres("inbox dismissals", () => {
await expect(dismissalsSvc.list(companyId, userId)).resolves.toEqual([]);
});
it("stores an attention card fingerprint and rejects one on other inbox items", async () => {
const companyId = randomUUID();
const userId = "board-user";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId,
companyIds: [companyId],
isInstanceAdmin: false,
};
next();
});
app.use("/api", inboxDismissalRoutes(db));
app.use(errorHandler);
await request(app)
.post(`/api/companies/${companyId}/inbox-dismissals`)
.send({ itemKey: "approval:approval-1", fingerprint: "abc.me" })
.expect(400);
const dismissed = await request(app)
.post(`/api/companies/${companyId}/inbox-dismissals`)
.send({ itemKey: "attention:interaction:1", fingerprint: "abc.me" })
.expect(201);
expect(dismissed.body).toMatchObject({ itemKey: "attention:interaction:1", kind: "dismiss", fingerprint: "abc.me" });
const snoozedUntil = "2099-01-01T00:00:00.000Z";
await request(app)
.post(`/api/companies/${companyId}/inbox-dismissals`)
.send({ itemKey: "attention:interaction:1", kind: "snooze", snoozedUntil, fingerprint: "def.other" })
.expect(201);
expect((await dismissalsSvc.list(companyId, userId))[0]).toMatchObject({ kind: "snooze", fingerprint: "def.other" });
// Hiding again without a fingerprint clears it, so the row never compares
// against content the user did not see.
await request(app)
.post(`/api/companies/${companyId}/inbox-dismissals`)
.send({ itemKey: "attention:interaction:1" })
.expect(201);
expect((await dismissalsSvc.list(companyId, userId))[0]?.fingerprint).toBeNull();
const [logged] = await db.select().from(activityLog)
.where(eq(activityLog.companyId, companyId))
.orderBy(asc(activityLog.createdAt))
.limit(1);
expect(logged?.details).toMatchObject({ itemKey: "attention:interaction:1", fingerprint: "abc.me" });
});
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
const companyId = randomUUID();
const userId = "board-user";
@@ -0,0 +1,240 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockProjectService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
listWorkspaces: vi.fn(),
updateWorkspace: vi.fn(),
removeWorkspace: vi.fn(),
remove: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
getMembership: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/activity-log.js", async () => ({
...await vi.importActual<typeof import("../services/activity-log.js")>("../services/activity-log.js"),
persistActivity: async (db: unknown, input: unknown) => { await mockLogActivity(db, input); return { activity: { id: "activity" }, publication: null }; },
publishActivity: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.doMock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.doMock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
}
const BOARD_ACTOR = {
type: "board",
userId: "board-user",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
async function createApp(actor: Record<string, unknown> = BOARD_ACTOR) {
const [{ projectRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/projects.js")>("../routes/projects.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", projectRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any));
app.use(errorHandler);
return app;
}
function buildProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "backlog",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project",
effectiveLocalFolder: "/tmp/project",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// PAP-749 task 1d: a project's decision owner gets the project's ownerless
// Needs You cards, so only a board user sets it, and only to an active member.
describe("project decision owner routes", { timeout: 60_000 }, () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/projects.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../services/environments.js");
vi.doUnmock("../services/secrets.js");
registerModuleMocks();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "project:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.listWorkspaces.mockResolvedValue([]);
mockProjectService.list.mockResolvedValue([]);
mockProjectService.getById.mockResolvedValue(buildProject());
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
});
it("sets an active member as the decision owner and logs the change", async () => {
mockAccessService.getMembership.mockResolvedValue({ status: "active" });
mockProjectService.update.mockResolvedValue(buildProject({ decisionOwnerUserId: "user-2" }));
const app = await createApp();
const res = await request(app).patch("/api/projects/project-1").send({ decisionOwnerUserId: "user-2" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.decisionOwnerUserId).toBe("user-2");
expect(mockAccessService.getMembership).toHaveBeenCalledWith("company-1", "user", "user-2");
expect(mockProjectService.update).toHaveBeenCalledWith("project-1", { decisionOwnerUserId: "user-2" });
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "project.updated",
details: expect.objectContaining({ changedKeys: ["decisionOwnerUserId"] }),
}));
});
it("clears the decision owner without a membership lookup", async () => {
mockProjectService.getById.mockResolvedValue(buildProject({ decisionOwnerUserId: "user-2" }));
mockProjectService.update.mockResolvedValue(buildProject({ decisionOwnerUserId: null }));
const app = await createApp();
const res = await request(app).patch("/api/projects/project-1").send({ decisionOwnerUserId: null });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAccessService.getMembership).not.toHaveBeenCalled();
});
it.each([
["not a member", null],
["a suspended member", { status: "suspended" }],
])("rejects a decision owner who is %s with 422", async (_label, membership) => {
mockAccessService.getMembership.mockResolvedValue(membership);
const app = await createApp();
const res = await request(app).patch("/api/projects/project-1").send({ decisionOwnerUserId: "outsider" });
expect(res.status).toBe(422);
expect(res.body.error).toContain("active member");
expect(mockProjectService.update).not.toHaveBeenCalled();
});
it("validates the decision owner on create too", async () => {
mockAccessService.getMembership.mockResolvedValue(null);
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({ name: "Project", decisionOwnerUserId: "outsider" });
expect(res.status).toBe(422);
expect(mockProjectService.create).not.toHaveBeenCalled();
});
it("does not let an agent change the decision owner", async () => {
const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", source: "agent_key" });
const res = await request(app).patch("/api/projects/project-1").send({ decisionOwnerUserId: "user-2" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockProjectService.update).not.toHaveBeenCalled();
});
});
@@ -16,7 +16,7 @@ import * as workspaceRecovery from "../services/workspace-recovery.js";
import * as workspaceRecoveryWork from "../services/workspace-recovery-work.js";
import { idleBackupWakeMarker, markIdleBackupWakeRequired } from "../services/idle-database-backup.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { decisionService, decisionRetentionService, toolAccessService, githubConnectionEventService, reconcileBuiltInAgentsOnStartup, reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup } from "../services/index.js";
import { decisionService, decisionRetentionService, toolAccessService, githubConnectionEventService, reconcileBuiltInAgentsOnStartup, reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup, expirePendingInteractionsOnClosedIssues } from "../services/index.js";
import { companies, runDatabaseBackup, type Db } from "@paperclipai/db";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -541,6 +541,7 @@ vi.mock("../services/index.js", () => ({
duplicates: 0,
})),
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
expirePendingInteractionsOnClosedIssues: vi.fn(async () => ({ issues: 0, expired: 0, failed: 0 })),
resolveHeartbeatSchedulingSuppression: resolveHeartbeatSchedulingSuppressionMock,
routineService: routineServiceFactoryMock,
statusCardService: vi.fn(() => ({})),
@@ -1247,6 +1248,7 @@ describe("startServer feedback export wiring", () => {
["built-in agents", reconcileBuiltInAgentsOnStartup],
["managed homes", reconcileCodexLocalManagedHomesOnStartup],
["runtime services", reconcilePersistedRuntimeServicesOnStartup],
["closed-issue interactions", expirePendingInteractionsOnClosedIssues],
] as const).flatMap(([name, reconcile]) => [false, true].map(fail => ({ name, reconcile, fail }))))
("counts detached $name startup writes after readiness (failure=$fail)", async ({ reconcile, fail }) => {
let finishStartup!: () => void;
+4 -1
View File
@@ -856,7 +856,10 @@ export async function createApp(
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(attentionRoutes(db));
api.use(attentionRoutes(db, {
pluginWorkerManager: workerManager,
decisionServiceOptions: opts.decisionServiceOptions,
}));
api.use(decisionTrainingRoutes(db));
api.use(decisionRoutes(db, opts.decisionServiceOptions));
api.use(decisionQueueRoutes(db));
+13
View File
@@ -168,6 +168,7 @@ import {
reconcileBuiltInAgentsOnStartup,
reconcileCodexLocalManagedHomesOnStartup,
reconcilePersistedRuntimeServicesOnStartup,
expirePendingInteractionsOnClosedIssues,
routineService,
toolAccessService,
workspaceOperationService,
@@ -1197,6 +1198,18 @@ async function startServerWithDatabaseTeardown(
logger.error({ err }, "startup reconciliation of built-in agents failed");
}));
// Expire pending cards that older code paths left on done or cancelled
// issues (PAP-749). Idempotent, once per start, never on a timer.
void trackIdleWork(expirePendingInteractionsOnClosedIssues(db as any)
.then((result) => {
if (result.expired > 0 || result.failed > 0) {
logger.warn(result, "expired pending interactions on closed issues");
}
})
.catch((err) => {
logger.error({ err }, "startup sweep of pending interactions on closed issues failed");
}));
// Force the instance onto the Kubernetes sandbox provider when configured via
// env (PAPERCLIP_EXECUTION_MODE=kubernetes). Runs BEFORE the heartbeat resumes
// queued runs so the policy + managed k8s environments are in place. A bad
+215 -23
View File
@@ -3,11 +3,16 @@ import { eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import {
addApprovalCommentSchema,
cancelApprovalSchema,
createApprovalSchema,
requestApprovalRevisionSchema,
resolveApprovalSchema,
resubmitApprovalSchema,
} from "@paperclipai/shared";
import { notFound, unprocessable } from "../errors.js";
import { resolvableApprovalStatuses } from "../lib/approval-records.js";
import { attentionAlreadyClosedError } from "../services/attention-already-closed.js";
import { attentionHandledDetails, attentionHandledFor, type AttentionHandledFor } from "../services/attention-receipts.js";
import { validate } from "../middleware/validate.js";
import { logger } from "../middleware/logger.js";
import {
@@ -41,29 +46,14 @@ function isStatusOnlyRecoveryContext(contextSnapshot: unknown) {
context.resumeRequiresNormalModel === true;
}
export function approvalRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
) {
const router = Router();
router.param("id", async (req, res, next, id) => {
try {
if (!(await canActorReadApproval(db, req.actor, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
next();
} catch (error) { next(error); }
});
const svc = approvalService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
/**
* Review-path wakes after an approval leaves `pending`: a linked task in review
* may have no other waiting path left, so its assignee is woken to choose one.
* Approve, reject and cancel share these.
*/
function approvalReviewPathWakes(db: Db, heartbeat: ReturnType<typeof heartbeatService>) {
const issueApprovalsSvc = issueApprovalService(db);
const issuesSvc = issueService(db);
const secretsSvc = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function lostReviewPathIssueIds(
companyId: string,
@@ -162,6 +152,181 @@ export function approvalRoutes(
}
}
return { approvalReviewPathContext, lostReviewPathIssueIds, queueAdditionalApprovalReviewPathWakes };
}
/**
* Withdraw an open approval because it is out of date (PAP-749 plan §2, Rule
* 6). It is never an approval: the gate stays closed. It records who withdrew
* it and why, logs `approval.cancelled`, and tells the requesting agent. The
* approval route and the attention close endpoint share this one path. Callers
* authorize a board user first.
*/
export async function cancelApprovalAndNotify(input: {
db: Db;
heartbeat: ReturnType<typeof heartbeatService>;
approvalId: string;
userId: string;
reason?: string | null;
/** Who the card was for, read before the cancel (PAP-749 plan §6). Omitted: read here. */
handledFor?: AttentionHandledFor | null;
}) {
const { db, heartbeat, userId } = input;
const svc = approvalService(db);
const existing = await svc.getById(input.approvalId);
if (!existing) throw notFound("Approval not found");
if (existing.type === "hire_agent") {
// Cancelling a hire would leave the pending agent with no decision.
throw unprocessable("Reject this hire request instead of withdrawing it.", { code: "approval_cancel_unsupported" });
}
const alreadyClosed = (approval: typeof existing) => attentionAlreadyClosedError({
sourceKind: "approval",
sourceId: approval.id,
status: approval.status,
resolvedByUserId: approval.decidedByUserId,
resolvedAt: approval.decidedAt,
});
if (!(resolvableApprovalStatuses as readonly string[]).includes(existing.status)) throw alreadyClosed(existing);
const reason = input.reason?.trim() || null;
const handledFor = input.handledFor !== undefined
? input.handledFor
: await attentionHandledFor(db, existing.companyId, { sourceKind: "approval", sourceId: existing.id }, userId);
const approval = await svc.cancel(existing.id, reason, { cancelledByUserId: userId });
if (!approval) {
// A different person resolved it between the read and the write.
throw alreadyClosed((await svc.getById(existing.id)) ?? existing);
}
const reviewPath = approvalReviewPathWakes(db, heartbeat);
const linkedIssues = await issueApprovalService(db).listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
const lostReviewIssueIds = await reviewPath.lostReviewPathIssueIds(approval.companyId, linkedIssues);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: userId,
action: "approval.cancelled",
entityType: "approval",
entityId: approval.id,
details: {
type: approval.type,
reason,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
...attentionHandledDetails(handledFor, "withdrawn", { reason }),
},
});
let primaryReviewPathWakeCovered = false;
if (approval.requestedByAgentId) {
const primaryReviewPathContext = primaryIssueId && lostReviewIssueIds.has(primaryIssueId)
? reviewPath.approvalReviewPathContext(approval.id)
: null;
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_cancelled",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
reason,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
...(primaryReviewPathContext ?? {}),
},
requestedByActorType: "user",
requestedByActorId: userId,
contextSnapshot: {
source: "approval.cancelled",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_cancelled",
...(primaryReviewPathContext ?? {}),
},
});
primaryReviewPathWakeCovered = Boolean(wakeRun && primaryReviewPathContext);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: userId,
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: { requesterAgentId: approval.requestedByAgentId, wakeRunId: wakeRun?.id ?? null, linkedIssueIds },
});
} catch (err) {
logger.warn(
{ err, approvalId: approval.id, requestedByAgentId: approval.requestedByAgentId },
"failed to queue requester wakeup after approval cancel",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: userId,
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
}
}
await reviewPath.queueAdditionalApprovalReviewPathWakes({
approvalId: approval.id,
approvalStatus: approval.status,
companyId: approval.companyId,
linkedIssues,
lostIssueIds: lostReviewIssueIds,
alreadyWoken: primaryReviewPathWakeCovered && approval.requestedByAgentId && primaryIssueId
? { agentId: approval.requestedByAgentId, issueId: primaryIssueId }
: null,
requestedByUserId: userId,
});
return { approval, issueId: primaryIssueId };
}
export function approvalRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
) {
const router = Router();
router.param("id", async (req, res, next, id) => {
try {
if (!(await canActorReadApproval(db, req.actor, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
next();
} catch (error) { next(error); }
});
const svc = approvalService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
const { approvalReviewPathContext, lostReviewPathIssueIds, queueAdditionalApprovalReviewPathWakes } =
approvalReviewPathWakes(db, heartbeat);
/** Who an approval card is for, read before a person decides it (PAP-749 plan §6). */
async function approvalHandledFor(id: string, userId: string | null | undefined) {
const approval = await svc.getById(id);
return approval
? attentionHandledFor(db, approval.companyId, { sourceKind: "approval", sourceId: id }, userId)
: null;
}
async function requireApprovalAccess(req: Request, id: string) {
const approval = await svc.getById(id);
if (!approval || !hasCompanyAccess(req, approval.companyId)) {
@@ -301,6 +466,8 @@ export function approvalRoutes(
return;
}
const decidedByUserId = req.actor.userId ?? "board";
// Who the card was for, read before the decision (PAP-749 plan §6).
const handledFor = await approvalHandledFor(id, req.actor.userId);
const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote);
if (applied) {
@@ -323,6 +490,7 @@ export function approvalRoutes(
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
...attentionHandledDetails(handledFor, "approved"),
},
});
@@ -417,6 +585,7 @@ export function approvalRoutes(
return;
}
const decidedByUserId = req.actor.userId ?? "board";
const handledFor = await approvalHandledFor(id, req.actor.userId);
const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote);
if (applied) {
@@ -429,7 +598,7 @@ export function approvalRoutes(
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
details: { type: approval.type, ...attentionHandledDetails(handledFor, "rejected", { reason: req.body.decisionNote }) },
});
await queueAdditionalApprovalReviewPathWakes({
approvalId: approval.id,
@@ -455,6 +624,7 @@ export function approvalRoutes(
return;
}
const decidedByUserId = req.actor.userId ?? "board";
const handledFor = await approvalHandledFor(id, req.actor.userId);
const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote);
await logActivity(db, {
@@ -464,13 +634,35 @@ export function approvalRoutes(
action: "approval.revision_requested",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
details: {
type: approval.type,
...attentionHandledDetails(handledFor, "revision_requested", { reason: req.body.decisionNote }),
},
});
res.json(redactApprovalPayload(approval));
},
);
// Withdraw an out-of-date approval (PAP-749 plan §2, Rule 6). Board users
// only; it never approves. 409 when it was already resolved.
router.post("/approvals/:id/cancel", validate(cancelApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await requireApprovalAccess(req, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const { approval } = await cancelApprovalAndNotify({
db, heartbeat, approvalId: id, userId: req.actor.userId, reason: req.body.reason,
});
res.json(redactApprovalPayload(approval));
});
router.post("/approvals/:id/resubmit", validate(resubmitApprovalSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Approval not found");
+261 -6
View File
@@ -1,11 +1,37 @@
import { Router } from "express";
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import type { AttentionSortMode } from "@paperclipai/shared";
import {
ATTENTION_SOURCE_KINDS,
DECISION_HISTORY_SCOPES,
attentionCloseSchema,
attentionTransferSchema,
type AttentionCloseResult,
type AttentionFeedView,
type AttentionSortMode,
type AttentionSourceKind,
type AttentionTransferResult,
} from "@paperclipai/shared";
import { attentionService } from "../services/attention.js";
import {
attentionHandledDetails,
attentionHandledFor,
attentionReceiptService,
type AttentionHandledSourceKind,
} from "../services/attention-receipts.js";
import { decisionHistoryService } from "../services/decision-history.js";
import { decisionService, type DecisionServiceOptions } from "../services/decisions.js";
import { createDecisionWakeOriginAgent } from "../services/decision-wakeup.js";
import { heartbeatService } from "../services/heartbeat.js";
import { logActivity } from "../services/activity-log.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { validate } from "../middleware/validate.js";
import { z } from "zod";
import { badRequest } from "../errors.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { badRequest, HttpError } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { cancelApprovalAndNotify } from "./approvals.js";
import { withdrawIssueThreadInteractionAndNotify } from "./issues.js";
const ATTENTION_FEED_VIEWS: readonly AttentionFeedView[] = ["mine", "unassigned", "company", "archived", "all"];
function optionalQueryString(value: unknown, field: string) {
if (value === undefined) return undefined;
@@ -13,9 +39,31 @@ function optionalQueryString(value: unknown, field: string) {
return value.trim();
}
export function attentionRoutes(db: Db) {
export function attentionRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager; decisionServiceOptions?: DecisionServiceOptions } = {},
) {
const router = Router();
const svc = attentionService(db);
const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager });
const decisions = decisionService(db, options.decisionServiceOptions ?? {
wakeOriginAgent: createDecisionWakeOriginAgent(heartbeat.wakeup),
});
/** Board user on a company card route; returns the source the path names. */
function cardRequest(req: Request) {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const userId = req.actor.userId;
const sourceKind = req.params.sourceKind as string;
if (!(ATTENTION_SOURCE_KINDS as readonly string[]).includes(sourceKind)) {
throw badRequest(`sourceKind must be one of ${ATTENTION_SOURCE_KINDS.join(", ")}`);
}
const sourceId = req.params.sourceId as string;
if (!z.string().uuid().safeParse(sourceId).success) throw badRequest("sourceId must be a UUID");
return { companyId, userId, sourceKind: sourceKind as AttentionSourceKind, sourceId };
}
router.get("/companies/:companyId/attention/summary", async (req, res) => {
const companyId = req.params.companyId as string;
@@ -43,9 +91,19 @@ export function attentionRoutes(db: Db) {
search: z.string().trim().max(200).optional(), outcome: z.string().max(40).optional(),
agentId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
sort: z.enum(["newest", "oldest"]).optional(), since: z.string().datetime().optional(),
// "Handled for me" (PAP-749 plan §6).
handledForMe: z.enum(["true", "false"]).optional(),
// "Handled by me or for me", or everyone's history (PAP-768).
scope: z.enum(DECISION_HISTORY_SCOPES).optional(),
}).strict().safeParse(req.query);
if (!query.success) throw badRequest("Invalid decision history filters");
res.json(await decisionHistoryService(db).list(companyId, { ...query.data, actor: req.actor, userId: req.actor.userId }));
const { handledForMe, ...filters } = query.data;
res.json(await decisionHistoryService(db).list(companyId, {
...filters,
handledForMe: handledForMe === "true",
actor: req.actor,
userId: req.actor.userId,
}));
});
router.get("/companies/:companyId/attention", async (req, res) => {
@@ -60,6 +118,11 @@ export function attentionRoutes(db: Db) {
const includeDismissed = req.query.includeDismissed === "true";
const archived = req.query.archived === "true";
const all = req.query.all === "true";
const viewValue = optionalQueryString(req.query.view, "view");
if (viewValue !== undefined && !ATTENTION_FEED_VIEWS.includes(viewValue as AttentionFeedView)) {
throw badRequest(`view must be one of ${ATTENTION_FEED_VIEWS.join(", ")}`);
}
const view = viewValue as AttentionFeedView | undefined;
const activitySince = optionalQueryString(req.query.activitySince, "activitySince");
const activityUntil = optionalQueryString(req.query.activityUntil, "activityUntil");
const queue = optionalQueryString(req.query.queue, "queue");
@@ -76,6 +139,7 @@ export function attentionRoutes(db: Db) {
includeDismissed,
actor: req.actor,
archived,
view,
all,
allowUnscopedAll: all,
activitySince,
@@ -85,8 +149,199 @@ export function attentionRoutes(db: Db) {
sort: sortValue as AttentionSortMode | undefined,
limit,
});
// Receipts sit at the top of For you only (PAP-749 plan §6, Rule 12).
if (view === "mine") {
feed.receipts = await attentionReceiptService(db).list(companyId, { userId: req.actor.userId, actor: req.actor });
}
res.json(feed);
});
// Claim an Unassigned card (PAP-749 plan §1, Rule 3): the claimer becomes the
// addressee, so the card leaves every other person's pool. Board users only;
// it changes where the card shows, never who may answer it.
router.post("/companies/:companyId/attention/:sourceKind/:sourceId/claim", async (req, res) => {
const { companyId, userId, sourceKind, sourceId } = cardRequest(req);
if (!userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const claim = await svc.claim(companyId, { sourceKind, sourceId, userId, actor: req.actor });
if (!claim.alreadyClaimed) {
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "attention.claimed",
entityType: "issue",
entityId: claim.issueId,
issueId: claim.issueId,
details: {
sourceKind: claim.sourceKind,
sourceId: claim.sourceId,
interactionId: claim.sourceId,
claimedByUserId: claim.claimedByUserId,
previousAddresseeAgentId: claim.previousAddresseeAgentId,
},
});
}
res.json({
sourceKind: claim.sourceKind,
sourceId: claim.sourceId,
claimedByUserId: claim.claimedByUserId,
alreadyClaimed: claim.alreadyClaimed,
});
});
// One Close… menu for every card (PAP-749 plan §2, Rule 6). Withdraw runs
// the source owner's own withdraw or cancel, which writes its own activity
// and tells the agent; archive and snooze hide the card for this person
// only. Every close writes one `attention.closed` entry. Board users only.
router.post(
"/companies/:companyId/attention/:sourceKind/:sourceId/close",
validate(attentionCloseSchema),
async (req, res) => {
const { companyId, userId, sourceKind, sourceId } = cardRequest(req);
if (!userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const body = req.body as { mode: "withdraw" | "archive" | "snooze"; reason?: string | null; until?: string | null; fingerprint?: string | null };
const reason = body.reason?.trim() || null;
let issueId: string | null;
let until: string | null = null;
let details: Record<string, unknown> = {};
if (body.mode === "withdraw") {
const target = await svc.withdrawTarget(companyId, { sourceKind, sourceId, actor: req.actor });
issueId = target.issueId;
// Who the card was for, read before the withdraw closes it, so that
// person gets "Withdrawn by @x" (PAP-749 plan §6, Rule 12).
const handledFor = await attentionHandledFor(
db, companyId, { sourceKind: target.sourceKind as AttentionHandledSourceKind, sourceId }, userId,
);
details = attentionHandledDetails(handledFor, "withdrawn", { reason });
// A different person may answer between the check and the write. The
// first answer wins; the second person gets "Already answered by @x".
try {
if (target.sourceKind === "issue_thread_interaction") {
await withdrawIssueThreadInteractionAndNotify({
db,
heartbeat,
issue: target.issue,
interactionId: sourceId,
body: { reason: reason ?? undefined },
actor: getActorInfo(req),
allowClosedIssueForHuman: true,
handledFor,
});
} else if (target.sourceKind === "approval") {
await cancelApprovalAndNotify({ db, heartbeat, approvalId: sourceId, userId, reason, handledFor });
} else {
await decisions.cancel(sourceId, { actorType: "user", actorId: userId }, {
reason,
activityDetails: attentionHandledDetails(handledFor, "withdrawn", { reason }),
});
}
} catch (error) {
if (error instanceof HttpError && error.status === 409) {
throw (await svc.alreadyClosedError(companyId, { sourceKind, sourceId, actor: req.actor })) ?? error;
}
throw error;
}
} else {
const hidden = await svc.hideForUser(companyId, {
sourceKind,
sourceId,
userId,
actor: req.actor,
mode: body.mode,
until: body.until,
fingerprint: body.fingerprint,
});
issueId = hidden.issueId;
until = hidden.until;
details = { itemKey: hidden.itemKey, fingerprint: hidden.fingerprint };
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "attention.closed",
entityType: issueId ? "issue" : "company",
entityId: issueId ?? companyId,
issueId,
details: { sourceKind, sourceId, mode: body.mode, reason, until, closedByUserId: userId, ...details },
});
const result: AttentionCloseResult = { sourceKind, sourceId, mode: body.mode, closedByUserId: userId, issueId, until };
res.json(result);
},
);
// Transfer a card to a different person, "Route to @x" (PAP-749 plan §2,
// Rule 6). The new owner becomes the addressee. Board users only.
router.post(
"/companies/:companyId/attention/:sourceKind/:sourceId/transfer",
validate(attentionTransferSchema),
async (req, res) => {
const { companyId, userId, sourceKind, sourceId } = cardRequest(req);
if (!userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const body = req.body as { userId: string; reason?: string | null };
// The person the card was for before the transfer gets "Transferred to
// @y by @x" (PAP-749 plan §6, Rule 12).
const handledFor = sourceKind === "issue_thread_interaction"
? await attentionHandledFor(db, companyId, { sourceKind, sourceId }, userId)
: null;
const transfer = await svc.transfer(companyId, { sourceKind, sourceId, toUserId: body.userId, actor: req.actor });
if (!transfer.alreadyTransferred) {
const reason = body.reason?.trim() || null;
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "attention.transferred",
entityType: "issue",
entityId: transfer.issueId,
issueId: transfer.issueId,
details: {
sourceKind: transfer.sourceKind,
sourceId: transfer.sourceId,
interactionId: transfer.sourceId,
transferredToUserId: transfer.transferredToUserId,
transferredByUserId: userId,
previousAddresseeUserId: transfer.previousAddresseeUserId,
previousAddresseeAgentId: transfer.previousAddresseeAgentId,
reason,
// No receipt when the card goes back to the same person.
...(handledFor?.userId === transfer.transferredToUserId
? {}
: attentionHandledDetails(handledFor, "transferred", { reason, transferredToUserId: transfer.transferredToUserId })),
},
});
}
const result: AttentionTransferResult = {
sourceKind: transfer.sourceKind,
sourceId: transfer.sourceId,
transferredToUserId: transfer.transferredToUserId,
previousAddresseeUserId: transfer.previousAddresseeUserId,
alreadyTransferred: transfer.alreadyTransferred,
};
res.json(result);
},
);
return router;
}
+17
View File
@@ -1272,6 +1272,23 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan
return;
}
// PAP-749 task 1d-lite: ownerless cards route to the default decision owner,
// so it must be a person who is an active member of this company.
if (typeof body.defaultDecisionOwnerUserId === "string") {
const membership = await access.getMembership(companyId, "user", body.defaultDecisionOwnerUserId);
if (membership?.status !== "active") {
throw unprocessable("Default decision owner must be an active member of this company");
}
}
// PAP-749 task 1d: budget hard stops also go to the budget owner, so the
// same rule applies.
if (typeof body.budgetOwnerUserId === "string") {
const membership = await access.getMembership(companyId, "user", body.budgetOwnerUserId);
if (membership?.status !== "active") {
throw unprocessable("Budget owner must be an active member of this company");
}
}
if (req.actor.type !== "agent") {
if (body.feedbackDataSharingEnabled === true && !existingCompany.feedbackDataSharingEnabled) {
body = {
+12 -2
View File
@@ -33,6 +33,7 @@ import {
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
import { attentionHandledDetails, attentionHandledFor } from "../services/attention-receipts.js";
import { badRequest, forbidden } from "../errors.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
@@ -371,8 +372,17 @@ export function costRoutes(
const companyId = req.params.companyId as string;
const incidentId = req.params.incidentId as string;
assertCompanyAccess(req, companyId);
const incident = await budgets.resolveIncident(companyId, incidentId, req.body, req.actor.userId ?? "board",
req.actor.source ?? null);
const actorUserId = req.actor.userId ?? "board";
// A hard stop is the budget owner's card. When someone else raises or
// keeps the limit, read that before the incident closes, so the owner
// gets "Kept paused by @x" or "Limit raised by @x", the verbs of the
// card's buttons and of Decision history (PAP-749 plan §6; PAP-767).
const handledFor = await attentionHandledFor(
db, companyId, { sourceKind: "budget_alert", sourceId: incidentId }, req.actor.userId,
);
const incident = await budgets.resolveIncident(companyId, incidentId, req.body, actorUserId, req.actor.source ?? null, {
activityDetails: attentionHandledDetails(handledFor, req.body.action === "keep_paused" ? "kept_paused" : "limit_raised"),
});
res.json(incident);
},
);
+11 -2
View File
@@ -14,6 +14,7 @@ import { validate } from "../middleware/validate.js";
import { decisionService, type DecisionServiceOptions } from "../services/decisions.js";
import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
import { attentionService } from "../services/attention.js";
import { attentionHandledDetails, attentionHandledFor } from "../services/attention-receipts.js";
import { authorizationDeniedDetails, authorizationService } from "../services/authorization.js";
import { canReadDecisionSource } from "../services/decision-queues.js";
import { hashAttentionArchiveManifest } from "../services/decision-retention.js";
@@ -182,7 +183,10 @@ export function decisionRoutes(db: Db, options: DecisionServiceOptions) {
const userId = boardUserId(req);
const decision = await getAccessibleResource(req, res, svc.get(req.params.id as string), "Decision not found");
if (!decision) return;
res.json(await svc.decide({ id: decision.id, decidedByUserId: userId, userActor: req.actor, ...req.body }));
// Who the card was for, read before the answer (PAP-749 plan §6).
const handledFor = await attentionHandledFor(db, decision.companyId, { sourceKind: "decision", sourceId: decision.id }, userId);
res.json(await svc.decide({ id: decision.id, decidedByUserId: userId, userActor: req.actor, ...req.body,
activityDetails: attentionHandledDetails(handledFor, "decided") }));
});
router.post("/decisions/:id/dismiss", validate(dismissSchema), async (req, res) => {
const userId = boardUserId(req);
@@ -194,7 +198,12 @@ export function decisionRoutes(db: Db, options: DecisionServiceOptions) {
assertBoardOrAgent(req);
const decision = await getAccessibleResource(req, res, svc.get(req.params.id as string), "Decision not found");
if (!decision) return;
const actor = getActorInfo(req); res.json(await svc.cancel(decision.id, { actorType: actor.actorType, actorId: actor.actorId, runId: actor.runId }));
const actor = getActorInfo(req);
const handledFor = actor.actorType === "user"
? await attentionHandledFor(db, decision.companyId, { sourceKind: "decision", sourceId: decision.id }, actor.actorId)
: null;
res.json(await svc.cancel(decision.id, { actorType: actor.actorType, actorId: actor.actorId, runId: actor.runId },
{ activityDetails: attentionHandledDetails(handledFor, "withdrawn") }));
});
return router;
}
+10 -2
View File
@@ -11,7 +11,13 @@ const inboxDismissalSchema = z.object({
itemKey: z.string().trim().min(1).regex(ITEM_KEY_RE, "Unsupported inbox item key"),
kind: z.enum(["dismiss", "snooze"]).default("dismiss"),
snoozedUntil: z.string().trim().optional(),
// The attention card's `changeFingerprint` (PAP-749 Rule 9). Personal
// presentation state only: a wrong value just lets the card return.
fingerprint: z.string().trim().min(1).max(200).optional(),
}).superRefine((value, ctx) => {
if (value.fingerprint != null && !value.itemKey.startsWith("attention:")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["fingerprint"], message: "Only attention items carry a fingerprint" });
}
if (value.kind === "dismiss") {
if (value.snoozedUntil != null) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Dismissals must not include snoozedUntil" });
@@ -68,9 +74,10 @@ export function inboxDismissalRoutes(db: Db) {
const userId = requireBoardUser(req, res);
if (!userId) return;
const fingerprint = req.body.fingerprint ?? null;
const dismissal = req.body.kind === "snooze"
? await svc.snooze(companyId, userId, req.body.itemKey, new Date(req.body.snoozedUntil))
: await svc.dismiss(companyId, userId, req.body.itemKey, new Date());
? await svc.snooze(companyId, userId, req.body.itemKey, new Date(req.body.snoozedUntil), new Date(), fingerprint)
: await svc.dismiss(companyId, userId, req.body.itemKey, new Date(), fingerprint);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
@@ -88,6 +95,7 @@ export function inboxDismissalRoutes(db: Db) {
kind: dismissal.kind,
dismissedAt: dismissal.dismissedAt,
snoozedUntil: dismissal.snoozedUntil,
fingerprint: dismissal.fingerprint,
},
});
+181 -74
View File
@@ -31,7 +31,7 @@ import { enqueueFastResponse } from "../services/fast-responses.js";
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js";
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../services/native-runtime/native-workspace-finalization-recovery-state.js";
import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js";
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
import type { IssuePrivacyConstraints, WithdrawIssueThreadInteraction } from "@paperclipai/shared";
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
import {
childCreatedByRequest,
@@ -210,6 +210,7 @@ import type { StorageService } from "../storage/types.js";
import { validate, validateIssueMutationBody } from "../middleware/validate.js";
import * as serviceIndex from "../services/index.js";
import { conversationDecisionReceiptsService } from "../services/conversation-decision-receipts.js";
import { attentionHandledDetails, attentionHandledFor, type AttentionHandledFor } from "../services/attention-receipts.js";
import {
accessService,
agentService,
@@ -2489,6 +2490,124 @@ function readSecretProposalContinuationContext(interaction: {
};
}
/**
* Withdraw a pending interaction and tell the agent: cancel a native question
* run that waits on it, log `issue.thread_interaction_withdrawn`, and queue the
* assignee's continuation wake. The issue route and the attention close
* endpoint (PAP-749 plan §2, Rule 6) share this one path. Callers authorize
* the actor first.
*/
export async function withdrawIssueThreadInteractionAndNotify(input: {
db: Db;
heartbeat: ReturnType<typeof heartbeatService>;
issue: {
id: string;
companyId: string;
assigneeAgentId: string | null;
status: string;
};
interactionId: string;
body: WithdrawIssueThreadInteraction;
actor: ReturnType<typeof getActorInfo>;
/** A person may withdraw a card that outlived its closed task. */
allowClosedIssueForHuman?: boolean;
/**
* Who the card was for, read before the withdraw (PAP-749 plan §6). Omitted:
* read here. `null`: the caller found no other person.
*/
handledFor?: AttentionHandledFor | null;
}) {
const { db, heartbeat, issue, actor } = input;
const handledFor = input.handledFor !== undefined
? input.handledFor
: await attentionHandledFor(
db,
issue.companyId,
{ sourceKind: "issue_thread_interaction", sourceId: input.interactionId },
actor.actorType === "user" ? actor.actorId : null,
);
let nativeRunId: string | null = null;
const interaction = await issueThreadInteractionService(db).withdrawInteraction(
issue,
input.interactionId,
input.body,
{
agentId: actor.agentId,
runId: actor.runId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
{
allowClosedIssueForHuman: input.allowClosedIssueForHuman,
afterResolveInTransaction: async (tx, resolved) => {
if (resolved.kind !== "ask_user_questions") return;
nativeRunId = await requestNativeQuestionRunCancellation(
tx,
resolved,
{
kind: "interaction_withdrawn",
interactionId: resolved.id,
},
);
},
},
);
if (nativeRunId) {
try {
await heartbeat.cancelRun(
nativeRunId,
"Question withdrawn while waiting for operator input",
{
resultJson: {
withdrawnInteractionId: interaction.id,
withdrawnByActorType: actor.actorType,
withdrawnByActorId: actor.actorId,
},
},
);
} catch (err) {
logger.warn(
{ err, runId: nativeRunId, interactionId: interaction.id },
"native question withdrawal cancellation deferred to recovery sweep",
);
}
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.thread_interaction_withdrawn",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
reason:
interaction.result && "reason" in interaction.result
? (interaction.result.reason ?? null)
: null,
...attentionHandledDetails(handledFor, "withdrawn", {
reason: interaction.result && "reason" in interaction.result ? interaction.result.reason : null,
}),
},
});
if (actor.agentId !== issue.assigneeAgentId) {
await queueResolvedInteractionContinuationWakeup({
db,
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.withdraw",
});
}
return interaction;
}
const REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS = 2_000;
function buildRequestItemVerdictsWakeIdempotencyKey(args: {
@@ -18454,6 +18573,13 @@ export function issueRoutes(
throw unprocessable(
"Remembered permission is only supported for tool reviews",
);
// Who the card was for, read before the answer (PAP-749 plan §6).
const handledFor = await attentionHandledFor(
db,
issue.companyId,
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
actor.actorType === "user" ? actor.actorId : null,
);
const { interaction, createdIssues, continuationIssue } =
await interactionSvc.acceptInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
@@ -18637,6 +18763,9 @@ export function issueRoutes(
interaction.kind === "suggest_tasks"
? (interaction.result?.skippedClientKeys?.length ?? 0)
: 0,
// The receipt uses the history verb: an accepted card reads
// "Approved by @x", an answered one "Answered by @x" (PAP-767).
...(interaction.status === "expired" ? {} : attentionHandledDetails(handledFor, interaction.status === "answered" ? "answered" : "approved")),
},
});
@@ -18759,6 +18888,13 @@ export function issueRoutes(
res.json(await interactionSvc.getById(current.id));
return;
}
// Who the card was for, read before the answer (PAP-749 plan §6).
const handledFor = await attentionHandledFor(
db,
issue.companyId,
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
actor.actorType === "user" ? actor.actorId : null,
);
const interaction = await interactionSvc.rejectInteraction(
issue,
interactionId,
@@ -18803,6 +18939,8 @@ export function issueRoutes(
interaction.kind === "request_checkbox_confirmation"
? (interaction.result?.reason ?? null)
: null,
// Reject and Revise… both record "Changes requested" (PAP-767).
...(interaction.status === "expired" ? {} : attentionHandledDetails(handledFor, "changes_requested")),
},
});
@@ -18847,6 +18985,13 @@ export function issueRoutes(
if (current.kind === "ask_user_questions") {
await validateNativeQuestionResponseInput(current, req.body);
}
// Who the card was for, read before the answer (PAP-749 plan §6).
const handledFor = await attentionHandledFor(
db,
issue.companyId,
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
actor.actorType === "user" ? actor.actorId : null,
);
const interaction = await interactionSvc.answerQuestions(
issue,
interactionId,
@@ -18885,6 +19030,7 @@ export function issueRoutes(
interaction.kind === "ask_user_questions"
? (interaction.result?.answers?.length ?? 0)
: 0,
...(interaction.status === "answered" ? attentionHandledDetails(handledFor, "answered") : {}),
},
});
@@ -18928,6 +19074,13 @@ export function issueRoutes(
const { interactionSvc, resolutionAuthorization } = authorizedResolution;
const actor = getActorInfo(req);
// Who the card was for, read before the answer (PAP-749 plan §6).
const handledFor = await attentionHandledFor(
db,
issue.companyId,
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
actor.actorType === "user" ? actor.actorId : null,
);
const { interaction, newlyResolvedItemIds } =
await interactionSvc.submitItemVerdicts(
issue,
@@ -18975,6 +19128,8 @@ export function issueRoutes(
interaction.kind === "request_item_verdicts"
? (interaction.result?.complete ?? false)
: false,
// Only the verdict that completes the review closes the card.
...(interaction.status === "answered" ? attentionHandledDetails(handledFor, "answered") : {}),
},
});
@@ -19025,82 +19180,14 @@ export function issueRoutes(
return;
await assertPendingReviewInteractionVerdictAllowed(req, issue, current);
const actor = getActorInfo(req);
let nativeRunId: string | null = null;
const interaction = await interactionSvc.withdrawInteraction(
const interaction = await withdrawIssueThreadInteractionAndNotify({
db,
heartbeat,
issue,
interactionId,
req.body,
{
agentId: actor.agentId,
runId: actor.runId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
{
afterResolveInTransaction: async (tx, resolved) => {
if (resolved.kind !== "ask_user_questions") return;
nativeRunId = await requestNativeQuestionRunCancellation(
tx,
resolved,
{
kind: "interaction_withdrawn",
interactionId: resolved.id,
},
);
},
},
);
if (nativeRunId) {
try {
await heartbeat.cancelRun(
nativeRunId,
"Question withdrawn while waiting for operator input",
{
resultJson: {
withdrawnInteractionId: interaction.id,
withdrawnByActorType: actor.actorType,
withdrawnByActorId: actor.actorId,
},
},
);
} catch (err) {
logger.warn(
{ err, runId: nativeRunId, interactionId: interaction.id },
"native question withdrawal cancellation deferred to recovery sweep",
);
}
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.thread_interaction_withdrawn",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
reason:
interaction.result && "reason" in interaction.result
? (interaction.result.reason ?? null)
: null,
},
body: req.body,
actor: getActorInfo(req),
});
if (actor.agentId !== issue.assigneeAgentId) {
await queueResolvedInteractionContinuationWakeup({
db,
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.withdraw",
});
}
res.json(interaction);
},
);
@@ -19154,6 +19241,13 @@ export function issueRoutes(
assertBoard(req);
const actor = getActorInfo(req);
// Who the card was for, read before the skip (PAP-749 plan §6).
const handledFor = await attentionHandledFor(
db,
issue.companyId,
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
actor.actorType === "user" ? actor.actorId : null,
);
const interaction = await issueThreadInteractionService(
db,
).skipInteraction(issue, interactionId, req.body, {
@@ -19180,6 +19274,9 @@ export function issueRoutes(
interaction.result && "reason" in interaction.result
? (interaction.result.reason ?? null)
: null,
...attentionHandledDetails(handledFor, "skipped", {
reason: interaction.result && "reason" in interaction.result ? interaction.result.reason : null,
}),
},
});
@@ -19212,6 +19309,13 @@ export function issueRoutes(
assertBoard(req);
const actor = getActorInfo(req);
// Who the card was for, read before the cancel (PAP-749 plan §6).
const handledFor = await attentionHandledFor(
db,
issue.companyId,
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
actor.actorType === "user" ? actor.actorId : null,
);
let nativeRunId: string | null = null;
const interaction = await issueThreadInteractionService(
db,
@@ -19256,6 +19360,9 @@ export function issueRoutes(
interaction.kind === "ask_user_questions"
? (interaction.result?.cancellationReason ?? null)
: null,
...attentionHandledDetails(handledFor, "withdrawn", {
reason: interaction.kind === "ask_user_questions" ? interaction.result?.cancellationReason : null,
}),
},
});
+100 -2
View File
@@ -150,8 +150,12 @@ import {
createApprovalSchema,
resolveApprovalSchema,
requestApprovalRevisionSchema,
cancelApprovalSchema,
resubmitApprovalSchema,
addApprovalCommentSchema,
// Attention
attentionCloseSchema,
attentionTransferSchema,
// Cost / budget
createCostEventSchema,
createFinanceEventSchema,
@@ -1453,6 +1457,10 @@ const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/companies/{companyId}/attention/history",
"GET /api/companies/{companyId}/attention/summary",
"GET /api/companies/{companyId}/attention/tasks",
"POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/claim",
"POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/close",
"POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/transfer",
"POST /api/approvals/{id}/cancel",
"POST /api/issues/{id}/interactions/{interactionId}/dismiss",
"POST /api/issues/{id}/retry",
"GET /api/companies/{companyId}/prompt-injection-prevention",
@@ -5981,6 +5989,20 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
registry.registerPath({
method: "post",
path: "/api/approvals/{id}/cancel",
tags: ["approvals"],
summary: "Withdraw an out-of-date approval",
description:
"Requires a board user. Cancels an open approval without a decision: it is never an approval, so the gate stays closed. Records the user and reason, writes approval.cancelled and wakes the requesting agent. Hire requests are rejected instead (422). 409 with code attention_already_closed when it was already resolved.",
request: {
params: z.object({ id: z.string() }),
body: jsonBody(cancelApprovalSchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
});
registry.registerPath({
method: "post",
path: "/api/approvals/{id}/resubmit",
@@ -6468,6 +6490,8 @@ registry.registerPath({
includeDismissed: z.enum(["true", "false"]).optional(),
archived: z.enum(["true", "false"]).optional(),
all: z.enum(["true", "false"]).optional(),
view: z.enum(["mine", "unassigned", "company", "archived", "all"]).optional()
.describe("Audience slice (PAP-749). `all` (default) keeps the full feed; `viewCounts` and the For you badge `deskBadgeCount` ignore it. `mine` also returns `receipts`: what a different person did with your cards in the last 7 days. `archived` returns only the open cards you archived or snoozed for yourself, whatever their audience; `viewCounts.archived` counts them and is never in the badge. `viewCounts.all` counts the Everyone tab (every person's open cards, board members only) and is never in the badge."),
activitySince: z.string().datetime().optional(),
activityUntil: z.string().datetime().optional(),
queue: z.string().min(1).optional(),
@@ -6484,6 +6508,13 @@ const attentionSummaryResponseSchema = z.object({
generatedAt: z.string().datetime(),
totalCount: z.number().int().nonnegative(),
deskBadgeCount: z.number().int().nonnegative(),
viewCounts: z.object({
mine: z.number().int().nonnegative(),
unassigned: z.number().int().nonnegative(),
company: z.number().int().nonnegative(),
archived: z.number().int().nonnegative(),
all: z.number().int().nonnegative(),
}).strict(),
countsBySourceKind: z.record(z.enum(ATTENTION_SOURCE_KINDS), z.number().int().nonnegative()),
}).strict();
@@ -6501,6 +6532,68 @@ for (const [projection, responseSchema] of [
responses: { 200: r.ok(responseSchema), 401: r.unauthorized, 403: r.forbidden },
});
}
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/attention/{sourceKind}/{sourceId}/claim",
tags: ["inbox"],
summary: "Claim an ownerless attention card",
description:
"Requires a board user with company access. Makes the caller the addressee of an ownerless card, so it leaves every other person's Unassigned pool and joins the caller's For you view. Placement only: the resolver policy still decides who may answer. Only issue_thread_interaction cards can be claimed (422 otherwise); a card already for another person or a runnable agent returns 409; claiming your own card again returns alreadyClaimed=true. Writes an attention.claimed activity entry.",
request: { params: z.object({ companyId: z.string(), sourceKind: z.string(), sourceId: z.string().uuid() }) },
responses: {
200: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
422: r.unprocessable,
},
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/attention/{sourceKind}/{sourceId}/close",
tags: ["inbox"],
summary: "Close an attention card: withdraw, archive or snooze",
description:
"Requires a board user with company write access. mode=withdraw closes the request for everyone because it is out of date; it is never an approval. It runs the source's own withdraw (interactions, including on a closed task) or cancel (decisions, approvals except hire requests), which writes its own activity entry and tells the agent. mode=archive and mode=snooze hide the card for the caller only, until its content changes or until `until`; the source stays open. Cards without a request to withdraw (budget, review, blocker, recovery, failed run, agent error, join request) return 422 for withdraw with the control to use instead. 404 when the caller cannot see the card; 409 with code attention_already_closed and the resolver when someone else answered first. Writes one attention.closed activity entry.",
request: {
params: z.object({ companyId: z.string(), sourceKind: z.string(), sourceId: z.string().uuid() }),
body: jsonBody(attentionCloseSchema),
},
responses: {
200: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
422: r.unprocessable,
},
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/attention/{sourceKind}/{sourceId}/transfer",
tags: ["inbox"],
summary: "Transfer an attention card to a different person",
description:
"Requires a board user with company write access. Routes the card to another person (\"Route to @x\"): they become its addressee, so it joins their For you view and only they may answer it. The new owner must be able to answer requests on the task (422 otherwise). Only issue_thread_interaction cards can be transferred (422 otherwise); chat questions stay with the person in the chat (422); a runnable agent's request or an answered card returns 409; routing to the current addressee returns alreadyTransferred=true. Writes an attention.transferred activity entry.",
request: {
params: z.object({ companyId: z.string(), sourceKind: z.string(), sourceId: z.string().uuid() }),
body: jsonBody(attentionTransferSchema),
},
responses: {
200: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
422: r.unprocessable,
},
});
// ─── Decisions ──────────────────────────────────────────────────────────────
@@ -6510,18 +6603,22 @@ registry.registerPath({
tags: ["inbox"],
summary: "List recorded decisions visible to the current board user",
description:
"Requires a board user with company access. Returns a DecisionHistoryPage with current task and decision audience access enforced. Only the declared filters are accepted. The default page size is 40, sorted newest first.",
"Requires a board user with company access. Returns a DecisionHistoryPage of closed interactions, decisions and approvals, with current task and decision audience access enforced. Only the declared filters are accepted. The default page size is 40, sorted newest first.",
request: {
params: z.object({ companyId: z.string() }),
query: z.object({
cursor: z.string().max(1000).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
search: z.string().trim().max(200).optional(),
outcome: z.string().max(40).optional(),
outcome: z.string().max(40).optional()
.describe("Outcome filter: accepted, answered, rejected, decided, withdrawn, expired (time-outs and supersedes, not task closes), issue_closed (closed with task) or cancelled. Other values match the stored status."),
agentId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
sort: z.enum(["newest", "oldest"]).optional(),
since: z.string().datetime().optional(),
handledForMe: z.enum(["true", "false"]).optional().describe("Only cards for you that a different person answered, withdrew or transferred."),
scope: z.enum(["mine", "all"]).optional()
.describe("Whose history (PAP-768). `mine` is \"Handled by me or for me\": what you answered, withdrew or transferred, what a different person handled for you, and cards whose audience is you (addressee or requester, else the task's chat user, human assignee or instigator). `all` (default) is every person's history. Outcome filters apply within either scope."),
}).strict(),
},
responses: {
@@ -7260,6 +7357,7 @@ registry.registerPath({
),
kind: z.enum(["dismiss", "snooze"]).optional(),
snoozedUntil: z.string().datetime().optional(),
fingerprint: z.string().max(200).optional(),
}),
),
},
+22
View File
@@ -111,6 +111,26 @@ export function projectRoutes(db: Db) {
);
}
/**
* PAP-749 task 1d: the project's decision owner gets this project's ownerless
* Needs You cards, so only a board user may choose them, and they must be an
* active member of the company. `null` clears it; agents cannot change it.
*/
async function assertProjectDecisionOwner(
req: Request,
companyId: string,
decisionOwnerUserId: unknown,
current: string | null = null,
) {
if (decisionOwnerUserId === undefined || decisionOwnerUserId === current) return;
if (req.actor.type !== "board") throw forbidden("Only board users can set a project's decision owner");
if (typeof decisionOwnerUserId !== "string") return;
const membership = await access.getMembership(companyId, "user", decisionOwnerUserId);
if (membership?.status !== "active") {
throw unprocessable("Project decision owner must be an active member of this company");
}
}
async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId;
const requestedCompanyId =
@@ -383,6 +403,7 @@ export function projectRoutes(db: Db) {
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
? await projectToolContext(db, req.actor, true) : null;
await assertProjectDecisionOwner(req, companyId, projectData.decisionOwnerUserId);
assertNoAgentHostWorkspaceCommandMutation(
req,
[
@@ -508,6 +529,7 @@ export function projectRoutes(db: Db) {
req,
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
);
await assertProjectDecisionOwner(req, existing.companyId, body.decisionOwnerUserId, existing.decisionOwnerUserId ?? null);
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
+4 -2
View File
@@ -63,8 +63,9 @@ export function approvalRecords(db: Db) {
// Cancel an open (pending/revision_requested) approval without a board
// decision — e.g. when its paired agent is terminated during duplicate
// cleanup. Idempotent: a no-op on already-resolved approvals.
cancel: async (id: string, reason?: string | null) => {
// cleanup, or when a person withdraws it as out of date (PAP-749). It is
// never an approval. Idempotent: a no-op (null) on already-resolved approvals.
cancel: async (id: string, reason?: string | null, options: { cancelledByUserId?: string | null } = {}) => {
const now = new Date();
const [existing] = await db.select({ companyId: approvals.companyId }).from(approvals).where(eq(approvals.id, id));
if (!existing) return null;
@@ -74,6 +75,7 @@ export function approvalRecords(db: Db) {
.set({
status: "cancelled",
decisionNote: reason ?? null,
...(options.cancelledByUserId ? { decidedByUserId: options.cancelledByUserId } : {}),
decidedAt: now,
updatedAt: now,
})
@@ -0,0 +1,27 @@
import type { AttentionAlreadyClosedDetails, AttentionSourceKind } from "@paperclipai/shared";
import { conflict } from "../errors.js";
/**
* The 409 for "a different person answered or closed this first" (PAP-749
* plan §2, special conditions). The first answer wins; the details name who
* resolved the source so the UI can show "Already answered by @x".
*/
export function attentionAlreadyClosedError(input: {
sourceKind: AttentionSourceKind;
sourceId: string;
status: string;
resolvedByUserId?: string | null;
resolvedByAgentId?: string | null;
resolvedAt?: Date | string | null;
}) {
const details: AttentionAlreadyClosedDetails = {
code: "attention_already_closed",
sourceKind: input.sourceKind,
sourceId: input.sourceId,
status: input.status,
resolvedByUserId: input.resolvedByUserId ?? null,
resolvedByAgentId: input.resolvedByAgentId ?? null,
resolvedAt: input.resolvedAt ? new Date(input.resolvedAt).toISOString() : null,
};
return conflict("Already answered", details);
}
@@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import {
audienceMatchesView,
escalateStaleAudience,
resolveAttentionAudience,
type AttentionAudienceInput,
type IssueAudienceContext,
} from "./attention.js";
const EMPTY_CTX: IssueAudienceContext = {
conversationUserId: null,
assigneeUserId: null,
instigatorUserId: null,
};
function input(overrides: Partial<AttentionAudienceInput> = {}): AttentionAudienceInput {
return { issueId: "issue-1", ...overrides };
}
function ctx(overrides: Partial<IssueAudienceContext> = {}): IssueAudienceContext {
return { ...EMPTY_CTX, ...overrides };
}
describe("resolveAttentionAudience — audience steps (PAP-749 plan §1, Rule 2)", () => {
it("routes an explicit user addressee to that user (step 1)", () => {
expect(resolveAttentionAudience(input({ addresseeUserId: "scott" }), ctx({ instigatorUserId: "dotta" })))
.toEqual({ kind: "user", userId: "scott", reason: "addressee" });
});
it("routes an approval to its requester", () => {
expect(resolveAttentionAudience(input({ requestedByUserId: "scott" }), null))
.toEqual({ kind: "user", userId: "scott", reason: "requester" });
});
it("routes a blocker/recovery card to its named user owner", () => {
expect(resolveAttentionAudience(input({ ownerUserId: "scott" }), null))
.toEqual({ kind: "user", userId: "scott", reason: "assignee" });
});
it("routes a chat card to the conversation user (step 2)", () => {
expect(resolveAttentionAudience(input(), ctx({ conversationUserId: "scott" })))
.toEqual({ kind: "user", userId: "scott", reason: "conversation" });
});
it("routes a review to its current human participant (step 3)", () => {
expect(resolveAttentionAudience(input({ reviewParticipantUserId: "scott" }), ctx({ assigneeUserId: "dotta" })))
.toEqual({ kind: "user", userId: "scott", reason: "reviewer" });
});
it("routes to the human assignee when no participant is named (step 3)", () => {
expect(resolveAttentionAudience(input(), ctx({ assigneeUserId: "scott" })))
.toEqual({ kind: "user", userId: "scott", reason: "assignee" });
});
it("routes to the instigator of the originating human work (step 4)", () => {
expect(resolveAttentionAudience(input(), ctx({ instigatorUserId: "scott" })))
.toEqual({ kind: "user", userId: "scott", reason: "instigator" });
});
it("sends company-operations alerts to the Company view", () => {
expect(resolveAttentionAudience(input({ issueId: null, companyScoped: true }), null))
.toEqual({ kind: "company", userId: null, reason: "none" });
});
it("sends a card with no owner to the claimable Unassigned pool (step 6)", () => {
expect(resolveAttentionAudience(input(), EMPTY_CTX))
.toEqual({ kind: "pool", userId: null, reason: "none" });
});
});
describe("resolveAttentionAudience — precedence", () => {
it("an explicit addressee beats every chain signal", () => {
expect(resolveAttentionAudience(
input({ addresseeUserId: "scott", requestedByUserId: "dotta", reviewParticipantUserId: "riley" }),
ctx({ conversationUserId: "dotta", assigneeUserId: "riley", instigatorUserId: "dotta" }),
)).toMatchObject({ userId: "scott", reason: "addressee" });
});
it("the conversation user beats a reviewer, assignee, and instigator", () => {
expect(resolveAttentionAudience(
input({ reviewParticipantUserId: "riley" }),
ctx({ conversationUserId: "scott", assigneeUserId: "riley", instigatorUserId: "dotta" }),
)).toMatchObject({ userId: "scott", reason: "conversation" });
});
it("a reviewer beats the assignee and the instigator", () => {
expect(resolveAttentionAudience(
input({ reviewParticipantUserId: "scott" }),
ctx({ assigneeUserId: "riley", instigatorUserId: "dotta" }),
)).toMatchObject({ userId: "scott", reason: "reviewer" });
});
it("the assignee beats the instigator", () => {
expect(resolveAttentionAudience(
input(),
ctx({ assigneeUserId: "scott", instigatorUserId: "dotta" }),
)).toMatchObject({ userId: "scott", reason: "assignee" });
});
});
describe("resolveAttentionAudience — default decision owner (PAP-749 task 1d-lite, step 5)", () => {
it("routes an ownerless card to the company default decision owner", () => {
expect(resolveAttentionAudience(input(), EMPTY_CTX, { defaultDecisionOwnerUserId: "scott" }))
.toEqual({ kind: "user", userId: "scott", reason: "escalation_owner" });
});
it("falls back to Unassigned when no default decision owner is set", () => {
expect(resolveAttentionAudience(input(), EMPTY_CTX, { defaultDecisionOwnerUserId: null }))
.toEqual({ kind: "pool", userId: null, reason: "none" });
// Omitting the config entirely behaves the same as an unset owner.
expect(resolveAttentionAudience(input(), EMPTY_CTX))
.toEqual({ kind: "pool", userId: null, reason: "none" });
});
it("does not override an earlier chain step with the default owner", () => {
// A card that already resolves to a person keeps that person; the default
// owner only catches cards that would otherwise fall through to the pool.
expect(resolveAttentionAudience(
input({ addresseeUserId: "dotta" }),
ctx({ instigatorUserId: "riley" }),
{ defaultDecisionOwnerUserId: "scott" },
)).toEqual({ kind: "user", userId: "dotta", reason: "addressee" });
expect(resolveAttentionAudience(
input(),
ctx({ instigatorUserId: "riley" }),
{ defaultDecisionOwnerUserId: "scott" },
)).toEqual({ kind: "user", userId: "riley", reason: "instigator" });
});
it("never pulls a company-operations alert onto the default owner", () => {
expect(resolveAttentionAudience(
input({ issueId: null, companyScoped: true }),
null,
{ defaultDecisionOwnerUserId: "scott" },
)).toEqual({ kind: "company", userId: null, reason: "none" });
});
it("the default owner's card lands in that user's `mine` view, not Unassigned", () => {
const audience = resolveAttentionAudience(input(), EMPTY_CTX, { defaultDecisionOwnerUserId: "scott" });
expect(audienceMatchesView(audience, "mine", "scott")).toBe(true);
expect(audienceMatchesView(audience, "unassigned", "scott")).toBe(false);
expect(audienceMatchesView(audience, "company", "scott")).toBe(false);
});
});
describe("resolveAttentionAudience — project and budget owners (PAP-749 task 1d)", () => {
it("routes an ownerless card to its project's decision owner before the company default", () => {
expect(resolveAttentionAudience(input(), ctx({ projectOwnerUserId: "riley" }), { defaultDecisionOwnerUserId: "scott" }))
.toEqual({ kind: "user", userId: "riley", reason: "project_owner" });
});
it("keeps earlier chain steps ahead of the project owner", () => {
expect(resolveAttentionAudience(input(), ctx({ instigatorUserId: "dotta", projectOwnerUserId: "riley" })))
.toEqual({ kind: "user", userId: "dotta", reason: "instigator" });
});
it("falls back to the default decision owner, then Unassigned, without a project owner", () => {
expect(resolveAttentionAudience(input(), ctx({ projectOwnerUserId: null }), { defaultDecisionOwnerUserId: "scott" }))
.toEqual({ kind: "user", userId: "scott", reason: "escalation_owner" });
expect(resolveAttentionAudience(input(), ctx({ projectOwnerUserId: null })))
.toEqual({ kind: "pool", userId: null, reason: "none" });
});
it("gives a budget hard stop to the budget owner and keeps it in Company", () => {
const audience = resolveAttentionAudience(
input({ issueId: null, companyScoped: true, escalatesToBudgetOwner: true }),
null,
{ budgetOwnerUserId: "scott", defaultDecisionOwnerUserId: "dotta" },
);
expect(audience).toEqual({ kind: "user", userId: "scott", reason: "budget_owner", alsoInCompany: true });
expect(audienceMatchesView(audience, "mine", "scott")).toBe(true);
expect(audienceMatchesView(audience, "mine", "dotta")).toBe(false);
// Critical incidents always show in Company, for every operator (Rule 3).
expect(audienceMatchesView(audience, "company", "scott")).toBe(true);
expect(audienceMatchesView(audience, "company", "dotta")).toBe(true);
expect(audienceMatchesView(audience, "unassigned", "dotta")).toBe(false);
});
it("leaves a budget hard stop in Company only when no budget owner is set", () => {
expect(resolveAttentionAudience(
input({ issueId: null, companyScoped: true, escalatesToBudgetOwner: true }),
null,
{ defaultDecisionOwnerUserId: "dotta" },
)).toEqual({ kind: "company", userId: null, reason: "none" });
});
it("never gives other company alerts to the budget owner", () => {
expect(resolveAttentionAudience(
input({ issueId: null, companyScoped: true }),
null,
{ budgetOwnerUserId: "scott" },
)).toEqual({ kind: "company", userId: null, reason: "none" });
});
it("escalates a required card waiting 30 days to its project owner, then the default owner", () => {
const now = Date.parse("2026-10-30T00:00:00Z");
const since = new Date(now - 31 * 86_400_000);
const audience = { kind: "user" as const, userId: "dotta", reason: "requester" as const };
expect(escalateStaleAudience({
audience, since, required: true, now,
companyConfig: { defaultDecisionOwnerUserId: "scott" },
ctx: ctx({ projectOwnerUserId: "riley" }),
})).toEqual({ kind: "user", userId: "riley", reason: "project_owner" });
expect(escalateStaleAudience({
audience, since, required: true, now,
companyConfig: { defaultDecisionOwnerUserId: "scott" },
ctx: ctx(),
})).toEqual({ kind: "user", userId: "scott", reason: "escalation_owner" });
expect(escalateStaleAudience({ audience, since, required: true, now, ctx: ctx() }))
.toEqual({ kind: "company", userId: null, reason: "none" });
});
});
describe("resolveAttentionAudience — forbidden routing signals", () => {
// `responsibleUserId`, the routine creator, the connected account, the agent
// owner, and a manual routine starter are deliberately not inputs, so a card
// whose only human signal would be one of them resolves to Unassigned, never
// to a person (PAP-749 plan §1, Rule 2).
it("does not route a card that has no addressee/owner/participant/instigator", () => {
expect(resolveAttentionAudience(input(), EMPTY_CTX))
.toEqual({ kind: "pool", userId: null, reason: "none" });
});
it("ignores a falsy owner/addressee rather than inventing a user", () => {
expect(resolveAttentionAudience(
input({ addresseeUserId: null, requestedByUserId: null, ownerUserId: null }),
EMPTY_CTX,
)).toEqual({ kind: "pool", userId: null, reason: "none" });
});
});
describe("audienceMatchesView (PAP-749 plan §1, Rule 3)", () => {
const userAudience = { kind: "user" as const, userId: "scott", reason: "addressee" as const };
const otherUserAudience = { kind: "user" as const, userId: "dotta", reason: "instigator" as const };
const poolAudience = { kind: "pool" as const, userId: null, reason: "none" as const };
const companyAudience = { kind: "company" as const, userId: null, reason: "none" as const };
it("`all` keeps every audience (the pre-PAP-749 superset)", () => {
for (const audience of [userAudience, otherUserAudience, poolAudience, companyAudience]) {
expect(audienceMatchesView(audience, "all", "scott")).toBe(true);
}
});
it("`mine` keeps only the requesting user's cards", () => {
expect(audienceMatchesView(userAudience, "mine", "scott")).toBe(true);
expect(audienceMatchesView(otherUserAudience, "mine", "scott")).toBe(false);
expect(audienceMatchesView(poolAudience, "mine", "scott")).toBe(false);
expect(audienceMatchesView(companyAudience, "mine", "scott")).toBe(false);
});
it("`mine` keeps nothing when the requesting user is unknown", () => {
expect(audienceMatchesView(userAudience, "mine", null)).toBe(false);
});
it("`unassigned` keeps only pool cards", () => {
expect(audienceMatchesView(poolAudience, "unassigned", "scott")).toBe(true);
expect(audienceMatchesView(userAudience, "unassigned", "scott")).toBe(false);
expect(audienceMatchesView(companyAudience, "unassigned", "scott")).toBe(false);
});
it("`company` keeps only company-operations cards", () => {
expect(audienceMatchesView(companyAudience, "company", "scott")).toBe(true);
expect(audienceMatchesView(poolAudience, "company", "scott")).toBe(false);
expect(audienceMatchesView(userAudience, "company", "scott")).toBe(false);
});
});
+391
View File
@@ -0,0 +1,391 @@
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
import {
activityLog,
agents,
approvals,
budgetIncidents,
companies,
decisions,
inboxDismissals,
issues,
issueThreadInteractions,
type Db,
} from "@paperclipai/db";
import {
ATTENTION_RECEIPT_DAYS,
ATTENTION_RECEIPT_OUTCOMES,
ATTENTION_SOURCE_KINDS,
type AttentionAudience,
type AttentionHandledDetails,
type AttentionReceipt,
type AttentionReceiptOutcome,
type AttentionSourceKind,
type BudgetScopeType,
} from "@paperclipai/shared";
import { logger } from "../middleware/logger.js";
import { evaluateAgentInvokability, type AgentOrgRow } from "./agent-invokability.js";
import { firstApprovalIssueId } from "./approval-visibility.js";
import {
approvalTitle,
escalateStaleAudience,
interactionLabel,
loadCompanyAttentionConfig,
resolveAttentionAudience,
resolveIssueAudienceContextMap,
type AttentionAudienceInput,
} from "./attention.js";
import { approvalReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { budgetScopeName } from "./budgets.js";
import { deferrableInteractionCondition } from "./issue-question-context.js";
/**
* Receipts for the other person (PAP-749 plan §6, Rule 12). When a person
* answers, withdraws or transfers a card that was for a different person, the
* action's activity entry names that person in `details.handledForUserId` and
* describes the card in `details.receipt`. The For you feed reads those
* entries back as receipts; there is no receipts table.
*/
const DAY_MS = 86_400_000;
const RECEIPT_LIMIT = 20;
/** Raw entries read per feed; several entries for one card collapse to one receipt. */
const RECEIPT_SCAN_LIMIT = 200;
export const ATTENTION_RECEIPT_DISMISSAL_PREFIX = "attention:receipt:";
/**
* Sources a person can answer, withdraw or transfer for somebody else. A
* budget card is for a person only when it is a hard stop with a budget owner
* (task 1d).
*/
export type AttentionHandledSourceKind = Extract<
AttentionSourceKind,
"issue_thread_interaction" | "approval" | "decision" | "budget_alert"
>;
/** The card a person is about to act on, and who it is for right now. */
export type AttentionHandledFor = {
userId: string;
actorUserId: string;
sourceKind: AttentionHandledSourceKind;
sourceId: string;
issueId: string | null;
title: string | null;
};
/** The activity `details` fragment of a receipt; empty when there is no other person. */
export type AttentionHandledDetailsFragment =
| { handledForUserId: string; receipt: AttentionHandledDetails }
| Record<string, never>;
/**
* Where one card shows right now, by the same rules as the feed (Rule 2 and
* the Rule 10 escalation), without building the whole feed. `null` when the
* source does not exist or is not a card for a person (an invokable agent's
* request stays with the agent).
*/
export async function resolveAttentionCardAudience(
db: Db,
companyId: string,
source: { sourceKind: AttentionHandledSourceKind; sourceId: string },
now = Date.now(),
): Promise<{ audience: AttentionAudience; issueId: string | null; title: string | null } | null> {
let input: AttentionAudienceInput;
let title: string | null;
let since: Date;
let required: boolean;
if (source.sourceKind === "issue_thread_interaction") {
const [row] = await db
.select({
issueId: issueThreadInteractions.issueId,
kind: issueThreadInteractions.kind,
title: issueThreadInteractions.title,
summary: issueThreadInteractions.summary,
addresseeUserId: issueThreadInteractions.addresseeUserId,
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
createdAt: issueThreadInteractions.createdAt,
required: sql<boolean>`not ${deferrableInteractionCondition()}`,
})
.from(issueThreadInteractions)
.where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.id, source.sourceId)))
.limit(1);
if (!row) return null;
if (row.addresseeAgentId && !row.addresseeUserId) {
// A request to an agent that can still run is not a card for anyone.
const companyAgentRows: AgentOrgRow[] = await db
.select({ id: agents.id, companyId: agents.companyId, name: agents.name, reportsTo: agents.reportsTo, status: agents.status })
.from(agents)
.where(eq(agents.companyId, companyId));
const addressee = companyAgentRows.find((agent) => agent.id === row.addresseeAgentId);
if (evaluateAgentInvokability(addressee, companyAgentRows).invokable) return null;
}
input = { issueId: row.issueId, addresseeUserId: row.addresseeUserId };
title = row.title ?? row.summary ?? interactionLabel(row.kind);
since = row.createdAt;
required = row.required === true;
} else if (source.sourceKind === "approval") {
const [row] = await db
.select({
type: approvals.type,
payload: approvals.payload,
requestedByUserId: approvals.requestedByUserId,
issueId: firstApprovalIssueId(),
updatedAt: approvals.updatedAt,
})
.from(approvals)
.where(and(eq(approvals.companyId, companyId), eq(approvals.id, source.sourceId)))
.limit(1);
if (!row) return null;
input = { issueId: row.issueId ?? null, requestedByUserId: row.requestedByUserId };
title = approvalTitle(row.type, row.payload);
since = row.updatedAt;
required = true;
} else if (source.sourceKind === "budget_alert") {
// The budget owner's card is the open hard stop (PAP-749 task 1d). It has
// no issue; everything else about a budget card is a Company alert.
const [row] = await db
.select({
scopeType: budgetIncidents.scopeType,
scopeId: budgetIncidents.scopeId,
thresholdType: budgetIncidents.thresholdType,
status: budgetIncidents.status,
createdAt: budgetIncidents.createdAt,
})
.from(budgetIncidents)
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.id, source.sourceId)))
.limit(1);
if (!row || row.status !== "open") return null;
const hard = row.thresholdType === "hard";
input = { issueId: null, companyScoped: true, escalatesToBudgetOwner: hard };
const scopeName = await budgetScopeName(db, row.scopeType as BudgetScopeType, row.scopeId);
title = `${scopeName} budget ${hard ? "hard stop" : "warning"}`;
since = row.createdAt;
required = false;
} else {
const [row] = await db
.select({ originIssueId: decisions.originIssueId, title: decisions.title, createdAt: decisions.createdAt })
.from(decisions)
.where(and(eq(decisions.companyId, companyId), eq(decisions.id, source.sourceId)))
.limit(1);
if (!row) return null;
input = { issueId: row.originIssueId };
title = row.title;
since = row.createdAt;
required = false;
}
const [contexts, config] = await Promise.all([
resolveIssueAudienceContextMap(db, companyId, [input.issueId]),
loadCompanyAttentionConfig(db, companyId),
]);
const companyConfig = {
defaultDecisionOwnerUserId: config.defaultDecisionOwnerUserId,
budgetOwnerUserId: config.budgetOwnerUserId,
};
const ctx = input.issueId ? contexts.get(input.issueId) ?? null : null;
const audience = escalateStaleAudience({
audience: resolveAttentionAudience(input, ctx, companyConfig),
since,
required,
now,
companyConfig,
ctx,
});
return { audience, issueId: input.issueId, title };
}
/**
* Who a card is for when a person acts on it, read before the action changes
* it. `null` when the actor is no person, the card is for nobody in
* particular, or the actor is that person (no receipt for yourself). Never
* throws: a missing receipt must not block the answer.
*/
export async function attentionHandledFor(
db: Db,
companyId: string,
source: { sourceKind: AttentionHandledSourceKind; sourceId: string },
actorUserId: string | null | undefined,
): Promise<AttentionHandledFor | null> {
if (!actorUserId) return null;
try {
const card = await resolveAttentionCardAudience(db, companyId, source);
if (!card || card.audience.kind !== "user" || !card.audience.userId) return null;
if (card.audience.userId === actorUserId) return null;
return {
userId: card.audience.userId,
actorUserId,
sourceKind: source.sourceKind,
sourceId: source.sourceId,
issueId: card.issueId,
title: card.title,
};
} catch (err) {
logger.warn({ err, companyId, ...source }, "attention receipt audience lookup failed");
return null;
}
}
/** Spread into the action's activity `details`. Empty when there is no other person. */
export function attentionHandledDetails(
handledFor: AttentionHandledFor | null | undefined,
outcome: AttentionReceiptOutcome,
extra: { reason?: string | null; transferredToUserId?: string | null } = {},
): AttentionHandledDetailsFragment {
if (!handledFor) return {};
return {
handledForUserId: handledFor.userId,
receipt: {
outcome,
sourceKind: handledFor.sourceKind,
sourceId: handledFor.sourceId,
issueId: handledFor.issueId,
title: handledFor.title,
actorUserId: handledFor.actorUserId,
...(extra.reason?.trim() ? { reason: extra.reason.trim() } : {}),
...(extra.transferredToUserId ? { transferredToUserId: extra.transferredToUserId } : {}),
},
};
}
export function attentionReceiptDismissalKey(activityId: string) {
return `${ATTENTION_RECEIPT_DISMISSAL_PREFIX}${activityId}`;
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
/** The stored receipt of one activity entry, or `null` when it is malformed. */
export function readAttentionHandledDetails(details: unknown): AttentionHandledDetails | null {
if (!details || typeof details !== "object") return null;
const receipt = (details as Record<string, unknown>).receipt;
if (!receipt || typeof receipt !== "object") return null;
const record = receipt as Record<string, unknown>;
const outcome = readString(record.outcome);
const sourceKind = readString(record.sourceKind);
const sourceId = readString(record.sourceId);
const actorUserId = readString(record.actorUserId);
if (!outcome || !(ATTENTION_RECEIPT_OUTCOMES as readonly string[]).includes(outcome)) return null;
if (!sourceKind || !(ATTENTION_SOURCE_KINDS as readonly string[]).includes(sourceKind)) return null;
if (!sourceId || !actorUserId) return null;
return {
outcome: outcome as AttentionReceiptOutcome,
sourceKind: sourceKind as AttentionSourceKind,
sourceId,
issueId: readString(record.issueId),
title: readString(record.title),
actorUserId,
reason: readString(record.reason),
transferredToUserId: readString(record.transferredToUserId),
};
}
/** SQL: the entry names this person as the one a card was for. */
export function handledForUserCondition(userId: string) {
return sql<boolean>`(${activityLog.details} ->> 'handledForUserId') = ${userId}`;
}
export function attentionReceiptService(db: Db) {
return {
/**
* Receipts for one person, newest first: one per card, from the last
* {@link ATTENTION_RECEIPT_DAYS} days, without the ones they dismissed or
* can no longer read.
*/
list: async (
companyId: string,
options: { userId: string; actor: AuthorizationActor; now?: number },
): Promise<AttentionReceipt[]> => {
const now = options.now ?? Date.now();
const since = new Date(now - ATTENTION_RECEIPT_DAYS * DAY_MS);
const rows = await db
.select({ id: activityLog.id, createdAt: activityLog.createdAt, details: activityLog.details })
.from(activityLog)
.where(and(
eq(activityLog.companyId, companyId),
handledForUserCondition(options.userId),
gte(activityLog.createdAt, since),
))
.orderBy(desc(activityLog.createdAt), desc(activityLog.id))
.limit(RECEIPT_SCAN_LIMIT);
// Withdraw through Needs You writes the source's own entry and
// `attention.closed`; both name the person. Keep the newest per card.
const seen = new Set<string>();
const candidates: Array<{ id: string; createdAt: Date; receipt: AttentionHandledDetails }> = [];
for (const row of rows) {
const receipt = readAttentionHandledDetails(row.details);
if (!receipt || receipt.actorUserId === options.userId) continue;
const key = `${receipt.sourceKind}:${receipt.sourceId}`;
if (seen.has(key)) continue;
seen.add(key);
candidates.push({ id: row.id, createdAt: row.createdAt, receipt });
}
if (candidates.length === 0) return [];
const keys = candidates.map((candidate) => attentionReceiptDismissalKey(candidate.id));
const issueIds = [...new Set(candidates.map((candidate) => candidate.receipt.issueId).filter((id): id is string => Boolean(id)))];
const readCondition = await issueReadSqlCondition(db, options.actor);
const approvalIds = candidates.filter(candidate => candidate.receipt.sourceKind === "approval").map(candidate => candidate.receipt.sourceId);
const approvalRead = await approvalReadSqlCondition(db, options.actor);
const [dismissedRows, issueRows, company, approvalRows] = await Promise.all([
db
.select({ itemKey: inboxDismissals.itemKey })
.from(inboxDismissals)
.where(and(
eq(inboxDismissals.companyId, companyId),
eq(inboxDismissals.userId, options.userId),
inArray(inboxDismissals.itemKey, keys),
)),
issueIds.length > 0
? db
.select({ id: issues.id, identifier: issues.identifier, title: issues.title })
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds), readCondition))
: Promise.resolve([]),
db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).then((r) => r[0] ?? null),
approvalIds.length ? db.select({ id: approvals.id }).from(approvals).where(and(
eq(approvals.companyId, companyId), inArray(approvals.id, approvalIds), approvalRead,
)) : Promise.resolve([]),
]);
const dismissed = new Set(dismissedRows.map((row) => row.itemKey));
const readableIssues = new Map(issueRows.map((row) => [row.id, row]));
const readableApprovals = new Set(approvalRows.map(row => row.id));
const prefix = company?.prefix ?? "PAP";
const receipts: AttentionReceipt[] = [];
for (const { id, createdAt, receipt } of candidates) {
const dismissalKey = attentionReceiptDismissalKey(id);
if (dismissed.has(dismissalKey)) continue;
if (receipt.sourceKind === "approval" && !readableApprovals.has(receipt.sourceId)) continue;
const issueRow = receipt.issueId ? readableIssues.get(receipt.issueId) : null;
// A task the person can no longer read takes its receipt with it.
if (receipt.issueId && !issueRow) continue;
const issueHref = issueRow ? `/${prefix}/issues/${issueRow.identifier ?? issueRow.id}` : null;
receipts.push({
id,
dismissalKey,
outcome: receipt.outcome,
sourceKind: receipt.sourceKind,
sourceId: receipt.sourceId,
title: receipt.title,
reason: receipt.reason ?? null,
actorUserId: receipt.actorUserId,
transferredToUserId: receipt.transferredToUserId ?? null,
issue: issueRow && issueHref
? { id: issueRow.id, identifier: issueRow.identifier, title: issueRow.title, href: issueHref }
: null,
href: receipt.sourceKind === "approval"
? `/${prefix}/approvals/${receipt.sourceId}`
: receipt.sourceKind === "budget_alert"
? `/${prefix}/costs`
: issueHref && receipt.sourceKind === "issue_thread_interaction"
? `${issueHref}#interaction-${receipt.sourceId}`
: issueHref,
createdAt: createdAt.toISOString(),
expiresAt: new Date(createdAt.getTime() + ATTENTION_RECEIPT_DAYS * DAY_MS).toISOString(),
});
if (receipts.length >= RECEIPT_LIMIT) break;
}
return receipts;
},
};
}
@@ -48,11 +48,11 @@ export function collapsePendingConfirmationsToNewest<T extends {
addresseeUserId?: string | null;
addresseeAgentId?: string | null;
}>(rows: T[], newestSettledConfirmationAt: ReadonlyMap<string, Date> = new Map()) {
const independent = (row: T) => payloadFlag(row.payload, "qualityReview");
const independent = (row: T) => payloadFlag(row.payload, "qualityReview") || governedAction(row.payload);
const newestByGroup = new Map<string, T>();
for (const row of rows) {
if (row.kind !== "request_confirmation" || independent(row)) continue;
const groupKey = `${row.issueId}:${row.kind}`;
const groupKey = confirmationAudienceKey(row);
const newest = newestByGroup.get(groupKey);
if (
!newest
@@ -65,8 +65,8 @@ export function collapsePendingConfirmationsToNewest<T extends {
return rows.filter((row) => {
if (row.kind !== "request_confirmation" || independent(row)) return true;
if (newestByGroup.get(`${row.issueId}:${row.kind}`)?.id !== row.id) return false;
const settledAt = governedAction(row.payload) ? undefined : newestSettledConfirmationAt.get(confirmationAudienceKey(row));
if (newestByGroup.get(confirmationAudienceKey(row))?.id !== row.id) return false;
const settledAt = newestSettledConfirmationAt.get(confirmationAudienceKey(row));
return !settledAt || settledAt.getTime() <= row.createdAt.getTime();
});
}
File diff suppressed because it is too large. Load diff
+16
View File
@@ -99,6 +99,7 @@ export type AuthorizationAction =
| "agent_instructions:update"
| "skill_config:update"
| "agent:read"
| "attention:route"
| "agent:wake"
| "company_scope:read"
| "decision_queue:manage"
@@ -196,6 +197,7 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null
return null;
}
if (
action === "attention:route" ||
action === "agent:read" ||
action === "agent:wake" ||
action === "company_scope:read" ||
@@ -2017,6 +2019,11 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
});
}
if (input.action === "attention:route" && input.actor.type !== "board") {
return deny({ action: input.action, reason: "deny_unsupported_action",
explanation: "Only a human routing administrator can reroute another person's request." });
}
if (input.action === "runtime:confirm_owner_stop" && input.actor.type !== "board") {
return deny({ action: input.action, reason: "deny_unsupported_action",
explanation: "Only a deployment administrator can confirm a workspace controller has stopped." });
@@ -2050,6 +2057,15 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
explanation: "Allowed because the actor is an instance admin.",
});
}
if (input.action === "attention:route") {
const membership = await getActiveMembership(companyId, "user", input.actor.userId ?? "");
if (membership?.membershipRole === "owner" || membership?.membershipRole === "admin") {
return allow({ action: input.action, reason: "allow_role_default",
explanation: "Allowed by the active company owner or administrator role." });
}
return deny({ action: input.action, reason: "deny_missing_grant",
explanation: "Only a company owner, company administrator, or instance administrator can reroute another person's request." });
}
if (input.action === "runtime:confirm_owner_stop") {
const membership = await getActiveMembership(companyId, "user", input.actor.userId ?? "");
if (membership?.membershipRole === "owner" || membership?.membershipRole === "admin") {
+10 -2
View File
@@ -187,6 +187,12 @@ async function resolveSummaryScopeRecord(db: Db, companyId: string, scopeType: B
return scope;
}
/** The display name of a budget scope, as budget incidents show it. */
export async function budgetScopeName(db: Db, scopeType: BudgetScopeType, scopeId: string): Promise<string> {
const scope = await resolveScopeRecord(db, scopeType, scopeId);
return normalizeScopeName(scopeType, scope.name);
}
// Keep a large dashboard from filling the database pool with queued reads.
async function mapBudgetReads<T, R>(rows: T[], read: (row: T) => Promise<R>): Promise<R[]> {
const result: R[] = [];
@@ -1012,6 +1018,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
actorUserId: string,
/** The resolving actor's session source, kept for the continuation Retry. */
actorSource?: string | null,
options: { activityDetails?: Record<string, unknown> } = {},
): Promise<BudgetIncident> => {
const incident = await db
.select()
@@ -1123,6 +1130,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
amount: input.amount ?? null,
scopeType: incident.scopeType,
scopeId: incident.scopeId,
...options.activityDetails,
},
});
@@ -1379,11 +1387,11 @@ export function budgetService(db: Db, _hooks: BudgetServiceHooks = {}) {
withBudgetSpan("budget.admission.inspect", () => withAccountingReadSnapshot(db, companyId,
tx => budgetServiceInTransaction(tx).getInvocationBlock(companyId, agentId, context))),
resolveIncident: async (companyId: string, incidentId: string, input: BudgetIncidentResolutionInput, actorUserId: string,
actorSource?: string | null) => {
actorSource?: string | null, options: { activityDetails?: Record<string, unknown> } = {}) => {
const [scope] = await db.select({ scopeType: budgetIncidents.scopeType, scopeId: budgetIncidents.scopeId }).from(budgetIncidents)
.where(and(eq(budgetIncidents.id, incidentId), eq(budgetIncidents.companyId, companyId)));
if (!scope) throw notFound("Budget incident not found");
return mutate(companyId, service => service.resolveIncident(companyId, incidentId, input, actorUserId, actorSource), scope);
return mutate(companyId, service => service.resolveIncident(companyId, incidentId, input, actorUserId, actorSource, options), scope);
},
};
}
@@ -0,0 +1,95 @@
import { and, eq, gte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issues, issueThreadInteractions } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { historicalQuestionCondition } from "./issue-question-context.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
const SWEEP_SOURCE = "attention.closed_issue_sweep";
/**
* Expire pending cards that still hang on done or cancelled issues (PAP-749
* plan §2). The status transition already runs the terminal-issue expiry, but
* older rows predate it and some write paths skipped it, so those cards kept
* asking for answers on closed work.
*
* Each issue goes through `expirePendingInteractionsForTerminalIssue`, so the
* rules stay in one place: a cancelled issue expires every pending card, and a
* done issue keeps its ordinary historical questions answerable. The candidate
* query uses the same conditions, so a second run finds nothing and changes
* nothing. It runs once per server start; it recovers durable rows and is not
* a polling loop.
*/
export async function expirePendingInteractionsOnClosedIssues(db: Db) {
const candidates = await db
.selectDistinct({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
status: issues.status,
})
.from(issueThreadInteractions)
.innerJoin(issues, and(
eq(issueThreadInteractions.issueId, issues.id),
eq(issueThreadInteractions.companyId, issues.companyId),
))
.where(and(
eq(issueThreadInteractions.status, "pending"),
or(
eq(issues.status, "cancelled"),
and(eq(issues.status, "done"), sql`not ${historicalQuestionCondition()}`),
),
));
const interactions = issueThreadInteractionService(db);
let expired = 0;
let failed = 0;
for (const issue of candidates) {
const startedAt = new Date();
let rows: Array<{ id: string; kind: string; status: string; result?: unknown }>;
try {
rows = await interactions.expirePendingInteractionsForTerminalIssue(issue, {});
} catch (err) {
// One bad card (e.g. a legacy payload) must not strand the rest; the
// next start retries it. Each card expires in its own transaction, so
// earlier cards on this issue may already be closed: log those too.
failed += 1;
logger.warn({ err, issueId: issue.id }, "closed-issue interaction sweep failed for one issue");
rows = await db
.select({
id: issueThreadInteractions.id,
kind: issueThreadInteractions.kind,
status: issueThreadInteractions.status,
result: issueThreadInteractions.result,
})
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.status, "expired"),
gte(issueThreadInteractions.resolvedAt, startedAt),
));
}
for (const row of rows) {
await logActivity(db, {
companyId: issue.companyId,
actorType: "system",
actorId: SWEEP_SOURCE,
action: "issue.thread_interaction_expired",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier ?? null,
interactionId: row.id,
interactionKind: row.kind,
interactionStatus: row.status,
source: SWEEP_SOURCE,
result: row.result ?? null,
},
});
}
expired += rows.length;
}
return { issues: candidates.length, expired, failed };
}
+2
View File
@@ -127,6 +127,8 @@ export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) {
budgetMonthlyCents: companies.budgetMonthlyCents,
spentMonthlyCents: companies.spentMonthlyCents,
defaultResponsibleUserId: companies.defaultResponsibleUserId,
defaultDecisionOwnerUserId: companies.defaultDecisionOwnerUserId,
budgetOwnerUserId: companies.budgetOwnerUserId,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
interactionResolverGovernance: companies.interactionResolverGovernance,
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
+337 -19
View File
@@ -1,8 +1,13 @@
import { and, asc, desc, eq, ilike, inArray, isNotNull, notInArray, or, sql, type SQL } from "drizzle-orm";
import { agents, companies, decisions, decisionTargetIssues, issues, issueThreadInteractions, projects, type Db } from "@paperclipai/db";
import type { DecisionHistoryItem, DecisionHistoryPage, DecisionHistoryQuery } from "@paperclipai/shared";
import { issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { and, asc, desc, eq, ilike, inArray, isNotNull, isNull, notInArray, or, sql, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { activityLog, agents, approvals, budgetIncidents, companies, decisions, decisionTargetIssues, issues, issueThreadInteractions, projects, type Db } from "@paperclipai/db";
import { ATTENTION_RECEIPT_OUTCOME_TEXT, type DecisionHistoryAction, type DecisionHistoryItem, type DecisionHistoryPage, type DecisionHistoryQuery, type DecisionHistoryResultOutcome } from "@paperclipai/shared";
import { approvalReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { badRequest } from "../errors.js";
import { firstApprovalIssueId } from "./approval-visibility.js";
import { approvalTitle, MAX_INSTIGATOR_CHAIN_DEPTH } from "./attention.js";
import { readAttentionHandledDetails } from "./attention-receipts.js";
import { humanCreatedIssueCondition } from "./issue-participation.js";
type HistoryOptions = DecisionHistoryQuery & { actor: AuthorizationActor; userId: string; issueIds?: string[] };
type Cursor = { at: string; id: string; sort: "newest" | "oldest" };
@@ -18,11 +23,112 @@ export function itemVerdictsHistoryLabel(result: unknown): string | null {
counts.defer ? `${counts.defer} deferred` : null].filter(Boolean).join(" · ") || "Review recorded";
}
function readRecordValue(value: unknown, key: string) {
return value && typeof value === "object" && !Array.isArray(value) && key in value
? (value as Record<string, unknown>)[key]
: undefined;
}
/**
* History labels by outcome. An interaction's `result.outcome` is more
* specific than its status: a withdrawn, skipped or task-closed card is stored
* as `cancelled` or `expired` (PAP-749 plan §2, Data and §3). A withdraw by a
* person reads `withdrawn_by_user`; one by the agent itself stays `withdrawn`.
*/
const HISTORY_OUTCOME_LABELS: Record<string, string> = {
accepted: "Approved", rejected: "Changes requested", answered: "Answers recorded", cancelled: "Cancelled",
expired: "Expired", failed: "Failed", stale_target: "Superseded", superseded_by_newer_request: "Superseded",
superseded_by_comment: "Answered in task", withdrawn: "Withdrawn", withdrawn_by_user: "Withdrawn by user",
skipped: "Skipped", issue_closed: "Closed with task",
};
/** Approval labels by status (PAP-749 plan §3: approvals in history). */
const APPROVAL_OUTCOME_LABELS: Record<string, string> = {
approved: "Approved", rejected: "Rejected", revision_requested: "Revision requested", cancelled: "Cancelled",
};
/** History outcome filters that read `result.outcome`, not the status. */
const RESULT_OUTCOME_FILTERS = new Set(["withdrawn", "withdrawn_by_user", "skipped", "issue_closed"]);
const RESULT_OUTCOMES: ReadonlySet<string> = new Set<DecisionHistoryResultOutcome>(["withdrawn", "skipped", "issue_closed"]);
/** The specific close an interaction result records, for the history filters and UI. */
export function interactionHistoryResultOutcome(result: unknown): DecisionHistoryResultOutcome | null {
const outcome = readRecordValue(result, "outcome");
return typeof outcome === "string" && RESULT_OUTCOMES.has(outcome) ? outcome as DecisionHistoryResultOutcome : null;
}
/**
* A budget hard stop's approval closes through the budget card: Raise limit
* approves it and Keep paused rejects it (`budgets.resolveIncident`).
*/
const BUDGET_APPROVAL_ACTIONS: Record<string, DecisionHistoryAction["outcome"]> = {
approved: "limit_raised", rejected: "kept_paused",
};
const APPROVAL_ACTIONS: Record<string, DecisionHistoryAction["outcome"]> = {
approved: "approved", rejected: "rejected", revision_requested: "revision_requested", cancelled: "withdrawn",
};
/** An approval's history label: a cancel by a person is a withdraw (PAP-749 plan §2, Rule 6). */
export function approvalHistoryLabel(row: { status: string; decidedByUserId: string | null; type?: string }) {
if (row.status === "cancelled" && row.decidedByUserId) return HISTORY_OUTCOME_LABELS.withdrawn_by_user!;
const budgetAction = row.type === "budget_override_required" ? BUDGET_APPROVAL_ACTIONS[row.status] : undefined;
if (budgetAction) return ATTENTION_RECEIPT_OUTCOME_TEXT[budgetAction];
return APPROVAL_OUTCOME_LABELS[row.status] ?? "Decision recorded";
}
/**
* What the person did, in the receipt's words (PAP-767): the UI renders
* "Kept paused by @dotta" from this, so a history row and the receipt for the
* same action read the same verb. `null` when no person acted.
*/
export function approvalHistoryAction(row: { status: string; decidedByUserId: string | null; type: string }): DecisionHistoryAction | null {
if (!row.decidedByUserId) return null;
const budgetAction = row.type === "budget_override_required" ? BUDGET_APPROVAL_ACTIONS[row.status] : undefined;
if (budgetAction) return { outcome: budgetAction, detail: null };
const outcome = APPROVAL_ACTIONS[row.status];
return outcome ? { outcome, detail: null } : null;
}
export function interactionHistoryAction(row: { kind: string; status: string; result: unknown; resolvedByUserId: string | null }): DecisionHistoryAction | null {
if (!row.resolvedByUserId) return null;
const resultOutcome = interactionHistoryResultOutcome(row.result);
if (resultOutcome === "withdrawn" || resultOutcome === "skipped") return { outcome: resultOutcome, detail: null };
if (resultOutcome) return null;
if (row.status === "accepted") return { outcome: "approved", detail: null };
if (row.status === "rejected") return { outcome: "changes_requested", detail: null };
if (row.status === "answered") {
return { outcome: "answered", detail: row.kind === "request_item_verdicts" ? itemVerdictsHistoryLabel(row.result) : null };
}
return null;
}
export function decisionHistoryAction(row: { status: string; resolvedByUserId: string | null; chosenLabel: string | null }): DecisionHistoryAction | null {
if (!row.resolvedByUserId) return null;
if (row.status === "decided") return { outcome: "decided", detail: row.chosenLabel };
if (row.status === "cancelled") return { outcome: "withdrawn", detail: null };
return null;
}
export function interactionHistoryLabel(row: { kind: string; status: string; result: unknown; resolvedByUserId: string | null }) {
const resultOutcome = readRecordValue(row.result, "outcome");
const outcome = typeof resultOutcome === "string" ? resultOutcome : row.status;
const key = outcome === "withdrawn" && row.resolvedByUserId ? "withdrawn_by_user" : outcome;
return (row.kind === "request_item_verdicts" ? itemVerdictsHistoryLabel(row.result) : null)
?? HISTORY_OUTCOME_LABELS[key] ?? HISTORY_OUTCOME_LABELS[row.status] ?? "Decision recorded";
}
/** The reason a person gave when they withdrew, skipped or cancelled the request. */
export function interactionHistoryReason(result: unknown): string | null {
const reason = readRecordValue(result, "reason") ?? readRecordValue(result, "cancellationReason");
return typeof reason === "string" && reason.trim() ? reason : null;
}
export function decodeDecisionHistoryCursor(value: string | undefined, sort: Cursor["sort"]): Cursor | null {
if (!value) return null;
try {
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
if (parsed.sort !== sort || typeof parsed.id !== "string" || !/^(decision|issue_thread_interaction):[0-9a-f-]{36}$/i.test(parsed.id)
if (parsed.sort !== sort || typeof parsed.id !== "string" || !/^(decision|issue_thread_interaction|approval):[0-9a-f-]{36}$/i.test(parsed.id)
|| typeof parsed.at !== "string" || !Number.isFinite(Date.parse(parsed.at))) throw new Error();
return parsed;
} catch { throw badRequest("Invalid decision history cursor"); }
@@ -62,6 +168,116 @@ export function pageDecisionHistory(items: DecisionHistoryItem[], limit: number,
? Buffer.from(JSON.stringify({ at: last.resolvedAt, id: last.id, sort })).toString("base64url") : null };
}
type HistorySourceId = SQL | typeof decisions.id | typeof issueThreadInteractions.id | typeof approvals.id;
/**
* A receipt on an action's activity entry that names this source and matches
* `person` (PAP-749 plan §6). Receipts exist only when the actor was not the
* card's person, so they record "handled for" and "handled by" across people.
*/
function receiptSourceCondition(companyId: string, person: SQL, sourceKind: DecisionHistoryItem["sourceKind"], sourceId: HistorySourceId) {
// A budget hard stop is answered on its budget card, so the receipt names
// the incident; history lists the incident's approval (PAP-767).
const budgetReceipt = sourceKind === "approval"
? sql`or (${activityLog.details} -> 'receipt' ->> 'sourceKind' = 'budget_alert'
and ${activityLog.details} -> 'receipt' ->> 'sourceId' in (
select ${budgetIncidents.id}::text from ${budgetIncidents}
where ${budgetIncidents.companyId} = ${companyId} and ${budgetIncidents.approvalId} = ${sourceId}))`
: sql``;
return sql<boolean>`exists (
select 1 from ${activityLog}
where ${activityLog.companyId} = ${companyId}
and ${person}
and ((${activityLog.details} -> 'receipt' ->> 'sourceKind' = ${sourceKind}
and ${activityLog.details} -> 'receipt' ->> 'sourceId' = ${sourceId}::text)
${budgetReceipt})
)`;
}
/**
* "Handled for me" (PAP-749 plan §6): a different person answered, withdrew
* or transferred this card while it was for `userId`.
*/
function handledForUserSourceCondition(companyId: string, userId: string, sourceKind: DecisionHistoryItem["sourceKind"], sourceId: HistorySourceId) {
return receiptSourceCondition(companyId, sql`(${activityLog.details} ->> 'handledForUserId') = ${userId}`, sourceKind, sourceId);
}
/** `userId` handled this card for someone else, or someone else handled it for `userId` (a transfer included). */
function handledByOrForUserSourceCondition(companyId: string, userId: string, sourceKind: DecisionHistoryItem["sourceKind"], sourceId: HistorySourceId) {
return receiptSourceCondition(companyId, sql`((${activityLog.details} ->> 'handledForUserId') = ${userId}
or (${activityLog.details} -> 'receipt' ->> 'actorUserId') = ${userId})`, sourceKind, sourceId);
}
/**
* The tasks `userId` instigated: the ones they created by hand, and the agent
* or routine work under them up to the next hand-made task, the same walk as
* the live audience (`resolveIssueAudienceContextMap`). One walk down from the
* person's own tasks over the (company, parent) index, not one per row.
*/
function instigatedIssueIdsSql(companyId: string, userId: string) {
const root = alias(issues, "history_instigator_root");
const child = alias(issues, "history_instigator_child");
return sql`with recursive history_instigated(id, depth) as (
select ${root.id}, 0 from ${issues} ${sql.identifier("history_instigator_root")}
where ${root.companyId} = ${companyId} and ${root.createdByUserId} = ${userId} and ${humanCreatedIssueCondition(root)}
union all
select ${child.id}, history_instigated.depth + 1 from ${issues} ${sql.identifier("history_instigator_child")}
inner join history_instigated on ${child.parentId} = history_instigated.id
where ${child.companyId} = ${companyId} and not ${humanCreatedIssueCondition(child)}
and history_instigated.depth < ${MAX_INSTIGATOR_CHAIN_DEPTH}
) select id from history_instigated`;
}
/**
* The card's task makes it `userId`'s (PAP-749 plan §1, Rule 2): the chat
* user, else the human assignee, else the instigator. Read from the task as it
* is now. Escalation owners are left out: a card that reached its project's
* or the company's decision owner is in their history once they handle it.
*/
function issueAudienceIsUserCondition(companyId: string, userId: string) {
return or(
eq(issues.conversationUserId, userId),
and(isNull(issues.conversationUserId), eq(issues.assigneeUserId, userId)),
and(isNull(issues.conversationUserId), isNull(issues.assigneeUserId),
sql`${issues.id} in (${instigatedIssueIdsSql(companyId, userId)})`),
);
}
/**
* The person each answered card was for, when someone else answered it
* ("Answered by @x for @y"). A transfer is not the answer, so it is skipped.
*/
async function handledForBySource(db: Db, companyId: string, items: Array<Pick<DecisionHistoryItem, "sourceKind" | "sourceId">>) {
const result = new Map<string, string>();
if (items.length === 0) return result;
// A budget hard stop's receipt names the incident, not its approval (PAP-767).
const approvalIds = items.filter((item) => item.sourceKind === "approval").map((item) => item.sourceId);
const incidentRows = approvalIds.length === 0 ? [] : await db
.select({ id: budgetIncidents.id, approvalId: budgetIncidents.approvalId })
.from(budgetIncidents)
.where(and(eq(budgetIncidents.companyId, companyId), inArray(budgetIncidents.approvalId, approvalIds)));
const approvalByIncident = new Map(incidentRows.map((row) => [row.id, row.approvalId!]));
const sourceIds = [...items.map((item) => item.sourceId), ...approvalByIncident.keys()];
const rows = await db
.select({ details: activityLog.details, createdAt: activityLog.createdAt })
.from(activityLog)
.where(and(
eq(activityLog.companyId, companyId),
sql`(${activityLog.details} ->> 'handledForUserId') IS NOT NULL`,
inArray(sql<string>`${activityLog.details} -> 'receipt' ->> 'sourceId'`, sourceIds),
))
.orderBy(desc(activityLog.createdAt));
for (const row of rows) {
const receipt = readAttentionHandledDetails(row.details);
const userId = (row.details as Record<string, unknown> | null)?.handledForUserId;
if (!receipt || receipt.outcome === "transferred" || typeof userId !== "string") continue;
const budgetApprovalId = receipt.sourceKind === "budget_alert" ? approvalByIncident.get(receipt.sourceId) : undefined;
const key = budgetApprovalId ? `approval:${budgetApprovalId}` : `${receipt.sourceKind}:${receipt.sourceId}`;
if (!result.has(key)) result.set(key, userId);
}
return result;
}
export function decisionHistoryService(db: Db) {
return { async list(companyId: string, options: HistoryOptions): Promise<DecisionHistoryPage> {
if (options.issueIds?.length === 0) return { items: [], nextCursor: null };
@@ -78,18 +294,76 @@ export function decisionHistoryService(db: Db) {
// ordering to that same precision; the canonical source key breaks ties.
const interactionTime = sql<Date>`date_trunc('milliseconds', ${issueThreadInteractions.resolvedAt})`;
const decisionTime = sql<Date>`date_trunc('milliseconds', coalesce(${decisions.decidedAt}, ${decisions.updatedAt}))`;
const approvalTime = sql<Date>`date_trunc('milliseconds', coalesce(${approvals.decidedAt}, ${approvals.updatedAt}))`;
const interactionKey = sql<string>`'issue_thread_interaction:' || ${issueThreadInteractions.id}`;
const decisionKey = sql<string>`'decision:' || ${decisions.id}`;
const approvalKey = sql<string>`'approval:' || ${approvals.id}`;
// Same fallback as the card title (`approvalTitle`), so search finds what the person sees.
const approvalTitleSql = sql<string>`coalesce(nullif(btrim(${approvals.payload}->>'title'), ''), nullif(btrim(${approvals.payload}->>'summary'), ''), replace(${approvals.type}, '_', ' '))`;
const approvalIssueId = firstApprovalIssueId();
const completedItemReview = and(eq(issueThreadInteractions.kind, "request_item_verdicts"),
eq(issueThreadInteractions.status, "answered"), sql`${issueThreadInteractions.result}->>'outcome' = 'resolved'`);
// Item reviews retain their canonical answered status, but outcome filters
// describe the recorded verdicts. A mixed review can match both outcomes.
const resultOutcomeFilter = options.outcome && RESULT_OUTCOME_FILTERS.has(options.outcome)
? options.outcome === "withdrawn_by_user" ? "withdrawn" : options.outcome
: null;
const withdrawnFilter = options.outcome === "withdrawn" || options.outcome === "withdrawn_by_user";
const interactionOutcome = options.outcome === "accepted" || options.outcome === "rejected"
? or(eq(issueThreadInteractions.status, options.outcome), and(completedItemReview,
sql`${issueThreadInteractions.result} @> ${JSON.stringify({ items: [{ verdict: options.outcome === "accepted" ? "approve" : "reject" }] })}::jsonb`))
: options.outcome === "answered"
? and(eq(issueThreadInteractions.status, "answered"), sql`${issueThreadInteractions.kind} <> 'request_item_verdicts'`)
: options.outcome ? eq(issueThreadInteractions.status, options.outcome) : undefined;
: resultOutcomeFilter
? and(sql`${issueThreadInteractions.result}->>'outcome' = ${resultOutcomeFilter}`,
options.outcome === "withdrawn_by_user" ? isNotNull(issueThreadInteractions.resolvedByUserId) : undefined)
// "Expired" is a time-out or supersede; a card that closed with its
// task is stored as expired too but has its own filter (plan §3).
: options.outcome === "expired"
? and(eq(issueThreadInteractions.status, "expired"),
sql`coalesce(${issueThreadInteractions.result}->>'outcome', '') <> 'issue_closed'`)
: options.outcome ? eq(issueThreadInteractions.status, options.outcome) : undefined;
// A decision a person withdrew is a cancelled decision with its closer
// recorded; skipped and task-closed outcomes do not exist for decisions.
const decisionOutcome = withdrawnFilter
? and(eq(decisions.status, "cancelled"), isNotNull(decisions.decidedByUserId))
: resultOutcomeFilter
? sql`false`
: options.outcome ? eq(decisions.status, options.outcome) : undefined;
// Approvals use their own statuses. Approved and rejected map to the
// shared Approved and Changes requested filters; a cancel by a person is
// a withdraw. Approvals never expire, answer, decide or close with a task.
const approvalStatusByFilter: Record<string, string> = { accepted: "approved", rejected: "rejected" };
const approvalOutcome = withdrawnFilter
? and(eq(approvals.status, "cancelled"), isNotNull(approvals.decidedByUserId))
: resultOutcomeFilter || options.outcome === "answered" || options.outcome === "decided" || options.outcome === "expired"
? sql`false`
: options.outcome ? eq(approvals.status, approvalStatusByFilter[options.outcome] ?? options.outcome) : undefined;
// "Handled by me or for me" (PAP-768): what the person answered, withdrew
// or transferred, what a different person handled for them, and the cards
// whose audience is them: the addressee or requester, else the task's person.
const mine = options.scope === "mine";
const userId = options.userId;
const interactionScope = mine ? or(
eq(issueThreadInteractions.resolvedByUserId, userId),
eq(issueThreadInteractions.addresseeUserId, userId),
and(isNull(issueThreadInteractions.addresseeUserId), issueAudienceIsUserCondition(companyId, userId)),
handledByOrForUserSourceCondition(companyId, userId, "issue_thread_interaction", issueThreadInteractions.id),
) : undefined;
const decisionScope = mine ? or(
eq(decisions.decidedByUserId, userId),
issueAudienceIsUserCondition(companyId, userId),
handledByOrForUserSourceCondition(companyId, userId, "decision", decisions.id),
) : undefined;
const approvalScope = mine ? or(
eq(approvals.decidedByUserId, userId),
eq(approvals.requestedByUserId, userId),
and(isNull(approvals.requestedByUserId), isNotNull(issues.id), issueAudienceIsUserCondition(companyId, userId)),
// A hard stop's override approval is folded into the budget owner's card.
and(eq(approvals.type, "budget_override_required"),
sql`exists (select 1 from ${companies} where ${companies.id} = ${companyId} and ${companies.budgetOwnerUserId} = ${userId})`),
handledByOrForUserSourceCondition(companyId, userId, "approval", approvals.id),
) : undefined;
const conditions = (time: SQL, key: SQL, title: SQL | typeof decisions.title | typeof issueThreadInteractions.title) => [
options.issueIds ? inArray(issues.id, options.issueIds) : undefined,
options.projectId ? eq(issues.projectId, options.projectId) : undefined,
@@ -98,9 +372,10 @@ export function decisionHistoryService(db: Db) {
cursor ? sql`(${time}, ${key}) ${sql.raw(sort === "oldest" ? ">" : "<")} (${cursor.at}::timestamptz, ${cursor.id})` : undefined,
];
const order = sort === "oldest" ? asc : desc;
// Two bounded queries, keyset-paginated before materializing payloads. Every
// task and every Decisions-v1 target passes the same task-read predicate.
const [interactionRows, decisionRows] = await Promise.all([
// Three bounded queries, keyset-paginated before materializing payloads.
// Every task and every Decisions-v1 target passes the same task-read
// predicate; an approval linked to a task needs that task to be readable.
const [interactionRows, decisionRows, approvalRows] = await Promise.all([
db.select({ sourceId: issueThreadInteractions.id, issueId: issues.id, issueIdentifier: issues.identifier, issueTitle: issues.title,
agentId: agents.id, agentName: agents.name, projectId: projects.id, projectName: projects.name,
title: issueThreadInteractions.title, outcome: issueThreadInteractions.status, resolvedAt: issueThreadInteractions.resolvedAt,
@@ -111,38 +386,81 @@ export function decisionHistoryService(db: Db) {
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, companyId)))
.where(and(eq(issueThreadInteractions.companyId, companyId), readable, isNotNull(issueThreadInteractions.resolvedAt),
notInArray(issueThreadInteractions.status, ["pending"]),
interactionOutcome,
interactionOutcome, interactionScope,
options.handledForMe ? handledForUserSourceCondition(companyId, options.userId, "issue_thread_interaction", issueThreadInteractions.id) : undefined,
options.agentId ? eq(issueThreadInteractions.createdByAgentId, options.agentId) : undefined,
...conditions(interactionTime, interactionKey, issueThreadInteractions.title)))
.orderBy(order(interactionTime), order(interactionKey)).limit(limit + 1),
db.select({ sourceId: decisions.id, issueId: issues.id, issueIdentifier: issues.identifier, issueTitle: issues.title,
agentId: agents.id, agentName: agents.name, projectId: projects.id, projectName: projects.name,
title: decisions.title, outcome: decisions.status, resolvedAt: decisionTime,
resolvedByUserId: decisions.decidedByUserId, chosenOptionId: decisions.chosenOptionId, options: decisions.options })
resolvedByUserId: decisions.decidedByUserId, chosenOptionId: decisions.chosenOptionId, options: decisions.options,
metadata: decisions.metadata })
.from(decisions).innerJoin(issues, and(eq(issues.id, decisions.originIssueId), eq(issues.companyId, companyId)))
.leftJoin(agents, and(eq(agents.id, decisions.originAgentId), eq(agents.companyId, companyId)))
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, companyId)))
.where(and(eq(decisions.companyId, companyId), readable, notInArray(decisions.status, ["open"]),
sql`not exists (select 1 from ${decisionTargetIssues} where ${decisionTargetIssues.decisionId} = ${decisions.id} and (${decisionTargetIssues.companyId} <> ${companyId} or ${decisionTargetIssues.issueId} not in (${readableIssues})))`,
options.outcome ? eq(decisions.status, options.outcome) : undefined,
decisionOutcome, decisionScope,
options.handledForMe ? handledForUserSourceCondition(companyId, options.userId, "decision", decisions.id) : undefined,
options.agentId ? eq(decisions.originAgentId, options.agentId) : undefined,
...conditions(decisionTime, decisionKey, decisions.title)))
.orderBy(order(decisionTime), order(decisionKey)).limit(limit + 1),
db.select({ sourceId: approvals.id, issueId: issues.id, issueIdentifier: issues.identifier, issueTitle: issues.title,
agentId: agents.id, agentName: agents.name, projectId: projects.id, projectName: projects.name,
type: approvals.type, payload: approvals.payload, outcome: approvals.status, resolvedAt: approvalTime,
resolvedByUserId: approvals.decidedByUserId, decisionNote: approvals.decisionNote })
.from(approvals).leftJoin(issues, and(eq(issues.id, approvalIssueId), eq(issues.companyId, companyId)))
.leftJoin(agents, and(eq(agents.id, approvals.requestedByAgentId), eq(agents.companyId, companyId)))
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, companyId)))
.where(and(eq(approvals.companyId, companyId), await approvalReadSqlCondition(db, options.actor), notInArray(approvals.status, ["pending"]),
or(sql`${approvalIssueId} is null`, and(isNotNull(issues.id), readable)),
approvalOutcome, approvalScope,
options.handledForMe ? handledForUserSourceCondition(companyId, options.userId, "approval", approvals.id) : undefined,
options.agentId ? eq(approvals.requestedByAgentId, options.agentId) : undefined,
...conditions(approvalTime, approvalKey, approvalTitleSql)))
.orderBy(order(approvalTime), order(approvalKey)).limit(limit + 1),
]);
const interactionItems: DecisionHistoryItem[] = interactionRows.map(({ kind, payload, result, ...row }) => {
const target = decisionHistoryTarget(payload);
const resultOutcome = result && "outcome" in result ? String(result.outcome) : row.outcome;
const labels: Record<string, string> = { accepted: "Approved", rejected: "Changes requested", answered: "Answers recorded", cancelled: "Cancelled", expired: "Expired", failed: "Failed", stale_target: "Superseded", superseded_by_newer_request: "Superseded", superseded_by_comment: "Answered in task" };
return { ...row, title: row.title ?? row.issueTitle, resolvedAt: new Date(row.resolvedAt!).toISOString(),
id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction", label: (kind === "request_item_verdicts" ? itemVerdictsHistoryLabel(result) : null) ?? labels[resultOutcome] ?? labels[row.outcome] ?? "Decision recorded", target,
id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction",
label: interactionHistoryLabel({ kind, status: row.outcome, result, resolvedByUserId: row.resolvedByUserId }),
action: interactionHistoryAction({ kind, status: row.outcome, result, resolvedByUserId: row.resolvedByUserId }),
resultOutcome: interactionHistoryResultOutcome(result),
reason: interactionHistoryReason(result), target,
href: decisionHistoryHref(company.prefix, row.issueIdentifier ?? row.issueId, "issue_thread_interaction", row.sourceId, target) };
});
const decisionItems: DecisionHistoryItem[] = decisionRows.map(({ chosenOptionId, options: choices, ...row }) => ({
const decisionItems: DecisionHistoryItem[] = decisionRows.map(({ chosenOptionId, options: choices, metadata, ...row }) => {
const chosenLabel = choices.find((choice) => choice.id === chosenOptionId)?.label ?? null;
return {
...row, id: `decision:${row.sourceId}`, sourceKind: "decision", resolvedByAgentId: null,
resolvedAt: new Date(row.resolvedAt).toISOString(), target: null,
label: choices.find((choice) => choice.id === chosenOptionId)?.label ?? (row.outcome === "expired" ? "Expired" : row.outcome === "cancelled" ? "Cancelled" : "Decision recorded"),
label: chosenLabel ?? (row.outcome === "expired" ? "Expired"
: row.outcome === "cancelled" ? HISTORY_OUTCOME_LABELS[row.resolvedByUserId ? "withdrawn_by_user" : "cancelled"] : "Decision recorded"),
action: decisionHistoryAction({ status: row.outcome, resolvedByUserId: row.resolvedByUserId, chosenLabel }),
resultOutcome: row.outcome === "cancelled" && row.resolvedByUserId ? "withdrawn" : null,
reason: interactionHistoryReason({ reason: metadata.cancelReason }),
href: decisionHistoryHref(company.prefix, row.issueIdentifier ?? row.issueId, "decision", row.sourceId, null),
}));
return pageDecisionHistory([...interactionItems, ...decisionItems], limit, sort);
};
});
const approvalItems: DecisionHistoryItem[] = approvalRows.map(({ type, payload, decisionNote, ...row }) => {
const withdrawn = row.outcome === "cancelled" && row.resolvedByUserId != null;
return {
...row, id: `approval:${row.sourceId}`, sourceKind: "approval", resolvedByAgentId: null,
title: approvalTitle(type, payload), resolvedAt: new Date(row.resolvedAt).toISOString(), target: null,
label: approvalHistoryLabel({ status: row.outcome, decidedByUserId: row.resolvedByUserId, type }),
action: approvalHistoryAction({ status: row.outcome, decidedByUserId: row.resolvedByUserId, type }),
resultOutcome: withdrawn ? "withdrawn" : null,
// The decision note is the withdraw reason only for a cancel.
reason: row.outcome === "cancelled" ? interactionHistoryReason({ reason: decisionNote }) : null,
// An approval's own page lists its linked tasks and the full decision.
href: `/${encodeURIComponent(company.prefix)}/approvals/${encodeURIComponent(row.sourceId)}`,
};
});
const page = pageDecisionHistory([...interactionItems, ...decisionItems, ...approvalItems], limit, sort);
const handledFor = await handledForBySource(db, companyId, page.items);
for (const item of page.items) item.handledForUserId = handledFor.get(item.id) ?? null;
return page;
} };
}
+15 -5
View File
@@ -703,7 +703,9 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
}
async function decide(input: { id: string; optionId: string; inputValues?: Record<string, string>; idempotencyKey?: string | null; decidedByUserId: string;
userActor: AuthorizationActor; dismissed?: boolean; dismissReason?: string | null }) {
userActor: AuthorizationActor; dismissed?: boolean; dismissReason?: string | null;
/** Extra activity details, e.g. the receipt for the person the card was for (PAP-749 plan §6). */
activityDetails?: Record<string, unknown> }) {
const current = await get(input.id); if (!current) throw notFound("Decision not found");
const metadata = current.metadata as Record<string, unknown>;
if (!verifyDecisionSpec(spec({ id: current.id, options: current.options, targetSnapshots: current.targetSnapshots as Record<string, Snapshot> }), current.signedSpec)) throw forbidden("Decision signature verification failed");
@@ -735,21 +737,29 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
await logActivity(db, { companyId: claimed.companyId, actorType: "system", actorId: "decision-executor", agentId: claimed.originAgentId, runId: claimed.originRunId,
responsibleUserIdOverride: input.decidedByUserId, action: input.dismissed ? "decision.dismissed" : "decision.decided", entityType: "decision", entityId: claimed.id,
details: { chosenOptionId: input.optionId, decidedByUserId: input.decidedByUserId, originResponsibleUserId: run?.responsibleUserId ?? null,
...(input.dismissed ? { dismissed: true, dismissReason: input.dismissReason ?? null } : {}) } });
...(input.dismissed ? { dismissed: true, dismissReason: input.dismissReason ?? null } : {}), ...input.activityDetails } });
return resumeDecision(claimed, input.userActor);
}
async function cancel(id: string, actor: { actorType: "agent" | "user"; actorId: string; runId?: string | null }) {
async function cancel(id: string, actor: { actorType: "agent" | "user"; actorId: string; runId?: string | null },
options: { reason?: string | null; activityDetails?: Record<string, unknown> } = {}) {
const current = await get(id); if (!current) throw notFound("Decision not found");
if (actor.actorType === "agent" && actor.actorId !== current.originAgentId) throw forbidden("Only the origin agent may cancel");
if (current.status === "cancelled") {
await continuations.deliver(current.id);
return (await get(id))!;
}
const [updated] = await writeDecisionChange(db, async tx => tx.update(decisions).set({ status: "cancelled", updatedAt: new Date(), metadata: { ...current.metadata,
// A person who withdraws an out-of-date decision (PAP-749) is recorded as
// its closer, with the reason, so history can say who withdrew it.
const reason = options.reason?.trim() || null;
const byUser = actor.actorType === "user" ? { decidedByUserId: actor.actorId, decidedAt: new Date() } : {};
const [updated] = await writeDecisionChange(db, async tx => tx.update(decisions).set({ status: "cancelled", ...byUser, updatedAt: new Date(), metadata: { ...current.metadata,
...(reason ? { cancelReason: reason } : {}),
...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, id), eq(decisions.status, "open"))).returning(), { companyId: current.companyId, sourceId: id });
if (!updated) throw conflict("decision_already_resolved", { code: "decision_already_resolved" });
await logActivity(db, { companyId: updated.companyId, actorType: actor.actorType, actorId: actor.actorId, runId: actor.runId, action: "decision.cancelled", entityType: "decision", entityId: id });
const details = { ...(reason ? { reason } : {}), ...options.activityDetails };
await logActivity(db, { companyId: updated.companyId, actorType: actor.actorType, actorId: actor.actorId, runId: actor.runId, action: "decision.cancelled", entityType: "decision", entityId: id,
...(Object.keys(details).length > 0 ? { details } : {}) });
await continuations.deliver(updated.id);
return (await get(id))!;
}
+10 -3
View File
@@ -8,11 +8,14 @@ export function inboxDismissalService(db: Db) {
companyId: string,
userId: string,
itemKey: string,
input: { kind: InboxDismissalKind; dismissedAt?: Date; snoozedUntil?: Date | null },
input: { kind: InboxDismissalKind; dismissedAt?: Date; snoozedUntil?: Date | null; fingerprint?: string | null },
) {
const now = new Date();
const dismissedAt = input.dismissedAt ?? now;
const snoozedUntil = input.kind === "snooze" ? input.snoozedUntil ?? null : null;
// A re-hide without a fingerprint clears the old one, so the row never
// compares against content the user did not see.
const fingerprint = input.fingerprint ?? null;
const [row] = await db
.insert(inboxDismissals)
.values({
@@ -22,6 +25,7 @@ export function inboxDismissalService(db: Db) {
kind: input.kind,
dismissedAt,
snoozedUntil,
fingerprint,
updatedAt: now,
})
.onConflictDoUpdate({
@@ -30,6 +34,7 @@ export function inboxDismissalService(db: Db) {
kind: input.kind,
dismissedAt,
snoozedUntil,
fingerprint,
updatedAt: now,
},
})
@@ -50,7 +55,8 @@ export function inboxDismissalService(db: Db) {
userId: string,
itemKey: string,
dismissedAt: Date = new Date(),
) => upsert(companyId, userId, itemKey, { kind: "dismiss", dismissedAt }),
fingerprint: string | null = null,
) => upsert(companyId, userId, itemKey, { kind: "dismiss", dismissedAt, fingerprint }),
snooze: async (
companyId: string,
@@ -58,7 +64,8 @@ export function inboxDismissalService(db: Db) {
itemKey: string,
snoozedUntil: Date,
dismissedAt: Date = new Date(),
) => upsert(companyId, userId, itemKey, { kind: "snooze", dismissedAt, snoozedUntil }),
fingerprint: string | null = null,
) => upsert(companyId, userId, itemKey, { kind: "snooze", dismissedAt, snoozedUntil, fingerprint }),
restore: async (companyId: string, userId: string, itemKey: string) => {
const [row] = await db
+1
View File
@@ -43,6 +43,7 @@ export {
type IssueListOptions,
} from "./issues.js";
export { issueThreadInteractionService } from "./issue-thread-interactions.js";
export { expirePendingInteractionsOnClosedIssues } from "./closed-issue-interaction-sweep.js";
export { githubConnectionEventService, type GitHubConnectionEventPollResult } from "./github-connection-events.js";
export {
assertIssueReviewVerdictActorAllowed,
+12 -4
View File
@@ -1,4 +1,5 @@
import { sql, type SQL } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { activityLog, chatConversations, chatMessageLinks, issueComments, issues, issueThreadInteractions } from "@paperclipai/db";
import { HUMAN_TASK_ORIGIN_KINDS } from "@paperclipai/shared";
import { activeIssueInteractionCondition } from "./issue-question-context.js";
@@ -71,11 +72,18 @@ export function automaticReadRecoveryCleanupCondition() {
)`;
}
export function humanCreatedIssueCondition() {
/**
* A task a person created by hand. `table` is `issues` or an alias of it, for a
* query that reads the same rule on another row (PAP-768: the instigator walk
* of the personal decision history).
*/
export function humanCreatedIssueCondition(
table: { createdByAgentId: AnyPgColumn; createdByUserId: AnyPgColumn; originKind: AnyPgColumn } = issues,
) {
return sql<boolean>`(
${issues.createdByAgentId} IS NULL
AND ${issues.createdByUserId} IS NOT NULL
AND ${issues.originKind} IN (${sql.join(HUMAN_TASK_ORIGIN_KINDS.map((kind) => sql`${kind}`), sql`, `)})
${table.createdByAgentId} IS NULL
AND ${table.createdByUserId} IS NOT NULL
AND ${table.originKind} IN (${sql.join(HUMAN_TASK_ORIGIN_KINDS.map((kind) => sql`${kind}`), sql`, `)})
)`;
}
+13 -4
View File
@@ -9,15 +9,24 @@ export function ordinaryQuestionCondition() {
and not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization']))`;
}
/** Deferring conversation input cannot authorize an action or a plan. */
export function deferredInteractionCondition() {
return sql<boolean>`(${issueThreadInteractions.dismissedAt} is not null
and ${issueThreadInteractions.kind} in ('ask_user_questions', 'request_confirmation', 'request_checkbox_confirmation', 'request_item_verdicts', 'suggest_tasks')
/**
* Conversation input that a person may defer. Everything else is a governed
* request (an approval-bearing action, credential, connection, governed clamp
* or document target) that keeps its gate until someone answers it.
*/
export function deferrableInteractionCondition() {
return sql<boolean>`(${issueThreadInteractions.kind} in ('ask_user_questions', 'request_confirmation', 'request_checkbox_confirmation', 'request_item_verdicts', 'suggest_tasks')
and ${issueThreadInteractions.effectiveResolverPolicySource} = 'requested'
and not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization'])
and coalesce(${issueThreadInteractions.payload}->'target'->>'type', '') <> 'issue_document')`;
}
/** Deferring conversation input cannot authorize an action or a plan. */
export function deferredInteractionCondition() {
return sql<boolean>`(${issueThreadInteractions.dismissedAt} is not null
and ${deferrableInteractionCondition()})`;
}
/** Historical questions remain answerable, including after task completion. */
export function historicalQuestionCondition() {
return sql`(${ordinaryQuestionCondition()} and (${deferredInteractionCondition()} or exists (
@@ -834,6 +834,57 @@ function isTerminalIssueStatus(status: string) {
return status === "done" || status === "cancelled";
}
/**
* Throws 422 unless `userId` is a person who may answer requests on this task.
* Interaction create (an explicit addressee) and attention transfer ("Route to
* @x") both use it, so a card never goes to someone who cannot answer it.
*/
export async function assertInteractionAddresseeUserAvailable(
db: Db,
issue: { id: string; companyId: string },
issueRow: Record<string, unknown> | undefined,
userId: string,
) {
const [user] = await db.select({ id: authUsers.id }).from(authUsers)
.where(eq(authUsers.id, userId));
const cloudManaged = isCloudManagedInstance();
// No-login installs have an implicit board, which need not have an
// auth row. Never infer this authority in authenticated/Cloud mode.
const localImplicit = userId === "local-board"
&& !cloudManaged && resolveDeploymentMode() === "local_trusted";
// Use the normal board mutation policy, including viewer restrictions,
// local/instance-admin eligibility and Cloud's no-stale-admin rule.
const decision = user || localImplicit ? await authorizationService(db).decide({
actor: {
type: "board",
userId,
source: localImplicit ? "local_implicit" : cloudManaged ? "cloud_tenant" : "session",
},
action: "issue:mutate",
resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, ...issueRow },
}) : null;
if (!decision?.allowed) {
throw unprocessable("addresseeUserId must identify a user authorized to respond in this company", {
code: "interaction_addressee_user_unavailable",
});
}
}
/** Routing changes the resolver addressee. Call under the task and interaction
* row locks, and validate the proposed recipient with the resolution owner's
* canonical audience and review-policy checks before changing that field. */
export async function assertInteractionRoutingRecipientAllowed(
db: Db,
issue: IssueResolutionContext,
interaction: IssueThreadInteractionRow,
userId: string,
) {
await assertInteractionAddresseeUserAvailable(db, issue, issue, userId);
await assertRequestConfirmationResolutionAllowedUnderLock(db, issue, {
...interaction, addresseeUserId: userId, addresseeAgentId: null,
}, { userId });
}
function interactionNotFoundError() {
return notFound("Interaction not found", { code: "interaction_not_found" });
}
@@ -4004,29 +4055,7 @@ export function issueThreadInteractionService(
throw conflict("Cannot create an interaction on a closed issue");
}
if (data.addresseeUserId) {
const [user] = await tx.select({ id: authUsers.id }).from(authUsers)
.where(eq(authUsers.id, data.addresseeUserId));
const cloudManaged = isCloudManagedInstance();
// No-login installs have an implicit board, which need not have an
// auth row. Never infer this authority in authenticated/Cloud mode.
const localImplicit = data.addresseeUserId === "local-board"
&& !cloudManaged && resolveDeploymentMode() === "local_trusted";
// Use the normal board mutation policy, including viewer restrictions,
// local/instance-admin eligibility and Cloud's no-stale-admin rule.
const decision = user || localImplicit ? await authorizationService(tx).decide({
actor: {
type: "board",
userId: data.addresseeUserId,
source: localImplicit ? "local_implicit" : cloudManaged ? "cloud_tenant" : "session",
},
action: "issue:mutate",
resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, ...issueRow },
}) : null;
if (!decision?.allowed) {
throw unprocessable("addresseeUserId must identify a user authorized to respond in this company", {
code: "interaction_addressee_user_unavailable",
});
}
await assertInteractionAddresseeUserAvailable(tx as unknown as Db, issue, issueRow, data.addresseeUserId);
}
if (
data.kind === "ask_user_questions" &&
@@ -5461,13 +5490,22 @@ export function issueThreadInteractionService(
},
withdrawInteraction: async (
issue: { id: string; companyId: string },
issue: { id: string; companyId: string; status?: string },
interactionId: string,
input: WithdrawIssueThreadInteraction,
actor: InteractionActor,
mutationOptions: InteractionResolutionMutationOptions = {},
mutationOptions: InteractionResolutionMutationOptions & {
/**
* Let a person withdraw a card that outlived its closed task (PAP-749
* plan §2, Rule 10). This is cleanup only: a closed task is never woken.
*/
allowClosedIssueForHuman?: boolean;
} = {},
) => {
assertIssueOpenForInteractionResolution(issue);
const humanActor = Boolean(actor.userId) && !actor.agentId && !actor.runId && !actor.systemId;
if (!(mutationOptions.allowClosedIssueForHuman && humanActor)) {
assertIssueOpenForInteractionResolution(issue);
}
const data = withdrawIssueThreadInteractionSchema.parse(input);
const current = await db
.select()
@@ -5495,6 +5533,19 @@ export function issueThreadInteractionService(
// request must not outlive a withdrawn card.
const updated = await db.transaction(async (tx) => {
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
const [lockedIssue] = await tx.select({
id: issues.id, companyId: issues.companyId, status: issues.status,
reviewPolicy: issues.reviewPolicy, createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId,
}).from(issues).where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))).for("update");
if (!lockedIssue) throw notFound("Issue not found");
if (!(mutationOptions.allowClosedIssueForHuman && humanActor)) assertIssueOpenForInteractionResolution(lockedIssue);
if (lockedIssue.status === "in_review" && isRequestConfirmationLikeKind(current.kind)
&& await isIssueReviewVerdictInteraction(tx as unknown as Db, { issue: lockedIssue, interaction: current })) {
const verdictActor = actor.agentId ? { type: "agent" as const, id: actor.agentId }
: actor.userId ? { type: "user" as const, id: actor.userId } : null;
if (!verdictActor) throw forbidden("A review withdrawal requires an authenticated agent or user");
await assertIssueReviewVerdictActorAllowed(tx as unknown as Db, { issue: lockedIssue, actor: verdictActor });
}
await notifyChatConfirmationResolution(tx, current);
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
else await notifyIssueReviewState(tx, current);
+19 -1
View File
@@ -3047,7 +3047,8 @@ function unreadForUserCondition(companyId: string, userId: string) {
OR ${issueComments.authorUserId} <> ${userId}
)
AND ${issueComments.createdAt} > ${myLastTouchAt}
) OR ${pendingUserRequestCondition(companyId, userId, myLastRequestReadAt)})
) OR ${pendingUserRequestCondition(companyId, userId, myLastRequestReadAt)}
OR ${withdrawnForUserCondition(companyId, userId, myLastRequestReadAt)})
)
`;
}
@@ -3082,6 +3083,23 @@ function mineParticipationIssueIds(db: Db, companyId: string, userId: string) {
return db.selectDistinct({ id: participationSources.id }).from(participationSources).as("mine_participants");
}
/**
* A different person withdrew a request on this task that was for this user
* after they last read it (PAP-749 plan §6, Rule 12): the task shows unread in
* their Mine list. Reads the receipt on the activity entry; no extra state.
*/
function withdrawnForUserCondition(companyId: string, userId: string, readAt: SQL) {
return sql<boolean>`EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND (${activityLog.details} ->> 'handledForUserId') = ${userId}
AND ${activityLog.details} -> 'receipt' ->> 'outcome' = 'withdrawn'
AND ${activityLog.details} -> 'receipt' ->> 'issueId' = ${issues.id}::text
AND ${activityLog.createdAt} > ${readAt}
)`;
}
function inboxVisibleForUserCondition(companyId: string, userId: string) {
return sql<boolean>`
NOT EXISTS (
+15 -11
View File
@@ -186,7 +186,10 @@
"packages/shared/src/app-definitions/zep.json": "d5e2021e9b4ed7ba9abb933242f1ccb26a67a638",
"packages/shared/src/arcade-app-catalog.json": "d0b5221ace19eabefb90cda401a8018384a62993",
"packages/shared/src/asana-connectors.ts": "6a585a7cdd6e364f2f4cd40c67775d185f4a662b",
"packages/shared/src/board-bootstrap.ts": "50bc87e70bb794c7bf50f9759546734b3202d311",
"packages/shared/src/attention-audience.test.ts": "3cd57e85ab4a9efdee5dab969b467a531c6c0c4e",
"packages/shared/src/attention-audience.ts": "4a580bde5e4193fb351dacd6a2e5677c318fbf72",
"packages/shared/src/attention-card-type.test.ts": "3703168f58df3c9d28a43e5b594555f6c4bb83f0",
"packages/shared/src/board-bootstrap.ts": "ab315655af80b11cd6340ec3bb86ae034e055e71",
"packages/shared/src/browser-use.ts": "9b02bc40c340eca46453ea9e92074e53a492c91f",
"packages/shared/src/case-handoffs.test.ts": "78a8115c35ab1719af288427dafcd62ca7080f25",
"packages/shared/src/case-handoffs.ts": "1db7e288c4b4a912407d9f23b6d19f5504967291",
@@ -264,7 +267,7 @@
"packages/shared/src/home-paths.ts": "fa4f1c440653b76b3dbb7a51026d557287deb45d",
"packages/shared/src/humanize-connection.test.ts": "ba9e90a44c63452b0bf9f733adb90241921e8eca",
"packages/shared/src/humanize-connection.ts": "fbd414476a0ef7106a7c80d8572208781b091699",
"packages/shared/src/index.ts": "3c81de3445bb7f5677e8fb5cdba5bdfaeb95cd2a",
"packages/shared/src/index.ts": "58b6dac8a1fa6ad09abafbec3a0e3117c1038813",
"packages/shared/src/issue-activity-identity.test.ts": "3dc294640f54c2be67943e3e0149cf4eff2c95ca",
"packages/shared/src/issue-activity-identity.ts": "1b0f07ec3fe0506aaaf80a4fa9893f3baf8f0075",
"packages/shared/src/issue-attribution.test.ts": "84563685e7f3c48029243cf1cc24a3a09ee3cde9",
@@ -370,14 +373,14 @@
"packages/shared/src/types/approval.ts": "4554d36f804cb74b825b29711ed4c622df07ff6d",
"packages/shared/src/types/artifact.ts": "6d022d34a718196df5c109a6164e7a70ede287e8",
"packages/shared/src/types/asset.ts": "d148d6cba0e9a8d2e3aa6cb9182df6f59f909fba",
"packages/shared/src/types/attention.ts": "4978a893caade91e41ed752f886a7c945738dfe6",
"packages/shared/src/types/attention.ts": "bd6fdf6938c23f6f6e86c496a7f17d376647d9f9",
"packages/shared/src/types/board-bootstrap.ts": "8eb0e0ec4c91369d12b268b9411ed512c8d1e8b1",
"packages/shared/src/types/budget.ts": "7b3fe924d571008d67093c051a13d2c7e67f3ebe",
"packages/shared/src/types/chat-channels.ts": "103eb76c5c517102e999b49b4bb726e0ea228902",
"packages/shared/src/types/chat-github.ts": "be6e7c951620ee6d107af651fba440938f973ddc",
"packages/shared/src/types/company-portability.ts": "76ce2cd19c05d788f57cde743c83b663a584648b",
"packages/shared/src/types/company-skill.ts": "259ee322e01e0c97532a6d5ff2e9753e3c1de460",
"packages/shared/src/types/company.ts": "40b5858ebabe3df694af955bd68d2222069a3993",
"packages/shared/src/types/company.ts": "9e0f7e2741900235149f196cc5b17e90b49d929c",
"packages/shared/src/types/connection-intent.ts": "883a9ea5d509780422a668906be058acf8c0eaeb",
"packages/shared/src/types/cost.ts": "20cbc1b6cc6c1a45643d3b25e5d66944c8ec2aba",
"packages/shared/src/types/dashboard.ts": "cdeaebdb7774b3b039dc5199a5ed8bc6cf32ce40",
@@ -397,16 +400,16 @@
"packages/shared/src/types/goal.ts": "29100192bcc3e0c4c4904e59b5d84874952e67a2",
"packages/shared/src/types/heartbeat.ts": "e5449f8a58a79951cef909d8f4330fe27e29ae3b",
"packages/shared/src/types/inbox-agent-policy.ts": "cb9d41d9681e80b834889268ad96015a39df92f6",
"packages/shared/src/types/inbox-dismissal.ts": "5d2c71ce44ad77266d286ec1b1119ab1f0390a9d",
"packages/shared/src/types/inbox-dismissal.ts": "58fb7013847396ac9971eb5799dd0bf0486e0371",
"packages/shared/src/types/inbox-mine.ts": "1d302b531d34842189704abd2f75f8743d09490d",
"packages/shared/src/types/index.ts": "01845e2a041eb892c3a193685471d635da2c1be9",
"packages/shared/src/types/index.ts": "eb8c4d6cf32f04706d4eb539066a1cbffcebe4f7",
"packages/shared/src/types/instance.ts": "2c40a18c05010991d93d719937faa4a43e47104f",
"packages/shared/src/types/issue-tree-control.ts": "75489fffd491b8350b05caf57d7796419797d9df",
"packages/shared/src/types/issue.ts": "725bd6f677b9d461460697d1ce10dee739a66ebe",
"packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec",
"packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358",
"packages/shared/src/types/plugin.ts": "016059b873c7189b8e65bb0dfa1ffa039c97c2ca",
"packages/shared/src/types/project.ts": "ae2e718acd652e362cf1ef91e69aad96d12042fe",
"packages/shared/src/types/project.ts": "78eeea3694ce860d5d0be59c158c399c03368078",
"packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e",
"packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8",
"packages/shared/src/types/routine.ts": "e5e5a93d63233029b9bf258558ad155677a4eca7",
@@ -442,11 +445,12 @@
"packages/shared/src/validators/agent.ts": "13ba04317b11c4281b7a2133a34630b4ad446dfd",
"packages/shared/src/validators/app-definition.ts": "9fb660249eaf49a71adeab222cdb29a6db41ce28",
"packages/shared/src/validators/approval.test.ts": "051d29b27400fdd1e311466587b276264cf072e4",
"packages/shared/src/validators/approval.ts": "c1c074dc2f06d46adae0df8fa236d43c7aa01a25",
"packages/shared/src/validators/approval.ts": "b8fd4da39568e3b70b767266196ea5303efeef25",
"packages/shared/src/validators/artifact.test.ts": "2f0c138a6fa46d68507515d3932e9bc4cef44374",
"packages/shared/src/validators/artifact.ts": "64871e29aeb6d3c6340b720a6861809b0eb77407",
"packages/shared/src/validators/asset.test.ts": "cc6b4a18b1b05c6cb130594b2c197d0e738ce0bd",
"packages/shared/src/validators/asset.ts": "93b9a747b0e7a2bce216db4ac3ab82ee42eb5cc3",
"packages/shared/src/validators/attention.ts": "137f6ca363e2c9b81961de5621c69a87a8c3d461",
"packages/shared/src/validators/budget.ts": "76ad91791c89d5be0f64cb23dd5587c493a7ead5",
"packages/shared/src/validators/chat-channels.test.ts": "eb157fcb00128a62d381e3e06630ed38c9a80314",
"packages/shared/src/validators/chat-channels.ts": "9d0adf5ce89655d22071061d5e085d2ed84625d6",
@@ -457,7 +461,7 @@
"packages/shared/src/validators/company-skill.test.ts": "ec28e5f9331f98bfc45234b7b22ef61b25963211",
"packages/shared/src/validators/company-skill.ts": "9131229a934370b63fd9f686b0dee1f9334e81d6",
"packages/shared/src/validators/company.test.ts": "038157bb5dded4081959754d12b1edee6f66fc41",
"packages/shared/src/validators/company.ts": "35e2e2feb6d5ab980409defde051f311de1e67fe",
"packages/shared/src/validators/company.ts": "6d5b231beffc01a79707d98b960a21171b6702e2",
"packages/shared/src/validators/connection-intent.test.ts": "a65d6b659b6dda712b4afed88c93fdb6287bdb5a",
"packages/shared/src/validators/connection-intent.ts": "33b4ceb713a0a131ebda6f9804571d42a98037a2",
"packages/shared/src/validators/cost.ts": "d86b9c83943825118d07cbcf6b51ec4266be3157",
@@ -474,7 +478,7 @@
"packages/shared/src/validators/folder.ts": "b486444ab09ec3d5a9afec7dbd2e71c1bc2beb67",
"packages/shared/src/validators/goal.ts": "602724134915e1e83c50115abba36b288c0abd3d",
"packages/shared/src/validators/inbox-agent-policy.ts": "29cef157f4d9b564fd813868038aa8c813df4a7b",
"packages/shared/src/validators/index.ts": "8bfd7d4e62afe42ab5d158de9d207beb7e708b93",
"packages/shared/src/validators/index.ts": "768aa6d275a34ce08ba5d2efca38d26694dcf92e",
"packages/shared/src/validators/instance.test.ts": "3faa9593a9a05f74429d9b1d2251906a03e9a903",
"packages/shared/src/validators/instance.ts": "d94ffda92ccacd3d9410562e2898410db1a100be",
"packages/shared/src/validators/issue-chat-bootstrap.test.ts": "e1ebb9aa94da22c825bc3ddd32ce2cfc98bfe645",
@@ -487,7 +491,7 @@
"packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53",
"packages/shared/src/validators/plugin.test.ts": "23bc443850d46d815877a71a44625fd42aebb00a",
"packages/shared/src/validators/plugin.ts": "2a90d3d00b64f802b9c30f957e97702250ab2db4",
"packages/shared/src/validators/project.ts": "0257acc29400f438aefc0a657250d8f28ec60838",
"packages/shared/src/validators/project.ts": "6ea168f5faf3161e6004ea47274f60634c77b219",
"packages/shared/src/validators/provider-trace.ts": "b38b6200a2e51513f5dca0f8b008f2fe68175e82",
"packages/shared/src/validators/resource-memberships.ts": "b124ac8a8f24b4dbb03ae82bcc87541f6b4ecdfd",
"packages/shared/src/validators/routine.test.ts": "1501907ade577c1ab4c4b3098397e5d080446878",
@@ -115,6 +115,13 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
detail: null,
dismissal: null,
...overrides,
audience: overrides.audience ?? { kind: "pool", userId: null, reason: "none" },
stale: overrides.stale ?? null,
changeFingerprint: overrides.changeFingerprint ?? "fixture.other",
closeModes: overrides.closeModes ?? ["archive", "snooze"],
obligationKey: overrides.obligationKey ?? null,
obligationLeadId: overrides.obligationLeadId ?? null,
required: overrides.required ?? false,
expiresAt: overrides.expiresAt ?? null,
ruleKey: overrides.ruleKey ?? null,
originAgentName: overrides.originAgentName ?? null,
@@ -40,7 +40,7 @@ const pending = (id: string) => ({ id, sourceKind: "issue_thread_interaction", s
createdAt: "2026-10-20T12:00:00Z", activityAt: "2026-10-20T12:00:00Z" }) as AttentionItem;
function feed(items: AttentionItem[]): AttentionFeed {
return { companyId: "company", generatedAt: "2026-10-27T12:00:00Z", totalCount: items.length, deskBadgeCount: 0,
nextCursor: null, countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items };
viewCounts: { mine: items.length, unassigned: 0, company: 0, archived: 0, all: items.length }, nextCursor: null, countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items };
}
function summary(items: AttentionItem[]) {
@@ -53,7 +53,7 @@ function HistoryPanel({ companyId, agents, onClose, mobile = false }: { companyI
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
const groups = new Map<string, typeof items>();
for (const item of items) {
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle : groupBy === "agent" ? item.agentName ?? "Other agents" : groupBy === "project" ? item.projectName ?? "No project" : "";
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle ?? "Other tasks" : groupBy === "agent" ? item.agentName ?? "Other agents" : groupBy === "project" ? item.projectName ?? "No project" : "";
groups.set(label, [...(groups.get(label) ?? []), item]);
}
const slot = typeof document !== "undefined" ? document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID) : null;
@@ -123,7 +123,7 @@ export function CoordinatorNeedsYou() {
const recentGroups = useMemo(() => {
const result = new Map<string, DecisionHistoryItem[]>();
for (const item of visibleRecentItems) {
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle ?? "Other tasks"
: groupBy === "agent" ? item.agentName ?? "Other agents"
: groupBy === "project" ? item.projectName ?? "No project" : "";
result.set(label, [...(result.get(label) ?? []), item]);
+2
View File
@@ -55,6 +55,8 @@ function makeCompany(id: string): Company {
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
defaultResponsibleUserId: null,
defaultDecisionOwnerUserId: null,
budgetOwnerUserId: null,
requireBoardApprovalForNewAgents: false,
interactionResolverGovernance: {},
feedbackDataSharingEnabled: false,
@@ -19,6 +19,7 @@ export const coordinatorTaskQuestionAttention: AttentionItem = {
href: `/issues/PAPER-102#interaction-${coordinatorTaskQuestion.id}` },
relatedIssue: { kind: "issue", id: coordinatorTaskQuestion.issueId, companyId: coordinatorTaskQuestion.companyId,
title: "Write the launch post", identifier: "PAPER-102", status: "blocked", href: "/issues/PAPER-102" },
audience: { kind: "pool", userId: null, reason: "none" }, stale: null, required: false, changeFingerprint: "fixture.other", closeModes: ["withdraw", "archive", "snooze"], obligationKey: null, obligationLeadId: null,
whyNow: "The task needs an audience.", decisionVerbs: [], inlineResolvable: true, entryRule: "pending", exitRule: "answered",
dedupKey: coordinatorTaskQuestion.id, dismissalKey: coordinatorTaskQuestion.id, dismissal: null, severity: "medium", rank: 1,
activityAt: new Date(coordinatorTaskQuestion.createdAt).toISOString(), createdAt: new Date(coordinatorTaskQuestion.createdAt).toISOString(), updatedAt: new Date(coordinatorTaskQuestion.updatedAt).toISOString(),
+5
View File
@@ -45,6 +45,10 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
createdAt: "2026-07-09T12:00:00Z",
updatedAt: "2026-07-09T12:00:00Z",
relatedIssue: null,
audience: { kind: "pool", userId: null, reason: "none" },
stale: null,
required: false,
changeFingerprint: "fixture.other", closeModes: ["archive", "snooze"], obligationKey: null, obligationLeadId: null,
project: null,
workspace: null,
expiresAt: null,
@@ -118,6 +122,7 @@ describe("attentionBadgeCount", () => {
generatedAt: "2026-07-09T12:00:00Z",
totalCount: 3,
deskBadgeCount: 2,
viewCounts: { mine: 2, unassigned: 1, company: 0, archived: 0, all: 3 },
nextCursor: "next-page",
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
items: [buildItem({ id: "1" }), buildItem({ id: "2" }), buildItem({ id: "3" })],
+1
View File
@@ -296,6 +296,7 @@ describe("inbox helpers", () => {
kind: "dismiss",
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
snoozedUntil: null,
fingerprint: null,
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
+6
View File
@@ -43,6 +43,8 @@ export const storybookCompanies: Company[] = [
budgetMonthlyCents: 250_000,
spentMonthlyCents: 67_500,
defaultResponsibleUserId: "user-board",
defaultDecisionOwnerUserId: null,
budgetOwnerUserId: null,
requireBoardApprovalForNewAgents: true,
interactionResolverGovernance: {},
feedbackDataSharingEnabled: true,
@@ -66,6 +68,8 @@ export const storybookCompanies: Company[] = [
budgetMonthlyCents: 180_000,
spentMonthlyCents: 39_500,
defaultResponsibleUserId: "user-board",
defaultDecisionOwnerUserId: null,
budgetOwnerUserId: null,
requireBoardApprovalForNewAgents: false,
interactionResolverGovernance: {},
feedbackDataSharingEnabled: false,
@@ -89,6 +93,8 @@ export const storybookCompanies: Company[] = [
budgetMonthlyCents: 90_000,
spentMonthlyCents: 91_200,
defaultResponsibleUserId: "user-board",
defaultDecisionOwnerUserId: null,
budgetOwnerUserId: null,
requireBoardApprovalForNewAgents: true,
interactionResolverGovernance: {},
feedbackDataSharingEnabled: false,
@@ -14,6 +14,10 @@ import { DecisionQueuePage } from "@/pages/DecisionQueuePage";
import { AttentionQueueRow } from "@/components/AttentionQueueRow";
import type { DecisionQueueDto } from "@/api/decisionQueues";
import { queryKeys } from "@/lib/queryKeys";
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
import { storybookAuthSession } from "../../fixtures/paperclipData";
const STORYBOOK_USER_ID = storybookAuthSession.user!.id;
// The company id the Storybook providers select (see .storybook/preview.tsx).
const companyId = "company-storybook";
@@ -94,6 +98,10 @@ function item(
href: "/PAP/issues/PAP-1000",
metadata: {},
},
audience: { kind: "user", userId: STORYBOOK_USER_ID, reason: "addressee" },
stale: null,
required: false,
changeFingerprint: "fixture.other", closeModes: ["archive", "snooze"], obligationKey: null, obligationLeadId: null,
project: null,
workspace: null,
expiresAt: null,
@@ -137,22 +145,15 @@ function approval(id: string, title: string, whyNow: string, overrides: Partial<
}
function feed(items: AttentionItem[]): AttentionFeed {
// New-today (surfaced today) or overdue decide-by — the sidebar badge load.
const startOfToday = Date.UTC(
new Date(NOW).getUTCFullYear(),
new Date(NOW).getUTCMonth(),
new Date(NOW).getUTCDate(),
);
const deskBadgeCount = items.filter(
(it) =>
!it.shelf &&
(it.decideBy === "today" || new Date(it.createdAt).getTime() >= startOfToday),
).length;
// The shared badge is the "For you" count (PAP-749 Rule 5); every fixture
// card here belongs to the Storybook user, with a few ownerless cards waiting.
const deskBadgeCount = items.filter((it) => !it.dismissal?.isActive).length;
return {
companyId,
generatedAt: iso(NOW),
totalCount: items.length,
deskBadgeCount,
viewCounts: { mine: deskBadgeCount, unassigned: 3, company: 2, archived: items.length - deskBadgeCount, all: deskBadgeCount + 5 },
nextCursor: null,
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
items,
@@ -367,11 +368,14 @@ function PrimeDeskFixtures({
useMemo(() => {
queryClient.setQueryData(queryKeys.decisionQueues.list(companyId), QUEUES);
queryClient.setQueryData(queryKeys.agents.list(companyId), AGENTS);
queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession);
if (items) {
// Keep this fixture aligned with the current page query.
queryClient.setQueryData(
[...queryKeys.attention(companyId), "with-dismissed", null, null],
feed(items),
);
queryClient.setQueryData(coordinatorAttentionQueryKey(companyId), feed(items));
}
if (queueItems) {
// The queue feed key carries the resolved activity bounds (null/null for
@@ -78,6 +78,13 @@ function item(
detail: null,
dismissal: null,
...overrides,
audience: overrides.audience ?? { kind: "pool", userId: null, reason: "none" },
stale: overrides.stale ?? null,
changeFingerprint: overrides.changeFingerprint ?? "fixture.other",
closeModes: overrides.closeModes ?? ["archive", "snooze"],
obligationKey: overrides.obligationKey ?? null,
obligationLeadId: overrides.obligationLeadId ?? null,
required: overrides.required ?? false,
expiresAt: overrides.expiresAt ?? null,
ruleKey: overrides.ruleKey ?? null,
originAgentName: overrides.originAgentName ?? null,
@@ -448,7 +455,6 @@ function Queue({
key={it.id}
item={it}
companyId={companyId}
variant="hidden"
expanded={false}
onToggleExpand={() => {}}
onDismiss={() => {}}
@@ -462,7 +468,7 @@ function Queue({
{dismissed.length > 0 && (
<section className="space-y-2">
<IssueGroupHeader label={`Dismissed (${dismissed.length})`} collapsible collapsed={!openCurtains} className="text-muted-foreground" />
<IssueGroupHeader label={`Archived (${dismissed.length})`} collapsible collapsed={!openCurtains} className="text-muted-foreground" />
{openCurtains && (
<div className="space-y-2">
{dismissed.map((it) => (
@@ -470,7 +476,6 @@ function Queue({
key={it.id}
item={it}
companyId={companyId}
variant="hidden"
expanded={false}
onToggleExpand={() => {}}
onDismiss={() => {}}
@@ -63,6 +63,10 @@ const stalledReviewRow: AttentionItem = {
createdAt: "2026-08-02T00:00:00.000Z",
updatedAt: "2026-08-02T00:30:00.000Z",
relatedIssue: null,
audience: { kind: "pool", userId: null, reason: "none" },
stale: null,
required: false,
changeFingerprint: "fixture.other", closeModes: ["archive", "snooze"], obligationKey: null, obligationLeadId: null,
project: null,
workspace: null,
detail: null,
@@ -36,7 +36,7 @@ const decisionItems = [
companyId: "storybook-company", subject: { ...item.subject, kind: "issue", id: item.id, companyId: "storybook-company", status: "in_review", href: null },
decisionVerbs: [], inlineResolvable: false, entryRule: "", exitRule: "", dedupKey: item.id, dismissalKey: item.id,
dismissal: null, rank: 0, activityAt: "2026-10-16T12:00:00Z", createdAt: "2026-10-16T12:00:00Z", updatedAt: "2026-10-16T12:00:00Z",
project: null, workspace: null, relatedIssue: null, detail: null, expiresAt: null, ruleKey: null, queues: [], shelf: false,
project: null, workspace: null, relatedIssue: null, audience: { kind: "pool", userId: null, reason: "none" }, stale: null, required: false, changeFingerprint: "fixture.other", closeModes: ["archive", "snooze"], obligationKey: null, obligationLeadId: null, detail: null, expiresAt: null, ruleKey: null, queues: [], shelf: false,
retentionDays: 30, keep: false, archivedAt: null, retentionVersion: 1, decideBy: null, decideByAttribution: null, snoozedUntil: null, trainingExampleId: null,
}));