feat(decisions): add desk workflow and retention (#10672)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Decisions desk shows work that needs a human decision > - The queue foundation can group and rank decision work > - Operators also need a focused daily view and a safe way to handle old work > - This pull request adds the desk controls, the aging shelf, and reversible retention > - It also binds bulk archive decisions to the exact reviewed item set > - The benefit is a smaller daily queue without lost or orphaned work ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: server, UI, database, and shared contracts. **Problem or motivation** Decision work can grow into one large company list. Operators need quick queue and date controls. Old items also need a safe retention path that does not delete work. **Proposed solution** Add queue and date controls to the Decisions desk. Compute the aging shelf on the server. Archive idle items after 90 days unless an operator keeps them. Keep archived items searchable and revivable. Notify origin agents in one batch per sweep. Bind bulk archive proposals to a signed, exact item manifest. **Alternatives considered** Client-only aging can drift across browsers and source kinds. Deleting old rows removes audit and recovery paths. An unsigned dynamic bulk query can archive items that the reviewer did not inspect. **Roadmap alignment** This work supports the Work Queues and decision-memory directions in `ROADMAP.md`. It extends the Decisions and attention-feed foundation from #10651. Related earlier work includes #9380, #10010, and #10474. ## What Changed - Added the queue rail, date chips, decide split, triage strip, queue page, and aging shelf UI. - Added server-owned shelf state with per-queue retention overrides. - Added reversible retention state, archive history, and an idempotent notification outbox. - Added the 90-day archive sweeper, Keep exemption, archived feed query, and revive actions. - Added one origin-agent notification per agent and sweep. - Added signed bulk archive proposals with exact-set and version checks. - Persisted queue-exclusion reasons atomically and kept cross-domain source resolution per-item until it has an exact-set transaction contract. - Added API contracts, OpenAPI entries, migration coverage, focused tests, and Storybook screens. ## Verification - `pnpm -r typecheck` - `pnpm test:run` (server: 329 files and 3,453 tests passed; UI: 408 files and 3,362 tests passed) - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts` - `pnpm build` - `pnpm check:token-gates` - Focused retention, attention, decisions, migration replay, startup, and UI API tests. - Complete queue snapshot regression with 51 items across the normal 50-item page boundary. The unmodified CLI test reports one warning assertion in this runtime because the harness injects static AWS credential variables. The isolated test passes when those two variables are removed. ## Risks - The migration adds retention and notification outbox tables. It uses idempotent table, index, and foreign-key creation. - Retention runs on the heartbeat scheduler interval. Compare-and-set version checks prevent stale archive writes. - Bulk archive acceptance fails closed when authority, activity, version, or the reviewed set changes. - Archive is reversible and does not delete source records. > 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 with `gpt-5.6-sol`. The model used tool calls, code execution, database migration generation, and test execution. The context-window size is not exposed in this runtime. ## 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: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
95d33e1788
commit
0a09e4d975
|
|
@ -179,12 +179,14 @@ This policy makes training exports self-describing while keeping the decision re
|
|||
|
||||
## 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.
|
||||
The decisions desk stores queue membership, decide-by/snooze state, and retention state in `decision_queues`, `decision_queue_items`, `decision_triage`, and `decision_retention`. 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.
|
||||
|
||||
`decision_retention` tracks the last observed source `activityAt`, Keep, reversible archive provenance, and monotonic source/archive versions. `decision_archive_notification_outbox` has a unique key over company, source identity, archive version, and immutable origin agent so repeated sweeps cannot enqueue duplicate notifications; delivery claims are retryable and coalesced per agent.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -461,6 +461,8 @@ Decision-desk triage uses company-scoped sidecars rather than adding queue field
|
|||
- `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.
|
||||
- `decision_retention` stores the attention source's last observed activity timestamp, monotonic version, Keep flag, and reversible archive provenance. Queue `retention_days` overrides use the shortest assigned queue threshold; otherwise the shelf threshold is 30 days.
|
||||
- `decision_archive_notification_outbox` records one retry-safe origin-agent notification per source/archive version. The 90-day internal sweeper archives only unkept rows and coalesces delivery per origin agent.
|
||||
- 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
|
||||
|
|
@ -990,9 +992,14 @@ The current app also exposes V1-supporting surfaces for:
|
|||
- `DELETE /companies/:companyId/decision-queues/:key/items/:sourceKind/:sourceId`
|
||||
- `GET /companies/:companyId/decision-queue-seed-rules`
|
||||
- `GET|PUT /companies/:companyId/decision-triage/:sourceKind/:sourceId`
|
||||
- `PATCH /companies/:companyId/decision-retention/:sourceKind/:sourceId` (Keep)
|
||||
- `POST /companies/:companyId/decision-retention/:sourceKind/:sourceId/archive|revive`
|
||||
- `POST /companies/:companyId/decision-archive-proposals`
|
||||
|
||||
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.
|
||||
|
||||
The attention feed returns server-computed `shelf`, `retentionDays`, `keep`, `archivedAt`, and `retentionVersion` fields. Archived rows are excluded by default and selected with `archived=true`. Bulk archive proposals bind the exact source identities, per-item reasons, activity timestamps, and expected retention versions into the signed decisions-v1 target snapshots; acceptance re-authorizes both proposer and decider and commits all rows or none.
|
||||
|
||||
## 11. Heartbeat and Adapter Contract
|
||||
|
||||
## 11.1 Adapter Interface
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
const DECISION_QUEUE_MIGRATIONS = [
|
||||
"0198_decision_queues_and_triage.sql",
|
||||
"0199_decision_queue_composite_key.sql",
|
||||
"0200_yellow_maria_hill.sql",
|
||||
] as const;
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
CREATE TABLE IF NOT EXISTS "decision_archive_notification_outbox" (
|
||||
"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,
|
||||
"archive_version" integer NOT NULL,
|
||||
"origin_agent_id" uuid NOT NULL,
|
||||
"origin_issue_id" uuid NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"attempt_count" integer DEFAULT 0 NOT NULL,
|
||||
"last_attempt_at" timestamp with time zone,
|
||||
"delivered_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "decision_archive_notification_outbox_status_check" CHECK ("decision_archive_notification_outbox"."status" IN ('pending', 'delivering', 'delivered'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "decision_retention" (
|
||||
"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,
|
||||
"source_activity_at" timestamp with time zone NOT NULL,
|
||||
"keep" boolean DEFAULT false NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"archived_reason" text,
|
||||
"archived_by_type" text,
|
||||
"archived_by_agent_id" uuid,
|
||||
"archived_by_user_id" text,
|
||||
"archived_by_run_id" uuid,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"archive_version" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "decision_retention_archive_actor_check" CHECK ((
|
||||
("decision_retention"."archived_at" IS NULL AND "decision_retention"."archived_by_type" IS NULL AND "decision_retention"."archived_by_agent_id" IS NULL AND "decision_retention"."archived_by_user_id" IS NULL)
|
||||
OR ("decision_retention"."archived_at" IS NOT NULL AND "decision_retention"."archived_by_type" = 'system' AND "decision_retention"."archived_by_agent_id" IS NULL AND "decision_retention"."archived_by_user_id" IS NULL)
|
||||
OR ("decision_retention"."archived_at" IS NOT NULL AND "decision_retention"."archived_by_type" = 'agent' AND "decision_retention"."archived_by_agent_id" IS NOT NULL AND "decision_retention"."archived_by_user_id" IS NULL)
|
||||
OR ("decision_retention"."archived_at" IS NOT NULL AND "decision_retention"."archived_by_type" = 'user' AND "decision_retention"."archived_by_agent_id" IS NULL AND "decision_retention"."archived_by_user_id" IS NOT NULL)
|
||||
))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_archive_notification_outbox_company_id_companies_id_fk' AND conrelid = 'public.decision_archive_notification_outbox'::regclass) THEN
|
||||
ALTER TABLE "decision_archive_notification_outbox" ADD CONSTRAINT "decision_archive_notification_outbox_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_archive_notification_outbox_origin_agent_id_agents_id_fk' AND conrelid = 'public.decision_archive_notification_outbox'::regclass) THEN
|
||||
ALTER TABLE "decision_archive_notification_outbox" ADD CONSTRAINT "decision_archive_notification_outbox_origin_agent_id_agents_id_fk" FOREIGN KEY ("origin_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_retention_company_id_companies_id_fk' AND conrelid = 'public.decision_retention'::regclass) THEN
|
||||
ALTER TABLE "decision_retention" ADD CONSTRAINT "decision_retention_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_retention_archived_by_agent_id_agents_id_fk' AND conrelid = 'public.decision_retention'::regclass) THEN
|
||||
ALTER TABLE "decision_retention" ADD CONSTRAINT "decision_retention_archived_by_agent_id_agents_id_fk" FOREIGN KEY ("archived_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_retention_archived_by_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.decision_retention'::regclass) THEN
|
||||
ALTER TABLE "decision_retention" ADD CONSTRAINT "decision_retention_archived_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("archived_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "decision_archive_notification_outbox_uq" ON "decision_archive_notification_outbox" USING btree ("company_id","source_kind","source_id","archive_version","origin_agent_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decision_archive_notification_outbox_pending_idx" ON "decision_archive_notification_outbox" USING btree ("status","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "decision_retention_company_source_uq" ON "decision_retention" USING btree ("company_id","source_kind","source_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decision_retention_company_archived_idx" ON "decision_retention" USING btree ("company_id","archived_at");
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1387,6 +1387,13 @@
|
|||
"when": 1785605818353,
|
||||
"tag": "0199_decision_queue_composite_key",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 200,
|
||||
"version": "7",
|
||||
"when": 1785635155420,
|
||||
"tag": "0200_yellow_maria_hill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -182,3 +182,75 @@ export const decisionTriageEvents = pgTable(
|
|||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const decisionRetention = pgTable(
|
||||
"decision_retention",
|
||||
{
|
||||
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(),
|
||||
sourceActivityAt: timestamp("source_activity_at", { withTimezone: true }).notNull(),
|
||||
keep: boolean("keep").notNull().default(false),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
archivedReason: text("archived_reason"),
|
||||
archivedByType: text("archived_by_type"),
|
||||
archivedByAgentId: uuid("archived_by_agent_id").references(() => agents.id),
|
||||
archivedByUserId: text("archived_by_user_id"),
|
||||
archivedByRunId: uuid("archived_by_run_id").references(() => heartbeatRuns.id),
|
||||
version: integer("version").notNull().default(1),
|
||||
archiveVersion: integer("archive_version").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companySourceUq: uniqueIndex("decision_retention_company_source_uq").on(
|
||||
table.companyId,
|
||||
table.sourceKind,
|
||||
table.sourceId,
|
||||
),
|
||||
companyArchivedIdx: index("decision_retention_company_archived_idx").on(table.companyId, table.archivedAt),
|
||||
archiveActorCheck: check(
|
||||
"decision_retention_archive_actor_check",
|
||||
sql`(
|
||||
(${table.archivedAt} IS NULL AND ${table.archivedByType} IS NULL AND ${table.archivedByAgentId} IS NULL AND ${table.archivedByUserId} IS NULL)
|
||||
OR (${table.archivedAt} IS NOT NULL AND ${table.archivedByType} = 'system' AND ${table.archivedByAgentId} IS NULL AND ${table.archivedByUserId} IS NULL)
|
||||
OR (${table.archivedAt} IS NOT NULL AND ${table.archivedByType} = 'agent' AND ${table.archivedByAgentId} IS NOT NULL AND ${table.archivedByUserId} IS NULL)
|
||||
OR (${table.archivedAt} IS NOT NULL AND ${table.archivedByType} = 'user' AND ${table.archivedByAgentId} IS NULL AND ${table.archivedByUserId} IS NOT NULL)
|
||||
)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const decisionArchiveNotificationOutbox = pgTable(
|
||||
"decision_archive_notification_outbox",
|
||||
{
|
||||
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(),
|
||||
archiveVersion: integer("archive_version").notNull(),
|
||||
originAgentId: uuid("origin_agent_id").notNull().references(() => agents.id),
|
||||
originIssueId: uuid("origin_issue_id").notNull(),
|
||||
status: text("status").notNull().default("pending"),
|
||||
attemptCount: integer("attempt_count").notNull().default(0),
|
||||
lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }),
|
||||
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
notificationUq: uniqueIndex("decision_archive_notification_outbox_uq").on(
|
||||
table.companyId,
|
||||
table.sourceKind,
|
||||
table.sourceId,
|
||||
table.archiveVersion,
|
||||
table.originAgentId,
|
||||
),
|
||||
pendingIdx: index("decision_archive_notification_outbox_pending_idx").on(table.status, table.createdAt),
|
||||
statusCheck: check(
|
||||
"decision_archive_notification_outbox_status_check",
|
||||
sql`${table.status} IN ('pending', 'delivering', 'delivered')`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export {
|
|||
decisionQueueItems,
|
||||
decisionTriage,
|
||||
decisionTriageEvents,
|
||||
decisionRetention,
|
||||
decisionArchiveNotificationOutbox,
|
||||
} from "./decision_queues.js";
|
||||
export { feedbackExports } from "./feedback_exports.js";
|
||||
export { issueReadStates } from "./issue_read_states.js";
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export type {
|
|||
DecisionChosenOptionCount,
|
||||
DecisionRuleKeyStats,
|
||||
DecisionStatsResponse,
|
||||
AttentionArchiveManifestEntry,
|
||||
AttentionArchiveTargetSnapshot,
|
||||
} from "./types/decision.js";
|
||||
|
||||
export {
|
||||
|
|
@ -121,12 +123,18 @@ export {
|
|||
createDecisionQueueSchema,
|
||||
updateDecisionQueueSchema,
|
||||
addDecisionQueueItemSchema,
|
||||
removeDecisionQueueItemSchema,
|
||||
decisionTriageDecideBySchema,
|
||||
updateDecisionTriageSchema,
|
||||
updateDecisionRetentionSchema,
|
||||
createDecisionArchiveProposalSchema,
|
||||
type CreateDecisionQueueInput,
|
||||
type UpdateDecisionQueueInput,
|
||||
type AddDecisionQueueItemInput,
|
||||
type RemoveDecisionQueueItemInput,
|
||||
type UpdateDecisionTriageInput,
|
||||
type UpdateDecisionRetentionInput,
|
||||
type CreateDecisionArchiveProposalInput,
|
||||
} from "./validators/decision-queue.js";
|
||||
export type {
|
||||
DecisionTrainingExample,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ export type AttentionSortMode = "activity" | "decide";
|
|||
|
||||
export interface AttentionFeedQuery {
|
||||
includeDismissed?: boolean;
|
||||
archived?: boolean;
|
||||
/** Return the complete filtered snapshot in one response. */
|
||||
all?: boolean;
|
||||
activitySince?: string;
|
||||
activityUntil?: string;
|
||||
queue?: string;
|
||||
|
|
@ -201,6 +204,11 @@ export interface AttentionItem {
|
|||
ruleKey: string | null;
|
||||
originAgentName: string | null;
|
||||
queues: AttentionQueueRef[];
|
||||
shelf: boolean;
|
||||
retentionDays: number;
|
||||
keep: boolean;
|
||||
archivedAt: string | null;
|
||||
retentionVersion: number;
|
||||
decideBy: string | null;
|
||||
decideByAttribution: AttentionTriageAttribution | null;
|
||||
snoozedUntil: string | null;
|
||||
|
|
|
|||
|
|
@ -101,3 +101,20 @@ export interface DecisionStatsResponse {
|
|||
totals: DecisionStatsCounts;
|
||||
groups: DecisionRuleKeyStats[];
|
||||
}
|
||||
|
||||
export interface AttentionArchiveManifestEntry {
|
||||
companyId: string;
|
||||
sourceKind: string;
|
||||
sourceId: string;
|
||||
expectedVersion: number;
|
||||
activityAt: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AttentionArchiveTargetSnapshot {
|
||||
status: "attention";
|
||||
assigneeAgentId: null;
|
||||
assigneeUserId: null;
|
||||
updatedAt: string;
|
||||
attentionArchive: AttentionArchiveManifestEntry;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -822,6 +822,8 @@ export type {
|
|||
DecisionChosenOptionCount,
|
||||
DecisionRuleKeyStats,
|
||||
DecisionStatsResponse,
|
||||
AttentionArchiveManifestEntry,
|
||||
AttentionArchiveTargetSnapshot,
|
||||
} from "./decision.js";
|
||||
export type {
|
||||
AccessUserProfile,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export const addDecisionQueueItemSchema = z.object({
|
|||
sourceId: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
|
||||
export const removeDecisionQueueItemSchema = z.object({
|
||||
reason: z.string().trim().max(2_000).optional(),
|
||||
}).strict().default({});
|
||||
|
||||
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;
|
||||
|
|
@ -43,7 +47,32 @@ export const updateDecisionTriageSchema = z.object({
|
|||
snoozedUntil: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, "At least one triage field is required");
|
||||
|
||||
export const updateDecisionRetentionSchema = z.object({
|
||||
keep: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export const createDecisionArchiveProposalSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
sourceKind: decisionAttentionSourceKindSchema,
|
||||
sourceId: z.string().trim().min(1).max(500),
|
||||
reason: z.string().trim().min(1).max(2_000),
|
||||
}).strict()).min(1).max(50),
|
||||
idempotencyKey: z.string().trim().min(1).max(500).optional(),
|
||||
}).strict().superRefine((value, ctx) => {
|
||||
const seen = new Set<string>();
|
||||
value.items.forEach((item, index) => {
|
||||
const key = `${item.sourceKind}:${item.sourceId}`;
|
||||
if (seen.has(key)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Archive proposal items must be unique", path: ["items", index] });
|
||||
}
|
||||
seen.add(key);
|
||||
});
|
||||
});
|
||||
|
||||
export type CreateDecisionQueueInput = z.infer<typeof createDecisionQueueSchema>;
|
||||
export type UpdateDecisionQueueInput = z.infer<typeof updateDecisionQueueSchema>;
|
||||
export type AddDecisionQueueItemInput = z.infer<typeof addDecisionQueueItemSchema>;
|
||||
export type RemoveDecisionQueueItemInput = z.infer<typeof removeDecisionQueueItemSchema>;
|
||||
export type UpdateDecisionTriageInput = z.infer<typeof updateDecisionTriageSchema>;
|
||||
export type UpdateDecisionRetentionInput = z.infer<typeof updateDecisionRetentionSchema>;
|
||||
export type CreateDecisionArchiveProposalInput = z.infer<typeof createDecisionArchiveProposalSchema>;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
createDb,
|
||||
decisionQueueItems,
|
||||
decisionQueues,
|
||||
decisionArchiveNotificationOutbox,
|
||||
decisionRetention,
|
||||
decisions,
|
||||
decisionTriage,
|
||||
decisionTriageEvents,
|
||||
|
|
@ -62,6 +64,8 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(decisionArchiveNotificationOutbox);
|
||||
await db.delete(decisionRetention);
|
||||
await db.delete(decisionTriageEvents);
|
||||
await db.delete(decisionTriage);
|
||||
await db.delete(decisionQueueItems);
|
||||
|
|
@ -1237,6 +1241,15 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
expect(secondPage.items.map((item) => item.subject.id)).toEqual([expiringLaterId]);
|
||||
expect(secondPage.nextCursor).toBeNull();
|
||||
|
||||
const completeSnapshot = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "urgent-releases",
|
||||
sort: "decide",
|
||||
all: true,
|
||||
});
|
||||
expect(completeSnapshot.items.map((item) => item.subject.id)).toEqual([expiringSoonId, expiringLaterId]);
|
||||
expect(completeSnapshot.nextCursor).toBeNull();
|
||||
|
||||
const dateFiltered = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "urgent-releases",
|
||||
|
|
@ -1254,6 +1267,73 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
.toBe(new Date(now + 60 * 60_000).toISOString());
|
||||
});
|
||||
|
||||
it("returns a complete queue snapshot beyond the normal page limit", async () => {
|
||||
const { companyId } = await seedCompany("ATS");
|
||||
const queueId = randomUUID();
|
||||
await db.insert(decisionQueues).values({
|
||||
id: queueId,
|
||||
companyId,
|
||||
key: "bulk-review",
|
||||
title: "Bulk review",
|
||||
createdByType: "user",
|
||||
createdByUserId: "board-user",
|
||||
});
|
||||
|
||||
const approvalRows = Array.from({ length: 101 }, (_, index) => ({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: { title: `Review ${index + 1}` },
|
||||
createdAt: new Date(Date.UTC(2026, 7, 2, 12, 0, index)),
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 2, 12, 0, index)),
|
||||
}));
|
||||
await db.insert(approvals).values(approvalRows);
|
||||
await db.insert(decisionQueueItems).values(approvalRows.map((approval) => ({
|
||||
companyId,
|
||||
queueId,
|
||||
sourceKind: "approval",
|
||||
sourceId: approval.id,
|
||||
addedByType: "user",
|
||||
addedByUserId: "board-user",
|
||||
})));
|
||||
|
||||
const firstPage = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "bulk-review",
|
||||
});
|
||||
expect(firstPage.items).toHaveLength(50);
|
||||
expect(firstPage.nextCursor).toBeTruthy();
|
||||
|
||||
const completeSnapshot = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "bulk-review",
|
||||
all: true,
|
||||
});
|
||||
expect(completeSnapshot.items).toHaveLength(101);
|
||||
expect(new Set(completeSnapshot.items.map((item) => item.id)).size).toBe(101);
|
||||
expect(completeSnapshot.nextCursor).toBeNull();
|
||||
|
||||
const internalSnapshot = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
all: true,
|
||||
allowUnscopedAll: true,
|
||||
});
|
||||
const internalSnapshotIds = new Set(internalSnapshot.items.map((item) => item.subject.id));
|
||||
expect(approvalRows.every((approval) => internalSnapshotIds.has(approval.id))).toBe(true);
|
||||
expect(internalSnapshot.items.length).toBeGreaterThan(100);
|
||||
expect(internalSnapshot.nextCursor).toBeNull();
|
||||
|
||||
await expect(attentionService(db).list(companyId, { userId: "board-user", all: true }))
|
||||
.rejects.toThrow("all requires a queue filter");
|
||||
await expect(attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "bulk-review",
|
||||
all: true,
|
||||
limit: 25,
|
||||
})).rejects.toThrow("all cannot be combined with cursor or limit");
|
||||
});
|
||||
|
||||
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.
|
||||
|
|
@ -1370,4 +1450,93 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
.expect(400, { error: "sort must be 'activity' or 'decide'" });
|
||||
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
|
||||
});
|
||||
|
||||
it("computes the aging shelf uniformly across approval, interaction, and review sources", async () => {
|
||||
const { companyId, workerId } = await seedCompany("AGE");
|
||||
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||
const idleAt = new Date("2026-06-30T12:00:00.000Z");
|
||||
const interactionIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "AGE-1",
|
||||
title: "Old questions",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
});
|
||||
const reviewIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "AGE-2",
|
||||
title: "Old review",
|
||||
status: "in_review",
|
||||
assigneeUserId: "board-user",
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
});
|
||||
const approvalId = randomUUID();
|
||||
const queueApprovalId = randomUUID();
|
||||
const interactionId = randomUUID();
|
||||
const queueIdleAt = new Date("2026-07-18T12:00:00.000Z");
|
||||
await db.insert(approvals).values([
|
||||
{
|
||||
id: approvalId,
|
||||
companyId,
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: workerId,
|
||||
status: "pending",
|
||||
payload: { title: "Old approval" },
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
},
|
||||
{
|
||||
id: queueApprovalId,
|
||||
companyId,
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: workerId,
|
||||
status: "pending",
|
||||
payload: { title: "Queue-retained approval" },
|
||||
createdAt: queueIdleAt,
|
||||
updatedAt: queueIdleAt,
|
||||
},
|
||||
]);
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId,
|
||||
companyId,
|
||||
issueId: interactionIssueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
createdByAgentId: workerId,
|
||||
payload: { version: 1, questions: [] },
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
});
|
||||
const [queue] = await db.insert(decisionQueues).values({
|
||||
companyId,
|
||||
key: "fast-aging",
|
||||
title: "Fast aging",
|
||||
retentionDays: 10,
|
||||
createdByType: "system",
|
||||
}).returning();
|
||||
await db.insert(decisionQueueItems).values({
|
||||
companyId,
|
||||
queueId: queue!.id,
|
||||
sourceKind: "approval",
|
||||
sourceId: queueApprovalId,
|
||||
addedByType: "system",
|
||||
});
|
||||
|
||||
const feed = await attentionService(db, { now: () => now }).list(companyId, {
|
||||
userId: "board-user",
|
||||
limit: 100,
|
||||
});
|
||||
const byKey = new Map(feed.items.map((item) => [`${item.sourceKind}:${item.subject.id}`, item]));
|
||||
for (const key of [
|
||||
`approval:${approvalId}`,
|
||||
`issue_thread_interaction:${interactionId}`,
|
||||
`review:${reviewIssueId}`,
|
||||
]) {
|
||||
expect(byKey.get(key)).toMatchObject({ shelf: true, retentionDays: 30, archivedAt: null });
|
||||
}
|
||||
expect(byKey.get(`approval:${queueApprovalId}`)).toMatchObject({ shelf: true, retentionDays: 10 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -230,11 +230,28 @@ describeEmbeddedPostgres("decision queue routes", () => {
|
|||
|
||||
await request(app(board))
|
||||
.delete(`/api/companies/${companyId}/decision-queues/triage/items/review/${issueId}`)
|
||||
.send({ reason: "Handled in the release review" })
|
||||
.expect(200);
|
||||
expect(await db.select().from(decisionQueueItems).where(and(
|
||||
eq(decisionQueueItems.companyId, companyId),
|
||||
eq(decisionQueueItems.sourceKind, "review"),
|
||||
))).toHaveLength(0);
|
||||
const removalAudit = await db.select().from(activityLog).where(and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.action, "decision_queue_item.removed"),
|
||||
)).then((rows) => rows[0]);
|
||||
expect(removalAudit?.details).toMatchObject({
|
||||
sourceKind: "review",
|
||||
sourceId: issueId,
|
||||
reason: "Handled in the release review",
|
||||
});
|
||||
const removalEvent = await db.select().from(decisionTriageEvents).where(and(
|
||||
eq(decisionTriageEvents.companyId, companyId),
|
||||
eq(decisionTriageEvents.sourceKind, "review"),
|
||||
eq(decisionTriageEvents.sourceId, issueId),
|
||||
eq(decisionTriageEvents.action, "queue_item.removed"),
|
||||
)).then((rows) => rows[0]);
|
||||
expect(removalEvent?.details).toEqual({ reason: "Handled in the release review" });
|
||||
|
||||
await db.delete(approvals).where(eq(approvals.id, approvalId));
|
||||
await request(app(board))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
decisionArchiveNotificationOutbox,
|
||||
decisionRetention,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { decisionRetentionService } from "../services/decision-retention.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePg = support.supported ? describe : describe.skip;
|
||||
|
||||
describePg("decision retention", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-retention-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(decisionArchiveNotificationOutbox);
|
||||
await db.delete(decisionRetention);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => tempDb?.cleanup());
|
||||
|
||||
async function seedReviewItems(count: number) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Retention",
|
||||
issuePrefix: `R${companyId.slice(0, 6)}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Origin",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
const old = new Date("2026-04-01T00:00:00.000Z");
|
||||
const issueIds = Array.from({ length: count }, () => randomUUID());
|
||||
await db.insert(issues).values(issueIds.map((id, index) => ({
|
||||
id,
|
||||
companyId,
|
||||
identifier: `RET-${index + 1}`,
|
||||
title: `Old review ${index + 1}`,
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
assigneeUserId: "board-user",
|
||||
createdByAgentId: agentId,
|
||||
createdAt: old,
|
||||
updatedAt: old,
|
||||
})));
|
||||
return { companyId, agentId, issueIds };
|
||||
}
|
||||
|
||||
const boardActor = (companyId: string) => ({
|
||||
type: "board" as const,
|
||||
source: "local_implicit" as const,
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const mutationActor = {
|
||||
actorType: "user" as const,
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
userId: "board-user",
|
||||
runId: null,
|
||||
agentApiKeyId: null,
|
||||
responsibleUserId: "board-user",
|
||||
};
|
||||
|
||||
it("auto-archives, excludes by default, returns archived rows, and revives", async () => {
|
||||
const { companyId, issueIds } = await seedReviewItems(1);
|
||||
const now = new Date("2026-08-02T00:00:00.000Z");
|
||||
const svc = decisionRetentionService(db);
|
||||
const before = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(before.items).toHaveLength(1);
|
||||
|
||||
expect(await svc.autoArchive({ companyId, items: before.items, now })).toBe(1);
|
||||
const current = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(current.items).toHaveLength(0);
|
||||
const archived = await attentionService(db, { now: () => now.getTime() }).list(companyId, { archived: true, limit: 100 });
|
||||
expect(archived.items[0]).toMatchObject({ subject: { id: issueIds[0] }, archivedAt: now.toISOString() });
|
||||
|
||||
await svc.revive({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId: issueIds[0]!,
|
||||
authActor: boardActor(companyId),
|
||||
actor: mutationActor,
|
||||
});
|
||||
const revived = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(revived.items[0]).toMatchObject({ subject: { id: issueIds[0] }, archivedAt: null });
|
||||
});
|
||||
|
||||
it("exempts Keep rows and batches one notification per origin agent", async () => {
|
||||
const { companyId, agentId, issueIds } = await seedReviewItems(3);
|
||||
const now = new Date("2026-08-02T00:00:00.000Z");
|
||||
const notifications: Array<Record<string, unknown>> = [];
|
||||
const svc = decisionRetentionService(db, { notifyOriginAgent: async (batch) => notifications.push(batch) });
|
||||
await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
await svc.setKeep({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId: issueIds[0]!,
|
||||
keep: true,
|
||||
authActor: boardActor(companyId),
|
||||
actor: mutationActor,
|
||||
});
|
||||
const refreshed = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(await svc.autoArchive({ companyId, items: refreshed.items, now })).toBe(2);
|
||||
expect(await svc.deliverNotifications()).toEqual({ notifiedAgents: 1, delivered: 2 });
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toMatchObject({ companyId, agentId, items: expect.arrayContaining([
|
||||
expect.objectContaining({ issueId: issueIds[1] }),
|
||||
expect.objectContaining({ issueId: issueIds[2] }),
|
||||
]) });
|
||||
expect((await svc.getState(companyId, "review", issueIds[0]!))?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("fails the whole reviewed manifest when one expected version changes", async () => {
|
||||
const { companyId, issueIds } = await seedReviewItems(2);
|
||||
const now = Date.parse("2026-08-02T00:00:00.000Z");
|
||||
const svc = decisionRetentionService(db);
|
||||
const feed = await attentionService(db, { now: () => now }).list(companyId, { limit: 100 });
|
||||
const manifest = feed.items.map((item) => ({
|
||||
companyId,
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.subject.id,
|
||||
expectedVersion: item.retentionVersion,
|
||||
activityAt: item.activityAt,
|
||||
reason: "Reviewed as stale",
|
||||
}));
|
||||
await svc.setKeep({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId: issueIds[0]!,
|
||||
keep: true,
|
||||
authActor: boardActor(companyId),
|
||||
actor: mutationActor,
|
||||
});
|
||||
await expect(svc.archiveReviewedManifest({
|
||||
companyId,
|
||||
manifest,
|
||||
originActor: boardActor(companyId),
|
||||
decidingActor: boardActor(companyId),
|
||||
decidedByUserId: "board-user",
|
||||
})).rejects.toThrow("archive_proposal_stale");
|
||||
expect((await db.select().from(decisionRetention)).every((row) => row.archivedAt === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
companyMemberships,
|
||||
createDb,
|
||||
decisionEffectExecutions,
|
||||
decisionRetention,
|
||||
decisions,
|
||||
decisionTargetIssues,
|
||||
heartbeatRuns,
|
||||
|
|
@ -22,6 +23,8 @@ import {
|
|||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { decisionService } from "../services/decisions.js";
|
||||
import { hashAttentionArchiveManifest } from "../services/decision-retention.js";
|
||||
import type { AttentionArchiveManifestEntry, AttentionArchiveTargetSnapshot } from "@paperclipai/shared";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePg = support.supported ? describe : describe.skip;
|
||||
|
|
@ -69,7 +72,7 @@ describePg("decisionService", () => {
|
|||
delete process.env.PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE;
|
||||
delete process.env.PAPERCLIP_DECISIONS_RECOVERY_GRACE_MS;
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
await db.delete(decisionEffectExecutions); await db.delete(decisionTargetIssues); await db.delete(decisions); await db.delete(activityLog);
|
||||
await db.delete(decisionEffectExecutions); await db.delete(decisionTargetIssues); await db.delete(decisions); await db.delete(decisionRetention); await db.delete(activityLog);
|
||||
await db.delete(issueComments); await db.delete(issueRelations); await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(agents); await db.delete(companyMemberships); await db.delete(authUsers); await db.delete(companies);
|
||||
});
|
||||
afterAll(async () => tempDb?.cleanup());
|
||||
|
|
@ -119,6 +122,64 @@ describePg("decisionService", () => {
|
|||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("binds bulk archive acceptance to the exact signed, versioned manifest", async () => {
|
||||
const extraIssueId = randomUUID();
|
||||
const unreviewedIssueId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: extraIssueId, companyId, title: "Second aging item", status: "in_review", priority: "medium", createdByAgentId: agentId, assigneeUserId: decidedByUserId },
|
||||
{ id: unreviewedIssueId, companyId, title: "Not reviewed", status: "in_review", priority: "medium", createdByAgentId: agentId, assigneeUserId: decidedByUserId },
|
||||
]);
|
||||
const activityAt = new Date("2026-04-01T00:00:00.000Z");
|
||||
await db.insert(decisionRetention).values([targetIssueId, extraIssueId, unreviewedIssueId].map((sourceId) => ({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId,
|
||||
sourceActivityAt: activityAt,
|
||||
})));
|
||||
const manifest: AttentionArchiveManifestEntry[] = [targetIssueId, extraIssueId].map((sourceId) => ({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId,
|
||||
expectedVersion: 1,
|
||||
activityAt: activityAt.toISOString(),
|
||||
reason: `Archive ${sourceId}`,
|
||||
}));
|
||||
const snapshots = Object.fromEntries(manifest.map((entry) => [
|
||||
`attention:${entry.sourceKind}:${entry.sourceId}`,
|
||||
{
|
||||
status: "attention",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
updatedAt: entry.activityAt,
|
||||
attentionArchive: entry,
|
||||
} satisfies AttentionArchiveTargetSnapshot,
|
||||
]));
|
||||
const created = await service().create({
|
||||
companyId,
|
||||
actor: agentActor(),
|
||||
agentId,
|
||||
runId,
|
||||
title: "Archive two?",
|
||||
body: "Reviewed exact set",
|
||||
options: [
|
||||
{ id: "archive", label: "Archive", style: "destructive", effects: [] },
|
||||
{ id: "keep", label: "Keep", effects: [] },
|
||||
],
|
||||
metadata: { kind: "attention_archive_proposal", manifestHash: hashAttentionArchiveManifest(manifest) },
|
||||
additionalTargetSnapshots: snapshots,
|
||||
});
|
||||
const result = await service().decide({
|
||||
id: created.id,
|
||||
optionId: "archive",
|
||||
decidedByUserId,
|
||||
userActor: boardActor(),
|
||||
});
|
||||
expect(result.executionStatus).toBe("succeeded");
|
||||
const states = await db.select().from(decisionRetention);
|
||||
expect(states.filter((row) => row.archivedAt).map((row) => row.sourceId).sort()).toEqual([extraIssueId, targetIssueId].sort());
|
||||
expect(states.find((row) => row.sourceId === unreviewedIssueId)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("skips strict stale targets and fails closed on intersection denial", async () => {
|
||||
const stale = await createCommentDecision("strict");
|
||||
await db.update(issues).set({ updatedAt: new Date(Date.now() + 1_000) }).where(eq(issues.id, targetIssueId));
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ const {
|
|||
} = vi.hoisted(() => {
|
||||
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
|
||||
const createBetterAuthInstanceMock = vi.fn(() => ({}));
|
||||
const createDbMock = vi.fn(() => ({}) as never);
|
||||
const createDbMock = vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({ where: vi.fn(async () => []) })),
|
||||
})),
|
||||
}) as never);
|
||||
const detectPortMock = vi.fn(async (port: number) => port);
|
||||
const deriveAuthTrustedOriginsMock = vi.fn(() => []);
|
||||
const resolveHeartbeatSchedulingSuppressionMock = vi.fn(() => ({
|
||||
|
|
@ -207,9 +211,16 @@ vi.mock("../services/index.js", () => ({
|
|||
agentMembershipsInserted: 0,
|
||||
humanGrantsInserted: 0,
|
||||
})),
|
||||
attentionService: vi.fn(() => ({
|
||||
list: vi.fn(async () => ({ items: [], nextCursor: null })),
|
||||
})),
|
||||
decisionService: vi.fn(() => ({
|
||||
sweepExpired: vi.fn(async () => ({ expired: 0 })),
|
||||
})),
|
||||
decisionRetentionService: vi.fn(() => ({
|
||||
autoArchive: vi.fn(async () => 0),
|
||||
deliverNotifications: vi.fn(async () => ({ notifiedAgents: 0, delivered: 0 })),
|
||||
})),
|
||||
feedbackService: feedbackServiceFactoryMock,
|
||||
bootstrapExecutionPolicyFromEnv: vi.fn(async () => null),
|
||||
applyManagedEnvironments: vi.fn(async () => null),
|
||||
|
|
|
|||
|
|
@ -42,11 +42,13 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
|||
import {
|
||||
feedbackService,
|
||||
applyManagedEnvironments,
|
||||
attentionService,
|
||||
backfillPrincipalAccessCompatibility,
|
||||
backfillLegacyToolOAuthTokens,
|
||||
bootstrapExecutionPolicyFromEnv,
|
||||
environmentCustomImageService,
|
||||
decisionService,
|
||||
decisionRetentionService,
|
||||
heartbeatService,
|
||||
issueService,
|
||||
instanceSettingsService,
|
||||
|
|
@ -73,7 +75,7 @@ import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
|
|||
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
|
||||
import { conflict } from "./errors.js";
|
||||
import { ensureDecisionSigningSecret } from "./services/decision-signing.js";
|
||||
import { createDecisionWakeOriginAgent } from "./services/decision-wakeup.js";
|
||||
import { createDecisionRetentionNotifyOriginAgent, createDecisionWakeOriginAgent } from "./services/decision-wakeup.js";
|
||||
import { coordinateHeartbeatSchedulerShutdown } from "./shutdown.js";
|
||||
import { systemdNotify } from "./services/systemd-notify.js";
|
||||
import { flushInFlightRunLogMirrors } from "./services/run-log-store.js";
|
||||
|
|
@ -913,6 +915,9 @@ export async function startServer(): Promise<StartedServer> {
|
|||
|
||||
if (heartbeat) {
|
||||
const decisionExecutor = decisionService(db as any, decisionServiceOptions);
|
||||
const retentionExecutor = decisionRetentionService(db as any, {
|
||||
notifyOriginAgent: createDecisionRetentionNotifyOriginAgent(heartbeat.wakeup),
|
||||
});
|
||||
drainHeartbeatRunsForShutdown = heartbeat.drainRunningRunsForShutdown;
|
||||
prepareHotRestartShutdown = heartbeat.prepareHotRestartShutdown;
|
||||
const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager });
|
||||
|
|
@ -1046,6 +1051,27 @@ export async function startServer(): Promise<StartedServer> {
|
|||
logger.warn({ ...toolHealthSweep }, "startup tool connection health sweep found failing connections");
|
||||
}
|
||||
await decisionExecutor.sweepExpired();
|
||||
const runRetentionSweep = async () => {
|
||||
const activeCompanies = await db.select({ id: companies.id }).from(companies).where(eq(companies.status, "active"));
|
||||
let archived = 0;
|
||||
for (const company of activeCompanies) {
|
||||
const items = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await attentionService(db as any).list(company.id, {
|
||||
includeDismissed: true,
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
items.push(...page.items);
|
||||
cursor = page.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
archived += await retentionExecutor.autoArchive({ companyId: company.id, items });
|
||||
}
|
||||
const notifications = await retentionExecutor.deliverNotifications();
|
||||
return { archived, ...notifications };
|
||||
};
|
||||
await runRetentionSweep();
|
||||
|
||||
heartbeatSchedulerInterval = setInterval(() => {
|
||||
// Async so the suppression checks below can honor the override-aware
|
||||
|
|
@ -1056,6 +1082,9 @@ export async function startServer(): Promise<StartedServer> {
|
|||
trackHeartbeatSchedulerWork(decisionExecutor.sweepExpired().catch((err: unknown) => {
|
||||
logger.error({ err }, "decision expiry sweep failed");
|
||||
}));
|
||||
trackHeartbeatSchedulerWork(runRetentionSweep().catch((err: unknown) => {
|
||||
logger.error({ err }, "decision retention sweep failed");
|
||||
}));
|
||||
const sweptRuntimeStatuses = heartbeat.sweepExpiredRuntimeStatuses();
|
||||
if (sweptRuntimeStatuses > 0) {
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export function attentionRoutes(db: Db) {
|
|||
}
|
||||
|
||||
const includeDismissed = req.query.includeDismissed === "true";
|
||||
const archived = req.query.archived === "true";
|
||||
const all = req.query.all === "true";
|
||||
const activitySince = optionalQueryString(req.query.activitySince, "activitySince");
|
||||
const activityUntil = optionalQueryString(req.query.activityUntil, "activityUntil");
|
||||
const queue = optionalQueryString(req.query.queue, "queue");
|
||||
|
|
@ -39,6 +41,8 @@ export function attentionRoutes(db: Db) {
|
|||
const feed = await svc.list(companyId, {
|
||||
userId: req.actor.userId,
|
||||
includeDismissed,
|
||||
archived,
|
||||
all,
|
||||
activitySince,
|
||||
activityUntil,
|
||||
queue,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import {
|
|||
addDecisionQueueItemSchema,
|
||||
createDecisionQueueSchema,
|
||||
decisionAttentionSourceKindSchema,
|
||||
removeDecisionQueueItemSchema,
|
||||
updateDecisionQueueSchema,
|
||||
updateDecisionRetentionSchema,
|
||||
updateDecisionTriageSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
|
|
@ -20,6 +22,7 @@ import {
|
|||
type DecisionMutationActor,
|
||||
} from "../services/decision-queues.js";
|
||||
import { assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { decisionRetentionService } from "../services/decision-retention.js";
|
||||
|
||||
function mutationActor(req: Parameters<typeof getActorInfo>[0]): DecisionMutationActor {
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -63,6 +66,7 @@ function sourceParams(req: Parameters<typeof getActorInfo>[0]) {
|
|||
export function decisionQueueRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = decisionQueueService(db);
|
||||
const retention = decisionRetentionService(db);
|
||||
|
||||
router.get("/companies/:companyId/decision-queue-seed-rules", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
|
|
@ -124,22 +128,27 @@ export function decisionQueueRoutes(db: Db) {
|
|||
},
|
||||
);
|
||||
|
||||
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.delete(
|
||||
"/companies/:companyId/decision-queues/:key/items/:sourceKind/:sourceId",
|
||||
validate(removeDecisionQueueItemSchema),
|
||||
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,
|
||||
reason: req.body.reason,
|
||||
authActor: req.actor,
|
||||
actor: mutationActor(req),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/decision-triage/:sourceKind/:sourceId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
|
|
@ -173,5 +182,48 @@ export function decisionQueueRoutes(db: Db) {
|
|||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/decision-retention/:sourceKind/:sourceId",
|
||||
validate(updateDecisionRetentionSchema),
|
||||
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 retention.setKeep({
|
||||
companyId,
|
||||
...source,
|
||||
keep: req.body.keep,
|
||||
authActor: req.actor,
|
||||
actor: mutationActor(req),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/companies/:companyId/decision-retention/:sourceKind/:sourceId/archive", 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 retention.archive({ companyId, ...source, authActor: req.actor, actor: mutationActor(req) }));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/decision-retention/:sourceKind/:sourceId/revive", 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 retention.revive({ companyId, ...source, authActor: req.actor, actor: mutationActor(req) }));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { decisionInputsSchema, decisionOptionsSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
createDecisionArchiveProposalSchema,
|
||||
decisionInputsSchema,
|
||||
decisionOptionsSchema,
|
||||
type AttentionArchiveManifestEntry,
|
||||
type AttentionArchiveTargetSnapshot,
|
||||
type AttentionItem,
|
||||
type CreateDecisionArchiveProposalInput,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { decisionService, type DecisionServiceOptions } from "../services/decisions.js";
|
||||
import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { authorizationDeniedDetails, authorizationService } from "../services/authorization.js";
|
||||
import { canReadDecisionSource } from "../services/decision-queues.js";
|
||||
import { hashAttentionArchiveManifest } from "../services/decision-retention.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
|
|
@ -39,6 +52,89 @@ function boardUserId(req: Parameters<typeof getActorInfo>[0]) {
|
|||
export function decisionRoutes(db: Db, options: DecisionServiceOptions) {
|
||||
const router = Router();
|
||||
const svc = decisionService(db, options);
|
||||
router.post(
|
||||
"/companies/:companyId/decision-archive-proposals",
|
||||
validate(createDecisionArchiveProposalSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const agent = agentContext(req);
|
||||
if (!agent) {
|
||||
res.status(403).json({ error: "Agent run context required" });
|
||||
return;
|
||||
}
|
||||
const access = await authorizationService(db).decide({
|
||||
actor: req.actor,
|
||||
action: "decision_triage:manage",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!access.allowed) throw forbidden(access.explanation, authorizationDeniedDetails(access));
|
||||
|
||||
const proposal = req.body as CreateDecisionArchiveProposalInput;
|
||||
const requested = new Map<string, CreateDecisionArchiveProposalInput["items"][number]>(proposal.items.map((item) => [
|
||||
`${item.sourceKind}:${item.sourceId}`,
|
||||
item,
|
||||
]));
|
||||
const found = new Map<string, AttentionItem>();
|
||||
const snapshot = await db.transaction(async (tx) => attentionService(tx as unknown as Db).list(companyId, {
|
||||
includeDismissed: true,
|
||||
all: true,
|
||||
allowUnscopedAll: true,
|
||||
}), { isolationLevel: "repeatable read" });
|
||||
for (const item of snapshot.items) {
|
||||
const key = `${item.sourceKind}:${item.subject.id}`;
|
||||
if (requested.has(key)) found.set(key, item);
|
||||
}
|
||||
|
||||
const manifest: AttentionArchiveManifestEntry[] = [];
|
||||
for (const [key, item] of [...requested.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const attentionItem = found.get(key);
|
||||
if (!attentionItem || !attentionItem.shelf || attentionItem.archivedAt) {
|
||||
throw unprocessable("Every archive proposal item must be on the current aging shelf");
|
||||
}
|
||||
if (!(await canReadDecisionSource(db, req.actor, companyId, attentionItem.sourceKind, attentionItem.subject.id))) {
|
||||
throw unprocessable("Every archive proposal item must be on the current aging shelf");
|
||||
}
|
||||
manifest.push({
|
||||
companyId,
|
||||
sourceKind: attentionItem.sourceKind,
|
||||
sourceId: attentionItem.subject.id,
|
||||
expectedVersion: attentionItem.retentionVersion,
|
||||
activityAt: attentionItem.activityAt,
|
||||
reason: item.reason,
|
||||
});
|
||||
}
|
||||
const manifestHash = hashAttentionArchiveManifest(manifest);
|
||||
const targetSnapshots = Object.fromEntries(manifest.map((entry) => [
|
||||
`attention:${entry.sourceKind}:${entry.sourceId}`,
|
||||
{
|
||||
status: "attention",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
updatedAt: entry.activityAt,
|
||||
attentionArchive: entry,
|
||||
} satisfies AttentionArchiveTargetSnapshot,
|
||||
]));
|
||||
const body = manifest.map((entry) => `- **${entry.sourceKind}:${entry.sourceId}** — ${entry.reason}`).join("\n");
|
||||
const created = await svc.create({
|
||||
companyId,
|
||||
actor: req.actor,
|
||||
...agent,
|
||||
title: `Archive ${manifest.length} aging decision${manifest.length === 1 ? "" : "s"}?`,
|
||||
body,
|
||||
ruleKey: "attention.bulk_archive",
|
||||
idempotencyKey: proposal.idempotencyKey ?? `attention-archive:${manifestHash}:${agent.runId}`,
|
||||
continuationPolicy: "wake_origin_agent",
|
||||
options: [
|
||||
{ id: "archive", label: "Archive reviewed items", style: "destructive", effects: [] },
|
||||
{ id: "keep", label: "Keep items", effects: [] },
|
||||
],
|
||||
metadata: { kind: "attention_archive_proposal", manifestHash },
|
||||
additionalTargetSnapshots: targetSnapshots,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
},
|
||||
);
|
||||
router.post("/companies/:companyId/decisions", validate(createSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId);
|
||||
const agent = agentContext(req); if (!agent) { res.status(403).json({ error: "Agent run context required" }); return; }
|
||||
|
|
|
|||
|
|
@ -50,11 +50,14 @@ import {
|
|||
// Decisions
|
||||
addDecisionQueueItemSchema,
|
||||
createDecisionQueueSchema,
|
||||
createDecisionArchiveProposalSchema,
|
||||
decisionAttentionSourceKindSchema,
|
||||
decisionInputsSchema,
|
||||
decisionOptionsSchema,
|
||||
removeDecisionQueueItemSchema,
|
||||
updateDecisionQueueSchema,
|
||||
updateDecisionTriageSchema,
|
||||
updateDecisionRetentionSchema,
|
||||
// Routine
|
||||
createRoutineSchema,
|
||||
updateRoutineSchema,
|
||||
|
|
@ -3276,7 +3279,20 @@ registry.registerPath({
|
|||
path: "/api/companies/{companyId}/attention",
|
||||
tags: ["inbox"],
|
||||
summary: "List decision-only attention feed items",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
query: z.object({
|
||||
includeDismissed: z.enum(["true", "false"]).optional(),
|
||||
archived: z.enum(["true", "false"]).optional(),
|
||||
all: z.enum(["true", "false"]).optional(),
|
||||
activitySince: z.string().datetime().optional(),
|
||||
activityUntil: z.string().datetime().optional(),
|
||||
queue: z.string().min(1).optional(),
|
||||
sort: z.enum(["activity", "decide"]).optional(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
}),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
|
|
@ -3343,6 +3359,25 @@ const decisionTriageSchema = z.object({
|
|||
updatedAt: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
const decisionRetentionSchema = z.object({
|
||||
id: z.string(),
|
||||
companyId: z.string(),
|
||||
sourceKind: decisionAttentionSourceKindSchema,
|
||||
sourceId: z.string(),
|
||||
sourceActivityAt: z.string().datetime(),
|
||||
keep: z.boolean(),
|
||||
archivedAt: z.string().datetime().nullable(),
|
||||
archivedReason: z.string().nullable(),
|
||||
archivedByType: z.enum(["agent", "user", "system"]).nullable(),
|
||||
archivedByAgentId: z.string().nullable(),
|
||||
archivedByUserId: z.string().nullable(),
|
||||
archivedByRunId: z.string().nullable(),
|
||||
version: z.number().int().positive(),
|
||||
archiveVersion: z.number().int().nonnegative(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/decision-queue-seed-rules",
|
||||
|
|
@ -3423,6 +3458,7 @@ registerCurrentRoute({
|
|||
path: "/api/companies/{companyId}/decision-queues/{key}/items/{sourceKind}/{sourceId}",
|
||||
tags: ["decision-queues"],
|
||||
summary: "Remove an item from a decision queue",
|
||||
body: removeDecisionQueueItemSchema,
|
||||
responses: {
|
||||
200: r.ok(decisionQueueItemSchema),
|
||||
400: r.badRequest,
|
||||
|
|
@ -3462,6 +3498,34 @@ registerCurrentRoute({
|
|||
},
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}/decision-retention/{sourceKind}/{sourceId}",
|
||||
tags: ["decision-queues"],
|
||||
summary: "Set Keep for an attention source",
|
||||
body: updateDecisionRetentionSchema,
|
||||
responses: { 200: r.ok(decisionRetentionSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
for (const action of ["archive", "revive"] as const) {
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: `/api/companies/{companyId}/decision-retention/{sourceKind}/{sourceId}/${action}`,
|
||||
tags: ["decision-queues"],
|
||||
summary: action === "archive" ? "Archive an attention source" : "Revive an archived attention source",
|
||||
responses: { 200: r.ok(decisionRetentionSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
}
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/decision-archive-proposals",
|
||||
tags: ["decisions"],
|
||||
summary: "Propose one signed bulk archive decision",
|
||||
body: createDecisionArchiveProposalSchema,
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
// Decisions
|
||||
|
||||
const createDecisionBodySchema = z.object({
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ import { issueService } from "./issues.js";
|
|||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
|
||||
import { decisionQueueService } from "./decision-queues.js";
|
||||
import {
|
||||
decisionRetentionService,
|
||||
DEFAULT_DECISION_SHELF_DAYS,
|
||||
} from "./decision-retention.js";
|
||||
|
||||
const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [
|
||||
"approval",
|
||||
|
|
@ -135,6 +139,8 @@ type BlockingIssueSummary = {
|
|||
|
||||
type AttentionListOptions = AttentionFeedQuery & {
|
||||
userId?: string | null;
|
||||
/** Internal-only escape hatch for callers that need one stable, unpaginated feed snapshot. */
|
||||
allowUnscopedAll?: boolean;
|
||||
};
|
||||
|
||||
type AttentionServiceOptions = {
|
||||
|
|
@ -344,6 +350,11 @@ type CreateAttentionItemInput = Omit<AttentionItem,
|
|||
| "ruleKey"
|
||||
| "originAgentName"
|
||||
| "queues"
|
||||
| "shelf"
|
||||
| "retentionDays"
|
||||
| "keep"
|
||||
| "archivedAt"
|
||||
| "retentionVersion"
|
||||
| "decideBy"
|
||||
| "decideByAttribution"
|
||||
| "snoozedUntil"
|
||||
|
|
@ -370,6 +381,11 @@ function createItem(input: CreateAttentionItemInput): AttentionItem {
|
|||
ruleKey: input.ruleKey ?? null,
|
||||
originAgentName: input.originAgentName ?? null,
|
||||
queues: [],
|
||||
shelf: false,
|
||||
retentionDays: DEFAULT_DECISION_SHELF_DAYS,
|
||||
keep: false,
|
||||
archivedAt: null,
|
||||
retentionVersion: 0,
|
||||
decideBy: null,
|
||||
decideByAttribution: null,
|
||||
snoozedUntil: null,
|
||||
|
|
@ -479,7 +495,7 @@ function parseActivityBoundary(value: string | undefined, field: "activitySince"
|
|||
return parsed;
|
||||
}
|
||||
|
||||
async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[]) {
|
||||
async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[], now: number) {
|
||||
if (items.length === 0) return items;
|
||||
const sourceIds = [...new Set(items.map((item) => item.subject.id))];
|
||||
const queueRows = await db
|
||||
|
|
@ -488,6 +504,7 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
|||
sourceId: decisionQueueItems.sourceId,
|
||||
key: decisionQueues.key,
|
||||
title: decisionQueues.title,
|
||||
retentionDays: decisionQueues.retentionDays,
|
||||
})
|
||||
.from(decisionQueueItems)
|
||||
.innerJoin(decisionQueues, and(
|
||||
|
|
@ -500,11 +517,17 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
|||
))
|
||||
.orderBy(asc(decisionQueues.title), asc(decisionQueues.key));
|
||||
const queuesBySource = new Map<string, AttentionQueueRef[]>();
|
||||
const retentionDaysBySource = new Map<string, number[]>();
|
||||
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);
|
||||
if (row.retentionDays != null) {
|
||||
const values = retentionDaysBySource.get(key) ?? [];
|
||||
values.push(row.retentionDays);
|
||||
retentionDaysBySource.set(key, values);
|
||||
}
|
||||
}
|
||||
|
||||
const triageRows = await db
|
||||
|
|
@ -529,7 +552,7 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
|||
.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 enriched = items.map((item) => {
|
||||
const triage = triageBySource.get(itemSourceKey(item));
|
||||
const decideBy = triage?.decideBy === "date" ? triage.decideByDate : triage?.decideBy ?? null;
|
||||
const decideByAttribution: AttentionTriageAttribution | null = triage ? {
|
||||
|
|
@ -551,6 +574,21 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
|||
snoozedUntil: triage?.snoozedUntil ? toIso(triage.snoozedUntil) : null,
|
||||
};
|
||||
});
|
||||
const retentionBySource = await decisionRetentionService(db).syncItems(companyId, enriched);
|
||||
return enriched.map((item) => {
|
||||
const key = itemSourceKey(item);
|
||||
const retention = retentionBySource.get(key);
|
||||
const overrides = retentionDaysBySource.get(key) ?? [];
|
||||
const retentionDays = overrides.length > 0 ? Math.min(...overrides) : DEFAULT_DECISION_SHELF_DAYS;
|
||||
return {
|
||||
...item,
|
||||
shelf: timestamp(item.activityAt) <= now - retentionDays * 86_400_000,
|
||||
retentionDays,
|
||||
keep: retention?.keep ?? false,
|
||||
archivedAt: retention?.archivedAt ? toIso(retention.archivedAt) : null,
|
||||
retentionVersion: retention?.version ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function betterDuplicate(left: AttentionItem, right: AttentionItem) {
|
||||
|
|
@ -824,6 +862,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
);
|
||||
return {
|
||||
list: async (companyId: string, options: AttentionListOptions = {}): Promise<AttentionFeed> => {
|
||||
if (options.all && !options.queue && !options.allowUnscopedAll) {
|
||||
throw badRequest("all requires a queue filter");
|
||||
}
|
||||
const prefix = await companyPrefix(db, companyId);
|
||||
const dismissals = await dismissalByKey(db, companyId, options.userId);
|
||||
const includeDismissed = options.includeDismissed === true;
|
||||
|
|
@ -1592,7 +1633,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
|
||||
const collectedItems = [...deduped.values()].sort(compareAttentionItems);
|
||||
await decisionQueueService(db).materializeSeededQueues(companyId, collectedItems);
|
||||
const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems);
|
||||
const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems, now);
|
||||
|
||||
const activitySince = parseActivityBoundary(options.activitySince, "activitySince");
|
||||
const activityUntil = parseActivityBoundary(options.activityUntil, "activityUntil");
|
||||
|
|
@ -1601,6 +1642,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
}
|
||||
const queueKey = options.queue?.trim() || null;
|
||||
const visibleItems = enrichedItems.filter((item) => {
|
||||
if (options.archived === true ? !item.archivedAt : Boolean(item.archivedAt)) return false;
|
||||
if (!includeDismissed && item.snoozedUntil && timestamp(item.snoozedUntil) > now) return false;
|
||||
const activity = timestamp(item.activityAt);
|
||||
if (activitySince != null && activity < activitySince) return false;
|
||||
|
|
@ -1616,20 +1658,30 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
? (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 items: AttentionItem[];
|
||||
let nextCursor: string | null;
|
||||
if (options.all) {
|
||||
if (options.cursor || options.limit !== undefined) {
|
||||
throw badRequest("all cannot be combined with cursor or limit");
|
||||
}
|
||||
items = rankedItems;
|
||||
nextCursor = null;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
items = rankedItems.slice(pageStart, pageStart + limit);
|
||||
const hasNextPage = pageStart + items.length < rankedItems.length;
|
||||
nextCursor = hasNextPage && items.length > 0 ? encodeCursor(sort, items[items.length - 1]!) : null;
|
||||
}
|
||||
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 }> = [];
|
||||
|
|
|
|||
|
|
@ -534,6 +534,7 @@ export function decisionQueueService(db: Db) {
|
|||
key: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
reason?: string;
|
||||
authActor: AuthorizationActor;
|
||||
actor: DecisionMutationActor;
|
||||
}) => {
|
||||
|
|
@ -569,6 +570,7 @@ export function decisionQueueService(db: Db) {
|
|||
sourceKind: input.sourceKind,
|
||||
sourceId: input.sourceId,
|
||||
action: "queue_item.removed",
|
||||
details: input.reason ? { reason: input.reason } : {},
|
||||
...eventActorColumns(input.actor),
|
||||
});
|
||||
await recordActivity(txDb, input.actor, {
|
||||
|
|
@ -576,7 +578,11 @@ export function decisionQueueService(db: Db) {
|
|||
action: "decision_queue_item.removed",
|
||||
entityType: "decision_queue",
|
||||
entityId: queue.id,
|
||||
details: { sourceKind: input.sourceKind, sourceId: input.sourceId },
|
||||
details: {
|
||||
sourceKind: input.sourceKind,
|
||||
sourceId: input.sourceId,
|
||||
...(input.reason ? { reason: input.reason } : {}),
|
||||
},
|
||||
});
|
||||
return toQueueItem(removed);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,482 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { and, asc, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
decisionArchiveNotificationOutbox,
|
||||
decisionRetention,
|
||||
decisions,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueRecoveryActions,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AttentionArchiveManifestEntry,
|
||||
AttentionItem,
|
||||
AttentionSourceKind,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import type { AuthorizationActor } from "./authorization.js";
|
||||
import {
|
||||
canReadDecisionSource,
|
||||
type DecisionMutationActor,
|
||||
} from "./decision-queues.js";
|
||||
|
||||
export const DEFAULT_DECISION_SHELF_DAYS = 30;
|
||||
export const DEFAULT_DECISION_ARCHIVE_DAYS = 90;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export type DecisionRetentionState = typeof decisionRetention.$inferSelect;
|
||||
export type ArchiveNotificationBatch = {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
items: Array<{
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
archiveVersion: number;
|
||||
issueId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function sourceKey(sourceKind: string, sourceId: string) {
|
||||
return `${sourceKind}:${sourceId}`;
|
||||
}
|
||||
|
||||
function canonicalManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
|
||||
return [...manifest].sort((left, right) => {
|
||||
const leftKey = `${left.companyId}:${left.sourceKind}:${left.sourceId}`;
|
||||
const rightKey = `${right.companyId}:${right.sourceKind}:${right.sourceId}`;
|
||||
return leftKey.localeCompare(rightKey);
|
||||
}).map((entry) => ({
|
||||
companyId: entry.companyId,
|
||||
sourceKind: entry.sourceKind,
|
||||
sourceId: entry.sourceId,
|
||||
expectedVersion: entry.expectedVersion,
|
||||
activityAt: entry.activityAt,
|
||||
reason: entry.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hashAttentionArchiveManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
|
||||
return createHash("sha256").update(JSON.stringify(canonicalManifest(manifest))).digest("hex");
|
||||
}
|
||||
|
||||
function archiveActorColumns(actor: DecisionMutationActor) {
|
||||
return {
|
||||
archivedByType: actor.actorType,
|
||||
archivedByAgentId: actor.agentId,
|
||||
archivedByUserId: actor.userId,
|
||||
archivedByRunId: actor.runId,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveOrigin(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
sourceKind: AttentionSourceKind,
|
||||
sourceId: string,
|
||||
) {
|
||||
let originAgentId: string | null = null;
|
||||
let originIssueId: string | null = null;
|
||||
if (sourceKind === "approval") {
|
||||
const row = await db.select({ agentId: approvals.requestedByAgentId, 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);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
} else if (sourceKind === "decision") {
|
||||
const row = await db.select({ agentId: decisions.originAgentId, issueId: decisions.originIssueId })
|
||||
.from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.id, sourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
} else if (sourceKind === "issue_thread_interaction") {
|
||||
const row = await db.select({ agentId: issueThreadInteractions.createdByAgentId, issueId: issueThreadInteractions.issueId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.id, sourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
} else if (sourceKind === "failed_run") {
|
||||
const row = await db.select({ agentId: heartbeatRuns.agentId, context: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, sourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const context = row?.context && typeof row.context === "object" ? row.context as Record<string, unknown> : {};
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = typeof context.issueId === "string" ? context.issueId : typeof context.taskId === "string" ? context.taskId : null;
|
||||
} else {
|
||||
const issueId = sourceKind === "recovery_action"
|
||||
? await db.select({ issueId: issueRecoveryActions.sourceIssueId }).from(issueRecoveryActions)
|
||||
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.id, sourceId)))
|
||||
.then((rows) => rows[0]?.issueId ?? null)
|
||||
: (["productivity_review", "blocker_attention", "review"] as string[]).includes(sourceKind) ? sourceId : null;
|
||||
if (issueId) {
|
||||
const row = await db.select({ agentId: issues.createdByAgentId, issueId: issues.id }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
}
|
||||
}
|
||||
if (!originAgentId || !originIssueId) return null;
|
||||
const activeAgent = await db.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), eq(agents.id, originAgentId), eq(agents.status, "active")))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return activeAgent ? { originAgentId, originIssueId } : null;
|
||||
}
|
||||
|
||||
export function decisionRetentionService(
|
||||
db: Db,
|
||||
options: { notifyOriginAgent?: (batch: ArchiveNotificationBatch) => Promise<unknown> } = {},
|
||||
) {
|
||||
async function syncItems(companyId: string, items: readonly AttentionItem[]) {
|
||||
if (items.length === 0) return new Map<string, DecisionRetentionState>();
|
||||
const unique = [...new Map(items.map((item) => [itemSourceKey(item), item])).values()];
|
||||
await db.insert(decisionRetention).values(unique.map((item) => ({
|
||||
companyId,
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.subject.id,
|
||||
sourceActivityAt: new Date(item.activityAt),
|
||||
}))).onConflictDoNothing({
|
||||
target: [decisionRetention.companyId, decisionRetention.sourceKind, decisionRetention.sourceId],
|
||||
});
|
||||
let rows = await db.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, companyId),
|
||||
inArray(decisionRetention.sourceId, unique.map((item) => item.subject.id)),
|
||||
));
|
||||
const byKey = new Map(rows.map((row) => [sourceKey(row.sourceKind, row.sourceId), row]));
|
||||
for (const item of unique) {
|
||||
const key = itemSourceKey(item);
|
||||
const row = byKey.get(key);
|
||||
const activityAt = new Date(item.activityAt);
|
||||
if (!row || row.sourceActivityAt.getTime() === activityAt.getTime()) continue;
|
||||
const updated = await db.update(decisionRetention).set({
|
||||
sourceActivityAt: activityAt,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(decisionRetention.id, row.id),
|
||||
ne(decisionRetention.sourceActivityAt, activityAt),
|
||||
)).returning().then((values) => values[0] ?? null);
|
||||
if (updated) byKey.set(key, updated);
|
||||
}
|
||||
rows = [...byKey.values()];
|
||||
return new Map(rows.map((row) => [sourceKey(row.sourceKind, row.sourceId), row]));
|
||||
}
|
||||
|
||||
function itemSourceKey(item: AttentionItem) {
|
||||
return sourceKey(item.sourceKind, item.subject.id);
|
||||
}
|
||||
|
||||
async function getState(companyId: string, sourceKind: AttentionSourceKind, sourceId: string) {
|
||||
return db.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, companyId),
|
||||
eq(decisionRetention.sourceKind, sourceKind),
|
||||
eq(decisionRetention.sourceId, sourceId),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function setKeep(input: {
|
||||
companyId: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
keep: boolean;
|
||||
authActor: AuthorizationActor;
|
||||
actor: DecisionMutationActor;
|
||||
}) {
|
||||
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
|
||||
throw notFound("Attention source not found");
|
||||
}
|
||||
const updated = await db.update(decisionRetention).set({
|
||||
keep: input.keep,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, input.sourceKind),
|
||||
eq(decisionRetention.sourceId, input.sourceId),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (!updated) throw notFound("Attention source not found");
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: input.actor.actorType,
|
||||
actorId: input.actor.actorId,
|
||||
agentId: input.actor.agentId,
|
||||
runId: input.actor.runId,
|
||||
agentApiKeyId: input.actor.agentApiKeyId,
|
||||
responsibleUserIdOverride: input.actor.responsibleUserId,
|
||||
action: "decision_retention.keep_updated",
|
||||
entityType: "attention_source",
|
||||
entityId: input.sourceId,
|
||||
details: { sourceKind: input.sourceKind, keep: input.keep },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function changeArchived(input: {
|
||||
companyId: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
archived: boolean;
|
||||
authActor: AuthorizationActor;
|
||||
actor: DecisionMutationActor;
|
||||
}) {
|
||||
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
|
||||
throw notFound("Attention source not found");
|
||||
}
|
||||
const now = new Date();
|
||||
const updated = await db.update(decisionRetention).set(input.archived ? {
|
||||
archivedAt: now,
|
||||
archivedReason: "manual",
|
||||
...archiveActorColumns(input.actor),
|
||||
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
} : {
|
||||
archivedAt: null,
|
||||
archivedReason: null,
|
||||
archivedByType: null,
|
||||
archivedByAgentId: null,
|
||||
archivedByUserId: null,
|
||||
archivedByRunId: null,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, input.sourceKind),
|
||||
eq(decisionRetention.sourceId, input.sourceId),
|
||||
input.archived ? isNull(decisionRetention.archivedAt) : isNotNull(decisionRetention.archivedAt),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (!updated) {
|
||||
const current = await getState(input.companyId, input.sourceKind, input.sourceId);
|
||||
if (!current) throw notFound("Attention source not found");
|
||||
return current;
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: input.actor.actorType,
|
||||
actorId: input.actor.actorId,
|
||||
agentId: input.actor.agentId,
|
||||
runId: input.actor.runId,
|
||||
agentApiKeyId: input.actor.agentApiKeyId,
|
||||
responsibleUserIdOverride: input.actor.responsibleUserId,
|
||||
action: input.archived ? "decision_retention.archived" : "decision_retention.revived",
|
||||
entityType: "attention_source",
|
||||
entityId: input.sourceId,
|
||||
details: { sourceKind: input.sourceKind },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function archiveReviewedManifest(input: {
|
||||
companyId: string;
|
||||
manifest: AttentionArchiveManifestEntry[];
|
||||
originActor: AuthorizationActor;
|
||||
decidingActor: AuthorizationActor;
|
||||
decidedByUserId: string;
|
||||
}) {
|
||||
const keys = input.manifest.map((entry) => sourceKey(entry.sourceKind, entry.sourceId));
|
||||
if (new Set(keys).size !== keys.length || input.manifest.some((entry) => entry.companyId !== input.companyId)) {
|
||||
throw unprocessable("Archive manifest must contain one exact company-scoped set");
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
for (const entry of input.manifest) {
|
||||
const kind = entry.sourceKind as AttentionSourceKind;
|
||||
if (!(await canReadDecisionSource(txDb, input.originActor, input.companyId, kind, entry.sourceId))
|
||||
|| !(await canReadDecisionSource(txDb, input.decidingActor, input.companyId, kind, entry.sourceId))) {
|
||||
throw conflict("archive_proposal_authority_changed", { code: "archive_proposal_authority_changed" });
|
||||
}
|
||||
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`decision-retention:${input.companyId}:${entry.sourceKind}:${entry.sourceId}`}, 0))`);
|
||||
const state = await txDb.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, entry.sourceKind),
|
||||
eq(decisionRetention.sourceId, entry.sourceId),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
if (!state || state.version !== entry.expectedVersion || state.sourceActivityAt.toISOString() !== entry.activityAt || state.archivedAt) {
|
||||
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
for (const entry of input.manifest) {
|
||||
const archived = await txDb.update(decisionRetention).set({
|
||||
archivedAt: now,
|
||||
archivedReason: "accepted_proposal",
|
||||
archivedByType: "user",
|
||||
archivedByAgentId: null,
|
||||
archivedByUserId: input.decidedByUserId,
|
||||
archivedByRunId: null,
|
||||
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, entry.sourceKind),
|
||||
eq(decisionRetention.sourceId, entry.sourceId),
|
||||
eq(decisionRetention.version, entry.expectedVersion),
|
||||
isNull(decisionRetention.archivedAt),
|
||||
)).returning({ id: decisionRetention.id });
|
||||
if (archived.length !== 1) {
|
||||
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
|
||||
}
|
||||
await logActivity(txDb, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "decision-archive-proposal",
|
||||
responsibleUserIdOverride: input.decidedByUserId,
|
||||
action: "decision_retention.archived",
|
||||
entityType: "attention_source",
|
||||
entityId: entry.sourceId,
|
||||
details: { sourceKind: entry.sourceKind, reason: "accepted_proposal" },
|
||||
});
|
||||
}
|
||||
return { archived: input.manifest.length };
|
||||
});
|
||||
}
|
||||
|
||||
async function autoArchive(input: { companyId: string; items: AttentionItem[]; now?: Date }) {
|
||||
const now = input.now ?? new Date();
|
||||
const cutoff = new Date(now.getTime() - DEFAULT_DECISION_ARCHIVE_DAYS * DAY_MS);
|
||||
const candidates = input.items.filter((item) => new Date(item.activityAt) <= cutoff && !item.keep && !item.archivedAt);
|
||||
let archived = 0;
|
||||
await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
for (const item of candidates) {
|
||||
const updated = await txDb.update(decisionRetention).set({
|
||||
archivedAt: now,
|
||||
archivedReason: "idle_ttl",
|
||||
archivedByType: "system",
|
||||
archivedByAgentId: null,
|
||||
archivedByUserId: null,
|
||||
archivedByRunId: null,
|
||||
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, item.sourceKind),
|
||||
eq(decisionRetention.sourceId, item.subject.id),
|
||||
eq(decisionRetention.version, item.retentionVersion),
|
||||
eq(decisionRetention.keep, false),
|
||||
isNull(decisionRetention.archivedAt),
|
||||
lte(decisionRetention.sourceActivityAt, cutoff),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (!updated) continue;
|
||||
archived += 1;
|
||||
const origin = await resolveOrigin(txDb, input.companyId, item.sourceKind, item.subject.id);
|
||||
if (origin) {
|
||||
await txDb.insert(decisionArchiveNotificationOutbox).values({
|
||||
companyId: input.companyId,
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.subject.id,
|
||||
archiveVersion: updated.archiveVersion,
|
||||
originAgentId: origin.originAgentId,
|
||||
originIssueId: origin.originIssueId,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
await logActivity(txDb, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "decision-retention-sweeper",
|
||||
action: "decision_retention.auto_archived",
|
||||
entityType: "attention_source",
|
||||
entityId: item.subject.id,
|
||||
details: { sourceKind: item.sourceKind, archiveVersion: updated.archiveVersion },
|
||||
});
|
||||
}
|
||||
});
|
||||
return archived;
|
||||
}
|
||||
|
||||
async function deliverNotifications(limit = 500) {
|
||||
if (!options.notifyOriginAgent) return { notifiedAgents: 0, delivered: 0 };
|
||||
const claimAt = new Date();
|
||||
const staleClaimAt = new Date(claimAt.getTime() - 5 * 60_000);
|
||||
await db.update(decisionArchiveNotificationOutbox).set({ status: "pending", updatedAt: claimAt })
|
||||
.where(and(
|
||||
eq(decisionArchiveNotificationOutbox.status, "delivering"),
|
||||
or(isNull(decisionArchiveNotificationOutbox.lastAttemptAt), lte(decisionArchiveNotificationOutbox.lastAttemptAt, staleClaimAt)),
|
||||
));
|
||||
const rows = await db.transaction(async (tx) => {
|
||||
const pendingIds = await tx.select({ id: decisionArchiveNotificationOutbox.id })
|
||||
.from(decisionArchiveNotificationOutbox)
|
||||
.where(eq(decisionArchiveNotificationOutbox.status, "pending"))
|
||||
.orderBy(asc(decisionArchiveNotificationOutbox.createdAt), asc(decisionArchiveNotificationOutbox.id))
|
||||
.limit(limit)
|
||||
.then((values) => values.map((value) => value.id));
|
||||
if (pendingIds.length === 0) return [];
|
||||
return tx.update(decisionArchiveNotificationOutbox).set({
|
||||
status: "delivering",
|
||||
lastAttemptAt: claimAt,
|
||||
attemptCount: sql`${decisionArchiveNotificationOutbox.attemptCount} + 1`,
|
||||
updatedAt: claimAt,
|
||||
}).where(and(
|
||||
inArray(decisionArchiveNotificationOutbox.id, pendingIds),
|
||||
eq(decisionArchiveNotificationOutbox.status, "pending"),
|
||||
)).returning();
|
||||
});
|
||||
const groups = new Map<string, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.companyId}:${row.originAgentId}`;
|
||||
const grouped = groups.get(key) ?? [];
|
||||
grouped.push(row);
|
||||
groups.set(key, grouped);
|
||||
}
|
||||
let notifiedAgents = 0;
|
||||
let delivered = 0;
|
||||
for (const grouped of groups.values()) {
|
||||
const first = grouped[0]!;
|
||||
try {
|
||||
await options.notifyOriginAgent({
|
||||
companyId: first.companyId,
|
||||
agentId: first.originAgentId,
|
||||
items: grouped.map((row) => ({
|
||||
sourceKind: row.sourceKind as AttentionSourceKind,
|
||||
sourceId: row.sourceId,
|
||||
archiveVersion: row.archiveVersion,
|
||||
issueId: row.originIssueId,
|
||||
})),
|
||||
});
|
||||
const deliveredAt = new Date();
|
||||
await db.update(decisionArchiveNotificationOutbox).set({
|
||||
status: "delivered",
|
||||
deliveredAt,
|
||||
lastAttemptAt: deliveredAt,
|
||||
updatedAt: deliveredAt,
|
||||
}).where(and(
|
||||
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
|
||||
eq(decisionArchiveNotificationOutbox.status, "delivering"),
|
||||
));
|
||||
notifiedAgents += 1;
|
||||
delivered += grouped.length;
|
||||
} catch {
|
||||
await db.update(decisionArchiveNotificationOutbox).set({
|
||||
status: "pending",
|
||||
lastAttemptAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
|
||||
eq(decisionArchiveNotificationOutbox.status, "delivering"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return { notifiedAgents, delivered };
|
||||
}
|
||||
|
||||
return {
|
||||
syncItems,
|
||||
getState,
|
||||
setKeep,
|
||||
archive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: true }),
|
||||
revive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: false }),
|
||||
archiveReviewedManifest,
|
||||
autoArchive,
|
||||
deliverNotifications,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { DecisionServiceOptions } from "./decisions.js";
|
||||
import type { ArchiveNotificationBatch } from "./decision-retention.js";
|
||||
|
||||
type HeartbeatWakeup = (
|
||||
agentId: string,
|
||||
|
|
@ -30,3 +31,22 @@ export function createDecisionWakeOriginAgent(
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createDecisionRetentionNotifyOriginAgent(
|
||||
wakeup: HeartbeatWakeup | null,
|
||||
): (batch: ArchiveNotificationBatch) => Promise<unknown> {
|
||||
if (!wakeup) return async () => null;
|
||||
return async (batch) => wakeup(batch.agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "attention_auto_archived",
|
||||
payload: {
|
||||
issueIds: [...new Set(batch.items.map((item) => item.issueId))],
|
||||
archives: batch.items.map((item) => ({
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.sourceId,
|
||||
archiveVersion: item.archiveVersion,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, count, desc, eq, gt, gte, inArray, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companyMemberships, decisionBundles, decisionEffectExecutions, decisions, decisionTargetIssues, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
||||
import type { DecisionEffect, DecisionInput, DecisionOption, DecisionStatsCounts, DecisionStatsResponse } from "@paperclipai/shared";
|
||||
import { companyMemberships, decisionBundles, decisionEffectExecutions, decisionRetention, decisions, decisionTargetIssues, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
||||
import { ATTENTION_SOURCE_KINDS } from "@paperclipai/shared";
|
||||
import type { AttentionArchiveManifestEntry, DecisionEffect, DecisionInput, DecisionOption, DecisionStatsCounts, DecisionStatsResponse } from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../errors.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { signDecisionSpec, verifyDecisionSpec } from "./decision-signing.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { decisionRetentionService, hashAttentionArchiveManifest } from "./decision-retention.js";
|
||||
|
||||
type Snapshot = { status: string; assigneeAgentId: string | null; assigneeUserId: string | null; updatedAt: string;
|
||||
descendantCount?: number; descendantIds?: string[]; childCount?: number };
|
||||
descendantCount?: number; descendantIds?: string[]; childCount?: number; attentionArchive?: unknown };
|
||||
type Wake = (input: { companyId: string; agentId: string; issueId: string; decisionId: string; outcome: "decided" | "expired" | "cancelled" }) => Promise<unknown>;
|
||||
export type DecisionServiceOptions = { wakeOriginAgent: Wake };
|
||||
const DAY = 86_400_000;
|
||||
|
|
@ -78,6 +80,43 @@ function canonicalJson(value: unknown): string {
|
|||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseAttentionArchiveManifest(
|
||||
companyId: string,
|
||||
targetSnapshots: Record<string, Snapshot>,
|
||||
): AttentionArchiveManifestEntry[] | null {
|
||||
const entries: AttentionArchiveManifestEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [key, snapshot] of Object.entries(targetSnapshots)) {
|
||||
if (!key.startsWith("attention:")) return null;
|
||||
const entry = snapshot.attentionArchive;
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
if (candidate.companyId !== companyId
|
||||
|| typeof candidate.sourceKind !== "string"
|
||||
|| !ATTENTION_SOURCE_KINDS.includes(candidate.sourceKind as (typeof ATTENTION_SOURCE_KINDS)[number])
|
||||
|| typeof candidate.sourceId !== "string"
|
||||
|| !candidate.sourceId
|
||||
|| !Number.isInteger(candidate.expectedVersion)
|
||||
|| Number(candidate.expectedVersion) < 1
|
||||
|| typeof candidate.activityAt !== "string"
|
||||
|| !Number.isFinite(Date.parse(candidate.activityAt))
|
||||
|| typeof candidate.reason !== "string"
|
||||
|| !candidate.reason.trim()) return null;
|
||||
const expectedKey = `attention:${candidate.sourceKind}:${candidate.sourceId}`;
|
||||
if (key !== expectedKey || seen.has(expectedKey)) return null;
|
||||
seen.add(expectedKey);
|
||||
entries.push({
|
||||
companyId,
|
||||
sourceKind: candidate.sourceKind,
|
||||
sourceId: candidate.sourceId,
|
||||
expectedVersion: Number(candidate.expectedVersion),
|
||||
activityAt: candidate.activityAt,
|
||||
reason: candidate.reason,
|
||||
});
|
||||
}
|
||||
return entries.length > 0 ? entries : null;
|
||||
}
|
||||
|
||||
function boardCanActDirectly(actor: AuthorizationActor, companyId: string) {
|
||||
if (actor.type !== "board") return false;
|
||||
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true;
|
||||
|
|
@ -91,6 +130,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
type CreateInput = { companyId: string; actor: AuthorizationActor; agentId: string; runId: string; bundleId?: string | null;
|
||||
ruleKey?: string | null; title: string; body: string; options: DecisionOption[]; inputs?: DecisionInput[] | null; expiresAt?: Date | null;
|
||||
idempotencyKey?: string | null; continuationPolicy?: "none" | "wake_origin_agent"; metadata?: Record<string, unknown> };
|
||||
type CreateInputWithSnapshots = CreateInput & { additionalTargetSnapshots?: Record<string, Snapshot> };
|
||||
|
||||
async function origin(companyId: string, agentId: string, runId: string) {
|
||||
const run = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)))
|
||||
|
|
@ -155,7 +195,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
return result;
|
||||
}
|
||||
|
||||
async function createInStore(input: CreateInput, dbOrTx: Db) {
|
||||
async function createInStore(input: CreateInputWithSnapshots, dbOrTx: Db) {
|
||||
if (input.idempotencyKey) {
|
||||
const lockKey = `decision-create:${input.companyId}:${input.idempotencyKey}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
|
|
@ -165,8 +205,14 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
const existing = await dbOrTx.select().from(decisions).where(and(eq(decisions.companyId, input.companyId), eq(decisions.idempotencyKey, input.idempotencyKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) {
|
||||
const archiveEquivalent = input.additionalTargetSnapshots === undefined || (
|
||||
canonicalJson(existing.metadata ?? {}) === canonicalJson(input.metadata ?? {}) &&
|
||||
canonicalJson(Object.fromEntries(Object.entries(existing.targetSnapshots as Record<string, Snapshot>)
|
||||
.filter(([key]) => key.startsWith("attention:")))) === canonicalJson(input.additionalTargetSnapshots)
|
||||
);
|
||||
const equivalent = existing.title === input.title && existing.body === input.body &&
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null);
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null) &&
|
||||
archiveEquivalent;
|
||||
if (!equivalent) throw conflict("Decision idempotency key already used with a different payload");
|
||||
return existing;
|
||||
}
|
||||
|
|
@ -179,7 +225,10 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
const ids = targetIds(input.options);
|
||||
const cancellationTargetIds = new Set(input.options.flatMap((option) => option.effects
|
||||
.filter((effect) => effect.type === "cancel_issue_tree").map((effect) => effect.targetIssueId)));
|
||||
const targetSnapshots = await snapshots(input.companyId, ids, input.actor, targetActions(input.options), cancellationTargetIds, dbOrTx);
|
||||
const targetSnapshots = {
|
||||
...await snapshots(input.companyId, ids, input.actor, targetActions(input.options), cancellationTargetIds, dbOrTx),
|
||||
...(input.additionalTargetSnapshots ?? {}),
|
||||
};
|
||||
const id = randomUUID();
|
||||
const [created] = await dbOrTx.insert(decisions).values({ id, companyId: input.companyId, bundleId: input.bundleId ?? null,
|
||||
originAgentId: input.agentId, originIssueId: provenance.issueId, originRunId: input.runId, ruleKey: input.ruleKey ?? null,
|
||||
|
|
@ -190,8 +239,14 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
const existing = input.idempotencyKey
|
||||
? await dbOrTx.select().from(decisions).where(and(eq(decisions.companyId, input.companyId), eq(decisions.idempotencyKey, input.idempotencyKey))).then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const archiveEquivalent = !existing || input.additionalTargetSnapshots === undefined || (
|
||||
canonicalJson(existing.metadata ?? {}) === canonicalJson(input.metadata ?? {}) &&
|
||||
canonicalJson(Object.fromEntries(Object.entries(existing.targetSnapshots as Record<string, Snapshot>)
|
||||
.filter(([key]) => key.startsWith("attention:")))) === canonicalJson(input.additionalTargetSnapshots)
|
||||
);
|
||||
const equivalent = existing && existing.title === input.title && existing.body === input.body &&
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null);
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null) &&
|
||||
archiveEquivalent;
|
||||
if (equivalent) return existing;
|
||||
throw conflict("Decision idempotency key already used with a different payload");
|
||||
}
|
||||
|
|
@ -202,7 +257,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
return created;
|
||||
}
|
||||
|
||||
async function create(input: CreateInput, dbOrTx?: Db) {
|
||||
async function create(input: CreateInputWithSnapshots, dbOrTx?: Db) {
|
||||
if (dbOrTx) return createInStore(input, dbOrTx);
|
||||
return db.transaction((tx) => createInStore(input, tx as unknown as Db));
|
||||
}
|
||||
|
|
@ -226,13 +281,29 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
}
|
||||
const rows = await db.select().from(decisions).where(and(...conditions)).orderBy(desc(decisions.createdAt)).limit(Math.min(filter.limit ?? 50, 100));
|
||||
const openDecisionIds = rows.filter((decision) => decision.status === "open").map((decision) => decision.id);
|
||||
const currentTargets = openDecisionIds.length
|
||||
const currentIssueTargetIds = rows.flatMap((decision) => Object.keys(decision.targetSnapshots as Record<string, Snapshot>))
|
||||
.filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id));
|
||||
const currentTargets = openDecisionIds.length && currentIssueTargetIds.length
|
||||
? await db.select({ id: issues.id, updatedAt: issues.updatedAt })
|
||||
.from(decisionTargetIssues)
|
||||
.innerJoin(issues, and(eq(issues.companyId, companyId), eq(issues.id, decisionTargetIssues.issueId)))
|
||||
.where(and(eq(decisionTargetIssues.companyId, companyId), inArray(decisionTargetIssues.decisionId, openDecisionIds)))
|
||||
.where(and(eq(decisionTargetIssues.companyId, companyId), inArray(decisionTargetIssues.decisionId, openDecisionIds), inArray(issues.id, currentIssueTargetIds)))
|
||||
: [];
|
||||
const currentTargetsById = new Map(currentTargets.map((target) => [target.id, target.updatedAt]));
|
||||
const attentionSnapshotIds = rows.flatMap((decision) => Object.values(decision.targetSnapshots as Record<string, Snapshot>))
|
||||
.map((snapshot) => snapshot.attentionArchive as AttentionArchiveManifestEntry | undefined)
|
||||
.filter((entry): entry is AttentionArchiveManifestEntry => Boolean(entry))
|
||||
.map((entry) => entry.sourceId);
|
||||
const currentAttentionStates = attentionSnapshotIds.length
|
||||
? await db.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, companyId),
|
||||
inArray(decisionRetention.sourceId, attentionSnapshotIds),
|
||||
))
|
||||
: [];
|
||||
const currentAttentionByKey = new Map(currentAttentionStates.map((state) => [
|
||||
`attention:${state.sourceKind}:${state.sourceId}`,
|
||||
state,
|
||||
]));
|
||||
const terminalDecisionIds = rows.filter((decision) => decision.status !== "open").map((decision) => decision.id);
|
||||
const terminalExecutions = terminalDecisionIds.length
|
||||
? await db.select().from(decisionEffectExecutions)
|
||||
|
|
@ -248,6 +319,13 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
return rows.map((decision) => {
|
||||
const changed: Record<string, boolean> = {};
|
||||
if (decision.status === "open") for (const [id, snapshot] of Object.entries(decision.targetSnapshots as Record<string, Snapshot>)) {
|
||||
if (id.startsWith("attention:")) {
|
||||
const entry = snapshot.attentionArchive as AttentionArchiveManifestEntry | undefined;
|
||||
const current = currentAttentionByKey.get(id);
|
||||
changed[id] = !entry || !current || current.version !== entry.expectedVersion
|
||||
|| current.sourceActivityAt.toISOString() !== entry.activityAt || Boolean(current.archivedAt);
|
||||
continue;
|
||||
}
|
||||
const currentUpdatedAt = currentTargetsById.get(id);
|
||||
changed[id] = !currentUpdatedAt || currentUpdatedAt.toISOString() !== snapshot.updatedAt;
|
||||
}
|
||||
|
|
@ -468,6 +546,58 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
const option = decision.options.find((item) => item.id === decision.chosenOptionId);
|
||||
if (!option || !decision.decidedByUserId) throw unprocessable("Stored decision outcome is invalid");
|
||||
const run = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, decision.originRunId)).then((rows) => rows[0] ?? null);
|
||||
const metadata = decision.metadata as Record<string, unknown>;
|
||||
if (metadata.kind === "attention_archive_proposal") {
|
||||
if (!verifyDecisionSpec(spec({
|
||||
id: decision.id,
|
||||
options: decision.options,
|
||||
targetSnapshots: decision.targetSnapshots as Record<string, Snapshot>,
|
||||
}), decision.signedSpec)) {
|
||||
await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "invalid_signature" } })
|
||||
.where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
if (option.id === "archive") {
|
||||
const manifest = parseAttentionArchiveManifest(
|
||||
decision.companyId,
|
||||
decision.targetSnapshots as Record<string, Snapshot>,
|
||||
);
|
||||
const manifestHash = manifest ? hashAttentionArchiveManifest(manifest) : null;
|
||||
if (!manifest || manifestHash !== metadata.manifestHash) {
|
||||
await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "manifest_mismatch" } })
|
||||
.where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
const originActor: AuthorizationActor = {
|
||||
type: "agent",
|
||||
agentId: decision.originAgentId,
|
||||
companyId: decision.companyId,
|
||||
runId: decision.originRunId,
|
||||
onBehalfOfUserId: run?.responsibleUserId ?? null,
|
||||
source: "agent_jwt",
|
||||
};
|
||||
try {
|
||||
await decisionRetentionService(db).archiveReviewedManifest({
|
||||
companyId: decision.companyId,
|
||||
manifest,
|
||||
originActor,
|
||||
decidingActor: userActor,
|
||||
decidedByUserId: decision.decidedByUserId,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "archive_proposal_failed";
|
||||
await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: message } })
|
||||
.where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
}
|
||||
await db.update(decisions).set({
|
||||
executionStatus: "succeeded",
|
||||
updatedAt: new Date(),
|
||||
metadata: { ...metadata, ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) },
|
||||
}).where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
for (let index = 0; index < option.effects.length; index += 1) await executeEffect(decision, option.effects[index]!, index, userActor, decision.decidedByUserId, run?.responsibleUserId ?? null);
|
||||
const rows = await db.select().from(decisionEffectExecutions).where(eq(decisionEffectExecutions.decisionId, decision.id));
|
||||
const successful = rows.filter((row) => row.status === "executed").length;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js
|
|||
export { attentionService } from "./attention.js";
|
||||
export { captureDecisionSnapshot, decisionTrainingService } from "./decision-training.js";
|
||||
export { decisionService } from "./decisions.js";
|
||||
export { decisionRetentionService } from "./decision-retention.js";
|
||||
export type {
|
||||
WorkTimelineActor,
|
||||
WorkTimelineEdge,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { Activity } from "./pages/Activity";
|
|||
import { CompanyAudit } from "./pages/audit/CompanyAudit";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { WhatNeedsMe } from "./pages/WhatNeedsMe";
|
||||
import { DecisionQueuePage } from "./pages/DecisionQueuePage";
|
||||
import { TrainingInspector, TrainingLibrary } from "./pages/Training";
|
||||
import { BoardChat } from "./pages/BoardChat";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
|
|
@ -280,6 +281,7 @@ function boardRoutes() {
|
|||
</Route>
|
||||
) : null}
|
||||
<Route path="decisions" element={<WhatNeedsMe />} />
|
||||
<Route path="decisions/queues/:key" element={<DecisionQueuePage />} />
|
||||
<Route path="decisions/training" element={<TrainingLibrary />} />
|
||||
<Route path="decisions/training/:id" element={<TrainingInspector />} />
|
||||
<Route path="training" element={<Navigate to="/decisions/training" replace />} />
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ describe("attentionApi.list", () => {
|
|||
it("encodes feed filters, decide sorting, and cursor pagination", async () => {
|
||||
await attentionApi.list("company-1", {
|
||||
includeDismissed: true,
|
||||
archived: true,
|
||||
activitySince: "2026-08-01T00:00:00.000Z",
|
||||
activityUntil: "2026-08-01T23:59:59.999Z",
|
||||
queue: "release review",
|
||||
|
|
@ -24,7 +25,15 @@ describe("attentionApi.list", () => {
|
|||
});
|
||||
|
||||
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",
|
||||
"/companies/company-1/attention?includeDismissed=true&archived=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("requests a complete filtered snapshot for complete-set actions", async () => {
|
||||
await attentionApi.list("company-1", { queue: "release review", all: true });
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/attention?all=true&queue=release+review",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export const attentionApi = {
|
|||
list: (companyId: string, options: AttentionFeedQuery = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.includeDismissed) params.set("includeDismissed", "true");
|
||||
if (options.archived) params.set("archived", "true");
|
||||
if (options.all) params.set("all", "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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import type {
|
||||
AttentionSourceKind,
|
||||
DecisionQueue,
|
||||
DecisionQueueItem,
|
||||
DecisionQueueSeedRule,
|
||||
DecisionTriage,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
// The shared types model timestamps as `Date` (the server row shape); over the
|
||||
// wire they arrive as ISO strings, so the UI-facing DTOs restate the time
|
||||
// fields as strings. Everything else round-trips unchanged.
|
||||
export type DecisionQueueDto = Omit<DecisionQueue, "createdAt" | "updatedAt"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type DecisionQueueItemDto = Omit<DecisionQueueItem, "createdAt"> & { createdAt: string };
|
||||
export type DecisionTriageDto = Omit<DecisionTriage, "createdAt" | "updatedAt" | "snoozedUntil"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
snoozedUntil: string | null;
|
||||
};
|
||||
|
||||
/** A seed rule bundled with its owning queue key/title, from the catalog endpoint. */
|
||||
export interface DecisionQueueSeedDefinition {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
rules: DecisionQueueSeedRule[];
|
||||
}
|
||||
|
||||
export interface CreateDecisionQueueBody {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
retentionDays?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateDecisionQueueBody {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
retentionDays?: number | null;
|
||||
seedRulesEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDecisionTriageBody {
|
||||
/** `today` | `this_week` | `whenever` | `YYYY-MM-DD` | null to clear. */
|
||||
decideBy?: string | null;
|
||||
/** ISO timestamp, or null to clear. */
|
||||
snoozedUntil?: string | null;
|
||||
}
|
||||
|
||||
export interface DecisionRetentionDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
sourceActivityAt: string;
|
||||
keep: boolean;
|
||||
archivedAt: string | null;
|
||||
archivedReason: string | null;
|
||||
version: number;
|
||||
archiveVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDecisionArchiveProposalBody {
|
||||
items: Array<{ sourceKind: AttentionSourceKind; sourceId: string; reason: string }>;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision queues + triage (PAP-16039 P1). Queues are named, company-scoped,
|
||||
* durable lists any agent or the board can feed; triage stores the per-source
|
||||
* decide-by / snooze that drives the desk's `sort=decide` ordering. The
|
||||
* attention feed already surfaces `queues[]` and `decideBy` per item — these
|
||||
* routes are the write path and the queue-rail/queue-page read path.
|
||||
*/
|
||||
export const decisionQueuesApi = {
|
||||
seedRules: (companyId: string) =>
|
||||
api.get<DecisionQueueSeedDefinition[]>(`/companies/${companyId}/decision-queue-seed-rules`),
|
||||
|
||||
list: (companyId: string) => api.get<DecisionQueueDto[]>(`/companies/${companyId}/decision-queues`),
|
||||
|
||||
create: (companyId: string, body: CreateDecisionQueueBody) =>
|
||||
api.post<DecisionQueueDto>(`/companies/${companyId}/decision-queues`, body),
|
||||
|
||||
update: (companyId: string, key: string, body: UpdateDecisionQueueBody) =>
|
||||
api.patch<DecisionQueueDto>(`/companies/${companyId}/decision-queues/${encodeURIComponent(key)}`, body),
|
||||
|
||||
listItems: (companyId: string, key: string) =>
|
||||
api.get<DecisionQueueItemDto[]>(`/companies/${companyId}/decision-queues/${encodeURIComponent(key)}/items`),
|
||||
|
||||
addItem: (companyId: string, key: string, sourceKind: AttentionSourceKind, sourceId: string) =>
|
||||
api.post<DecisionQueueItemDto>(
|
||||
`/companies/${companyId}/decision-queues/${encodeURIComponent(key)}/items`,
|
||||
{ sourceKind, sourceId },
|
||||
),
|
||||
|
||||
removeItem: (
|
||||
companyId: string,
|
||||
key: string,
|
||||
sourceKind: AttentionSourceKind,
|
||||
sourceId: string,
|
||||
reason?: string,
|
||||
) =>
|
||||
api.delete<DecisionQueueItemDto>(
|
||||
`/companies/${companyId}/decision-queues/${encodeURIComponent(key)}/items/${encodeURIComponent(sourceKind)}/${encodeURIComponent(sourceId)}`,
|
||||
reason ? { reason } : {},
|
||||
),
|
||||
|
||||
getTriage: (companyId: string, sourceKind: AttentionSourceKind, sourceId: string) =>
|
||||
api.get<DecisionTriageDto | null>(
|
||||
`/companies/${companyId}/decision-triage/${encodeURIComponent(sourceKind)}/${encodeURIComponent(sourceId)}`,
|
||||
),
|
||||
|
||||
updateTriage: (
|
||||
companyId: string,
|
||||
sourceKind: AttentionSourceKind,
|
||||
sourceId: string,
|
||||
body: UpdateDecisionTriageBody,
|
||||
) =>
|
||||
api.put<DecisionTriageDto>(
|
||||
`/companies/${companyId}/decision-triage/${encodeURIComponent(sourceKind)}/${encodeURIComponent(sourceId)}`,
|
||||
body,
|
||||
),
|
||||
|
||||
setKeep: (companyId: string, sourceKind: AttentionSourceKind, sourceId: string, keep: boolean) =>
|
||||
api.patch<DecisionRetentionDto>(
|
||||
`/companies/${companyId}/decision-retention/${encodeURIComponent(sourceKind)}/${encodeURIComponent(sourceId)}`,
|
||||
{ keep },
|
||||
),
|
||||
|
||||
archive: (companyId: string, sourceKind: AttentionSourceKind, sourceId: string) =>
|
||||
api.post<DecisionRetentionDto>(
|
||||
`/companies/${companyId}/decision-retention/${encodeURIComponent(sourceKind)}/${encodeURIComponent(sourceId)}/archive`,
|
||||
{},
|
||||
),
|
||||
|
||||
revive: (companyId: string, sourceKind: AttentionSourceKind, sourceId: string) =>
|
||||
api.post<DecisionRetentionDto>(
|
||||
`/companies/${companyId}/decision-retention/${encodeURIComponent(sourceKind)}/${encodeURIComponent(sourceId)}/revive`,
|
||||
{},
|
||||
),
|
||||
|
||||
proposeArchive: (companyId: string, body: CreateDecisionArchiveProposalBody) =>
|
||||
api.post(`/companies/${companyId}/decision-archive-proposals`, body),
|
||||
};
|
||||
|
|
@ -117,6 +117,11 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
|||
ruleKey: overrides.ruleKey ?? null,
|
||||
originAgentName: overrides.originAgentName ?? null,
|
||||
queues: overrides.queues ?? [],
|
||||
shelf: overrides.shelf ?? false,
|
||||
retentionDays: overrides.retentionDays ?? 30,
|
||||
keep: overrides.keep ?? false,
|
||||
archivedAt: overrides.archivedAt ?? null,
|
||||
retentionVersion: overrides.retentionVersion ?? 1,
|
||||
decideBy: overrides.decideBy ?? null,
|
||||
decideByAttribution: overrides.decideByAttribution ?? null,
|
||||
snoozedUntil: overrides.snoozedUntil ?? null,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { memo, useState, type KeyboardEvent, type ReactNode } from "react";
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlarmClock,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
|
|
@ -24,11 +25,13 @@ import {
|
|||
attentionImageUrl,
|
||||
attentionStatus,
|
||||
attentionTaskRef,
|
||||
decideByLabel,
|
||||
isInlineResolvable,
|
||||
sourceMeta,
|
||||
} from "../lib/attention";
|
||||
import { isTrainable } from "../lib/decisionTraining";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { DecisionTriageStrip } from "./DecisionTriageStrip";
|
||||
import { StatusGlyph } from "./StatusGlyph";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent } from "./ui/collapsible";
|
||||
|
|
@ -86,6 +89,10 @@ interface AttentionQueueRowProps {
|
|||
/** "active" renders the live queue row; "hidden" renders a curtain row. */
|
||||
variant?: "active" | "hidden";
|
||||
agentMap?: Map<string, Agent>;
|
||||
/** Company agents, for the triage strip's route-to-agent picker. */
|
||||
agents?: Agent[];
|
||||
/** Render the per-card triage strip (queue/decide-by/snooze/route) when expanded. */
|
||||
showTriage?: boolean;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
selected?: boolean;
|
||||
|
|
@ -109,6 +116,8 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
onRestore,
|
||||
variant = "active",
|
||||
agentMap,
|
||||
agents,
|
||||
showTriage = false,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
selected = false,
|
||||
|
|
@ -130,10 +139,12 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
// "n more" affordance in the expanded gallery.
|
||||
const issueHref = item.relatedIssue?.href ?? href;
|
||||
// Inline-resolvable active rows expand to reveal their resolver; rows with
|
||||
// images expand to reveal a larger gallery (PAP-13544). Either case gives a
|
||||
// header/thumbnail click somewhere to go. Non-inline, image-less rows keep the
|
||||
// explicit Open button and never toggle on a stray click.
|
||||
const expandable = inline || (!isHidden && hasImages);
|
||||
// images expand to reveal a larger gallery (PAP-13544); triage-enabled rows
|
||||
// expand to reveal the per-card triage strip (PAP-16032 §4.5). Any of these
|
||||
// gives a header/thumbnail click somewhere to go. Non-inline, image-less rows
|
||||
// with no triage keep the explicit Open button and never toggle on a stray click.
|
||||
const triageEnabled = showTriage && !isHidden;
|
||||
const expandable = inline || (!isHidden && hasImages) || triageEnabled;
|
||||
// Any issue-anchored approval or interaction is
|
||||
// trainable at any time (pending or resolved). Trained/untrained renders
|
||||
// purely from the feed's `trainingExampleId` — no per-row fetch.
|
||||
|
|
@ -256,6 +267,22 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
</Link>
|
||||
</>
|
||||
)}
|
||||
{item.decideBy && (
|
||||
<>
|
||||
<EyebrowSeparator />
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground"
|
||||
data-attention-decide-by={item.decideBy}
|
||||
title={decideByProvenance(item) ? `Set by ${decideByProvenance(item)}` : undefined}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3" />
|
||||
{decideByLabel(item.decideBy)}
|
||||
{decideByProvenance(item) && (
|
||||
<span className="text-muted-foreground/80">· set by {decideByProvenance(item)}</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{trainable && trained && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -378,6 +405,7 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
<CollapsibleContent data-decision-disclosure className="-mt-4">
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
{hasImages && <ExpandedImages images={images} issueHref={issueHref} />}
|
||||
{triageEnabled && <DecisionTriageStrip item={item} companyId={companyId} agents={agents} />}
|
||||
{inline && (
|
||||
<InlineResolver
|
||||
item={item}
|
||||
|
|
@ -704,6 +732,18 @@ function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who set this row's decide-by deadline, for the card's provenance line
|
||||
* ("· set by Prioritizer"). Returns null when unattributed so the chip shows
|
||||
* the deadline alone rather than a hollow "set by".
|
||||
*/
|
||||
function decideByProvenance(item: AttentionItem): string | null {
|
||||
const attribution = item.decideByAttribution;
|
||||
if (!attribution) return null;
|
||||
if (attribution.type === "agent") return attribution.agentName ?? "an agent";
|
||||
return "you";
|
||||
}
|
||||
|
||||
/** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */
|
||||
function reappearLabel(snoozedUntil: string): string {
|
||||
const diffMs = new Date(snoozedUntil).getTime() - Date.now();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { cloneElement, isValidElement, useState } from "react";
|
||||
import { CalendarRange } from "lucide-react";
|
||||
import {
|
||||
ATTENTION_DATE_RANGE_OPTIONS,
|
||||
type AttentionDateRangeId,
|
||||
} from "../lib/attention";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
export interface AttentionCustomRange {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
interface DecisionDateChipsProps {
|
||||
value: AttentionDateRangeId;
|
||||
custom: AttentionCustomRange;
|
||||
onChange: (value: AttentionDateRangeId, custom: AttentionCustomRange) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-range chips (PAP-16032 §4.2 / wireframe screen 1, annotation 2). Filter
|
||||
* the desk on activity date; the resolved bounds go to the server
|
||||
* (`activitySince`/`activityUntil`) so the page never ships the whole feed to
|
||||
* filter on the client. "Custom" opens a from/to range picker.
|
||||
*/
|
||||
export function DecisionDateChips({ value, custom, onChange }: DecisionDateChipsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-decision-date-chips>
|
||||
{ATTENTION_DATE_RANGE_OPTIONS.map(([id, label]) => (
|
||||
<ChipButton key={id} active={value === id} onClick={() => onChange(id, custom)}>
|
||||
{label}
|
||||
</ChipButton>
|
||||
))}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<ChipButton active={value === "custom"} asChild>
|
||||
<button type="button">
|
||||
<CalendarRange className="h-3.5 w-3.5" />
|
||||
{value === "custom" && (custom.from || custom.to)
|
||||
? `${custom.from ?? "…"} → ${custom.to ?? "…"}`
|
||||
: "Custom"}
|
||||
</button>
|
||||
</ChipButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto space-y-2 p-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={custom.from ?? ""}
|
||||
max={custom.to ?? undefined}
|
||||
className="rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
onChange={(event) => onChange("custom", { ...custom, from: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={custom.to ?? ""}
|
||||
min={custom.from ?? undefined}
|
||||
className="rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
onChange={(event) => onChange("custom", { ...custom, to: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onChange("all", { from: null, to: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipButton({
|
||||
active,
|
||||
onClick,
|
||||
asChild,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick?: () => void;
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const className = cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
);
|
||||
if (asChild) {
|
||||
// The child is a real <button>; merge the chip class straight onto it so the
|
||||
// flex layout (icon + label inline) lands on the button, not a wrapper span.
|
||||
// A bare wrapper span left the button unstyled → its icon stacked above the
|
||||
// label and the chip grew taller than its siblings.
|
||||
if (isValidElement<{ className?: string }>(children)) {
|
||||
return cloneElement(children, {
|
||||
className: cn(className, children.props.className),
|
||||
});
|
||||
}
|
||||
return <span className={className}>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick} aria-pressed={active}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { decisionQueuesApi } from "../api/decisionQueues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const RECENT_ACTIVITY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Href for a queue page under the current company prefix, or `/decisions` for All. */
|
||||
export function decisionsHref(queueKey?: string | null): string {
|
||||
return queueKey ? `/decisions/queues/${encodeURIComponent(queueKey)}` : "/decisions";
|
||||
}
|
||||
|
||||
interface DecisionQueueRailProps {
|
||||
companyId: string;
|
||||
/** The active queue key, or null when the desk (All) is active. */
|
||||
activeQueueKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue quicklinks (PAP-16032 §4.1 / wireframe screen 1, annotation 1). The
|
||||
* chips are *this company's queues*, ordered by most-recently-updated (the
|
||||
* server already sorts them that way) — not a hardcoded set of types. "All" is
|
||||
* the only fixed chip. A dot marks a queue with recent activity; the count is
|
||||
* the queue's pending items.
|
||||
*/
|
||||
export function DecisionQueueRail({ companyId, activeQueueKey = null }: DecisionQueueRailProps) {
|
||||
const { data: queues } = useQuery({
|
||||
queryKey: queryKeys.decisionQueues.list(companyId),
|
||||
queryFn: () => decisionQueuesApi.list(companyId),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
// Nothing to show until at least one queue exists — the desk still works
|
||||
// without the rail, so render nothing rather than a lone "All" chip.
|
||||
if (!queues || queues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap items-center gap-1.5" aria-label="Decision queues" data-decision-queue-rail>
|
||||
<Chip href={decisionsHref(null)} active={activeQueueKey == null} label="All" />
|
||||
{queues.map((queue) => {
|
||||
const recent = now - new Date(queue.updatedAt).getTime() < RECENT_ACTIVITY_MS;
|
||||
return (
|
||||
<Chip
|
||||
key={queue.key}
|
||||
href={decisionsHref(queue.key)}
|
||||
active={activeQueueKey === queue.key}
|
||||
label={queue.title}
|
||||
count={queue.itemCount}
|
||||
recent={recent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
href,
|
||||
active,
|
||||
label,
|
||||
count,
|
||||
recent,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
label: string;
|
||||
count?: number;
|
||||
recent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{recent && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-label="Recent activity" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
{count != null && count > 0 && (
|
||||
<span className="tabular-nums text-(length:--text-nano) text-muted-foreground">{count}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,6 +113,11 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
|||
ruleKey: overrides.ruleKey ?? null,
|
||||
originAgentName: overrides.originAgentName ?? null,
|
||||
queues: overrides.queues ?? [],
|
||||
shelf: overrides.shelf ?? false,
|
||||
retentionDays: overrides.retentionDays ?? 30,
|
||||
keep: overrides.keep ?? false,
|
||||
archivedAt: overrides.archivedAt ?? null,
|
||||
retentionVersion: overrides.retentionVersion ?? 1,
|
||||
decideBy: overrides.decideBy ?? null,
|
||||
decideByAttribution: overrides.decideByAttribution ?? null,
|
||||
snoozedUntil: overrides.snoozedUntil ?? null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,458 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlarmClock, CalendarClock, ChevronDown, Loader2, Plus, UserPlus, X } from "lucide-react";
|
||||
import { buildAgentMentionHref, type Agent, type AttentionItem, type AttentionSourceKind } from "@paperclipai/shared";
|
||||
import { decisionQueuesApi } from "../api/decisionQueues";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
attentionTaskRef,
|
||||
DECIDE_BY_OPTIONS,
|
||||
decideByLabel,
|
||||
type DecideByPreset,
|
||||
} from "../lib/attention";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/** Snooze presets shared with the row menu, resolved at click time. */
|
||||
const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [
|
||||
{ label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() },
|
||||
{ label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() },
|
||||
{ label: "Tomorrow", resolve: () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
} },
|
||||
{ label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() },
|
||||
];
|
||||
|
||||
/** Slugify a queue title into a URL-safe kebab key the API will accept. */
|
||||
function toQueueKey(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
/** Compact countdown/overdue label for an expiry timestamp. */
|
||||
function expiryLabel(expiresAt: string): { text: string; overdue: boolean } {
|
||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||
if (!Number.isFinite(diff)) return { text: "", overdue: false };
|
||||
if (diff <= 0) return { text: "Expired", overdue: true };
|
||||
const mins = Math.round(diff / 60000);
|
||||
if (mins < 60) return { text: `Expires in ${mins}m`, overdue: false };
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return { text: `Expires in ${hrs}h`, overdue: false };
|
||||
const days = Math.round(hrs / 24);
|
||||
return { text: `Expires in ${days}d`, overdue: false };
|
||||
}
|
||||
|
||||
interface DecisionTriageStripProps {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
/** Company agents, for the route-to-agent picker (optional). */
|
||||
agents?: Agent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card triage strip (PAP-16032 §4.5 / wireframe screen 3). Exposes the
|
||||
* write path the "agents alongside" also use: decide-by urgency (drives the
|
||||
* desk's Decide-now / Can-wait split), queue membership, snooze, and
|
||||
* route-to-agent. Every write is attributed and rendered as a setting, never a
|
||||
* silent mutation. Queue/decide-by/snooze go through the P1 triage APIs; the
|
||||
* expiry chip surfaces the underlying decision's TTL read-only.
|
||||
*/
|
||||
export function DecisionTriageStrip({ item, companyId, agents }: DecisionTriageStripProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const sourceKind = item.sourceKind;
|
||||
const sourceId = item.subject.id;
|
||||
const taskRef = attentionTaskRef(item);
|
||||
const relatedIssueId = item.relatedIssue?.id
|
||||
?? (typeof item.subject.metadata?.issueId === "string" ? item.subject.metadata.issueId : null)
|
||||
?? (item.subject.kind === "issue" ? item.subject.id : null);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.triage(companyId, sourceKind, sourceId) });
|
||||
};
|
||||
|
||||
const onError = (verb: string) => (error: unknown) =>
|
||||
pushToast({
|
||||
title: `Could not ${verb}`,
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
|
||||
const setDecideBy = useMutation({
|
||||
mutationFn: (decideBy: string | null) =>
|
||||
decisionQueuesApi.updateTriage(companyId, sourceKind, sourceId, { decideBy }),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("set when to decide"),
|
||||
});
|
||||
const setSnooze = useMutation({
|
||||
mutationFn: (snoozedUntil: string | null) =>
|
||||
decisionQueuesApi.updateTriage(companyId, sourceKind, sourceId, { snoozedUntil }),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("snooze"),
|
||||
});
|
||||
const addToQueue = useMutation({
|
||||
mutationFn: (key: string) => decisionQueuesApi.addItem(companyId, key, sourceKind, sourceId),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("add to queue"),
|
||||
});
|
||||
const removeFromQueue = useMutation({
|
||||
mutationFn: (key: string) => decisionQueuesApi.removeItem(companyId, key, sourceKind, sourceId),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("remove from queue"),
|
||||
});
|
||||
const routeToAgent = useMutation({
|
||||
mutationFn: (agent: Agent) => {
|
||||
if (!relatedIssueId) throw new Error("This decision has no linked task to route from.");
|
||||
const mention = `[@${agent.name}](${buildAgentMentionHref(agent.id)})`;
|
||||
const body =
|
||||
`${mention} — could you look at this decision${taskRef ? ` on ${taskRef.identifier}` : ""}, `
|
||||
+ `prepare a recommendation, and re-surface it on the decisions desk? (routed from the desk)`;
|
||||
return issuesApi.addComment(relatedIssueId, body);
|
||||
},
|
||||
onSuccess: (_result, agent) => {
|
||||
invalidate();
|
||||
pushToast({ title: `Routed to ${agent.name}`, tone: "success" });
|
||||
},
|
||||
onError: onError("route to agent"),
|
||||
});
|
||||
|
||||
const pending = setDecideBy.isPending || setSnooze.isPending || addToQueue.isPending || removeFromQueue.isPending;
|
||||
const decideBy = item.decideBy;
|
||||
const isDatePreset = decideBy != null && /^\d{4}-\d{2}-\d{2}$/.test(decideBy);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3"
|
||||
data-decision-triage-strip
|
||||
// Contained inside the expandable panel; stop the card's expand toggle
|
||||
// from firing when the operator interacts with a control.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{/* Expiry — read-only countdown so a dying confirmation is visible. */}
|
||||
{item.expiresAt && (() => {
|
||||
const { text, overdue } = expiryLabel(item.expiresAt);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<AlarmClock className={cn("h-3.5 w-3.5", overdue ? "text-destructive" : "text-muted-foreground")} />
|
||||
<span className={overdue ? "font-medium text-destructive" : "text-muted-foreground"}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* When to decide — the importance signal that drives desk ordering. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">When to decide</span>
|
||||
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="When to decide">
|
||||
{DECIDE_BY_OPTIONS.map(([value, label]) => (
|
||||
<SegmentButton
|
||||
key={value}
|
||||
active={decideBy === (value satisfies DecideByPreset)}
|
||||
disabled={pending}
|
||||
onClick={() => setDecideBy.mutate(decideBy === value ? null : value)}
|
||||
>
|
||||
{label}
|
||||
</SegmentButton>
|
||||
))}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<SegmentButton active={isDatePreset} disabled={pending}>
|
||||
<CalendarClock className="h-3.5 w-3.5" />
|
||||
{isDatePreset ? decideByLabel(decideBy) : "Pick date"}
|
||||
</SegmentButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-2">
|
||||
<input
|
||||
type="date"
|
||||
defaultValue={isDatePreset ? decideBy ?? "" : ""}
|
||||
className="rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
onChange={(event) => {
|
||||
if (event.target.value) setDecideBy.mutate(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{/* Provenance ("· set by …") is rendered once, in the card meta row
|
||||
(AttentionQueueRow), which shows in both collapsed and expanded
|
||||
states — repeating it here duplicated it on the expanded card. */}
|
||||
</div>
|
||||
|
||||
{/* Queues — current membership as removable chips + add/create. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Queues</span>
|
||||
{item.queues.map((queue) => (
|
||||
<span
|
||||
key={queue.key}
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{queue.title}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive disabled:opacity-50"
|
||||
aria-label={`Remove from ${queue.title}`}
|
||||
disabled={pending}
|
||||
onClick={() => removeFromQueue.mutate(queue.key)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<QueuePicker
|
||||
companyId={companyId}
|
||||
existingKeys={new Set(item.queues.map((queue) => queue.key))}
|
||||
disabled={pending}
|
||||
onAdd={(key) => addToQueue.mutate(key)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Snooze + route-to-agent. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.snoozedUntil ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<AlarmClock className="h-3.5 w-3.5" />
|
||||
Snoozed until {new Date(item.snoozedUntil).toLocaleString()}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
disabled={pending}
|
||||
onClick={() => setSnooze.mutate(null)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 gap-1" disabled={pending}>
|
||||
<AlarmClock className="h-3.5 w-3.5" />
|
||||
Snooze
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{SNOOZE_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem key={preset.label} onClick={() => setSnooze.mutate(preset.resolve())}>
|
||||
{preset.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<RouteToAgentPicker
|
||||
agents={agents ?? []}
|
||||
disabled={routeToAgent.isPending || !relatedIssueId}
|
||||
disabledReason={!relatedIssueId ? "No linked task to route from" : undefined}
|
||||
onRoute={(agent) => routeToAgent.mutate(agent)}
|
||||
/>
|
||||
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentButton({
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-xs transition-colors disabled:opacity-50",
|
||||
active
|
||||
? "border-primary bg-primary/10 font-medium text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function QueuePicker({
|
||||
companyId,
|
||||
existingKeys,
|
||||
disabled,
|
||||
onAdd,
|
||||
}: {
|
||||
companyId: string;
|
||||
existingKeys: Set<string>;
|
||||
disabled?: boolean;
|
||||
onAdd: (key: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const { data: queues } = useQuery({
|
||||
queryKey: queryKeys.decisionQueues.list(companyId),
|
||||
queryFn: () => decisionQueuesApi.list(companyId),
|
||||
enabled: open,
|
||||
});
|
||||
const create = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
decisionQueuesApi.create(companyId, { key: toQueueKey(name), title: name.trim() }),
|
||||
onSuccess: (queue) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(companyId) });
|
||||
onAdd(queue.key);
|
||||
setTitle("");
|
||||
setCreating(false);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Could not create queue",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const available = (queues ?? []).filter((queue) => !existingKeys.has(queue.key));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 gap-1" disabled={disabled}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Queue
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-1">
|
||||
{creating ? (
|
||||
<div className="flex flex-col gap-1.5 p-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && title.trim()) create.mutate(title);
|
||||
if (event.key === "Escape") setCreating(false);
|
||||
}}
|
||||
placeholder="New queue name…"
|
||||
className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => setCreating(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="xs" disabled={!title.trim() || create.isPending} onClick={() => create.mutate(title)}>
|
||||
{create.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 space-y-0.5 overflow-y-auto">
|
||||
{available.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No other queues yet.</p>
|
||||
)}
|
||||
{available.map((queue) => (
|
||||
<button
|
||||
key={queue.key}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={() => {
|
||||
onAdd(queue.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{queue.title}</span>
|
||||
{queue.itemCount > 0 && (
|
||||
<span className="ml-2 shrink-0 text-(length:--text-nano) tabular-nums text-muted-foreground">
|
||||
{queue.itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuSeparatorLike />
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New queue…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparatorLike() {
|
||||
return <div className="my-1 h-px bg-border" />;
|
||||
}
|
||||
|
||||
function RouteToAgentPicker({
|
||||
agents,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onRoute,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
onRoute: (agent: Agent) => void;
|
||||
}) {
|
||||
const active = agents.filter((agent) => agent.status !== "terminated");
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1"
|
||||
disabled={disabled || active.length === 0}
|
||||
title={disabledReason}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Route to agent
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
|
||||
{active.map((agent) => (
|
||||
<DropdownMenuItem key={agent.id} onClick={() => onRoute(agent)}>
|
||||
{agent.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -49,6 +49,11 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
|||
ruleKey: null,
|
||||
originAgentName: null,
|
||||
queues: [],
|
||||
shelf: false,
|
||||
retentionDays: 30,
|
||||
keep: false,
|
||||
archivedAt: null,
|
||||
retentionVersion: 1,
|
||||
decideBy: null,
|
||||
decideByAttribution: null,
|
||||
snoozedUntil: null,
|
||||
|
|
|
|||
|
|
@ -263,6 +263,193 @@ export function attentionBadgeCount(feed: AttentionFeed | null | undefined): num
|
|||
return feed?.decideNowCount ?? 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decide-by / today's desk (PAP-16032 §4.3)
|
||||
//
|
||||
// The desk splits items into "Decide now" and "Can wait" and orders them by a
|
||||
// per-item `decideBy` field. The server owns the authoritative ranking
|
||||
// (`sort=decide`) and the badge's `decideNowCount`; these client helpers mirror
|
||||
// that logic *exactly* (same UTC day/week boundaries) so the on-page split and
|
||||
// the badge never disagree. Keep in lockstep with
|
||||
// `server/src/services/attention.ts` (`decideOrder`/`isDecideNow`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MS_PER_DAY_DECIDE = 24 * 60 * 60 * 1_000;
|
||||
|
||||
function startOfUtcDay(now: number): number {
|
||||
const value = new Date(now);
|
||||
return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate());
|
||||
}
|
||||
|
||||
function endOfUtcDay(now: number): number {
|
||||
return startOfUtcDay(now) + MS_PER_DAY_DECIDE - 1;
|
||||
}
|
||||
|
||||
function endOfUtcWeek(now: number): number {
|
||||
const start = startOfUtcDay(now);
|
||||
const weekday = new Date(start).getUTCDay();
|
||||
const daysUntilSunday = weekday === 0 ? 0 : 7 - weekday;
|
||||
return start + (daysUntilSunday + 1) * MS_PER_DAY_DECIDE - 1;
|
||||
}
|
||||
|
||||
/** [bucket, deadline] — lower bucket / earlier deadline sorts first. */
|
||||
export function attentionDecideOrder(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];
|
||||
}
|
||||
|
||||
/** Due today or overdue — the "Decide now" shelf, and what the badge counts. */
|
||||
export function attentionIsDecideNow(item: AttentionItem, now: number): boolean {
|
||||
const [bucket, deadline] = attentionDecideOrder(item, now);
|
||||
return bucket === 0 && deadline <= endOfUtcDay(now);
|
||||
}
|
||||
|
||||
/** Split the desk into its two shelves, preserving input order within each. */
|
||||
export function partitionDecideNow(
|
||||
items: AttentionItem[],
|
||||
now: number,
|
||||
): { decideNow: AttentionItem[]; canWait: AttentionItem[] } {
|
||||
const decideNow: AttentionItem[] = [];
|
||||
const canWait: AttentionItem[] = [];
|
||||
for (const item of items) {
|
||||
if (attentionIsDecideNow(item, now)) decideNow.push(item);
|
||||
else canWait.push(item);
|
||||
}
|
||||
return { decideNow, canWait };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aging shelf (PAP-16032 §4.4) — items idle past the threshold leave the desk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default idle threshold before an item drops from the desk to the shelf. */
|
||||
export const ATTENTION_AGING_DAYS = 30;
|
||||
|
||||
/** Milliseconds since the item last saw activity. */
|
||||
export function attentionIdleMs(item: AttentionItem, now: number): number {
|
||||
const ts = new Date(item.activityAt).getTime();
|
||||
return Number.isFinite(ts) ? Math.max(0, now - ts) : 0;
|
||||
}
|
||||
|
||||
/** Server-computed shelf membership, including per-queue retention overrides. */
|
||||
export function attentionIsAging(item: AttentionItem): boolean {
|
||||
return item.shelf;
|
||||
}
|
||||
|
||||
/** Idle duration in whole days, for the shelf's "idle N days" label. */
|
||||
export function attentionIdleDays(item: AttentionItem, now: number): number {
|
||||
return Math.floor(attentionIdleMs(item, now) / MS_PER_DAY_DECIDE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decide-by control (triage strip) — the segmented options an operator/agent
|
||||
// picks from. `date` is handled separately by a date input.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DecideByPreset = "today" | "this_week" | "whenever";
|
||||
|
||||
export const DECIDE_BY_OPTIONS: ReadonlyArray<[DecideByPreset, string]> = [
|
||||
["today", "Today"],
|
||||
["this_week", "This week"],
|
||||
["whenever", "Whenever"],
|
||||
];
|
||||
|
||||
/** Human label for any stored `decideBy` value (preset or `YYYY-MM-DD`). */
|
||||
export function decideByLabel(decideBy: string | null): string {
|
||||
if (!decideBy) return "Not set";
|
||||
if (decideBy === "today") return "Today";
|
||||
if (decideBy === "this_week") return "This week";
|
||||
if (decideBy === "whenever") return "Whenever";
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(decideBy)) {
|
||||
const parsed = new Date(`${decideBy}T00:00:00.000Z`);
|
||||
return Number.isFinite(parsed.getTime())
|
||||
? parsed.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" })
|
||||
: decideBy;
|
||||
}
|
||||
return decideBy;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date-range chips (PAP-16032 §4.2) — resolve to server-side activity bounds.
|
||||
// The desk filters `activityAt` server-side (activitySince/Until) rather than
|
||||
// shipping the whole feed and filtering on the client.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AttentionDateRangeId = "all" | "today" | "yesterday" | "last_7_days" | "this_month" | "custom";
|
||||
|
||||
export const ATTENTION_DATE_RANGE_OPTIONS: ReadonlyArray<[AttentionDateRangeId, string]> = [
|
||||
["all", "All"],
|
||||
["today", "Today"],
|
||||
["yesterday", "Yesterday"],
|
||||
["last_7_days", "Last 7 days"],
|
||||
["this_month", "This month"],
|
||||
];
|
||||
|
||||
export interface AttentionActivityBounds {
|
||||
activitySince?: string;
|
||||
activityUntil?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a range chip to `{activitySince, activityUntil}` ISO bounds. Uses
|
||||
* local calendar boundaries (what the operator means by "today"); `custom`
|
||||
* takes the caller's explicit from/to dates.
|
||||
*/
|
||||
export function resolveAttentionDateRange(
|
||||
range: AttentionDateRangeId,
|
||||
now: number,
|
||||
custom?: { from?: string | null; to?: string | null },
|
||||
): AttentionActivityBounds {
|
||||
const startOfLocalDay = (ms: number) => {
|
||||
const d = new Date(ms);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
const endOfLocalDay = (ms: number) => {
|
||||
const d = new Date(ms);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d;
|
||||
};
|
||||
switch (range) {
|
||||
case "all":
|
||||
return {};
|
||||
case "today":
|
||||
return { activitySince: startOfLocalDay(now).toISOString() };
|
||||
case "yesterday": {
|
||||
const start = startOfLocalDay(now - MS_PER_DAY_DECIDE);
|
||||
const end = endOfLocalDay(now - MS_PER_DAY_DECIDE);
|
||||
return { activitySince: start.toISOString(), activityUntil: end.toISOString() };
|
||||
}
|
||||
case "last_7_days":
|
||||
return { activitySince: startOfLocalDay(now - 6 * MS_PER_DAY_DECIDE).toISOString() };
|
||||
case "this_month": {
|
||||
const d = new Date(now);
|
||||
const start = new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
|
||||
return { activitySince: start.toISOString() };
|
||||
}
|
||||
case "custom": {
|
||||
const bounds: AttentionActivityBounds = {};
|
||||
if (custom?.from) {
|
||||
const from = new Date(`${custom.from}T00:00:00`);
|
||||
if (Number.isFinite(from.getTime())) bounds.activitySince = from.toISOString();
|
||||
}
|
||||
if (custom?.to) {
|
||||
const to = new Date(`${custom.to}T23:59:59.999`);
|
||||
if (Number.isFinite(to.getTime())) bounds.activityUntil = to.toISOString();
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grouping / sorting / filtering (PAP-13408 — Inbox-style toolbar)
|
||||
//
|
||||
|
|
|
|||
|
|
@ -395,6 +395,13 @@ export const queryKeys = {
|
|||
forTargetIssue: (companyId: string, issueId: string) =>
|
||||
["decisions", companyId, "target", issueId] as const,
|
||||
},
|
||||
decisionQueues: {
|
||||
list: (companyId: string) => ["decision-queues", companyId] as const,
|
||||
items: (companyId: string, key: string) => ["decision-queues", companyId, "items", key] as const,
|
||||
seedRules: (companyId: string) => ["decision-queues", companyId, "seed-rules"] as const,
|
||||
triage: (companyId: string, sourceKind: string, sourceId: string) =>
|
||||
["decision-triage", companyId, sourceKind, sourceId] as const,
|
||||
},
|
||||
workTimeline: (companyId: string, lens?: string) => ["work-timeline", companyId, lens ?? "all"] as const,
|
||||
userProfile: (companyId: string, userSlug: string) =>
|
||||
["user-profile", companyId, userSlug] as const,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Settings2, X } from "lucide-react";
|
||||
import type { Agent, AttentionItem } from "@paperclipai/shared";
|
||||
import { useParams } from "@/lib/router";
|
||||
import { attentionApi } from "../api/attention";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { decisionQueuesApi } from "../api/decisionQueues";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useInboxDismissals } from "../hooks/useInboxBadge";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AttentionQueueRow } from "../components/AttentionQueueRow";
|
||||
import { DecisionQueueRail } from "../components/DecisionQueueRail";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
|
||||
/**
|
||||
* Queue page (PAP-16032 §4.1 / wireframe screen 2). A homogeneous list of one
|
||||
* queue's pending decisions with per-item resolution, exclusion (with an
|
||||
* optional reason), and the queue's seed-rules card with an enable/disable
|
||||
* toggle. The list reuses the desk's card so a decision looks and resolves the
|
||||
* same wherever it is surfaced.
|
||||
*/
|
||||
export function DecisionQueuePage() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToastActions();
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams<{ key: string }>();
|
||||
const queueKey = params.key ?? "";
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const { dismiss, snooze } = useInboxDismissals(selectedCompanyId);
|
||||
|
||||
const { data: queues } = useQuery({
|
||||
queryKey: queryKeys.decisionQueues.list(selectedCompanyId!),
|
||||
queryFn: () => decisionQueuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const queue = useMemo(() => queues?.find((q) => q.key === queueKey) ?? null, [queues, queueKey]);
|
||||
|
||||
const {
|
||||
data: feed,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: [...queryKeys.attention(selectedCompanyId!), "queue", queueKey],
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { queue: queueKey, all: true }),
|
||||
enabled: !!selectedCompanyId && !!queueKey,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
const agentMap = useMemo(() => {
|
||||
const map = new Map<string, Agent>();
|
||||
for (const agent of agents ?? []) map.set(agent.id, agent);
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Decisions", href: "/decisions" }, { label: queue?.title ?? queueKey }]);
|
||||
}, [setBreadcrumbs, queue?.title, queueKey]);
|
||||
|
||||
const items = useMemo(
|
||||
() => (feed?.items ?? []).filter((item) => !(item.dismissal?.isActive ?? false)),
|
||||
[feed],
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(selectedCompanyId!) });
|
||||
};
|
||||
|
||||
const toggleSeedRules = useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
decisionQueuesApi.update(selectedCompanyId!, queueKey, { seedRulesEnabled: enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(selectedCompanyId!) }),
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not update seeding",
|
||||
body: err instanceof Error ? err.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="approvals" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-bold">{queue?.title ?? queueKey}</h1>
|
||||
{queue?.description && <p className="mt-0.5 text-sm text-muted-foreground">{queue.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DecisionQueueRail companyId={selectedCompanyId} activeQueueKey={queueKey} />
|
||||
|
||||
{queue && queue.seedRules.length > 0 && (
|
||||
<SeedRulesCard
|
||||
enabled={queue.seedRulesEnabled}
|
||||
rules={queue.seedRules.map((rule) => rule.description)}
|
||||
pending={toggleSeedRules.isPending}
|
||||
onToggle={() => toggleSeedRules.mutate(!queue.seedRulesEnabled)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border py-14 text-center">
|
||||
<p className="text-sm font-medium text-foreground">This queue is empty.</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Decisions land here when they match the queue's rules or an agent adds them.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<QueueItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
queueKey={queueKey}
|
||||
agentMap={agentMap}
|
||||
agents={agents}
|
||||
currentUserId={currentUserId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={(next) => setExpandedId((prev) => (prev === next.id ? null : next.id))}
|
||||
onDismiss={(next) => dismiss(next.dismissalKey)}
|
||||
onSnooze={(next, until) => snooze(next.dismissalKey, until)}
|
||||
onExcluded={invalidate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedRulesCard({
|
||||
enabled,
|
||||
rules,
|
||||
pending,
|
||||
onToggle,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
rules: string[];
|
||||
pending: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<Settings2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Auto-seeding {enabled ? "on" : "off"}</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{rules.map((rule) => (
|
||||
<li key={rule} className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
||||
<Check className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 shrink-0" disabled={pending} onClick={onToggle}>
|
||||
{pending && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueItemRow({
|
||||
item,
|
||||
companyId,
|
||||
queueKey,
|
||||
agentMap,
|
||||
agents,
|
||||
currentUserId,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onDismiss,
|
||||
onSnooze,
|
||||
onExcluded,
|
||||
}: {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
queueKey: string;
|
||||
agentMap: Map<string, Agent>;
|
||||
agents: Agent[] | undefined;
|
||||
currentUserId: string | null;
|
||||
expanded: boolean;
|
||||
onToggleExpand: (item: AttentionItem) => void;
|
||||
onDismiss: (item: AttentionItem) => void;
|
||||
onSnooze: (item: AttentionItem, snoozedUntil: string) => void;
|
||||
onExcluded: () => void;
|
||||
}) {
|
||||
const { pushToast } = useToastActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const exclude = useMutation({
|
||||
mutationFn: async () => {
|
||||
await decisionQueuesApi.removeItem(
|
||||
companyId,
|
||||
queueKey,
|
||||
item.sourceKind,
|
||||
item.subject.id,
|
||||
reason.trim() || undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
pushToast({ title: "Removed from queue", body: item.subject.title ?? undefined, tone: "info" });
|
||||
onExcluded();
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not exclude",
|
||||
body: err instanceof Error ? err.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-end px-1">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="xs" className="h-7 gap-1 text-muted-foreground">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Exclude
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 space-y-2 p-3">
|
||||
<p className="text-xs font-medium text-foreground">Remove from this queue</p>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="Reason (optional)…"
|
||||
className="min-h-16 w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className={cn(exclude.isPending && "opacity-80")}
|
||||
disabled={exclude.isPending}
|
||||
onClick={() => exclude.mutate()}
|
||||
>
|
||||
{exclude.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Exclude
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<AttentionQueueRow
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
expanded={expanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onDismiss={onDismiss}
|
||||
onSnooze={onSnooze}
|
||||
agentMap={agentMap}
|
||||
agents={agents}
|
||||
showTriage
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpDown, Check, CheckCircle2, GraduationCap, Inbox, Layers, ListFilter } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowUpDown, Check, CheckCircle2, GraduationCap, Inbox, Layers, ListFilter, Loader2, Sun } from "lucide-react";
|
||||
import type { Agent, AttentionItem, AttentionSubject } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { attentionApi } from "../api/attention";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
|
|
@ -13,8 +13,12 @@ import { useToastActions } from "../context/ToastContext";
|
|||
import { useInboxDismissals } from "../hooks/useInboxBadge";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
ATTENTION_AGING_DAYS,
|
||||
ATTENTION_GROUP_BY_OPTIONS,
|
||||
ATTENTION_SORT_OPTIONS,
|
||||
attentionDecideOrder,
|
||||
attentionIdleDays,
|
||||
attentionIsAging,
|
||||
buildAttentionFilterOptions,
|
||||
countActiveAttentionFilters,
|
||||
defaultAttentionFilterState,
|
||||
|
|
@ -26,22 +30,29 @@ import {
|
|||
loadAttentionSortOrder,
|
||||
loadCollapsedAttentionGroupKeys,
|
||||
NO_GROUP_SENTINEL,
|
||||
partitionDecideNow,
|
||||
planAttentionRenderRows,
|
||||
resolveAttentionDateRange,
|
||||
saveAttentionFilters,
|
||||
saveAttentionGroupBy,
|
||||
saveAttentionSortOrder,
|
||||
saveCollapsedAttentionGroupKeys,
|
||||
sortAttentionItems,
|
||||
sourceMeta,
|
||||
type AttentionDateRangeId,
|
||||
type AttentionFilterState,
|
||||
type AttentionGroup,
|
||||
type AttentionGroupBy,
|
||||
type AttentionSortOrder,
|
||||
} from "../lib/attention";
|
||||
import { decisionQueuesApi } from "../api/decisionQueues";
|
||||
import { decisionTrainingHref } from "../lib/decisionTraining";
|
||||
import { cn } from "../lib/utils";
|
||||
import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AttentionQueueRow } from "../components/AttentionQueueRow";
|
||||
import { DecisionQueueRail } from "../components/DecisionQueueRail";
|
||||
import { DecisionDateChips, type AttentionCustomRange } from "../components/DecisionDateChips";
|
||||
import { DecisionResolver } from "../components/DecisionResolver";
|
||||
import { DecisionTrainingDrawer } from "../components/DecisionTrainingDrawer";
|
||||
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
||||
|
|
@ -116,9 +127,19 @@ export function WhatNeedsMe() {
|
|||
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => new Set());
|
||||
const [snoozedOpen, setSnoozedOpen] = useState(false);
|
||||
const [dismissedOpen, setDismissedOpen] = useState(false);
|
||||
const [agingOpen, setAgingOpen] = useState(false);
|
||||
const [decidedOpen, setDecidedOpen] = useState(false);
|
||||
const [expiredOpen, setExpiredOpen] = useState(false);
|
||||
|
||||
// Date-range chips (PAP-16032 §4.2) — resolve to server-side activity bounds.
|
||||
const [dateRange, setDateRange] = useState<AttentionDateRangeId>("all");
|
||||
const [customRange, setCustomRange] = useState<AttentionCustomRange>({ from: null, to: null });
|
||||
|
||||
// `?decisionId=` deep link (PAP-16032 §4.7) — focus/expand the referenced card.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkDecisionId = searchParams.get("decisionId");
|
||||
const [deepLinkConsumed, setDeepLinkConsumed] = useState(false);
|
||||
|
||||
// Optimistic hide/restore. Reset whenever a fresh feed lands (server truth).
|
||||
const [pendingHide, setPendingHide] = useState<Set<string>>(() => new Set());
|
||||
const [pendingRestore, setPendingRestore] = useState<Set<string>>(() => new Set());
|
||||
|
|
@ -126,6 +147,15 @@ export function WhatNeedsMe() {
|
|||
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
|
||||
const { pushToast } = useToastActions();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Date chips resolve to server-side activity bounds. Anchored to start-of-day,
|
||||
// so the resolved ISO strings are stable across renders within the same day —
|
||||
// safe to key the feed query on without thrashing.
|
||||
const activityBounds = useMemo(
|
||||
() => resolveAttentionDateRange(dateRange, Date.now(), customRange),
|
||||
[dateRange, customRange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Decisions" }]);
|
||||
|
|
@ -145,8 +175,13 @@ export function WhatNeedsMe() {
|
|||
// Distinct from the sidebar badge's `queryKeys.attention` so dismissed rows
|
||||
// (needed for the curtains) never inflate the badge count. Invalidating the
|
||||
// `["attention", companyId]` prefix still cascades to this query.
|
||||
queryKey: [...queryKeys.attention(selectedCompanyId!), "with-dismissed"],
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true }),
|
||||
queryKey: [
|
||||
...queryKeys.attention(selectedCompanyId!),
|
||||
"with-dismissed",
|
||||
activityBounds.activitySince ?? null,
|
||||
activityBounds.activityUntil ?? null,
|
||||
],
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true, ...activityBounds }),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
|
@ -199,6 +234,19 @@ export function WhatNeedsMe() {
|
|||
),
|
||||
[allItems, pendingHide, pendingRestore],
|
||||
);
|
||||
|
||||
// The server's clock at feed time — used for the decide-by split and the aging
|
||||
// idle labels so they match `decideNowCount` and the sidebar badge exactly,
|
||||
// and stay stable across renders (Date.now() only as a pre-load fallback).
|
||||
const now = useMemo(
|
||||
() => (feed?.generatedAt ? new Date(feed.generatedAt).getTime() : Date.now()),
|
||||
[feed?.generatedAt],
|
||||
);
|
||||
|
||||
// Aging shelf (§4.4): items the server flags as idle past retention leave the
|
||||
// live desk for their own curtain, so today's desk shows only fresh decisions.
|
||||
const agingItems = useMemo(() => activeItems.filter(attentionIsAging), [activeItems]);
|
||||
const deskItems = useMemo(() => activeItems.filter((item) => !attentionIsAging(item)), [activeItems]);
|
||||
const snoozedItems = useMemo(
|
||||
() =>
|
||||
allItems.filter(
|
||||
|
|
@ -216,14 +264,37 @@ export function WhatNeedsMe() {
|
|||
[allItems, pendingRestore],
|
||||
);
|
||||
|
||||
const filterOptions = useMemo(() => buildAttentionFilterOptions(activeItems), [activeItems]);
|
||||
const filterOptions = useMemo(() => buildAttentionFilterOptions(deskItems), [deskItems]);
|
||||
|
||||
// Filter → sort → group, all client-side so switching re-buckets without a refetch.
|
||||
const groups = useMemo(() => {
|
||||
const filtered = filterAttentionItems(activeItems, filters);
|
||||
// Filter → sort → group, all client-side so switching re-buckets without a
|
||||
// refetch. In the default (ungrouped) view the desk splits into the two §4.3
|
||||
// shelves — "Decide now" (due today / overdue) and "Can wait" — ordered by
|
||||
// decide-by; any explicit group-by keeps the Inbox-style activity grouping.
|
||||
const groups = useMemo<AttentionGroup[]>(() => {
|
||||
const filtered = filterAttentionItems(deskItems, filters);
|
||||
if (groupBy === "none") {
|
||||
const ordered = [...filtered].sort((a, b) => {
|
||||
const [aBucket, aDeadline] = attentionDecideOrder(a, now);
|
||||
const [bBucket, bDeadline] = attentionDecideOrder(b, now);
|
||||
if (aBucket !== bBucket) return aBucket - bBucket;
|
||||
if (aDeadline !== bDeadline) return aDeadline - bDeadline;
|
||||
return a.rank - b.rank;
|
||||
});
|
||||
const { decideNow, canWait } = partitionDecideNow(ordered, now);
|
||||
const shelves: AttentionGroup[] = [];
|
||||
if (decideNow.length > 0) shelves.push({ key: "desk:decide-now", label: "Decide now", items: decideNow });
|
||||
if (canWait.length > 0) shelves.push({ key: "desk:can-wait", label: "Can wait", items: canWait });
|
||||
return shelves;
|
||||
}
|
||||
const sorted = sortAttentionItems(filtered, sortOrder);
|
||||
return groupAttentionItems(sorted, groupBy);
|
||||
}, [activeItems, filters, sortOrder, groupBy]);
|
||||
}, [deskItems, filters, sortOrder, groupBy, now]);
|
||||
|
||||
// "Today is clear" — the desk has decisions but none are due today.
|
||||
const deskClearToday = useMemo(
|
||||
() => groupBy === "none" && deskItems.length > 0 && !groups.some((group) => group.key === "desk:decide-now"),
|
||||
[groupBy, deskItems, groups],
|
||||
);
|
||||
|
||||
const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]);
|
||||
const keyboardItems = useMemo(
|
||||
|
|
@ -317,14 +388,39 @@ export function WhatNeedsMe() {
|
|||
document.getElementById(`attention-row-${selectedAttentionId}`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedAttentionId]);
|
||||
|
||||
// `?decisionId=` deep link (§4.7): focus and expand the referenced decision
|
||||
// card once the feed lands, then drop the param so a later manual collapse is
|
||||
// not re-forced on the next refetch. Wins over the generic auto-expand below.
|
||||
useEffect(() => {
|
||||
if (deepLinkConsumed || !deepLinkDecisionId || allItems.length === 0) return;
|
||||
const target = allItems.find(
|
||||
(item) => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId,
|
||||
);
|
||||
setDeepLinkConsumed(true);
|
||||
setAutoExpandDone(true);
|
||||
if (target) {
|
||||
setExpandedId(target.id);
|
||||
setSelectedAttentionId(target.id);
|
||||
setSelectionFromKeyboard(true);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("decisionId");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [allItems, deepLinkConsumed, deepLinkDecisionId, setSearchParams]);
|
||||
|
||||
// Auto-expand the topmost inline-capable decision, once.
|
||||
useEffect(() => {
|
||||
if (autoExpandDone || activeItems.length === 0) return;
|
||||
const sorted = sortAttentionItems(activeItems, sortOrder);
|
||||
if (autoExpandDone || deskItems.length === 0) return;
|
||||
const sorted = sortAttentionItems(deskItems, sortOrder);
|
||||
const topInline = sorted.find((item) => isInlineResolvable(item));
|
||||
if (topInline) setExpandedId(topInline.id);
|
||||
setAutoExpandDone(true);
|
||||
}, [activeItems, autoExpandDone, sortOrder]);
|
||||
}, [deskItems, autoExpandDone, sortOrder]);
|
||||
|
||||
const updateGroupBy = (next: AttentionGroupBy) => {
|
||||
setGroupBy(next);
|
||||
|
|
@ -572,6 +668,20 @@ export function WhatNeedsMe() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue quicklinks + date-range chips (§4.1–§4.2). The rail self-hides
|
||||
when the company has no queues; the chips filter the desk server-side. */}
|
||||
<div className="space-y-2">
|
||||
<DecisionQueueRail companyId={selectedCompanyId} activeQueueKey={null} />
|
||||
<DecisionDateChips
|
||||
value={dateRange}
|
||||
custom={customRange}
|
||||
onChange={(value, custom) => {
|
||||
setDateRange(value);
|
||||
setCustomRange(custom);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
||||
|
||||
{!hasAnything ? (
|
||||
|
|
@ -579,9 +689,11 @@ export function WhatNeedsMe() {
|
|||
) : (
|
||||
<div className="space-y-4">
|
||||
{visibleCount === 0 ? (
|
||||
<CaughtUpNote filtered={activeItems.length > 0} />
|
||||
<CaughtUpNote filtered={deskItems.length > 0} />
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
<>
|
||||
{deskClearToday && <TodayClearNote />}
|
||||
{groups.map((group) => {
|
||||
const groupLabel = group.label;
|
||||
const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key);
|
||||
return (
|
||||
|
|
@ -638,6 +750,8 @@ export function WhatNeedsMe() {
|
|||
onSnooze={handleSnooze}
|
||||
onTrain={handleTrain}
|
||||
agentMap={agentMap}
|
||||
agents={agents}
|
||||
showTriage
|
||||
currentUserId={currentUserId}
|
||||
selected={selectionFromKeyboard && selectedAttentionId === item.id}
|
||||
/>
|
||||
|
|
@ -650,7 +764,8 @@ export function WhatNeedsMe() {
|
|||
)}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{snoozedItems.length > 0 && (
|
||||
|
|
@ -701,6 +816,35 @@ export function WhatNeedsMe() {
|
|||
</Curtain>
|
||||
)}
|
||||
|
||||
{agingItems.length > 0 && (
|
||||
<Curtain
|
||||
label="Aging"
|
||||
count={agingItems.length}
|
||||
open={agingOpen}
|
||||
onToggle={() => setAgingOpen((prev) => !prev)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Idle past {ATTENTION_AGING_DAYS} days — kept off the desk. Keep any you still want surfaced.
|
||||
</p>
|
||||
{agingItems.map((item) => (
|
||||
<AgingItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
now={now}
|
||||
agentMap={agentMap}
|
||||
agents={agents}
|
||||
currentUserId={currentUserId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
onTrain={handleTrain}
|
||||
/>
|
||||
))}
|
||||
</Curtain>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -964,6 +1108,106 @@ function Curtain({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim banner shown at the top of the desk when there are decisions but none
|
||||
* are due today — the "Decide now" shelf is empty, so we say so rather than
|
||||
* leading with a bare "Can wait" header (§4.3 "empty state when today is clear").
|
||||
*/
|
||||
function TodayClearNote() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3">
|
||||
<Sun className="h-4 w-4 shrink-0 text-green-500" />
|
||||
<p className="text-sm text-foreground">
|
||||
Nothing needs a decision <span className="font-medium">today</span>. Everything below can wait.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An aging-shelf row (§4.4): the standard card, prefaced by an idle-duration
|
||||
* label and a "Keep on desk" affordance that clears the shelf flag server-side
|
||||
* (P1 retention `keep`). Archival/sweeper mechanics are P5 — this is the split
|
||||
* plus the Keep stub only.
|
||||
*/
|
||||
function AgingItemRow({
|
||||
item,
|
||||
companyId,
|
||||
now,
|
||||
agentMap,
|
||||
agents,
|
||||
currentUserId,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onDismiss,
|
||||
onSnooze,
|
||||
onTrain,
|
||||
}: {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
now: number;
|
||||
agentMap: Map<string, Agent>;
|
||||
agents: Agent[] | undefined;
|
||||
currentUserId: string | null;
|
||||
expanded: boolean;
|
||||
onToggleExpand: (item: AttentionItem) => void;
|
||||
onDismiss: (item: AttentionItem) => void;
|
||||
onSnooze: (item: AttentionItem, snoozedUntil: string) => void;
|
||||
onTrain: (item: AttentionItem) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const idleDays = attentionIdleDays(item, now);
|
||||
const keep = useMutation({
|
||||
mutationFn: () => decisionQueuesApi.setKeep(companyId, item.sourceKind, item.subject.id, true),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
pushToast({ title: "Kept on desk", body: item.subject.title ?? undefined, tone: "success" });
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Could not keep this decision",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-(length:--text-nano) text-muted-foreground">
|
||||
Idle {idleDays} {idleDays === 1 ? "day" : "days"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1"
|
||||
disabled={keep.isPending || item.keep}
|
||||
onClick={() => keep.mutate()}
|
||||
>
|
||||
{keep.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
{item.keep ? "Kept" : "Keep on desk"}
|
||||
</Button>
|
||||
</div>
|
||||
<AttentionQueueRow
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
expanded={expanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onDismiss={onDismiss}
|
||||
onSnooze={onSnooze}
|
||||
onTrain={onTrain}
|
||||
agentMap={agentMap}
|
||||
agents={agents}
|
||||
showTriage
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaughtUpNote({ filtered }: { filtered: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border py-10 text-center">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,470 @@
|
|||
import { useMemo, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
Agent,
|
||||
AttentionFeed,
|
||||
AttentionItem,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
} from "@paperclipai/shared";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { WhatNeedsMe } from "@/pages/WhatNeedsMe";
|
||||
import { DecisionQueuePage } from "@/pages/DecisionQueuePage";
|
||||
import { AttentionQueueRow } from "@/components/AttentionQueueRow";
|
||||
import type { DecisionQueueDto } from "@/api/decisionQueues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
// The company id the Storybook providers select (see .storybook/preview.tsx).
|
||||
const companyId = "company-storybook";
|
||||
|
||||
// A fixed clock so the decide-by split ("today" vs "this week") and the aging
|
||||
// "idle N days" labels are deterministic across renders/screenshots.
|
||||
const NOW = Date.parse("2026-08-02T15:00:00Z");
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
const iso = (ms: number) => new Date(ms).toISOString();
|
||||
|
||||
// --- fixture builders -------------------------------------------------------
|
||||
|
||||
/** The board operator, so `route to agent` mentions have someone to hand to. */
|
||||
const AGENTS: Agent[] = [
|
||||
{ id: "agent-prioritizer", name: "Prioritizer", status: "idle" },
|
||||
{ id: "agent-coder", name: "ClaudeCoder", status: "working" },
|
||||
{ id: "agent-qa", name: "QA", status: "idle" },
|
||||
] as unknown as Agent[];
|
||||
|
||||
function attribution(agentName: string) {
|
||||
return {
|
||||
type: "agent" as const,
|
||||
agentId: "agent-prioritizer",
|
||||
agentName,
|
||||
userId: null,
|
||||
runId: "run-prio-1",
|
||||
responsibleUserId: null,
|
||||
updatedAt: iso(NOW - 2 * HOUR),
|
||||
};
|
||||
}
|
||||
|
||||
function item(
|
||||
id: string,
|
||||
sourceKind: AttentionSourceKind,
|
||||
severity: AttentionSeverity,
|
||||
title: string,
|
||||
whyNow: string,
|
||||
overrides: Partial<AttentionItem> = {},
|
||||
): AttentionItem {
|
||||
const base: AttentionItem = {
|
||||
id,
|
||||
companyId,
|
||||
sourceKind,
|
||||
subject: {
|
||||
kind: "issue",
|
||||
id: `${id}-subject`,
|
||||
companyId,
|
||||
title,
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: "/PAP/issues/PAP-1000",
|
||||
metadata: {},
|
||||
},
|
||||
whyNow,
|
||||
decisionVerbs: [
|
||||
{ id: "approve", label: "Approve", description: null },
|
||||
{ id: "reject", label: "Reject", description: null },
|
||||
],
|
||||
inlineResolvable: false,
|
||||
entryRule: "",
|
||||
exitRule: "",
|
||||
dedupKey: `${id}-dedup`,
|
||||
dismissalKey: `attention:${id}-dedup`,
|
||||
dismissal: null,
|
||||
severity,
|
||||
rank: 0,
|
||||
activityAt: iso(NOW - HOUR),
|
||||
createdAt: iso(NOW - 3 * DAY),
|
||||
updatedAt: iso(NOW - HOUR),
|
||||
relatedIssue: {
|
||||
kind: "issue",
|
||||
id: "issue-1000",
|
||||
companyId,
|
||||
title: "Ship the decisions desk",
|
||||
identifier: "PAP-1000",
|
||||
status: "in_progress",
|
||||
href: "/PAP/issues/PAP-1000",
|
||||
metadata: {},
|
||||
},
|
||||
project: null,
|
||||
workspace: null,
|
||||
expiresAt: null,
|
||||
ruleKey: null,
|
||||
originAgentName: null,
|
||||
queues: [],
|
||||
shelf: false,
|
||||
retentionDays: 30,
|
||||
keep: false,
|
||||
archivedAt: null,
|
||||
retentionVersion: 1,
|
||||
decideBy: null,
|
||||
decideByAttribution: null,
|
||||
snoozedUntil: null,
|
||||
detail: null,
|
||||
trainingExampleId: null,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
/** Approval-shaped item (symmetric accept/reject → bulk-eligible on a queue). */
|
||||
function approval(id: string, title: string, whyNow: string, overrides: Partial<AttentionItem> = {}) {
|
||||
return item(id, "approval", "high", title, whyNow, {
|
||||
inlineResolvable: true,
|
||||
subject: {
|
||||
kind: "approval",
|
||||
id: `${id}-approval`,
|
||||
companyId,
|
||||
title,
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: `/PAP/approvals/${id}`,
|
||||
metadata: { type: "merge_pr" },
|
||||
},
|
||||
decisionVerbs: [
|
||||
{ id: "approve", label: "Approve", description: null },
|
||||
{ id: "reject", label: "Reject", description: null },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function feed(items: AttentionItem[]): AttentionFeed {
|
||||
const decideNowCount = items.filter(
|
||||
(it) => it.decideBy === "today" && !it.shelf,
|
||||
).length;
|
||||
return {
|
||||
companyId,
|
||||
generatedAt: iso(NOW),
|
||||
totalCount: items.length,
|
||||
decideNowCount,
|
||||
nextCursor: null,
|
||||
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
const PRS_QUEUE: DecisionQueueDto = {
|
||||
id: "queue-prs",
|
||||
companyId,
|
||||
key: "prs",
|
||||
title: "PRs to review",
|
||||
description: "Pull requests waiting on a merge decision.",
|
||||
createdByType: "agent",
|
||||
createdByAgentId: "agent-prioritizer",
|
||||
createdByUserId: null,
|
||||
createdByRunId: "run-1",
|
||||
retentionDays: 30,
|
||||
seedRules: [
|
||||
{
|
||||
key: "pr-work-product",
|
||||
description: "Issues that reach in-review with a pull-request work product",
|
||||
signal: "issue_has_pull_request_work_product",
|
||||
},
|
||||
],
|
||||
seedRulesEnabled: true,
|
||||
itemCount: 4,
|
||||
createdAt: iso(NOW - 9 * DAY),
|
||||
updatedAt: iso(NOW - 20 * 60 * 1000),
|
||||
};
|
||||
|
||||
const QUEUES: DecisionQueueDto[] = [
|
||||
PRS_QUEUE,
|
||||
{
|
||||
...PRS_QUEUE,
|
||||
id: "queue-plans",
|
||||
key: "plan-approvals",
|
||||
title: "Plan approvals",
|
||||
description: "Plan documents awaiting board sign-off.",
|
||||
seedRules: [
|
||||
{
|
||||
key: "plan-confirmation",
|
||||
description: "Plan-document confirmations opened on an issue",
|
||||
signal: "plan_document_confirmation",
|
||||
},
|
||||
],
|
||||
itemCount: 2,
|
||||
updatedAt: iso(NOW - 3 * HOUR),
|
||||
},
|
||||
{
|
||||
...PRS_QUEUE,
|
||||
id: "queue-questions",
|
||||
key: "questions",
|
||||
title: "Questions",
|
||||
description: "Open questions from agents that need an answer.",
|
||||
seedRules: [
|
||||
{
|
||||
key: "ask-user-questions",
|
||||
description: "Agents asking the board a structured question",
|
||||
signal: "ask_user_questions",
|
||||
},
|
||||
],
|
||||
seedRulesEnabled: false,
|
||||
itemCount: 1,
|
||||
updatedAt: iso(NOW - 30 * HOUR),
|
||||
},
|
||||
];
|
||||
|
||||
const PRS_QUEUE_REF = { key: "prs", title: "PRs to review" };
|
||||
|
||||
// --- desk items -------------------------------------------------------------
|
||||
|
||||
const DESK_ITEMS: AttentionItem[] = [
|
||||
approval("pr-1", "Merge PR #10664: decisions desk data layer", "PR passed CI and is waiting on a merge decision.", {
|
||||
decideBy: "today",
|
||||
decideByAttribution: attribution("Prioritizer"),
|
||||
queues: [PRS_QUEUE_REF],
|
||||
expiresAt: iso(NOW + 5 * HOUR),
|
||||
activityAt: iso(NOW - 40 * 60 * 1000),
|
||||
project: { id: "proj-desk", name: "Decisions", urlKey: "decisions", color: "#7c3aed", icon: "layers" },
|
||||
}),
|
||||
item(
|
||||
"conf-1",
|
||||
"issue_thread_interaction",
|
||||
"high",
|
||||
"Approve plan: heartbeat.ts split",
|
||||
"A plan is awaiting your approval before children are created.",
|
||||
{
|
||||
inlineResolvable: true,
|
||||
decideBy: "today",
|
||||
decideByAttribution: attribution("Prioritizer"),
|
||||
queues: [{ key: "plan-approvals", title: "Plan approvals" }],
|
||||
subject: {
|
||||
kind: "interaction",
|
||||
id: "intx-plan",
|
||||
companyId,
|
||||
title: "Approve plan: heartbeat.ts split",
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: "/PAP/issues/PAP-1000#plan",
|
||||
metadata: { kind: "request_confirmation", issueId: "issue-1000" },
|
||||
},
|
||||
decisionVerbs: [
|
||||
{ id: "approve", label: "Approve plan", description: null },
|
||||
{ id: "request_changes", label: "Request changes", description: null },
|
||||
],
|
||||
activityAt: iso(NOW - 90 * 60 * 1000),
|
||||
detail: { kind: "plan_approval", issueTitle: "heartbeat split", planTitle: "Coverage-first, 5 phases", summaryExcerpt: null, images: [] },
|
||||
},
|
||||
),
|
||||
approval("pr-2", "Merge PR #10651: prioritized attention feed", "Second review approved; ready to merge.", {
|
||||
decideBy: "this_week",
|
||||
decideByAttribution: attribution("Prioritizer"),
|
||||
queues: [PRS_QUEUE_REF],
|
||||
activityAt: iso(NOW - 5 * HOUR),
|
||||
}),
|
||||
item(
|
||||
"review-1",
|
||||
"review",
|
||||
"medium",
|
||||
"Design review: settings redesign",
|
||||
"In-review issue is waiting on a human reviewer.",
|
||||
{
|
||||
decideBy: "whenever",
|
||||
activityAt: iso(NOW - 26 * HOUR),
|
||||
project: { id: "proj-beta", name: "Beta", urlKey: "beta", color: "#0f766e", icon: "rocket" },
|
||||
},
|
||||
),
|
||||
item(
|
||||
"budget-1",
|
||||
"budget_alert",
|
||||
"low",
|
||||
"Company budget crossed 85%",
|
||||
"Budget crossed the 85% threshold.",
|
||||
{ relatedIssue: null, inlineResolvable: false, activityAt: iso(NOW - 2 * DAY) },
|
||||
),
|
||||
];
|
||||
|
||||
// The desk when nothing is due today — every item is deferred ("whenever"), so
|
||||
// the "Decide now" shelf is empty and the desk shows the "today is clear" note.
|
||||
const CLEAR_TODAY_ITEMS: AttentionItem[] = DESK_ITEMS.filter((it) => it.decideBy !== "today").map((it) => ({
|
||||
...it,
|
||||
decideBy: "whenever",
|
||||
decideByAttribution: null,
|
||||
}));
|
||||
|
||||
// Aging shelf: items idle past the 30-day retention leave the desk. The server
|
||||
// sets `shelf: true`; here we mirror that plus a couple of live desk items so
|
||||
// the shelf renders alongside a populated desk.
|
||||
const AGING_ITEMS: AttentionItem[] = [
|
||||
...DESK_ITEMS.slice(0, 2),
|
||||
item("aging-1", "review", "low", "Old PR review: legacy exporter", "No activity in over a month.", {
|
||||
shelf: true,
|
||||
decideBy: null,
|
||||
activityAt: iso(NOW - 47 * DAY),
|
||||
relatedIssue: {
|
||||
kind: "issue",
|
||||
id: "issue-legacy",
|
||||
companyId,
|
||||
title: "Legacy exporter cleanup",
|
||||
identifier: "PAP-812",
|
||||
status: "backlog",
|
||||
href: "/PAP/issues/PAP-812",
|
||||
metadata: {},
|
||||
},
|
||||
}),
|
||||
item("aging-2", "budget_alert", "low", "Budget alert from last quarter", "Idle 63 days.", {
|
||||
shelf: true,
|
||||
decideBy: null,
|
||||
relatedIssue: null,
|
||||
inlineResolvable: false,
|
||||
activityAt: iso(NOW - 63 * DAY),
|
||||
}),
|
||||
];
|
||||
|
||||
// A single fully-triaged card, to show the per-card triage strip (wireframe
|
||||
// screen 3) with every control populated: decide-by set + attribution, queue
|
||||
// membership chip, an expiry countdown and the route-to-agent picker.
|
||||
const TRIAGE_ITEM = approval(
|
||||
"triage-1",
|
||||
"Merge PR #10664: decisions desk data layer",
|
||||
"PR passed CI and is waiting on a merge decision.",
|
||||
{
|
||||
decideBy: "today",
|
||||
decideByAttribution: attribution("Prioritizer"),
|
||||
queues: [PRS_QUEUE_REF, { key: "plan-approvals", title: "Plan approvals" }],
|
||||
expiresAt: iso(NOW + 4 * HOUR),
|
||||
project: { id: "proj-desk", name: "Decisions", urlKey: "decisions", color: "#7c3aed", icon: "layers" },
|
||||
},
|
||||
);
|
||||
|
||||
// --- seeding wrapper --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Seed the react-query cache the desk/queue pages read from. The Storybook
|
||||
* QueryClient uses `staleTime: Infinity` + `retry: false`, so seeded queries
|
||||
* render immediately and never hit the network — the pages run their real code
|
||||
* paths against fixture data.
|
||||
*/
|
||||
function PrimeDeskFixtures({
|
||||
items,
|
||||
queueItems,
|
||||
children,
|
||||
}: {
|
||||
items?: AttentionItem[];
|
||||
queueItems?: AttentionItem[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
useMemo(() => {
|
||||
queryClient.setQueryData(queryKeys.decisionQueues.list(companyId), QUEUES);
|
||||
queryClient.setQueryData(queryKeys.agents.list(companyId), AGENTS);
|
||||
if (items) {
|
||||
queryClient.setQueryData(
|
||||
[...queryKeys.attention(companyId), "with-dismissed", null, null],
|
||||
feed(items),
|
||||
);
|
||||
}
|
||||
if (queueItems) {
|
||||
queryClient.setQueryData(
|
||||
[...queryKeys.attention(companyId), "queue", "prs"],
|
||||
feed(queueItems),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryClient]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Pages/Decisions Desk",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
/**
|
||||
* Screen 1 — today's desk. Queue rail + date chips on top, then the desk split
|
||||
* into "Decide now" (due today) and "Can wait", with decide-by + provenance
|
||||
* ("set by Prioritizer") on the cards.
|
||||
*/
|
||||
export const TodaysDesk: Story = {
|
||||
render: () => (
|
||||
<PrimeDeskFixtures items={DESK_ITEMS}>
|
||||
<div className="p-6">
|
||||
<WhatNeedsMe />
|
||||
</div>
|
||||
</PrimeDeskFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
/** Screen 1 variant — the desk when today is clear (nothing due today). */
|
||||
export const DeskClearToday: Story = {
|
||||
render: () => (
|
||||
<PrimeDeskFixtures items={CLEAR_TODAY_ITEMS}>
|
||||
<div className="p-6">
|
||||
<WhatNeedsMe />
|
||||
</div>
|
||||
</PrimeDeskFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
/** Screen 1 variant — the aging shelf; the "Aging" curtain holds idle items. */
|
||||
export const AgingShelf: Story = {
|
||||
render: () => (
|
||||
<PrimeDeskFixtures items={AGING_ITEMS}>
|
||||
<div className="p-6">
|
||||
<WhatNeedsMe />
|
||||
</div>
|
||||
</PrimeDeskFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen 2 — a queue page. Homogeneous list where each source-native decision
|
||||
* resolves per item (approve/reject on the card, plus Exclude-with-reason);
|
||||
* the queue rail (active chip) and the seed-rules card with its enable/disable
|
||||
* toggle sit above. Bulk accept/reject was pulled until a cross-domain
|
||||
* exact-set transaction can preserve side effects atomically (see PAP-16032
|
||||
* follow-up); screen 2 ships per-item-only for now.
|
||||
*/
|
||||
export const QueuePage: Story = {
|
||||
render: () => (
|
||||
<PrimeDeskFixtures
|
||||
queueItems={[
|
||||
approval("q-pr-1", "Merge PR #10664: decisions desk data layer", "CI green; ready to merge.", { queues: [PRS_QUEUE_REF] }),
|
||||
approval("q-pr-2", "Merge PR #10651: prioritized attention feed", "Second review approved.", { queues: [PRS_QUEUE_REF] }),
|
||||
approval("q-pr-3", "Merge PR #10636: quieter workspace-ready", "Reviewed and passing.", { queues: [PRS_QUEUE_REF] }),
|
||||
approval("q-pr-4", "Merge PR #10623: inbox sort pinning", "Awaiting a merge decision.", { queues: [PRS_QUEUE_REF] }),
|
||||
]}
|
||||
>
|
||||
<div className="p-6">
|
||||
<Routes location="/decisions/queues/prs">
|
||||
<Route path="/decisions/queues/:key" element={<DecisionQueuePage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</PrimeDeskFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen 3 — the per-card triage strip. A single card, expanded, showing the
|
||||
* decide-by control (with "set by Prioritizer"), queue chips + picker, snooze
|
||||
* and route-to-agent, plus the read-only expiry countdown.
|
||||
*/
|
||||
export const CardTriageStrip: Story = {
|
||||
render: () => (
|
||||
<PrimeDeskFixtures>
|
||||
<div className="max-w-3xl space-y-2 p-6">
|
||||
<AttentionQueueRow
|
||||
item={TRIAGE_ITEM}
|
||||
companyId={companyId}
|
||||
agents={AGENTS}
|
||||
showTriage
|
||||
expanded
|
||||
onToggleExpand={() => {}}
|
||||
onDismiss={() => {}}
|
||||
onSnooze={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</PrimeDeskFixtures>
|
||||
),
|
||||
};
|
||||
|
|
@ -82,6 +82,11 @@ function item(
|
|||
ruleKey: overrides.ruleKey ?? null,
|
||||
originAgentName: overrides.originAgentName ?? null,
|
||||
queues: overrides.queues ?? [],
|
||||
shelf: overrides.shelf ?? false,
|
||||
retentionDays: overrides.retentionDays ?? 30,
|
||||
keep: overrides.keep ?? false,
|
||||
archivedAt: overrides.archivedAt ?? null,
|
||||
retentionVersion: overrides.retentionVersion ?? 1,
|
||||
decideBy: overrides.decideBy ?? null,
|
||||
decideByAttribution: overrides.decideByAttribution ?? null,
|
||||
snoozedUntil: overrides.snoozedUntil ?? null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue