feat(decisions): add queues and prioritized attention feed (#10651)

<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source control plane for companies of AI
agents.
> - Operators use the attention feed to find decisions that need action.
> - The feed has eleven source kinds, but it has no durable queue or
triage state.
> - The feed also returns every item and lacks decision deadlines,
snooze state, and decision-focused ordering.
> - This pull request adds secure queue sidecars and enriches the
attention feed with triage data, filters, cursor pagination, and
decide-now ranking.
> - The benefit is a bounded feed that can show the most urgent
decisions first without weakening source visibility rules.

## Linked Issues or Issue Description

This pull request replaces the closed
[#10634](https://github.com/paperclipai/paperclip/pull/10634). It
combines that queue foundation with the dependent attention-feed change
as one review unit.

**Subsystem affected**

Database schema, shared contracts, server authorization and REST APIs,
and the UI attention client library.

**Problem or motivation**

The attention feed can contain hundreds of mixed decision items.
Operators cannot group them into durable queues, set a decision
deadline, snooze an item, or request a bounded page ordered by urgency.
The current client must download the full feed on each refresh.

**Proposed solution**

Store queue membership and triage state by stable attention identity.
Re-authorize each source during queue reads and writes. Enrich attention
items with queue, deadline, snooze, expiry, rule, and origin data. Add
activity and queue filters, opaque cursor pagination, decide-focused
ordering, and a decide-now count.

**Alternatives considered**

Adding queue fields to every source would duplicate schema and
authorization logic across eleven source kinds. Client-only filtering
and sorting would still transfer the full feed and would make pagination
unstable.

**Roadmap alignment**

This change improves the core decision-attention surface and operator
oversight. It does not implement the separate general-purpose work queue
milestone in `ROADMAP.md`.

## What Changed

- Added company-scoped queue, membership, triage, and append-only event
tables with actor and run provenance.
- Added queue CRUD, item membership, starter-rule discovery, and
decide-by and snooze endpoints.
- Kept source authorization on each queue mutation, read, and count.
- Added attention fields for expiry, rule, origin agent, queues,
decide-by attribution, and snooze state.
- Added activity date filters, queue filters, opaque cursor pagination,
and configurable page limits.
- Added decide-now ordering by deadline, expiry, severity, and activity.
- Added `decideNowCount` and excluded actively snoozed items from the
default feed.
- Updated the shared and UI client contracts.
- Added focused server, route, OpenAPI, and UI client tests.

## Verification

- `pnpm exec vitest run server/src/__tests__/attention-service.test.ts
server/src/__tests__/decision-queues-routes.test.ts
server/src/__tests__/openapi-routes.test.ts ui/src/api/attention.test.ts
ui/src/lib/attention.test.ts` (72 tests passed)
- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm -r --filter @paperclipai/db --filter @paperclipai/shared
--filter @paperclipai/server --filter @paperclipai/ui typecheck`
- `git diff --check origin/master...HEAD`

## Risks

- The migration adds four company-scoped tables and provenance foreign
keys. Migration numbering and safety checks pass.
- Attention reads can lazily create starter queues and memberships.
Inserts are idempotent, audited, and transactional.
- Cursor validity depends on the filtered feed. The API returns a clear
validation error when the cursor item no longer exists in that feed.
- Queue reads re-check source visibility. This favors correct
authorization over fewer queries.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, model `gpt-5`. The runtime used agentic reasoning,
repository tools, code execution, and test execution. The runtime did
not expose the 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-01 20:37:39 -05:00 committed by GitHub
parent 14d4db6330
commit 30c49c8327
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 78329 additions and 40 deletions

View File

@ -177,6 +177,14 @@ Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `s
This policy makes training exports self-describing while keeping the decision record usable after a comment deletion without retaining content the author removed.
## Decision queues and triage provenance
The decisions desk stores queue membership and decide-by/snooze state in `decision_queues`, `decision_queue_items`, and `decision_triage`. These sidecars use the stable attention identity `(source_kind, source_id)` so all attention source kinds can participate without copying source titles, bodies, projects, or other visibility-sensitive data.
`decision_triage_events` is append-only history for queue and triage changes. Current rows and history both carry server-derived user/agent, heartbeat run, API-key, and responsible-user attribution where applicable. Queue reads must resolve and authorize their source rows at read time; a sidecar row is never a visibility grant.
Triage writes serialize on the company and attention-source identity so concurrent partial updates preserve both fields and produce monotonic history versions.
## Plugin database namespaces
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.

View File

@ -455,6 +455,14 @@ The current implementation includes additional V1-control-plane tables beyond th
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`.
- Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos.
Decision-desk triage uses company-scoped sidecars rather than adding queue fields to every attention source:
- `decision_queues` stores durable named queues, optional retention overrides, server-derived creator/run provenance, and data-backed seed rules.
- `decision_queue_items` keys membership by `(queue_id, source_kind, source_id)` and repeats `company_id` for company-consistent joins.
- `decision_triage` keys current decide-by/snooze state by `(company_id, source_kind, source_id)` and preserves the latest setter attribution.
- `decision_triage_events` is the immutable mutation history for queue membership and triage overrides, including actor, run, API-key, and responsible-user provenance.
- Queue membership never grants source visibility. Item writes re-authorize the referenced source, and queue reads re-authorize every member before returning rows or counts.
## 8. State Machines
## 8.1 Agent Status
@ -975,6 +983,15 @@ The current app also exposes V1-supporting surfaces for:
- plugin installation, configuration, state, jobs, logs, webhooks, and plugin database namespace migration
- company import/export preview/apply, feedback export/vote routes, instance backup/config routes, invites, join requests, memberships, and permission grants
- company skill policy read/replace/reset/simulation, enforced by the same core evaluator used by skill mutation routes
- decision queues and per-attention-item triage:
- `GET|POST /companies/:companyId/decision-queues`
- `PATCH /companies/:companyId/decision-queues/:key`
- `GET|POST /companies/:companyId/decision-queues/:key/items`
- `DELETE /companies/:companyId/decision-queues/:key/items/:sourceKind/:sourceId`
- `GET /companies/:companyId/decision-queue-seed-rules`
- `GET|PUT /companies/:companyId/decision-triage/:sourceKind/:sourceId`
Queue and triage mutations accept board non-viewers and active standard-scope agents, apply responsible-user intersection for run JWTs, and reject low-trust, `task_bridge`, and `skill_test` contexts. Missing, cross-company, and unauthorized attention sources share the same not-found response.
## 11. Heartbeat and Adapter Contract

View File

@ -0,0 +1,95 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const DECISION_QUEUE_MIGRATIONS = [
"0198_decision_queues_and_triage.sql",
"0199_decision_queue_composite_key.sql",
] as const;
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function readMigration(fileName: string): Promise<string> {
return fs.promises.readFile(new URL(`./migrations/${fileName}`, import.meta.url), "utf8");
}
async function reapplyDecisionQueueMigrations(sql: ReturnType<typeof postgres>): Promise<void> {
for (const fileName of DECISION_QUEUE_MIGRATIONS) {
await sql.unsafe(await readMigration(fileName));
}
}
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping decision queue migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("decision queue migrations", () => {
it(
"can be reapplied without changing schema or existing data",
async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-decision-queue-migrations-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
const companyId = randomUUID();
const queueId = randomUUID();
const itemId = randomUUID();
try {
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, 'Migration Test', 'DQM')
`;
await sql`
INSERT INTO "decision_queues" (
"id", "company_id", "key", "title", "created_by_type", "created_by_user_id"
) VALUES (${queueId}, ${companyId}, 'migration-test', 'Migration Test', 'user', 'test-user')
`;
await sql`
INSERT INTO "decision_queue_items" (
"id", "company_id", "queue_id", "source_kind", "source_id", "added_by_type", "added_by_user_id"
) VALUES (${itemId}, ${companyId}, ${queueId}, 'issue', 'DQM-1', 'user', 'test-user')
`;
await reapplyDecisionQueueMigrations(sql);
await reapplyDecisionQueueMigrations(sql);
const rows = await sql<{ id: string }[]>`
SELECT "id" FROM "decision_queue_items" WHERE "id" = ${itemId}
`;
expect(rows).toEqual([{ id: itemId }]);
const constraints = await sql<{ conname: string }[]>`
SELECT "conname"
FROM "pg_constraint"
WHERE "conname" IN (
'decision_queues_id_company_uq',
'decision_queue_items_queue_company_fk'
)
ORDER BY "conname"
`;
expect(constraints).toEqual([
{ conname: "decision_queue_items_queue_company_fk" },
{ conname: "decision_queues_id_company_uq" },
]);
} finally {
await sql.end();
}
},
30_000,
);
});

View File

@ -0,0 +1,161 @@
CREATE TABLE IF NOT EXISTS "decision_queue_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"queue_id" uuid NOT NULL,
"source_kind" text NOT NULL,
"source_id" text NOT NULL,
"added_by_type" text NOT NULL,
"added_by_agent_id" uuid,
"added_by_user_id" text,
"added_by_run_id" uuid,
"added_by_agent_api_key_id" uuid,
"responsible_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "decision_queue_items_actor_check" CHECK ((
("decision_queue_items"."added_by_type" = 'agent' AND "decision_queue_items"."added_by_agent_id" IS NOT NULL AND "decision_queue_items"."added_by_user_id" IS NULL)
OR ("decision_queue_items"."added_by_type" = 'user' AND "decision_queue_items"."added_by_agent_id" IS NULL AND "decision_queue_items"."added_by_user_id" IS NOT NULL)
OR ("decision_queue_items"."added_by_type" = 'system' AND "decision_queue_items"."added_by_agent_id" IS NULL AND "decision_queue_items"."added_by_user_id" IS NULL)
))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "decision_queues" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"key" text NOT NULL,
"title" text NOT NULL,
"description" text,
"created_by_type" text NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_by_run_id" uuid,
"created_by_agent_api_key_id" uuid,
"retention_days" integer,
"seed_rules" jsonb DEFAULT '[]'::jsonb NOT NULL,
"seed_rules_enabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "decision_queues_creator_check" CHECK ((
("decision_queues"."created_by_type" = 'agent' AND "decision_queues"."created_by_agent_id" IS NOT NULL AND "decision_queues"."created_by_user_id" IS NULL)
OR ("decision_queues"."created_by_type" = 'user' AND "decision_queues"."created_by_agent_id" IS NULL AND "decision_queues"."created_by_user_id" IS NOT NULL)
OR ("decision_queues"."created_by_type" = 'system' AND "decision_queues"."created_by_agent_id" IS NULL AND "decision_queues"."created_by_user_id" IS NULL)
)),
CONSTRAINT "decision_queues_retention_days_check" CHECK ("decision_queues"."retention_days" IS NULL OR ("decision_queues"."retention_days" >= 1 AND "decision_queues"."retention_days" <= 3650))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "decision_triage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"source_kind" text NOT NULL,
"source_id" text NOT NULL,
"decide_by" text,
"decide_by_date" date,
"snoozed_until" timestamp with time zone,
"set_by_type" text NOT NULL,
"set_by_agent_id" uuid,
"set_by_user_id" text,
"set_by_run_id" uuid,
"set_by_agent_api_key_id" uuid,
"responsible_user_id" text,
"version" integer DEFAULT 1 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "decision_triage_actor_check" CHECK ((
("decision_triage"."set_by_type" = 'agent' AND "decision_triage"."set_by_agent_id" IS NOT NULL AND "decision_triage"."set_by_user_id" IS NULL)
OR ("decision_triage"."set_by_type" = 'user' AND "decision_triage"."set_by_agent_id" IS NULL AND "decision_triage"."set_by_user_id" IS NOT NULL)
)),
CONSTRAINT "decision_triage_decide_by_check" CHECK ((
("decision_triage"."decide_by" IS NULL AND "decision_triage"."decide_by_date" IS NULL)
OR ("decision_triage"."decide_by" IN ('today', 'this_week', 'whenever') AND "decision_triage"."decide_by_date" IS NULL)
OR ("decision_triage"."decide_by" = 'date' AND "decision_triage"."decide_by_date" IS NOT NULL)
))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "decision_triage_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"queue_id" uuid,
"source_kind" text,
"source_id" text,
"action" text NOT NULL,
"actor_type" text NOT NULL,
"actor_agent_id" uuid,
"actor_user_id" text,
"actor_run_id" uuid,
"agent_api_key_id" uuid,
"responsible_user_id" text,
"details" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "decision_triage_events_actor_check" CHECK ((
("decision_triage_events"."actor_type" = 'agent' AND "decision_triage_events"."actor_agent_id" IS NOT NULL AND "decision_triage_events"."actor_user_id" IS NULL)
OR ("decision_triage_events"."actor_type" = 'user' AND "decision_triage_events"."actor_agent_id" IS NULL AND "decision_triage_events"."actor_user_id" IS NOT NULL)
OR ("decision_triage_events"."actor_type" = 'system' AND "decision_triage_events"."actor_agent_id" IS NULL AND "decision_triage_events"."actor_user_id" IS NULL)
))
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "decision_queues_id_company_uq" ON "decision_queues" USING btree ("id","company_id");
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queue_items_company_id_companies_id_fk' AND conrelid = 'public.decision_queue_items'::regclass) THEN
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queue_items_added_by_agent_id_agents_id_fk' AND conrelid = 'public.decision_queue_items'::regclass) THEN
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_added_by_agent_id_agents_id_fk" FOREIGN KEY ("added_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queue_items_added_by_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.decision_queue_items'::regclass) THEN
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("added_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk' AND conrelid = 'public.decision_queue_items'::regclass) THEN
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("added_by_agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queue_items_queue_company_fk' AND conrelid = 'public.decision_queue_items'::regclass) THEN
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_queue_company_fk" FOREIGN KEY ("queue_id","company_id") REFERENCES "public"."decision_queues"("id","company_id") ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queues_company_id_companies_id_fk' AND conrelid = 'public.decision_queues'::regclass) THEN
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queues_created_by_agent_id_agents_id_fk' AND conrelid = 'public.decision_queues'::regclass) THEN
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queues_created_by_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.decision_queues'::regclass) THEN
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk' AND conrelid = 'public.decision_queues'::regclass) THEN
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("created_by_agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_company_id_companies_id_fk' AND conrelid = 'public.decision_triage'::regclass) THEN
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_set_by_agent_id_agents_id_fk' AND conrelid = 'public.decision_triage'::regclass) THEN
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_set_by_agent_id_agents_id_fk" FOREIGN KEY ("set_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_set_by_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.decision_triage'::regclass) THEN
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_set_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("set_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk' AND conrelid = 'public.decision_triage'::regclass) THEN
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("set_by_agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_events_company_id_companies_id_fk' AND conrelid = 'public.decision_triage_events'::regclass) THEN
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_events_queue_id_decision_queues_id_fk' AND conrelid = 'public.decision_triage_events'::regclass) THEN
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_queue_id_decision_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."decision_queues"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_events_actor_agent_id_agents_id_fk' AND conrelid = 'public.decision_triage_events'::regclass) THEN
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_actor_agent_id_agents_id_fk" FOREIGN KEY ("actor_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_events_actor_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.decision_triage_events'::regclass) THEN
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_actor_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("actor_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_triage_events_agent_api_key_id_agent_api_keys_id_fk' AND conrelid = 'public.decision_triage_events'::regclass) THEN
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "decision_queue_items_queue_source_uq" ON "decision_queue_items" USING btree ("queue_id","source_kind","source_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_queue_items_company_source_idx" ON "decision_queue_items" USING btree ("company_id","source_kind","source_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "decision_queues_company_key_uq" ON "decision_queues" USING btree ("company_id","key");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_queues_company_updated_idx" ON "decision_queues" USING btree ("company_id","updated_at");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "decision_triage_company_source_uq" ON "decision_triage" USING btree ("company_id","source_kind","source_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_triage_company_decide_by_idx" ON "decision_triage" USING btree ("company_id","decide_by");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_triage_events_company_source_created_idx" ON "decision_triage_events" USING btree ("company_id","source_kind","source_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_triage_events_queue_created_idx" ON "decision_triage_events" USING btree ("queue_id","created_at");

View File

@ -0,0 +1,10 @@
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queues_id_company_uq' AND conrelid = 'public.decision_queues'::regclass) THEN
ALTER TABLE "decision_queue_items" DROP CONSTRAINT IF EXISTS "decision_queue_items_queue_company_fk";
DROP INDEX IF EXISTS "decision_queues_id_company_uq";
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_id_company_uq" UNIQUE("id","company_id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_queue_items_queue_company_fk' AND conrelid = 'public.decision_queue_items'::regclass) THEN
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_queue_company_fk" FOREIGN KEY ("queue_id","company_id") REFERENCES "public"."decision_queues"("id","company_id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1373,6 +1373,20 @@
"when": 1785175200000,
"tag": "0197_decisions_v1",
"breakpoints": true
},
{
"idx": 198,
"version": "7",
"when": 1785603158310,
"tag": "0198_decision_queues_and_triage",
"breakpoints": true
},
{
"idx": 199,
"version": "7",
"when": 1785605818353,
"tag": "0199_decision_queue_composite_key",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,184 @@
import { sql } from "drizzle-orm";
import {
boolean,
check,
date,
foreignKey,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
unique,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type { DecisionQueueSeedRule } from "@paperclipai/shared";
import { agentApiKeys } from "./agent_api_keys.js";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const decisionQueues = pgTable(
"decision_queues",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
key: text("key").notNull(),
title: text("title").notNull(),
description: text("description"),
createdByType: text("created_by_type").notNull(),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
createdByUserId: text("created_by_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id),
createdByAgentApiKeyId: uuid("created_by_agent_api_key_id").references(() => agentApiKeys.id),
retentionDays: integer("retention_days"),
seedRules: jsonb("seed_rules").$type<DecisionQueueSeedRule[]>().notNull().default([]),
seedRulesEnabled: boolean("seed_rules_enabled").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyKeyUq: uniqueIndex("decision_queues_company_key_uq").on(table.companyId, table.key),
companyUpdatedIdx: index("decision_queues_company_updated_idx").on(table.companyId, table.updatedAt),
idCompanyUq: unique("decision_queues_id_company_uq").on(table.id, table.companyId),
creatorCheck: check(
"decision_queues_creator_check",
sql`(
(${table.createdByType} = 'agent' AND ${table.createdByAgentId} IS NOT NULL AND ${table.createdByUserId} IS NULL)
OR (${table.createdByType} = 'user' AND ${table.createdByAgentId} IS NULL AND ${table.createdByUserId} IS NOT NULL)
OR (${table.createdByType} = 'system' AND ${table.createdByAgentId} IS NULL AND ${table.createdByUserId} IS NULL)
)`,
),
retentionDaysCheck: check(
"decision_queues_retention_days_check",
sql`${table.retentionDays} IS NULL OR (${table.retentionDays} >= 1 AND ${table.retentionDays} <= 3650)`,
),
}),
);
export const decisionQueueItems = pgTable(
"decision_queue_items",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
queueId: uuid("queue_id").notNull(),
sourceKind: text("source_kind").notNull(),
sourceId: text("source_id").notNull(),
addedByType: text("added_by_type").notNull(),
addedByAgentId: uuid("added_by_agent_id").references(() => agents.id),
addedByUserId: text("added_by_user_id"),
addedByRunId: uuid("added_by_run_id").references(() => heartbeatRuns.id),
addedByAgentApiKeyId: uuid("added_by_agent_api_key_id").references(() => agentApiKeys.id),
responsibleUserId: text("responsible_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
queueSourceUq: uniqueIndex("decision_queue_items_queue_source_uq").on(
table.queueId,
table.sourceKind,
table.sourceId,
),
companySourceIdx: index("decision_queue_items_company_source_idx").on(
table.companyId,
table.sourceKind,
table.sourceId,
),
queueCompanyFk: foreignKey({
columns: [table.queueId, table.companyId],
foreignColumns: [decisionQueues.id, decisionQueues.companyId],
name: "decision_queue_items_queue_company_fk",
}).onDelete("cascade"),
actorCheck: check(
"decision_queue_items_actor_check",
sql`(
(${table.addedByType} = 'agent' AND ${table.addedByAgentId} IS NOT NULL AND ${table.addedByUserId} IS NULL)
OR (${table.addedByType} = 'user' AND ${table.addedByAgentId} IS NULL AND ${table.addedByUserId} IS NOT NULL)
OR (${table.addedByType} = 'system' AND ${table.addedByAgentId} IS NULL AND ${table.addedByUserId} IS NULL)
)`,
),
}),
);
export const decisionTriage = pgTable(
"decision_triage",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
sourceKind: text("source_kind").notNull(),
sourceId: text("source_id").notNull(),
decideBy: text("decide_by"),
decideByDate: date("decide_by_date"),
snoozedUntil: timestamp("snoozed_until", { withTimezone: true }),
setByType: text("set_by_type").notNull(),
setByAgentId: uuid("set_by_agent_id").references(() => agents.id),
setByUserId: text("set_by_user_id"),
setByRunId: uuid("set_by_run_id").references(() => heartbeatRuns.id),
setByAgentApiKeyId: uuid("set_by_agent_api_key_id").references(() => agentApiKeys.id),
responsibleUserId: text("responsible_user_id"),
version: integer("version").notNull().default(1),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySourceUq: uniqueIndex("decision_triage_company_source_uq").on(
table.companyId,
table.sourceKind,
table.sourceId,
),
companyDecideByIdx: index("decision_triage_company_decide_by_idx").on(table.companyId, table.decideBy),
actorCheck: check(
"decision_triage_actor_check",
sql`(
(${table.setByType} = 'agent' AND ${table.setByAgentId} IS NOT NULL AND ${table.setByUserId} IS NULL)
OR (${table.setByType} = 'user' AND ${table.setByAgentId} IS NULL AND ${table.setByUserId} IS NOT NULL)
)`,
),
decideByCheck: check(
"decision_triage_decide_by_check",
sql`(
(${table.decideBy} IS NULL AND ${table.decideByDate} IS NULL)
OR (${table.decideBy} IN ('today', 'this_week', 'whenever') AND ${table.decideByDate} IS NULL)
OR (${table.decideBy} = 'date' AND ${table.decideByDate} IS NOT NULL)
)`,
),
}),
);
export const decisionTriageEvents = pgTable(
"decision_triage_events",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
queueId: uuid("queue_id").references(() => decisionQueues.id),
sourceKind: text("source_kind"),
sourceId: text("source_id"),
action: text("action").notNull(),
actorType: text("actor_type").notNull(),
actorAgentId: uuid("actor_agent_id").references(() => agents.id),
actorUserId: text("actor_user_id"),
actorRunId: uuid("actor_run_id").references(() => heartbeatRuns.id),
agentApiKeyId: uuid("agent_api_key_id").references(() => agentApiKeys.id),
responsibleUserId: text("responsible_user_id"),
details: jsonb("details").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySourceCreatedIdx: index("decision_triage_events_company_source_created_idx").on(
table.companyId,
table.sourceKind,
table.sourceId,
table.createdAt,
),
queueCreatedIdx: index("decision_triage_events_queue_created_idx").on(table.queueId, table.createdAt),
actorCheck: check(
"decision_triage_events_actor_check",
sql`(
(${table.actorType} = 'agent' AND ${table.actorAgentId} IS NOT NULL AND ${table.actorUserId} IS NULL)
OR (${table.actorType} = 'user' AND ${table.actorAgentId} IS NULL AND ${table.actorUserId} IS NOT NULL)
OR (${table.actorType} = 'system' AND ${table.actorAgentId} IS NULL AND ${table.actorUserId} IS NULL)
)`,
),
}),
);

View File

@ -84,6 +84,12 @@ export { userInboxAgentPolicies } from "./user_inbox_agent_policies.js";
export { inboxDismissals } from "./inbox_dismissals.js";
export { feedbackVotes } from "./feedback_votes.js";
export { decisionTrainingExamples } from "./decision_training_examples.js";
export {
decisionQueues,
decisionQueueItems,
decisionTriage,
decisionTriageEvents,
} from "./decision_queues.js";
export { feedbackExports } from "./feedback_exports.js";
export { issueReadStates } from "./issue_read_states.js";
export { assets } from "./assets.js";

View File

@ -95,13 +95,39 @@ export type {
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
AttentionFeedQuery,
AttentionProjectRef,
AttentionQueueRef,
AttentionSeverity,
AttentionSortMode,
AttentionSourceKind,
AttentionSubject,
AttentionSubjectKind,
AttentionTriageAttribution,
AttentionWorkspaceRef,
} from "./types/attention.js";
export { ATTENTION_SOURCE_KINDS } from "./types/attention.js";
export type {
DecisionQueue,
DecisionQueueItem,
DecisionQueueSeedRule,
DecisionQueueSeedRuleSignal,
DecisionTriage,
DecisionTriageDecideBy,
} from "./types/decision-queue.js";
export {
decisionAttentionSourceKindSchema,
decisionQueueKeySchema,
createDecisionQueueSchema,
updateDecisionQueueSchema,
addDecisionQueueItemSchema,
decisionTriageDecideBySchema,
updateDecisionTriageSchema,
type CreateDecisionQueueInput,
type UpdateDecisionQueueInput,
type AddDecisionQueueItemInput,
type UpdateDecisionTriageInput,
} from "./validators/decision-queue.js";
export type {
DecisionTrainingExample,
DecisionTrainingNotesHistoryEntry,

View File

@ -1,17 +1,20 @@
import type { InboxDismissalKind } from "./inbox-dismissal.js";
export type AttentionSourceKind =
| "approval"
| "decision"
| "issue_thread_interaction"
| "join_request"
| "recovery_action"
| "productivity_review"
| "blocker_attention"
| "review"
| "failed_run"
| "budget_alert"
| "agent_error_alert";
export const ATTENTION_SOURCE_KINDS = [
"approval",
"decision",
"issue_thread_interaction",
"join_request",
"recovery_action",
"productivity_review",
"blocker_attention",
"review",
"failed_run",
"budget_alert",
"agent_error_alert",
] as const;
export type AttentionSourceKind = (typeof ATTENTION_SOURCE_KINDS)[number];
export type AttentionSubjectKind =
| "approval"
@ -56,6 +59,33 @@ export interface AttentionWorkspaceRef {
name: string;
}
export interface AttentionQueueRef {
key: string;
title: string;
}
export interface AttentionTriageAttribution {
type: "agent" | "user";
agentId: string | null;
agentName: string | null;
userId: string | null;
runId: string | null;
responsibleUserId: string | null;
updatedAt: string;
}
export type AttentionSortMode = "activity" | "decide";
export interface AttentionFeedQuery {
includeDismissed?: boolean;
activitySince?: string;
activityUntil?: string;
queue?: string;
sort?: AttentionSortMode;
cursor?: string;
limit?: number;
}
export interface AttentionDetailImage {
assetId: string;
alt?: string | null;
@ -167,6 +197,13 @@ export interface AttentionItem {
relatedIssue: AttentionSubject | null;
project: AttentionProjectRef | null;
workspace: AttentionWorkspaceRef | null;
expiresAt: string | null;
ruleKey: string | null;
originAgentName: string | null;
queues: AttentionQueueRef[];
decideBy: string | null;
decideByAttribution: AttentionTriageAttribution | null;
snoozedUntil: string | null;
detail: AttentionItemDetail | null;
trainingExampleId: string | null;
}
@ -175,6 +212,8 @@ export interface AttentionFeed {
companyId: string;
generatedAt: string;
totalCount: number;
decideNowCount: number;
nextCursor: string | null;
countsBySourceKind: Record<AttentionSourceKind, number>;
items: AttentionItem[];
}

View File

@ -0,0 +1,63 @@
import type { AttentionSourceKind } from "./attention.js";
export type DecisionQueueSeedRuleSignal =
| "issue_has_pull_request_work_product"
| "plan_document_confirmation"
| "ask_user_questions";
export interface DecisionQueueSeedRule {
key: string;
description: string;
signal: DecisionQueueSeedRuleSignal;
}
export interface DecisionQueueItem {
id: string;
companyId: string;
queueId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
addedByType: "agent" | "user" | "system";
addedByAgentId: string | null;
addedByUserId: string | null;
addedByRunId: string | null;
responsibleUserId: string | null;
createdAt: Date;
}
export interface DecisionQueue {
id: string;
companyId: string;
key: string;
title: string;
description: string | null;
createdByType: "agent" | "user" | "system";
createdByAgentId: string | null;
createdByUserId: string | null;
createdByRunId: string | null;
retentionDays: number | null;
seedRules: DecisionQueueSeedRule[];
seedRulesEnabled: boolean;
itemCount: number;
createdAt: Date;
updatedAt: Date;
}
export type DecisionTriageDecideBy = "today" | "this_week" | "whenever" | string;
export interface DecisionTriage {
id: string;
companyId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
decideBy: DecisionTriageDecideBy | null;
snoozedUntil: Date | null;
setByType: "agent" | "user";
setByAgentId: string | null;
setByUserId: string | null;
setByRunId: string | null;
responsibleUserId: string | null;
version: number;
createdAt: Date;
updatedAt: Date;
}

View File

@ -19,13 +19,26 @@ export type {
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
AttentionFeedQuery,
AttentionProjectRef,
AttentionQueueRef,
AttentionSeverity,
AttentionSortMode,
AttentionSourceKind,
AttentionSubject,
AttentionSubjectKind,
AttentionTriageAttribution,
AttentionWorkspaceRef,
} from "./attention.js";
export { ATTENTION_SOURCE_KINDS } from "./attention.js";
export type {
DecisionQueue,
DecisionQueueItem,
DecisionQueueSeedRule,
DecisionQueueSeedRuleSignal,
DecisionTriage,
DecisionTriageDecideBy,
} from "./decision-queue.js";
export type {
DecisionTrainingExample,
DecisionTrainingNotesHistoryEntry,

View File

@ -0,0 +1,49 @@
import { z } from "zod";
import { ATTENTION_SOURCE_KINDS } from "../types/attention.js";
export const decisionAttentionSourceKindSchema = z.enum(ATTENTION_SOURCE_KINDS);
export const decisionQueueKeySchema = z.string()
.trim()
.min(1)
.max(80)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Queue key must be URL-safe lowercase kebab-case");
export const createDecisionQueueSchema = z.object({
key: decisionQueueKeySchema,
title: z.string().trim().min(1).max(120),
description: z.string().trim().max(2_000).nullable().optional(),
retentionDays: z.number().int().min(1).max(3_650).nullable().optional(),
}).strict();
export const updateDecisionQueueSchema = z.object({
title: z.string().trim().min(1).max(120).optional(),
description: z.string().trim().max(2_000).nullable().optional(),
retentionDays: z.number().int().min(1).max(3_650).nullable().optional(),
seedRulesEnabled: z.boolean().optional(),
}).strict().refine((value) => Object.keys(value).length > 0, "At least one queue field is required");
export const addDecisionQueueItemSchema = z.object({
sourceKind: decisionAttentionSourceKindSchema,
sourceId: z.string().trim().min(1).max(500),
}).strict();
const calendarDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => {
const parsed = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}, "Invalid calendar date");
export const decisionTriageDecideBySchema = z.union([
z.enum(["today", "this_week", "whenever"]),
calendarDateSchema,
]);
export const updateDecisionTriageSchema = z.object({
decideBy: decisionTriageDecideBySchema.nullable().optional(),
snoozedUntil: z.string().datetime({ offset: true }).nullable().optional(),
}).strict().refine((value) => Object.keys(value).length > 0, "At least one triage field is required");
export type CreateDecisionQueueInput = z.infer<typeof createDecisionQueueSchema>;
export type UpdateDecisionQueueInput = z.infer<typeof updateDecisionQueueSchema>;
export type AddDecisionQueueItemInput = z.infer<typeof addDecisionQueueItemSchema>;
export type UpdateDecisionTriageInput = z.infer<typeof updateDecisionTriageSchema>;

View File

@ -12,6 +12,11 @@ import {
budgetPolicies,
companies,
createDb,
decisionQueueItems,
decisionQueues,
decisions,
decisionTriage,
decisionTriageEvents,
documents,
heartbeatRunEvents,
heartbeatRuns,
@ -57,6 +62,11 @@ describeEmbeddedPostgres("attention service", () => {
afterEach(async () => {
await db.delete(inboxDismissals);
await db.delete(decisionTriageEvents);
await db.delete(decisionTriage);
await db.delete(decisionQueueItems);
await db.delete(decisionQueues);
await db.delete(decisions);
await db.delete(issueThreadInteractions);
await db.delete(issueApprovals);
await db.delete(issueAttachments);
@ -1081,6 +1091,246 @@ describeEmbeddedPostgres("attention service", () => {
expect(visibleApproval).toBeTruthy();
});
it("enriches, filters, paginates, snoozes, and ranks the decide-now feed", async () => {
const { companyId, workerId, errorAgentId } = await seedCompany("ATP");
const now = Date.now();
const originIssueId = await insertIssue({
companyId,
identifier: "ATP-1",
title: "Decision origin",
status: "in_progress",
assigneeAgentId: workerId,
updatedAt: new Date(now - 10 * 60_000),
});
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId: workerId,
status: "succeeded",
contextSnapshot: { issueId: originIssueId },
});
const expiringSoonId = randomUUID();
const expiringLaterId = randomUUID();
const wheneverId = randomUUID();
const snoozedId = randomUUID();
const decisionValues = [
{ id: expiringSoonId, ruleKey: "release.soon", title: "Expires sooner", updatedAt: new Date(now - 60_000), expiresAt: new Date(now + 60 * 60_000) },
{ id: expiringLaterId, ruleKey: "release.later", title: "Expires later", updatedAt: new Date(now - 2 * 60_000), expiresAt: new Date(now + 2 * 60 * 60_000) },
{ id: wheneverId, ruleKey: "release.whenever", title: "Can wait", updatedAt: new Date(now), expiresAt: new Date(now + 30 * 60_000) },
{ id: snoozedId, ruleKey: "release.snoozed", title: "Snoozed", updatedAt: new Date(now - 3 * 60_000), expiresAt: new Date(now + 15 * 60_000) },
];
await db.insert(decisions).values(decisionValues.map((value) => ({
...value,
companyId,
originAgentId: workerId,
originIssueId,
originRunId: runId,
body: value.title,
options: [],
status: "open",
signedSpec: "test",
targetSnapshots: {},
createdAt: value.updatedAt,
})));
const queueId = randomUUID();
await db.insert(decisionQueues).values({
id: queueId,
companyId,
key: "urgent-releases",
title: "Urgent releases",
createdByType: "user",
createdByUserId: "board-user",
});
await db.insert(decisionQueueItems).values([expiringSoonId, expiringLaterId].map((sourceId) => ({
companyId,
queueId,
sourceKind: "decision",
sourceId,
addedByType: "user",
addedByUserId: "board-user",
})));
await db.insert(decisionTriage).values([
expiringSoonId,
expiringLaterId,
snoozedId,
].map((sourceId) => ({
companyId,
sourceKind: "decision",
sourceId,
decideBy: "today",
setByType: "agent",
setByAgentId: workerId,
snoozedUntil: sourceId === snoozedId ? new Date(now + 60 * 60_000) : null,
})));
await db.insert(decisionTriage).values({
companyId,
sourceKind: "decision",
sourceId: wheneverId,
decideBy: "whenever",
setByType: "agent",
setByAgentId: workerId,
});
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "hire_agent",
status: "pending",
payload: { title: "Newer medium-severity approval" },
createdAt: new Date(now + 5 * 60_000),
updatedAt: new Date(now + 5 * 60_000),
});
await db.update(agents).set({ updatedAt: new Date(now - 5 * 60_000) }).where(eq(agents.id, errorAgentId));
const feed = await attentionService(db).list(companyId, {
userId: "board-user",
sort: "decide",
limit: 20,
});
expect(feed.decideNowCount).toBe(2);
expect(feed.items.some((item) => item.subject.id === snoozedId)).toBe(false);
expect(feed.items.slice(0, 3).map((item) => item.subject.id)).toEqual([
expiringSoonId,
expiringLaterId,
wheneverId,
]);
const erroredAgentIndex = feed.items.findIndex((item) => item.subject.id === errorAgentId);
const approvalIndex = feed.items.findIndex((item) => item.subject.id === approvalId);
expect(erroredAgentIndex).toBeGreaterThan(-1);
expect(erroredAgentIndex).toBeLessThan(approvalIndex);
const enriched = feed.items.find((item) => item.subject.id === expiringSoonId);
expect(enriched).toMatchObject({
expiresAt: decisionValues[0]!.expiresAt.toISOString(),
ruleKey: "release.soon",
originAgentName: "Worker",
queues: [{ key: "urgent-releases", title: "Urgent releases" }],
decideBy: "today",
decideByAttribution: {
type: "agent",
agentId: workerId,
agentName: "Worker",
},
snoozedUntil: null,
});
const firstPage = await attentionService(db).list(companyId, {
userId: "board-user",
queue: "urgent-releases",
sort: "decide",
limit: 1,
});
expect(firstPage).toMatchObject({ totalCount: 2, decideNowCount: 2 });
expect(firstPage.items.map((item) => item.subject.id)).toEqual([expiringSoonId]);
expect(firstPage.nextCursor).toBeTruthy();
const secondPage = await attentionService(db).list(companyId, {
userId: "board-user",
queue: "urgent-releases",
sort: "decide",
limit: 1,
cursor: firstPage.nextCursor!,
});
expect(secondPage.items.map((item) => item.subject.id)).toEqual([expiringLaterId]);
expect(secondPage.nextCursor).toBeNull();
const dateFiltered = await attentionService(db).list(companyId, {
userId: "board-user",
queue: "urgent-releases",
activitySince: new Date(now - 90_000).toISOString(),
activityUntil: new Date(now).toISOString(),
});
expect(dateFiltered.items.map((item) => item.subject.id)).toEqual([expiringSoonId]);
const withSnoozed = await attentionService(db).list(companyId, {
userId: "board-user",
includeDismissed: true,
limit: 20,
});
expect(withSnoozed.items.find((item) => item.subject.id === snoozedId)?.snoozedUntil)
.toBe(new Date(now + 60 * 60_000).toISOString());
});
it("keeps this-week deadlines in the current UTC week", async () => {
const { companyId, workerId } = await seedCompany("ATW");
const now = Date.parse("2026-08-02T12:00:00.000Z"); // Sunday in an ISO Monday-Sunday week.
const originIssueId = await insertIssue({
companyId,
identifier: "ATW-1",
title: "Decision origin",
status: "in_progress",
assigneeAgentId: workerId,
updatedAt: new Date(now),
});
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId: workerId,
status: "succeeded",
contextSnapshot: { issueId: originIssueId },
});
const thisWeekId = randomUUID();
const nextWeekId = randomUUID();
await db.insert(decisions).values([
{ id: thisWeekId, title: "This week", expiresAt: new Date("2026-08-09T12:00:00.000Z") },
{ id: nextWeekId, title: "Next week", expiresAt: new Date("2026-08-03T12:00:00.000Z") },
].map((value) => ({
...value,
companyId,
originAgentId: workerId,
originIssueId,
originRunId: runId,
body: value.title,
options: [],
status: "open" as const,
signedSpec: "test",
targetSnapshots: {},
createdAt: new Date(now),
updatedAt: new Date(now),
})));
await db.insert(decisionTriage).values([
{
companyId,
sourceKind: "decision",
sourceId: thisWeekId,
decideBy: "this_week",
setByType: "user",
setByUserId: "board-user",
},
{
companyId,
sourceKind: "decision",
sourceId: nextWeekId,
decideBy: "date",
decideByDate: "2026-08-03",
setByType: "user",
setByUserId: "board-user",
},
]);
const feed = await attentionService(db, { now: () => now }).list(companyId, {
userId: "board-user",
sort: "decide",
limit: 20,
});
const decisionItems = feed.items.filter((item) => item.sourceKind === "decision");
expect(decisionItems.map((item) => item.subject.id)).toEqual([thisWeekId, nextWeekId]);
expect(decisionItems[0]).toMatchObject({
decideBy: "this_week",
expiresAt: "2026-08-09T12:00:00.000Z",
});
expect(decisionItems[1]).toMatchObject({
decideBy: "2026-08-03",
expiresAt: "2026-08-03T12:00:00.000Z",
});
});
it("serves the route for board users and rejects agent callers", async () => {
const { companyId } = await seedCompany("ATR");
@ -1112,6 +1362,12 @@ describeEmbeddedPostgres("attention service", () => {
};
await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200);
await request(app(board))
.get(`/api/companies/${companyId}/attention?activitySince=yesterday`)
.expect(400, { error: "activitySince must be an ISO timestamp" });
await request(app(board))
.get(`/api/companies/${companyId}/attention?sort=oldest`)
.expect(400, { error: "sort must be 'activity' or 'decide'" });
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
});
});

View File

@ -1734,7 +1734,7 @@ describeEmbeddedPostgres("authorization service", () => {
});
});
it("scopes task bridge keys away from company-wide reads and unrelated issue writes", async () => {
it("scopes task bridge JWTs away from company-wide reads and unrelated issue writes", async () => {
const company = await createCompany(db, "TaskBridge");
const bridgeAgent = await createAgent(db, company.id);
const targetAgent = await createAgent(db, company.id);
@ -1751,7 +1751,7 @@ describeEmbeddedPostgres("authorization service", () => {
type: "agent" as const,
agentId: bridgeAgent.id,
companyId: company.id,
source: "agent_key" as const,
source: "agent_jwt" as const,
keyId,
keyScope: {
kind: "task_bridge" as const,

View File

@ -0,0 +1,475 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
companies,
createDb,
decisionQueueItems,
decisionQueues,
decisionTriage,
decisionTriageEvents,
issueThreadInteractions,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import type { AttentionItem } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { decisionQueueRoutes } from "../routes/decision-queues.js";
import { decisionQueueService } from "../services/decision-queues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres decision queue tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("decision queue routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-queues-");
db = createDb(tempDb.connectionString);
}, 30_000);
afterEach(async () => {
await db.delete(decisionTriageEvents);
await db.delete(decisionQueueItems);
await db.delete(decisionTriage);
await db.delete(decisionQueues);
await db.delete(activityLog);
await db.delete(issueThreadInteractions);
await db.delete(issueWorkProducts);
await db.delete(approvals);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seed() {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const approvalId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Decision Queue Co",
issuePrefix: "DQC",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Prioritizer",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "DQC-1",
title: "Review the rollout",
status: "in_review",
assigneeAgentId: agentId,
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "ask_user_questions",
status: "pending",
payload: { version: 1, questions: [] } as never,
});
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "request_board_approval",
status: "pending",
payload: {},
});
return { companyId, agentId, issueId, interactionId, approvalId };
}
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", decisionQueueRoutes(db));
testApp.use(errorHandler);
return testApp;
}
function boardActor(companyId: string, userId = "board-user") {
return {
type: "board",
source: "local_implicit",
userId,
companyIds: [companyId],
isInstanceAdmin: false,
};
}
function agentActor(companyId: string, agentId: string) {
return {
type: "agent",
source: "agent_key",
companyId,
agentId,
keyId: null,
keyScope: { kind: "standard" },
runId: null,
};
}
it("creates idempotently, patches, lists by updated time, and audits queue mutations", async () => {
const { companyId } = await seed();
const board = boardActor(companyId);
const first = await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "launches",
title: "Launches",
description: "Ship decisions",
}).expect(201);
const repeated = await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "launches",
title: "Ignored duplicate title",
}).expect(200);
expect(repeated.body.id).toBe(first.body.id);
expect(repeated.body.title).toBe("Launches");
expect(repeated.body.itemCount).toBe(0);
const patched = await request(app(board))
.patch(`/api/companies/${companyId}/decision-queues/launches`)
.send({ title: "Launch desk", retentionDays: 45 })
.expect(200);
expect(patched.body).toMatchObject({ title: "Launch desk", retentionDays: 45 });
await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "older",
title: "Older",
}).expect(201);
const listed = await request(app(board)).get(`/api/companies/${companyId}/decision-queues`).expect(200);
expect(listed.body.map((queue: { key: string }) => queue.key)).toEqual(["older", "launches"]);
const seedRules = await request(app(board))
.get(`/api/companies/${companyId}/decision-queue-seed-rules`)
.expect(200);
expect(seedRules.body.map((seed: { key: string }) => seed.key)).toEqual(["prs", "plans", "questions"]);
const events = await db.select().from(decisionTriageEvents)
.where(eq(decisionTriageEvents.queueId, first.body.id));
expect(events.map((event) => event.action)).toEqual(["queue.created", "queue.updated"]);
expect(events.every((event) => event.actorUserId === "board-user")).toBe(true);
});
it("adds and removes three attention source kinds and hides board-only membership from agents", async () => {
const { companyId, agentId, issueId, interactionId, approvalId } = await seed();
const board = boardActor(companyId);
await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "triage",
title: "Triage",
}).expect(201);
for (const source of [
{ sourceKind: "approval", sourceId: approvalId },
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
{ sourceKind: "review", sourceId: issueId },
]) {
await request(app(board))
.post(`/api/companies/${companyId}/decision-queues/triage/items`)
.send(source)
.expect(201);
}
const items = await request(app(board))
.get(`/api/companies/${companyId}/decision-queues/triage/items`)
.expect(200);
expect(items.body).toHaveLength(3);
const repeated = await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "triage",
title: "Ignored duplicate title",
}).expect(200);
expect(repeated.body.itemCount).toBe(3);
const agentList = await request(app(agentActor(companyId, agentId)))
.get(`/api/companies/${companyId}/decision-queues`)
.expect(200);
expect(agentList.body[0].itemCount).toBe(2);
const agentItems = await request(app(agentActor(companyId, agentId)))
.get(`/api/companies/${companyId}/decision-queues/triage/items`)
.expect(200);
expect(agentItems.body.map((item: { sourceKind: string }) => item.sourceKind).sort()).toEqual([
"issue_thread_interaction",
"review",
]);
await request(app(agentActor(companyId, agentId)))
.delete(`/api/companies/${companyId}/decision-queues/triage/items/approval/${approvalId}`)
.expect(404);
await request(app(board))
.delete(`/api/companies/${companyId}/decision-queues/triage/items/review/${issueId}`)
.expect(200);
expect(await db.select().from(decisionQueueItems).where(and(
eq(decisionQueueItems.companyId, companyId),
eq(decisionQueueItems.sourceKind, "review"),
))).toHaveLength(0);
await db.delete(approvals).where(eq(approvals.id, approvalId));
await request(app(board))
.delete(`/api/companies/${companyId}/decision-queues/triage/items/approval/${approvalId}`)
.expect(200);
expect(await db.select().from(decisionQueueItems).where(and(
eq(decisionQueueItems.companyId, companyId),
eq(decisionQueueItems.sourceKind, "approval"),
))).toHaveLength(0);
});
it("materializes data-backed starter queues from plan, question, and pull-request signals", async () => {
const { companyId, issueId, interactionId } = await seed();
await db.insert(issueWorkProducts).values({
companyId,
issueId,
type: "pull_request",
provider: "github",
title: "PR 42",
status: "open",
});
function attentionItem(input: {
sourceKind: AttentionItem["sourceKind"];
sourceId: string;
subjectKind: AttentionItem["subject"]["kind"];
metadata?: Record<string, unknown>;
issueId?: string;
}): AttentionItem {
return {
id: `${input.sourceKind}:${input.sourceId}`,
companyId,
sourceKind: input.sourceKind,
subject: {
kind: input.subjectKind,
id: input.sourceId,
companyId,
title: "Seed candidate",
identifier: null,
status: "pending",
href: null,
metadata: input.metadata,
},
whyNow: "test",
decisionVerbs: [],
inlineResolvable: true,
entryRule: "test",
exitRule: "test",
dedupKey: `${input.sourceKind}:${input.sourceId}`,
dismissalKey: `${input.sourceKind}:${input.sourceId}`,
dismissal: null,
severity: "medium",
rank: 1,
activityAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
relatedIssue: input.issueId ? {
kind: "issue",
id: input.issueId,
companyId,
title: "Issue",
identifier: "DQC-1",
status: "in_review",
href: null,
} : null,
project: null,
workspace: null,
detail: null,
trainingExampleId: null,
};
}
const planId = randomUUID();
const candidates = [
attentionItem({ sourceKind: "review", sourceId: issueId, subjectKind: "issue", issueId }),
attentionItem({
sourceKind: "issue_thread_interaction",
sourceId: planId,
subjectKind: "interaction",
metadata: { kind: "request_confirmation", isPlanTarget: true, issueId },
issueId,
}),
attentionItem({
sourceKind: "issue_thread_interaction",
sourceId: interactionId,
subjectKind: "interaction",
metadata: { kind: "ask_user_questions", issueId },
issueId,
}),
];
await decisionQueueService(db).materializeSeededQueues(companyId, candidates);
const queues = await db.select().from(decisionQueues).where(eq(decisionQueues.companyId, companyId));
expect(queues.map((queue) => queue.key).sort()).toEqual(["plans", "prs", "questions"]);
expect(queues.every((queue) => queue.seedRulesEnabled && queue.seedRules.length === 1)).toBe(true);
const seededItems = await db.select({ queueId: decisionQueueItems.queueId })
.from(decisionQueueItems)
.where(eq(decisionQueueItems.companyId, companyId));
expect(seededItems).toHaveLength(5);
const prQueue = queues.find((queue) => queue.key === "prs")!;
expect(seededItems.filter((item) => item.queueId === prQueue.id)).toHaveLength(3);
const firstEvents = await db.select().from(decisionTriageEvents)
.where(eq(decisionTriageEvents.companyId, companyId));
const firstActivity = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
const firstUpdatedAt = new Map(queues.map((queue) => [queue.key, queue.updatedAt.toISOString()]));
await decisionQueueService(db).materializeSeededQueues(companyId, candidates);
const unchangedQueues = await db.select().from(decisionQueues).where(eq(decisionQueues.companyId, companyId));
expect(unchangedQueues.every((queue) => queue.updatedAt.toISOString() === firstUpdatedAt.get(queue.key))).toBe(true);
expect(await db.select().from(decisionTriageEvents).where(eq(decisionTriageEvents.companyId, companyId)))
.toHaveLength(firstEvents.length);
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)))
.toHaveLength(firstActivity.length);
const questionsQueue = queues.find((queue) => queue.key === "questions")!;
await db.update(decisionQueues).set({ seedRulesEnabled: false }).where(eq(decisionQueues.id, questionsQueue.id));
await decisionQueueService(db).materializeSeededQueues(companyId, [attentionItem({
sourceKind: "issue_thread_interaction",
sourceId: randomUUID(),
subjectKind: "interaction",
metadata: { kind: "ask_user_questions", issueId },
issueId,
})]);
expect(await db.select().from(decisionQueueItems).where(eq(decisionQueueItems.queueId, questionsQueue.id)))
.toHaveLength(1);
});
it("records agent decide-by, preserves override history, and exposes the board override attribution", async () => {
const { companyId, agentId, issueId } = await seed();
const agent = agentActor(companyId, agentId);
const board = boardActor(companyId, "override-user");
const agentSet = await request(app(agent))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ decideBy: "today", snoozedUntil: "2026-08-03T12:00:00.000Z" })
.expect(200);
expect(agentSet.body).toMatchObject({
decideBy: "today",
setByType: "agent",
setByAgentId: agentId,
version: 1,
});
const overridden = await request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ decideBy: "2026-08-08", snoozedUntil: null })
.expect(200);
expect(overridden.body).toMatchObject({
decideBy: "2026-08-08",
setByType: "user",
setByUserId: "override-user",
version: 2,
});
const events = await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId),
eq(decisionTriageEvents.sourceKind, "review"),
eq(decisionTriageEvents.sourceId, issueId),
));
expect(events).toHaveLength(2);
expect(events[0]?.actorAgentId).toBe(agentId);
expect(events[1]?.actorUserId).toBe("override-user");
expect(events[1]?.details).toMatchObject({ previousDecideBy: "today", decideBy: "2026-08-08" });
const current = await db.select().from(decisionTriage).where(eq(decisionTriage.sourceId, issueId));
expect(current[0]?.setByUserId).toBe("override-user");
});
it("serializes concurrent partial triage updates without losing state or reusing a version", async () => {
const { companyId, issueId } = await seed();
const board = boardActor(companyId);
const [decideByResult, snoozeResult] = await Promise.all([
request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ decideBy: "today" }),
request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ snoozedUntil: "2026-08-03T12:00:00.000Z" }),
]);
expect(decideByResult.status).toBe(200);
expect(snoozeResult.status).toBe(200);
expect([decideByResult.body.version, snoozeResult.body.version].sort()).toEqual([1, 2]);
const current = await request(app(board))
.get(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.expect(200);
expect(current.body).toMatchObject({
decideBy: "today",
snoozedUntil: "2026-08-03T12:00:00.000Z",
version: 2,
});
const events = await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId),
eq(decisionTriageEvents.sourceKind, "review"),
eq(decisionTriageEvents.sourceId, issueId),
));
expect(events.map((event) => event.details.version).sort()).toEqual([1, 2]);
});
it("rejects task-bridge JWTs and returns the same 404 for missing and unauthorized sources", async () => {
const { companyId, agentId, approvalId } = await seed();
const bridge = {
...agentActor(companyId, agentId),
source: "agent_jwt",
keyId: randomUUID(),
keyScope: { kind: "task_bridge", parentIssueId: randomUUID() },
};
await request(app(bridge)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "blocked",
title: "Blocked",
}).expect(403);
const skillTest = {
...agentActor(companyId, agentId),
source: "agent_jwt",
keyScope: { kind: "skill_test", issueId: randomUUID() },
};
await request(app(skillTest)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "skill-test-blocked",
title: "Skill test blocked",
}).expect(403);
await request(app(boardActor(companyId))).post(`/api/companies/${companyId}/decision-queues`).send({
key: "secure",
title: "Secure",
}).expect(201);
const standardAgent = app(agentActor(companyId, agentId));
const unauthorized = await request(standardAgent)
.post(`/api/companies/${companyId}/decision-queues/secure/items`)
.send({ sourceKind: "approval", sourceId: approvalId })
.expect(404);
const missing = await request(standardAgent)
.post(`/api/companies/${companyId}/decision-queues/secure/items`)
.send({ sourceKind: "approval", sourceId: randomUUID() })
.expect(404);
expect(unauthorized.body).toEqual(missing.body);
});
});

View File

@ -26,6 +26,7 @@ const apiPrefixes: Record<string, string> = {
"company-skill-policy.ts": "/api",
"costs.ts": "/api",
"dashboard.ts": "/api",
"decision-queues.ts": "/api",
"decisions.ts": "/api",
"decision-training.ts": "/api",
"environments.ts": "/api",
@ -190,6 +191,26 @@ describe("openapi routes", () => {
expect(res.body.paths["/api/companies/{companyId}/folders/items/move"].post.summary).toBe(
"Move an item into or out of a folder",
);
const createQueue = res.body.paths["/api/companies/{companyId}/decision-queues"].post;
expect(createQueue.security).toContainEqual({ AgentBearerAuth: [] });
expect(createQueue.responses["200"]).toBeDefined();
expect(createQueue.responses["201"]).toBeDefined();
expect(createQueue.requestBody.content["application/json"].schema).toMatchObject({
type: "object",
properties: {
key: { type: "string", minLength: 1, maxLength: 80 },
title: { type: "string", minLength: 1, maxLength: 120 },
},
required: ["key", "title"],
});
const updateTriage = res.body.paths[
"/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}"
].put;
expect(updateTriage.responses["422"]).toBeDefined();
expect(updateTriage.requestBody.content["application/json"].schema.properties).toMatchObject({
decideBy: { nullable: true },
snoozedUntil: { type: "string", format: "date-time", nullable: true },
});
expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools"].get)).not.toContain("sessionToken");
expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools/call"].post)).not.toContain("sessionToken");
});

View File

@ -43,6 +43,7 @@ import { dashboardRoutes } from "./routes/dashboard.js";
import { attentionRoutes } from "./routes/attention.js";
import { decisionTrainingRoutes } from "./routes/decision-training.js";
import { decisionRoutes } from "./routes/decisions.js";
import { decisionQueueRoutes } from "./routes/decision-queues.js";
import type { DecisionServiceOptions } from "./services/decisions.js";
import { userProfileRoutes } from "./routes/user-profiles.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
@ -407,6 +408,7 @@ export async function createApp(
api.use(attentionRoutes(db));
api.use(decisionTrainingRoutes(db));
api.use(decisionRoutes(db, opts.decisionServiceOptions));
api.use(decisionQueueRoutes(db));
api.use(userProfileRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(sidebarPreferenceRoutes(db));

View File

@ -1,8 +1,16 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import type { AttentionSortMode } from "@paperclipai/shared";
import { attentionService } from "../services/attention.js";
import { badRequest } from "../errors.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
function optionalQueryString(value: unknown, field: string) {
if (value === undefined) return undefined;
if (typeof value !== "string" || !value.trim()) throw badRequest(`${field} must be a non-empty string`);
return value.trim();
}
export function attentionRoutes(db: Db) {
const router = Router();
const svc = attentionService(db);
@ -17,9 +25,26 @@ export function attentionRoutes(db: Db) {
}
const includeDismissed = req.query.includeDismissed === "true";
const activitySince = optionalQueryString(req.query.activitySince, "activitySince");
const activityUntil = optionalQueryString(req.query.activityUntil, "activityUntil");
const queue = optionalQueryString(req.query.queue, "queue");
const cursor = optionalQueryString(req.query.cursor, "cursor");
const sortValue = optionalQueryString(req.query.sort, "sort");
if (sortValue !== undefined && sortValue !== "activity" && sortValue !== "decide") {
throw badRequest("sort must be 'activity' or 'decide'");
}
const limitValue = optionalQueryString(req.query.limit, "limit");
const limit = limitValue === undefined ? undefined : Number(limitValue);
if (limit !== undefined && !Number.isInteger(limit)) throw badRequest("limit must be an integer");
const feed = await svc.list(companyId, {
userId: req.actor.userId,
includeDismissed,
activitySince,
activityUntil,
queue,
cursor,
sort: sortValue as AttentionSortMode | undefined,
limit,
});
res.json(feed);
});

View File

@ -0,0 +1,177 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
addDecisionQueueItemSchema,
createDecisionQueueSchema,
decisionAttentionSourceKindSchema,
updateDecisionQueueSchema,
updateDecisionTriageSchema,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import {
authorizationDeniedDetails,
authorizationService,
type AuthorizationAction,
} from "../services/authorization.js";
import {
DECISION_QUEUE_SEEDS,
decisionQueueService,
type DecisionMutationActor,
} from "../services/decision-queues.js";
import { assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js";
function mutationActor(req: Parameters<typeof getActorInfo>[0]): DecisionMutationActor {
const actor = getActorInfo(req);
return {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserId: req.actor.onBehalfOfUserId ?? (actor.actorType === "user" ? actor.actorId : null),
};
}
async function assertDecisionAccess(
db: Db,
req: Parameters<typeof getActorInfo>[0],
companyId: string,
action: Extract<AuthorizationAction, "decision_queue:read" | "decision_queue:manage" | "decision_triage:manage">,
) {
assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
const decision = await authorizationService(db).decide({
actor: req.actor,
action,
resource: { type: "company", companyId },
});
if (!decision.allowed) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
}
function sourceParams(req: Parameters<typeof getActorInfo>[0]) {
const parsed = decisionAttentionSourceKindSchema.safeParse(req.params.sourceKind);
if (!parsed.success) return null;
const sourceId = (req.params.sourceId as string | undefined)?.trim();
if (!sourceId || sourceId.length > 500) return null;
return { sourceKind: parsed.data, sourceId };
}
export function decisionQueueRoutes(db: Db) {
const router = Router();
const svc = decisionQueueService(db);
router.get("/companies/:companyId/decision-queue-seed-rules", async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:read");
res.json(DECISION_QUEUE_SEEDS);
});
router.get("/companies/:companyId/decision-queues", async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:read");
res.json(await svc.list(companyId, req.actor));
});
router.post("/companies/:companyId/decision-queues", validate(createDecisionQueueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:manage");
const result = await svc.create({
companyId,
authActor: req.actor,
actor: mutationActor(req),
...req.body,
});
res.status(result.created ? 201 : 200).json(result.queue);
});
router.patch("/companies/:companyId/decision-queues/:key", validate(updateDecisionQueueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:manage");
res.json(await svc.update({
companyId,
key: req.params.key as string,
patch: req.body,
authActor: req.actor,
actor: mutationActor(req),
}));
});
router.get("/companies/:companyId/decision-queues/:key/items", async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:read");
res.json(await svc.listItems(companyId, req.params.key as string, req.actor));
});
router.post(
"/companies/:companyId/decision-queues/:key/items",
validate(addDecisionQueueItemSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:manage");
const result = await svc.addItem({
companyId,
key: req.params.key as string,
sourceKind: req.body.sourceKind,
sourceId: req.body.sourceId,
authActor: req.actor,
actor: mutationActor(req),
});
res.status(result.created ? 201 : 200).json(result.item);
},
);
router.delete("/companies/:companyId/decision-queues/:key/items/:sourceKind/:sourceId", async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:manage");
const source = sourceParams(req);
if (!source) {
res.status(400).json({ error: "Invalid attention source identity" });
return;
}
res.json(await svc.removeItem({
companyId,
key: req.params.key as string,
...source,
authActor: req.actor,
actor: mutationActor(req),
}));
});
router.get("/companies/:companyId/decision-triage/:sourceKind/:sourceId", async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_queue:read");
const source = sourceParams(req);
if (!source) {
res.status(400).json({ error: "Invalid attention source identity" });
return;
}
res.json(await svc.getTriage(companyId, source.sourceKind, source.sourceId, req.actor));
});
router.put(
"/companies/:companyId/decision-triage/:sourceKind/:sourceId",
validate(updateDecisionTriageSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertDecisionAccess(db, req, companyId, "decision_triage:manage");
const source = sourceParams(req);
if (!source) {
res.status(400).json({ error: "Invalid attention source identity" });
return;
}
res.json(await svc.updateTriage({
companyId,
...source,
...req.body,
authActor: req.actor,
actor: mutationActor(req),
}));
},
);
return router;
}

View File

@ -24,6 +24,7 @@ export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { attentionRoutes } from "./attention.js";
export { decisionRoutes } from "./decisions.js";
export { decisionQueueRoutes } from "./decision-queues.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
export { resourceMembershipRoutes } from "./resource-memberships.js";

View File

@ -48,8 +48,13 @@ import {
companyArtifactsQuerySchema,
companyArtifactsResponseSchema,
// Decisions
addDecisionQueueItemSchema,
createDecisionQueueSchema,
decisionAttentionSourceKindSchema,
decisionInputsSchema,
decisionOptionsSchema,
updateDecisionQueueSchema,
updateDecisionTriageSchema,
// Routine
createRoutineSchema,
updateRoutineSchema,
@ -3277,6 +3282,188 @@ registry.registerPath({
// ─── Decisions ──────────────────────────────────────────────────────────────
// Decision queues and triage
const decisionQueueSeedRuleSchema = z.object({
key: z.string(),
description: z.string(),
signal: z.enum([
"issue_has_pull_request_work_product",
"plan_document_confirmation",
"ask_user_questions",
]),
}).strict();
const decisionQueueSchema = z.object({
id: z.string(),
companyId: z.string(),
key: z.string(),
title: z.string(),
description: z.string().nullable(),
createdByType: z.enum(["agent", "user", "system"]),
createdByAgentId: z.string().nullable(),
createdByUserId: z.string().nullable(),
createdByRunId: z.string().nullable(),
retentionDays: z.number().int().nullable(),
seedRules: z.array(decisionQueueSeedRuleSchema),
seedRulesEnabled: z.boolean(),
itemCount: z.number().int().nonnegative(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
}).strict();
const decisionQueueItemSchema = z.object({
id: z.string(),
companyId: z.string(),
queueId: z.string(),
sourceKind: decisionAttentionSourceKindSchema,
sourceId: z.string(),
addedByType: z.enum(["agent", "user", "system"]),
addedByAgentId: z.string().nullable(),
addedByUserId: z.string().nullable(),
addedByRunId: z.string().nullable(),
responsibleUserId: z.string().nullable(),
createdAt: z.string().datetime(),
}).strict();
const decisionTriageSchema = z.object({
id: z.string(),
companyId: z.string(),
sourceKind: decisionAttentionSourceKindSchema,
sourceId: z.string(),
decideBy: z.string().nullable(),
snoozedUntil: z.string().datetime().nullable(),
setByType: z.enum(["agent", "user"]),
setByAgentId: z.string().nullable(),
setByUserId: z.string().nullable(),
setByRunId: z.string().nullable(),
responsibleUserId: z.string().nullable(),
version: z.number().int().positive(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
}).strict();
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/decision-queue-seed-rules",
tags: ["decision-queues"],
summary: "List built-in decision queue seed rules",
responses: { 200: r.ok(z.array(decisionQueueSeedRuleSchema)), 401: r.unauthorized, 403: r.forbidden },
});
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/decision-queues",
tags: ["decision-queues"],
summary: "List decision queues",
responses: { 200: r.ok(z.array(decisionQueueSchema)), 401: r.unauthorized, 403: r.forbidden },
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/decision-queues",
tags: ["decision-queues"],
summary: "Create a decision queue",
body: createDecisionQueueSchema,
responses: {
200: r.ok(decisionQueueSchema),
201: r.ok(decisionQueueSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
},
});
registerCurrentRoute({
method: "patch",
path: "/api/companies/{companyId}/decision-queues/{key}",
tags: ["decision-queues"],
summary: "Update a decision queue",
body: updateDecisionQueueSchema,
responses: {
200: r.ok(decisionQueueSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
},
});
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/decision-queues/{key}/items",
tags: ["decision-queues"],
summary: "List visible items in a decision queue",
responses: {
200: r.ok(z.array(decisionQueueItemSchema)),
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
},
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/decision-queues/{key}/items",
tags: ["decision-queues"],
summary: "Add an item to a decision queue",
body: addDecisionQueueItemSchema,
responses: {
200: r.ok(decisionQueueItemSchema),
201: r.ok(decisionQueueItemSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
},
});
registerCurrentRoute({
method: "delete",
path: "/api/companies/{companyId}/decision-queues/{key}/items/{sourceKind}/{sourceId}",
tags: ["decision-queues"],
summary: "Remove an item from a decision queue",
responses: {
200: r.ok(decisionQueueItemSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
},
});
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}",
tags: ["decision-queues"],
summary: "Get decision triage for an attention source",
responses: {
200: r.ok(decisionTriageSchema.nullable()),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
},
});
registerCurrentRoute({
method: "put",
path: "/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}",
tags: ["decision-queues"],
summary: "Set decision triage for an attention source",
body: updateDecisionTriageSchema,
responses: {
200: r.ok(decisionTriageSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
422: r.unprocessable,
},
});
// Decisions
const createDecisionBodySchema = z.object({
title: z.string().trim().min(1).max(500),
body: z.string().max(100_000),

View File

@ -6,7 +6,10 @@ import {
assets,
companies,
decisionBundles,
decisionQueueItems,
decisionQueues,
decisionTrainingExamples,
decisionTriage,
decisions,
heartbeatRunEvents,
heartbeatRuns,
@ -28,20 +31,26 @@ import { deriveProjectUrlKey } from "@paperclipai/shared";
import type {
AttentionDecisionVerb,
AttentionFeed,
AttentionFeedQuery,
AttentionDetailImage,
AttentionItem,
AttentionItemDetail,
AttentionProjectRef,
AttentionQueueRef,
AttentionSeverity,
AttentionSortMode,
AttentionSourceKind,
AttentionSubject,
AttentionTriageAttribution,
AttentionWorkspaceRef,
} from "@paperclipai/shared";
import { badRequest } from "../errors.js";
import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js";
import { budgetService } from "./budgets.js";
import { issueService } from "./issues.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
import { decisionQueueService } from "./decision-queues.js";
const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [
"approval",
@ -87,6 +96,8 @@ const DETAIL_EXCERPT_LENGTH = 160;
const DETAIL_IMAGE_LIMIT = 3;
const OPEN_DECISION_DEFAULT_LIMIT = 500;
const OPEN_DECISION_MAX_LIMIT = 1_000;
const ATTENTION_PAGE_DEFAULT_LIMIT = 50;
const ATTENTION_PAGE_MAX_LIMIT = 100;
type IssueSummaryRow = {
id: string;
@ -122,13 +133,13 @@ type BlockingIssueSummary = {
title: string | null;
};
type AttentionListOptions = {
type AttentionListOptions = AttentionFeedQuery & {
userId?: string | null;
includeDismissed?: boolean;
};
type AttentionServiceOptions = {
openDecisionLimit?: number;
now?: () => number;
};
function emptyCounts(): Record<AttentionSourceKind, number> {
@ -322,9 +333,28 @@ function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb
return verbs;
}
type CreateAttentionItemInput = Omit<AttentionItem, "id" | "dismissalKey" | "rank" | "dismissal" | "project" | "workspace" | "detail" | "trainingExampleId"> & {
type CreateAttentionItemInput = Omit<AttentionItem,
| "id"
| "dismissalKey"
| "rank"
| "dismissal"
| "project"
| "workspace"
| "expiresAt"
| "ruleKey"
| "originAgentName"
| "queues"
| "decideBy"
| "decideByAttribution"
| "snoozedUntil"
| "detail"
| "trainingExampleId"
> & {
project?: AttentionProjectRef | null;
workspace?: AttentionWorkspaceRef | null;
expiresAt?: string | null;
ruleKey?: string | null;
originAgentName?: string | null;
detail?: AttentionItemDetail | null;
};
@ -336,6 +366,13 @@ function createItem(input: CreateAttentionItemInput): AttentionItem {
dismissal: null,
project: input.project ?? null,
workspace: input.workspace ?? null,
expiresAt: input.expiresAt ?? null,
ruleKey: input.ruleKey ?? null,
originAgentName: input.originAgentName ?? null,
queues: [],
decideBy: null,
decideByAttribution: null,
snoozedUntil: null,
detail: input.detail ?? null,
trainingExampleId: null,
rank: 0,
@ -352,6 +389,170 @@ function compareAttentionItems(left: AttentionItem, right: AttentionItem) {
return left.dedupKey.localeCompare(right.dedupKey);
}
function sourceKey(sourceKind: AttentionSourceKind, sourceId: string) {
return `${sourceKind}:${sourceId}`;
}
function itemSourceKey(item: AttentionItem) {
return sourceKey(item.sourceKind, item.subject.id);
}
function readMetadataAgentId(item: AttentionItem) {
const metadata = item.subject.metadata;
const value = metadata?.originAgentId ?? metadata?.createdByAgentId ?? metadata?.requestedByAgentId
?? metadata?.agentId ?? (item.subject.kind === "agent" ? item.subject.id : null);
return typeof value === "string" && value.length > 0 ? value : null;
}
function startOfUtcDay(now: number) {
const value = new Date(now);
return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate());
}
function endOfUtcDay(now: number) {
return startOfUtcDay(now) + 24 * 60 * 60 * 1_000 - 1;
}
function endOfUtcWeek(now: number) {
const start = startOfUtcDay(now);
const weekday = new Date(start).getUTCDay();
// Use an ISO-style Monday-Sunday week. Sunday (0) is already the last
// day of the current week; every other day advances only to that Sunday.
const daysUntilSunday = weekday === 0 ? 0 : 7 - weekday;
return start + (daysUntilSunday + 1) * 24 * 60 * 60 * 1_000 - 1;
}
function decideOrder(item: AttentionItem, now: number): [number, number] {
if (item.decideBy === "today") return [0, endOfUtcDay(now)];
if (item.decideBy === "this_week") return [0, endOfUtcWeek(now)];
if (item.decideBy && /^\d{4}-\d{2}-\d{2}$/.test(item.decideBy)) {
const deadline = Date.parse(`${item.decideBy}T23:59:59.999Z`);
if (Number.isFinite(deadline)) return [0, deadline];
}
if (item.decideBy === "whenever") return [1, Number.MAX_SAFE_INTEGER];
return [2, Number.MAX_SAFE_INTEGER];
}
function isDecideNow(item: AttentionItem, now: number) {
const [bucket, deadline] = decideOrder(item, now);
return bucket === 0 && deadline <= endOfUtcDay(now);
}
function compareDecideItems(left: AttentionItem, right: AttentionItem, now: number) {
const [leftBucket, leftDeadline] = decideOrder(left, now);
const [rightBucket, rightDeadline] = decideOrder(right, now);
if (leftBucket !== rightBucket) return leftBucket - rightBucket;
if (leftDeadline !== rightDeadline) return leftDeadline - rightDeadline;
const leftExpiry = left.expiresAt ? timestamp(left.expiresAt) : Number.MAX_SAFE_INTEGER;
const rightExpiry = right.expiresAt ? timestamp(right.expiresAt) : Number.MAX_SAFE_INTEGER;
if (leftExpiry !== rightExpiry) return leftExpiry - rightExpiry;
const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity];
if (severityDiff !== 0) return severityDiff;
return compareAttentionItems(left, right);
}
function encodeCursor(sort: AttentionSortMode, item: AttentionItem) {
return Buffer.from(JSON.stringify({ v: 1, sort, id: item.id }), "utf8").toString("base64url");
}
function decodeCursor(cursor: string, sort: AttentionSortMode) {
try {
const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record<string, unknown>;
if (decoded.v !== 1 || decoded.sort !== sort || typeof decoded.id !== "string" || !decoded.id) {
throw new Error("invalid cursor shape");
}
return decoded.id;
} catch {
throw badRequest("Invalid attention cursor");
}
}
function parseActivityBoundary(value: string | undefined, field: "activitySince" | "activityUntil") {
if (value === undefined) return null;
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
throw badRequest(`${field} must be an ISO timestamp`);
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) throw badRequest(`${field} must be an ISO timestamp`);
return parsed;
}
async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[]) {
if (items.length === 0) return items;
const sourceIds = [...new Set(items.map((item) => item.subject.id))];
const queueRows = await db
.select({
sourceKind: decisionQueueItems.sourceKind,
sourceId: decisionQueueItems.sourceId,
key: decisionQueues.key,
title: decisionQueues.title,
})
.from(decisionQueueItems)
.innerJoin(decisionQueues, and(
eq(decisionQueueItems.queueId, decisionQueues.id),
eq(decisionQueues.companyId, companyId),
))
.where(and(
eq(decisionQueueItems.companyId, companyId),
inArray(decisionQueueItems.sourceId, sourceIds),
))
.orderBy(asc(decisionQueues.title), asc(decisionQueues.key));
const queuesBySource = new Map<string, AttentionQueueRef[]>();
for (const row of queueRows) {
const key = sourceKey(row.sourceKind as AttentionSourceKind, row.sourceId);
const queues = queuesBySource.get(key) ?? [];
queues.push({ key: row.key, title: row.title });
queuesBySource.set(key, queues);
}
const triageRows = await db
.select()
.from(decisionTriage)
.where(and(
eq(decisionTriage.companyId, companyId),
inArray(decisionTriage.sourceId, sourceIds),
));
const triageBySource = new Map(triageRows.map((row) => [
sourceKey(row.sourceKind as AttentionSourceKind, row.sourceId),
row,
]));
const agentIds = [...new Set([
...items.map(readMetadataAgentId),
...triageRows.map((row) => row.setByAgentId),
].filter((value): value is string => Boolean(value)))];
const agentNameById = new Map(agentIds.length === 0 ? [] : await db
.select({ id: agents.id, name: agents.name })
.from(agents)
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)))
.then((rows) => rows.map((row) => [row.id, row.name] as const)));
return items.map((item) => {
const triage = triageBySource.get(itemSourceKey(item));
const decideBy = triage?.decideBy === "date" ? triage.decideByDate : triage?.decideBy ?? null;
const decideByAttribution: AttentionTriageAttribution | null = triage ? {
type: triage.setByType as AttentionTriageAttribution["type"],
agentId: triage.setByAgentId ?? null,
agentName: triage.setByAgentId ? agentNameById.get(triage.setByAgentId) ?? null : null,
userId: triage.setByUserId ?? null,
runId: triage.setByRunId ?? null,
responsibleUserId: triage.responsibleUserId ?? null,
updatedAt: toIso(triage.updatedAt),
} : null;
const originAgentId = readMetadataAgentId(item);
return {
...item,
originAgentName: originAgentId ? agentNameById.get(originAgentId) ?? null : null,
queues: queuesBySource.get(itemSourceKey(item)) ?? [],
decideBy,
decideByAttribution,
snoozedUntil: triage?.snoozedUntil ? toIso(triage.snoozedUntil) : null,
};
});
}
function betterDuplicate(left: AttentionItem, right: AttentionItem) {
return compareAttentionItems(left, right) <= 0 ? left : right;
}
@ -616,9 +817,9 @@ function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
return typeof issueId === "string" && issueId.length > 0 ? issueId : null;
}
export function attentionService(db: Db, options: AttentionServiceOptions = {}) {
export function attentionService(db: Db, serviceOptions: AttentionServiceOptions = {}) {
const openDecisionLimit = Math.min(
Math.max(Math.trunc(options.openDecisionLimit ?? OPEN_DECISION_DEFAULT_LIMIT), 1),
Math.max(Math.trunc(serviceOptions.openDecisionLimit ?? OPEN_DECISION_DEFAULT_LIMIT), 1),
OPEN_DECISION_MAX_LIMIT,
);
return {
@ -626,7 +827,7 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
const prefix = await companyPrefix(db, companyId);
const dismissals = await dismissalByKey(db, companyId, options.userId);
const includeDismissed = options.includeDismissed === true;
const now = Date.now();
const now = serviceOptions.now?.() ?? Date.now();
const collected: AttentionItem[] = [];
const add = (item: AttentionItem) => {
@ -715,6 +916,7 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
title: issueThreadInteractions.title,
summary: issueThreadInteractions.summary,
payload: issueThreadInteractions.payload,
createdByAgentId: issueThreadInteractions.createdByAgentId,
createdAt: issueThreadInteractions.createdAt,
updatedAt: issueThreadInteractions.updatedAt,
})
@ -754,6 +956,7 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
metadata: {
kind: interaction.kind,
issueId: interaction.issueId,
createdByAgentId: interaction.createdByAgentId,
isPlanTarget,
targetDocumentKey: isPlanTarget ? "plan" : null,
},
@ -778,9 +981,11 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
id: decisions.id,
bundleId: decisions.bundleId,
originAgentId: decisions.originAgentId,
ruleKey: decisions.ruleKey,
title: decisions.title,
body: decisions.body,
status: decisions.status,
expiresAt: decisions.expiresAt,
originIssueId: decisions.originIssueId,
createdAt: decisions.createdAt,
updatedAt: decisions.updatedAt,
@ -813,6 +1018,8 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
exitRule: "Decision is decided, expired, or cancelled.",
dedupKey: `decision:${decision.id}`,
severity: "medium",
expiresAt: decision.expiresAt ? toIso(decision.expiresAt) : null,
ruleKey: decision.ruleKey,
activityAt: toIso(decision.updatedAt),
createdAt: toIso(decision.createdAt),
updatedAt: toIso(decision.updatedAt),
@ -1383,9 +1590,47 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
deduped.set(item.dedupKey, current ? betterDuplicate(current, item) : item);
}
const items = [...deduped.values()]
.sort(compareAttentionItems)
const collectedItems = [...deduped.values()].sort(compareAttentionItems);
await decisionQueueService(db).materializeSeededQueues(companyId, collectedItems);
const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems);
const activitySince = parseActivityBoundary(options.activitySince, "activitySince");
const activityUntil = parseActivityBoundary(options.activityUntil, "activityUntil");
if (activitySince != null && activityUntil != null && activitySince > activityUntil) {
throw badRequest("activitySince must be before or equal to activityUntil");
}
const queueKey = options.queue?.trim() || null;
const visibleItems = enrichedItems.filter((item) => {
if (!includeDismissed && item.snoozedUntil && timestamp(item.snoozedUntil) > now) return false;
const activity = timestamp(item.activityAt);
if (activitySince != null && activity < activitySince) return false;
if (activityUntil != null && activity > activityUntil) return false;
if (queueKey && !item.queues.some((queue) => queue.key === queueKey)) return false;
return true;
});
const sort = options.sort ?? "activity";
if (sort !== "activity" && sort !== "decide") throw badRequest("sort must be 'activity' or 'decide'");
const rankedItems = visibleItems
.sort(sort === "decide"
? (left, right) => compareDecideItems(left, right, now)
: compareAttentionItems)
.map((item, index) => ({ ...item, rank: index + 1 }));
const limit = options.limit ?? ATTENTION_PAGE_DEFAULT_LIMIT;
if (!Number.isInteger(limit) || limit < 1 || limit > ATTENTION_PAGE_MAX_LIMIT) {
throw badRequest(`limit must be an integer between 1 and ${ATTENTION_PAGE_MAX_LIMIT}`);
}
let pageStart = 0;
if (options.cursor) {
const cursorItemId = decodeCursor(options.cursor, sort);
const cursorIndex = rankedItems.findIndex((item) => item.id === cursorItemId);
if (cursorIndex < 0) throw badRequest("Attention cursor no longer matches the filtered feed");
pageStart = cursorIndex + 1;
}
const items = rankedItems.slice(pageStart, pageStart + limit);
const hasNextPage = pageStart + items.length < rankedItems.length;
const nextCursor = hasNextPage && items.length > 0 ? encodeCursor(sort, items[items.length - 1]!) : null;
if (options.userId) {
const trainable: Array<{ sourceKind: "approval" | "interaction"; sourceId: string }> = [];
for (const item of items) {
@ -1423,12 +1668,14 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
}
}
const countsBySourceKind = emptyCounts();
for (const item of items) countsBySourceKind[item.sourceKind] += 1;
for (const item of rankedItems) countsBySourceKind[item.sourceKind] += 1;
return {
companyId,
generatedAt: new Date().toISOString(),
totalCount: items.length,
totalCount: rankedItems.length,
decideNowCount: rankedItems.filter((item) => isDecideNow(item, now)).length,
nextCursor,
countsBySourceKind,
items,
};

View File

@ -63,6 +63,9 @@ export type AuthorizationAction =
| "agent:read"
| "agent:wake"
| "company_scope:read"
| "decision_queue:manage"
| "decision_queue:read"
| "decision_triage:manage"
| "issue:comment"
| "issue:mutate"
| "issue:read"
@ -145,6 +148,9 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null
action === "agent:read" ||
action === "agent:wake" ||
action === "company_scope:read" ||
action === "decision_queue:manage" ||
action === "decision_queue:read" ||
action === "decision_triage:manage" ||
action === "issue:read" ||
action === "project:read" ||
action === "runtime:manage" ||
@ -969,6 +975,9 @@ export function authorizationService(db: Db) {
if (
input.action === "company_scope:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
input.action === "agent_config:read" ||
input.action === "agent_config:update" ||
input.action === "skill_config:update" ||
@ -1140,6 +1149,9 @@ export function authorizationService(db: Db) {
if (
input.action === "company_scope:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "project:read" ||
@ -1206,6 +1218,9 @@ export function authorizationService(db: Db) {
if (
input.action === "company_scope:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "project:read" ||
@ -1570,6 +1585,7 @@ export function authorizationService(db: Db) {
if (
input.action === "agent:read" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:read" ||
input.action === "issue:read" ||
input.action === "project:read"
) {
@ -1580,7 +1596,12 @@ export function authorizationService(db: Db) {
});
}
if (
(input.action === "issue:comment" || input.action === "issue:mutate") &&
(
input.action === "issue:comment" ||
input.action === "issue:mutate" ||
input.action === "decision_queue:manage" ||
input.action === "decision_triage:manage"
) &&
membership.membershipRole !== "viewer"
) {
return allow({
@ -1638,6 +1659,9 @@ export function authorizationService(db: Db) {
if (
input.action === "agent:read" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
@ -1647,7 +1671,10 @@ export function authorizationService(db: Db) {
// Mirroring the tasks:assign carve-out above, viewers keep the
// read-only visibility actions but not the privileged ones.
const requiresNonViewer =
input.action === "runtime:manage" || input.action === "secrets:read";
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_triage:manage";
if (membership && (!requiresNonViewer || membership.membershipRole !== "viewer")) {
return allow({
action: input.action,
@ -1725,7 +1752,7 @@ export function authorizationService(db: Db) {
if (skillTestDecision) return skillTestDecision;
}
if (input.actor.source === "agent_key" && input.actor.keyScope?.kind === "task_bridge") {
if (input.actor.keyScope?.kind === "task_bridge") {
const keyId = input.actor.keyId ?? null;
if (!keyId) {
return deny({
@ -1771,6 +1798,7 @@ export function authorizationService(db: Db) {
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:read" ||
input.action === "issue:comment" ||
input.action === "issue:read" ||
input.action === "project:read" ||
@ -1907,6 +1935,7 @@ export function authorizationService(db: Db) {
if (
input.action === "agent:read" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:read" ||
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
@ -1919,6 +1948,21 @@ export function authorizationService(db: Db) {
});
}
if (input.action === "decision_queue:manage" || input.action === "decision_triage:manage") {
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
return deny({
action: input.action,
reason: "deny_missing_membership",
explanation: "Actor agent is not active in the target company.",
});
}
return allow({
action: input.action,
reason: "allow_company_agent",
explanation: "Allowed for an active standard-scope company agent.",
});
}
if (input.action === "agent:wake" && input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
return allow({
action: input.action,

View File

@ -0,0 +1,775 @@
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
budgetIncidents,
decisionQueueItems,
decisionQueues,
decisions,
decisionTriage,
decisionTriageEvents,
heartbeatRuns,
issueApprovals,
issueRecoveryActions,
issueThreadInteractions,
issueWorkProducts,
issues,
joinRequests,
} from "@paperclipai/db";
import type {
AttentionItem,
AttentionSourceKind,
DecisionQueue,
DecisionQueueItem,
DecisionQueueSeedRule,
DecisionTriage,
} from "@paperclipai/shared";
import { notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import {
authorizationService,
type AuthorizationActor,
type AuthorizationResource,
} from "./authorization.js";
export type DecisionMutationActor = {
actorType: "agent" | "user" | "system";
actorId: string;
agentId: string | null;
userId: string | null;
runId: string | null;
agentApiKeyId: string | null;
responsibleUserId: string | null;
};
type SeedDefinition = {
key: string;
title: string;
description: string;
rules: DecisionQueueSeedRule[];
};
export const DECISION_QUEUE_SEEDS: readonly SeedDefinition[] = [
{
key: "prs",
title: "PRs",
description: "Pull-request and merge decisions detected from issue work products.",
rules: [{
key: "issue-pull-request-work-product",
signal: "issue_has_pull_request_work_product",
description: "Attention items whose issue has a pull_request work product.",
}],
},
{
key: "plans",
title: "Plans",
description: "Plan revisions waiting for confirmation.",
rules: [{
key: "plan-document-confirmation",
signal: "plan_document_confirmation",
description: "Pending request_confirmation interactions bound to the issue's plan document.",
}],
},
{
key: "questions",
title: "Questions",
description: "Structured questions waiting for a board response.",
rules: [{
key: "ask-user-questions",
signal: "ask_user_questions",
description: "Pending ask_user_questions interactions.",
}],
},
] as const;
const SYSTEM_ACTOR: DecisionMutationActor = {
actorType: "system",
actorId: "decision_queue_seed",
agentId: null,
userId: null,
runId: null,
agentApiKeyId: null,
responsibleUserId: null,
};
function creatorColumns(actor: DecisionMutationActor) {
return {
createdByType: actor.actorType,
createdByAgentId: actor.agentId,
createdByUserId: actor.userId,
createdByRunId: actor.runId,
createdByAgentApiKeyId: actor.agentApiKeyId,
};
}
function addedByColumns(actor: DecisionMutationActor) {
return {
addedByType: actor.actorType,
addedByAgentId: actor.agentId,
addedByUserId: actor.userId,
addedByRunId: actor.runId,
addedByAgentApiKeyId: actor.agentApiKeyId,
};
}
function eventActorColumns(actor: DecisionMutationActor) {
return {
actorType: actor.actorType,
actorAgentId: actor.agentId,
actorUserId: actor.userId,
actorRunId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserId: actor.responsibleUserId,
};
}
function toQueue(row: typeof decisionQueues.$inferSelect, itemCount: number): DecisionQueue {
return {
id: row.id,
companyId: row.companyId,
key: row.key,
title: row.title,
description: row.description ?? null,
createdByType: row.createdByType as DecisionQueue["createdByType"],
createdByAgentId: row.createdByAgentId ?? null,
createdByUserId: row.createdByUserId ?? null,
createdByRunId: row.createdByRunId ?? null,
retentionDays: row.retentionDays ?? null,
seedRules: row.seedRules ?? [],
seedRulesEnabled: row.seedRulesEnabled,
itemCount,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function toQueueItem(row: typeof decisionQueueItems.$inferSelect): DecisionQueueItem {
return {
id: row.id,
companyId: row.companyId,
queueId: row.queueId,
sourceKind: row.sourceKind as AttentionSourceKind,
sourceId: row.sourceId,
addedByType: row.addedByType as DecisionQueueItem["addedByType"],
addedByAgentId: row.addedByAgentId ?? null,
addedByUserId: row.addedByUserId ?? null,
addedByRunId: row.addedByRunId ?? null,
responsibleUserId: row.responsibleUserId ?? null,
createdAt: row.createdAt,
};
}
function toTriage(row: typeof decisionTriage.$inferSelect): DecisionTriage {
return {
id: row.id,
companyId: row.companyId,
sourceKind: row.sourceKind as AttentionSourceKind,
sourceId: row.sourceId,
decideBy: row.decideBy === "date" ? row.decideByDate : row.decideBy,
snoozedUntil: row.snoozedUntil ?? null,
setByType: row.setByType as DecisionTriage["setByType"],
setByAgentId: row.setByAgentId ?? null,
setByUserId: row.setByUserId ?? null,
setByRunId: row.setByRunId ?? null,
responsibleUserId: row.responsibleUserId ?? null,
version: row.version,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async function loadIssueResource(db: Db, companyId: string, issueId: string): Promise<AuthorizationResource | null> {
return db
.select({
issueId: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
parentIssueId: issues.parentId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
originKind: issues.originKind,
originId: issues.originId,
status: issues.status,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId), isNull(issues.hiddenAt)))
.then((rows) => rows[0] ? ({ type: "issue", ...rows[0] } as const) : null);
}
async function sourceIssueId(
db: Db,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
): Promise<{ exists: boolean; issueId: string | null; ownerAgentId?: string | null; agentId?: string | null }> {
switch (sourceKind) {
case "approval": {
const row = await db.select({ id: approvals.id, issueId: issueApprovals.issueId })
.from(approvals)
.leftJoin(issueApprovals, and(
eq(issueApprovals.companyId, companyId),
eq(issueApprovals.approvalId, approvals.id),
))
.where(and(eq(approvals.companyId, companyId), eq(approvals.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "decision": {
const row = await db.select({ issueId: decisions.originIssueId })
.from(decisions)
.where(and(eq(decisions.companyId, companyId), eq(decisions.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "issue_thread_interaction": {
const row = await db.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "recovery_action": {
const row = await db.select({ issueId: issueRecoveryActions.sourceIssueId })
.from(issueRecoveryActions)
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "productivity_review":
case "blocker_attention":
case "review": {
const row = await db.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, sourceId), isNull(issues.hiddenAt)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.id ?? null };
}
case "failed_run": {
const row = await db.select({ agentId: heartbeatRuns.agentId, contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, sourceId)))
.then((rows) => rows[0] ?? null);
const snapshot = row?.contextSnapshot && typeof row.contextSnapshot === "object"
? row.contextSnapshot as Record<string, unknown>
: {};
const issueId = typeof snapshot.issueId === "string"
? snapshot.issueId
: typeof snapshot.taskId === "string" ? snapshot.taskId : null;
return { exists: Boolean(row), issueId, ownerAgentId: row?.agentId ?? null };
}
case "agent_error_alert": {
const row = await db.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: null, agentId: row?.id ?? null };
}
case "join_request": {
const row = await db.select({ id: joinRequests.id })
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: null };
}
case "budget_alert": {
const row = await db.select({ id: budgetIncidents.id })
.from(budgetIncidents)
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: null };
}
}
}
export async function canReadDecisionSource(
db: Db,
actor: AuthorizationActor,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
) {
const source = await sourceIssueId(db, companyId, sourceKind, sourceId);
if (!source.exists) return false;
const authz = authorizationService(db);
if (source.issueId) {
const resource = await loadIssueResource(db, companyId, source.issueId);
if (!resource) return false;
return (await authz.decide({ actor, action: "issue:read", resource })).allowed;
}
if (sourceKind === "agent_error_alert" && source.agentId) {
return (await authz.decide({
actor,
action: "agent:read",
resource: { type: "agent", companyId, agentId: source.agentId },
})).allowed;
}
if (sourceKind === "failed_run" && actor.type === "agent") {
return source.ownerAgentId === actor.agentId;
}
// Join requests, unlinked approvals, and budget incidents are board-only
// governance data. Same-company existence is deliberately not authority.
if (actor.type !== "board") return false;
return (await authz.decide({
actor,
action: "company_scope:read",
resource: { type: "company", companyId },
})).allowed;
}
async function requireSourceRead(
db: Db,
actor: AuthorizationActor,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
) {
if (!(await canReadDecisionSource(db, actor, companyId, sourceKind, sourceId))) {
throw notFound("Attention source not found");
}
}
async function recordActivity(
db: Db,
actor: DecisionMutationActor,
input: { companyId: string; action: string; entityType: string; entityId: string; details?: Record<string, unknown> },
) {
await logActivity(db, {
companyId: input.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: actor.responsibleUserId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
details: input.details ?? null,
});
}
function itemIssueId(item: AttentionItem) {
if (item.subject.kind === "issue") return item.subject.id;
const related = item.relatedIssue?.kind === "issue" ? item.relatedIssue.id : null;
if (related) return related;
const metadataIssueId = item.subject.metadata?.issueId ?? item.subject.metadata?.originIssueId;
return typeof metadataIssueId === "string" ? metadataIssueId : null;
}
export function decisionQueueService(db: Db) {
async function getQueue(companyId: string, key: string) {
return db.select().from(decisionQueues)
.where(and(eq(decisionQueues.companyId, companyId), eq(decisionQueues.key, key)))
.then((rows) => rows[0] ?? null);
}
async function visibleItems(companyId: string, queueId: string, authActor: AuthorizationActor) {
const rows = await db.select().from(decisionQueueItems)
.where(and(eq(decisionQueueItems.companyId, companyId), eq(decisionQueueItems.queueId, queueId)))
.orderBy(desc(decisionQueueItems.createdAt), desc(decisionQueueItems.id));
const visible: DecisionQueueItem[] = [];
for (const row of rows) {
if (await canReadDecisionSource(db, authActor, companyId, row.sourceKind as AttentionSourceKind, row.sourceId)) {
visible.push(toQueueItem(row));
}
}
return visible;
}
return {
create: async (input: {
companyId: string;
key: string;
title: string;
description?: string | null;
retentionDays?: number | null;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const result = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const inserted = await txDb.insert(decisionQueues).values({
companyId: input.companyId,
key: input.key,
title: input.title,
description: input.description ?? null,
retentionDays: input.retentionDays ?? null,
...creatorColumns(input.actor),
}).onConflictDoNothing({ target: [decisionQueues.companyId, decisionQueues.key] }).returning();
const row = inserted[0] ?? await txDb.select().from(decisionQueues)
.where(and(eq(decisionQueues.companyId, input.companyId), eq(decisionQueues.key, input.key)))
.then((rows) => rows[0] ?? null);
if (!row) throw new Error("Decision queue create did not return a row");
if (inserted[0]) {
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: row.id,
action: "queue.created",
...eventActorColumns(input.actor),
details: { key: row.key },
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue.created",
entityType: "decision_queue",
entityId: row.id,
details: { key: row.key },
});
}
return { row, created: Boolean(inserted[0]) };
});
const itemCount = result.created
? 0
: (await visibleItems(input.companyId, result.row.id, input.authActor)).length;
return { queue: toQueue(result.row, itemCount), created: result.created };
},
list: async (companyId: string, authActor: AuthorizationActor) => {
const rows = await db.select().from(decisionQueues)
.where(eq(decisionQueues.companyId, companyId))
.orderBy(desc(decisionQueues.updatedAt), desc(decisionQueues.id));
const result: DecisionQueue[] = [];
for (const row of rows) {
const items = await visibleItems(companyId, row.id, authActor);
result.push(toQueue(row, items.length));
}
return result;
},
update: async (input: {
companyId: string;
key: string;
patch: { title?: string; description?: string | null; retentionDays?: number | null; seedRulesEnabled?: boolean };
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const row = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const updated = await txDb.update(decisionQueues).set({ ...input.patch, updatedAt: new Date() })
.where(and(eq(decisionQueues.companyId, input.companyId), eq(decisionQueues.key, input.key)))
.returning().then((rows) => rows[0] ?? null);
if (!updated) throw notFound("Decision queue not found");
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: updated.id,
action: "queue.updated",
...eventActorColumns(input.actor),
details: { fields: Object.keys(input.patch) },
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue.updated",
entityType: "decision_queue",
entityId: updated.id,
details: { key: updated.key, fields: Object.keys(input.patch) },
});
return updated;
});
return toQueue(row, (await visibleItems(input.companyId, row.id, input.authActor)).length);
},
listItems: async (companyId: string, key: string, authActor: AuthorizationActor) => {
const queue = await getQueue(companyId, key);
if (!queue) throw notFound("Decision queue not found");
return visibleItems(companyId, queue.id, authActor);
},
addItem: async (input: {
companyId: string;
key: string;
sourceKind: AttentionSourceKind;
sourceId: string;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const queue = await getQueue(input.companyId, input.key);
if (!queue) throw notFound("Decision queue not found");
await requireSourceRead(db, input.authActor, input.companyId, input.sourceKind, input.sourceId);
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const inserted = await txDb.insert(decisionQueueItems).values({
companyId: input.companyId,
queueId: queue.id,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
responsibleUserId: input.actor.responsibleUserId,
...addedByColumns(input.actor),
}).onConflictDoNothing({
target: [decisionQueueItems.queueId, decisionQueueItems.sourceKind, decisionQueueItems.sourceId],
}).returning();
const row = inserted[0] ?? await txDb.select().from(decisionQueueItems).where(and(
eq(decisionQueueItems.queueId, queue.id),
eq(decisionQueueItems.sourceKind, input.sourceKind),
eq(decisionQueueItems.sourceId, input.sourceId),
)).then((rows) => rows[0] ?? null);
if (!row) throw new Error("Decision queue item create did not return a row");
if (inserted[0]) {
await txDb.update(decisionQueues).set({ updatedAt: new Date() })
.where(and(eq(decisionQueues.id, queue.id), eq(decisionQueues.companyId, input.companyId)));
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: queue.id,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
action: "queue_item.added",
...eventActorColumns(input.actor),
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue_item.added",
entityType: "decision_queue",
entityId: queue.id,
details: { sourceKind: input.sourceKind, sourceId: input.sourceId },
});
}
return { item: toQueueItem(row), created: Boolean(inserted[0]) };
});
},
removeItem: async (input: {
companyId: string;
key: string;
sourceKind: AttentionSourceKind;
sourceId: string;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const queue = await getQueue(input.companyId, input.key);
if (!queue) throw notFound("Decision queue not found");
const sourceReadable = await canReadDecisionSource(
db,
input.authActor,
input.companyId,
input.sourceKind,
input.sourceId,
);
// Board operators may clean up an existing sidecar after its source has
// disappeared. Agents still need source-level read authority so removal
// cannot be used to probe hidden membership.
if (!sourceReadable && input.authActor.type !== "board") {
throw notFound("Attention source not found");
}
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const removed = await txDb.delete(decisionQueueItems).where(and(
eq(decisionQueueItems.companyId, input.companyId),
eq(decisionQueueItems.queueId, queue.id),
eq(decisionQueueItems.sourceKind, input.sourceKind),
eq(decisionQueueItems.sourceId, input.sourceId),
)).returning().then((rows) => rows[0] ?? null);
if (!removed) throw notFound("Decision queue item not found");
await txDb.update(decisionQueues).set({ updatedAt: new Date() })
.where(and(eq(decisionQueues.id, queue.id), eq(decisionQueues.companyId, input.companyId)));
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: queue.id,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
action: "queue_item.removed",
...eventActorColumns(input.actor),
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue_item.removed",
entityType: "decision_queue",
entityId: queue.id,
details: { sourceKind: input.sourceKind, sourceId: input.sourceId },
});
return toQueueItem(removed);
});
},
getTriage: async (
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
authActor: AuthorizationActor,
) => {
await requireSourceRead(db, authActor, companyId, sourceKind, sourceId);
const row = await db.select().from(decisionTriage).where(and(
eq(decisionTriage.companyId, companyId),
eq(decisionTriage.sourceKind, sourceKind),
eq(decisionTriage.sourceId, sourceId),
)).then((rows) => rows[0] ?? null);
return row ? toTriage(row) : null;
},
updateTriage: async (input: {
companyId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
decideBy?: string | null;
snoozedUntil?: string | null;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
await requireSourceRead(db, input.authActor, input.companyId, input.sourceKind, input.sourceId);
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const lockKey = `decision-triage:${input.companyId}:${input.sourceKind}:${input.sourceId}`;
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
const current = await txDb.select().from(decisionTriage).where(and(
eq(decisionTriage.companyId, input.companyId),
eq(decisionTriage.sourceKind, input.sourceKind),
eq(decisionTriage.sourceId, input.sourceId),
)).then((rows) => rows[0] ?? null);
const decideByDate = input.decideBy && /^\d{4}-\d{2}-\d{2}$/.test(input.decideBy) ? input.decideBy : null;
const decideBy = input.decideBy === undefined
? current?.decideBy ?? null
: decideByDate ? "date" : input.decideBy;
const finalDecideByDate = input.decideBy === undefined ? current?.decideByDate ?? null : decideByDate;
const snoozedUntil = input.snoozedUntil === undefined
? current?.snoozedUntil ?? null
: input.snoozedUntil === null ? null : new Date(input.snoozedUntil);
if (snoozedUntil && snoozedUntil.getTime() > Date.now() + 5 * 366 * 24 * 60 * 60 * 1_000) {
throw unprocessable("snoozedUntil must be within five years");
}
const now = new Date();
const values = {
decideBy,
decideByDate: finalDecideByDate,
snoozedUntil,
setByType: input.actor.actorType as "agent" | "user",
setByAgentId: input.actor.agentId,
setByUserId: input.actor.userId,
setByRunId: input.actor.runId,
setByAgentApiKeyId: input.actor.agentApiKeyId,
responsibleUserId: input.actor.responsibleUserId,
version: (current?.version ?? 0) + 1,
updatedAt: now,
};
const row = await txDb.insert(decisionTriage).values({
companyId: input.companyId,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
...values,
}).onConflictDoUpdate({
target: [decisionTriage.companyId, decisionTriage.sourceKind, decisionTriage.sourceId],
set: values,
}).returning().then((rows) => rows[0]!);
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
action: "triage.updated",
...eventActorColumns(input.actor),
details: {
previousDecideBy: current ? toTriage(current).decideBy : null,
decideBy: toTriage(row).decideBy,
previousSnoozedUntil: current?.snoozedUntil?.toISOString() ?? null,
snoozedUntil: row.snoozedUntil?.toISOString() ?? null,
version: row.version,
},
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_triage.updated",
entityType: "attention_source",
entityId: `${input.sourceKind}:${input.sourceId}`,
details: { sourceKind: input.sourceKind, sourceId: input.sourceId, version: row.version },
});
return toTriage(row);
});
},
materializeSeededQueues: async (companyId: string, items: AttentionItem[]) => {
if (items.length === 0) return;
const issueIds = [...new Set(items.map(itemIssueId).filter((id): id is string => Boolean(id)))];
const prIssueIds = new Set(issueIds.length === 0 ? [] : await db
.select({ issueId: issueWorkProducts.issueId })
.from(issueWorkProducts)
.where(and(
eq(issueWorkProducts.companyId, companyId),
eq(issueWorkProducts.type, "pull_request"),
inArray(issueWorkProducts.issueId, issueIds),
)).then((rows) => rows.map((row) => row.issueId)));
const matches = new Map<string, AttentionItem[]>();
for (const item of items) {
if (prIssueIds.has(itemIssueId(item) ?? "")) {
matches.set("prs", [...(matches.get("prs") ?? []), item]);
}
if (item.sourceKind === "issue_thread_interaction" && item.subject.metadata?.isPlanTarget === true) {
matches.set("plans", [...(matches.get("plans") ?? []), item]);
}
if (item.sourceKind === "issue_thread_interaction" && item.subject.metadata?.kind === "ask_user_questions") {
matches.set("questions", [...(matches.get("questions") ?? []), item]);
}
}
for (const seed of DECISION_QUEUE_SEEDS) {
const matchingItems = matches.get(seed.key) ?? [];
if (matchingItems.length === 0) continue;
await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const insertedQueue = await txDb.insert(decisionQueues).values({
companyId,
key: seed.key,
title: seed.title,
description: seed.description,
createdByType: "system",
seedRules: seed.rules,
seedRulesEnabled: true,
}).onConflictDoNothing({ target: [decisionQueues.companyId, decisionQueues.key] }).returning();
const queue = insertedQueue[0] ?? await txDb.select().from(decisionQueues)
.where(and(eq(decisionQueues.companyId, companyId), eq(decisionQueues.key, seed.key)))
.then((rows) => rows[0] ?? null);
if (!queue || !queue.seedRulesEnabled) return;
if (insertedQueue[0]) {
await txDb.insert(decisionTriageEvents).values({
companyId,
queueId: queue.id,
action: "queue.seeded",
...eventActorColumns(SYSTEM_ACTOR),
details: { key: seed.key, rules: seed.rules.map((rule) => rule.key) },
});
await recordActivity(txDb, SYSTEM_ACTOR, {
companyId,
action: "decision_queue.seeded",
entityType: "decision_queue",
entityId: queue.id,
details: { key: seed.key },
});
}
let insertedAnyItem = false;
for (const item of matchingItems) {
const inserted = await txDb.insert(decisionQueueItems).values({
companyId,
queueId: queue.id,
sourceKind: item.sourceKind,
sourceId: item.subject.id,
addedByType: "system",
}).onConflictDoNothing({
target: [decisionQueueItems.queueId, decisionQueueItems.sourceKind, decisionQueueItems.sourceId],
}).returning();
if (!inserted[0]) continue;
insertedAnyItem = true;
await txDb.insert(decisionTriageEvents).values({
companyId,
queueId: queue.id,
sourceKind: item.sourceKind,
sourceId: item.subject.id,
action: "queue_item.seeded",
...eventActorColumns(SYSTEM_ACTOR),
details: { seedKey: seed.key },
});
await recordActivity(txDb, SYSTEM_ACTOR, {
companyId,
action: "decision_queue_item.seeded",
entityType: "decision_queue",
entityId: queue.id,
details: { sourceKind: item.sourceKind, sourceId: item.subject.id, seedKey: seed.key },
});
}
if (insertedAnyItem) {
await txDb.update(decisionQueues).set({ updatedAt: new Date() })
.where(and(eq(decisionQueues.companyId, companyId), eq(decisionQueues.id, queue.id)));
}
});
}
},
};
}

View File

@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("./client", () => ({ api: mockApi }));
import { attentionApi } from "./attention";
describe("attentionApi.list", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.get.mockResolvedValue({ items: [] });
});
it("encodes feed filters, decide sorting, and cursor pagination", async () => {
await attentionApi.list("company-1", {
includeDismissed: true,
activitySince: "2026-08-01T00:00:00.000Z",
activityUntil: "2026-08-01T23:59:59.999Z",
queue: "release review",
sort: "decide",
cursor: "next/page",
limit: 25,
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/attention?includeDismissed=true&activitySince=2026-08-01T00%3A00%3A00.000Z&activityUntil=2026-08-01T23%3A59%3A59.999Z&queue=release+review&sort=decide&cursor=next%2Fpage&limit=25",
);
});
it("omits the query delimiter when no options are supplied", async () => {
await attentionApi.list("company-1");
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/attention");
});
});

View File

@ -1,4 +1,4 @@
import type { AttentionFeed } from "@paperclipai/shared";
import type { AttentionFeed, AttentionFeedQuery } from "@paperclipai/shared";
import { api } from "./client";
export const attentionApi = {
@ -7,8 +7,16 @@ export const attentionApi = {
* unions every attention source (approvals, interactions, recovery, reviews,
* failures, budget) into one ranked queue with the §0 contract.
*/
list: (companyId: string, options: { includeDismissed?: boolean } = {}) =>
api.get<AttentionFeed>(
`/companies/${companyId}/attention${options.includeDismissed ? "?includeDismissed=true" : ""}`,
),
list: (companyId: string, options: AttentionFeedQuery = {}) => {
const params = new URLSearchParams();
if (options.includeDismissed) params.set("includeDismissed", "true");
if (options.activitySince) params.set("activitySince", options.activitySince);
if (options.activityUntil) params.set("activityUntil", options.activityUntil);
if (options.queue) params.set("queue", options.queue);
if (options.sort) params.set("sort", options.sort);
if (options.cursor) params.set("cursor", options.cursor);
if (options.limit !== undefined) params.set("limit", String(options.limit));
const query = params.toString();
return api.get<AttentionFeed>(`/companies/${companyId}/attention${query ? `?${query}` : ""}`);
},
};

View File

@ -113,6 +113,13 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
detail: null,
dismissal: null,
...overrides,
expiresAt: overrides.expiresAt ?? null,
ruleKey: overrides.ruleKey ?? null,
originAgentName: overrides.originAgentName ?? null,
queues: overrides.queues ?? [],
decideBy: overrides.decideBy ?? null,
decideByAttribution: overrides.decideByAttribution ?? null,
snoozedUntil: overrides.snoozedUntil ?? null,
trainingExampleId: overrides.trainingExampleId ?? null,
};
}

View File

@ -108,8 +108,15 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
workspace: null,
detail: null,
dismissal: null,
trainingExampleId: null,
...overrides,
expiresAt: overrides.expiresAt ?? null,
ruleKey: overrides.ruleKey ?? null,
originAgentName: overrides.originAgentName ?? null,
queues: overrides.queues ?? [],
decideBy: overrides.decideBy ?? null,
decideByAttribution: overrides.decideByAttribution ?? null,
snoozedUntil: overrides.snoozedUntil ?? null,
trainingExampleId: overrides.trainingExampleId ?? null,
};
}

View File

@ -45,6 +45,13 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
relatedIssue: null,
project: null,
workspace: null,
expiresAt: null,
ruleKey: null,
originAgentName: null,
queues: [],
decideBy: null,
decideByAttribution: null,
snoozedUntil: null,
detail: null,
dismissal: null,
...overrides,
@ -94,15 +101,17 @@ describe("isInlineResolvable", () => {
});
describe("attentionBadgeCount", () => {
it("counts every queue row as a decision (mentions/unread never enter the feed)", () => {
it("uses the server's pre-pagination decide-now count", () => {
const feed: AttentionFeed = {
companyId: "c1",
generatedAt: "2026-07-09T12:00:00Z",
totalCount: 3,
decideNowCount: 2,
nextCursor: "next-page",
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
items: [buildItem({ id: "1" }), buildItem({ id: "2" }), buildItem({ id: "3" })],
};
expect(attentionBadgeCount(feed)).toBe(3);
expect(attentionBadgeCount(feed)).toBe(2);
});
it("is zero for an empty or missing feed", () => {

View File

@ -1,6 +1,7 @@
import type {
AttentionDetailImage,
AttentionFeed,
AttentionFeedQuery,
AttentionItem,
AttentionItemDetail,
AttentionProjectRef,
@ -9,6 +10,8 @@ import type {
AttentionWorkspaceRef,
} from "@paperclipai/shared";
export type AttentionListOptions = AttentionFeedQuery;
/**
* Source kinds the queue can fully resolve in-row. Everything else deep-links
* to its native surface reviews are *never* inline (converged PAP-12628),
@ -252,13 +255,12 @@ export function attentionImageUrl(assetId: string): string {
}
/**
* Decisions-only badge count. Every feed row *is* a pending decision (the
* server drops anything without a decision verb into Activity, per the §0
* invariant), and mentions/unread never enter the feed so the row count is
* the decisions-only number. `/inbox` keeps its own unread count untouched.
* The sidebar intentionally reflects only items whose decide-by deadline is
* due now. The count is computed before pagination, so badge polling can fetch
* a small first page without losing the company-wide urgency signal.
*/
export function attentionBadgeCount(feed: AttentionFeed | null | undefined): number {
return feed?.items.length ?? 0;
return feed?.decideNowCount ?? 0;
}
// ---------------------------------------------------------------------------

View File

@ -78,6 +78,13 @@ function item(
detail: null,
dismissal: null,
...overrides,
expiresAt: overrides.expiresAt ?? null,
ruleKey: overrides.ruleKey ?? null,
originAgentName: overrides.originAgentName ?? null,
queues: overrides.queues ?? [],
decideBy: overrides.decideBy ?? null,
decideByAttribution: overrides.decideByAttribution ?? null,
snoozedUntil: overrides.snoozedUntil ?? null,
trainingExampleId: overrides.trainingExampleId ?? null,
};
}