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:
parent
14d4db6330
commit
30c49c8327
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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}` : ""}`);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue