mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Preserve local master attention recovery work
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4d50fa9f29
commit
63994d0454
39 files changed
+3552
-42
No files matched your search
@@ -298,6 +298,7 @@ export const ISSUE_RECOVERY_ACTION_KINDS = [
|
||||
"configuration_validation",
|
||||
"active_run_watchdog",
|
||||
"issue_graph_liveness",
|
||||
"dead_in_water",
|
||||
] as const;
|
||||
export type IssueRecoveryActionKind = (typeof ISSUE_RECOVERY_ACTION_KINDS)[number];
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ export {
|
||||
type ResponsibleUserDenialCopy,
|
||||
type ResponsibleUserDenialTone,
|
||||
} from "./responsible-user-denial.js";
|
||||
export type {
|
||||
AttentionDecisionVerb,
|
||||
AttentionFeed,
|
||||
AttentionItem,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
AttentionSubject,
|
||||
AttentionSubjectKind,
|
||||
} from "./types/attention.js";
|
||||
|
||||
export type {
|
||||
PipelineAutomationRetryBlocker,
|
||||
PipelineAutomationRetryCleanupOptions,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export type AttentionSourceKind =
|
||||
| "approval"
|
||||
| "issue_thread_interaction"
|
||||
| "join_request"
|
||||
| "recovery_action"
|
||||
| "productivity_review"
|
||||
| "blocker_attention"
|
||||
| "review"
|
||||
| "failed_run"
|
||||
| "budget_alert"
|
||||
| "agent_error_alert";
|
||||
|
||||
export type AttentionSubjectKind =
|
||||
| "approval"
|
||||
| "issue"
|
||||
| "interaction"
|
||||
| "join_request"
|
||||
| "recovery_action"
|
||||
| "run"
|
||||
| "budget_incident"
|
||||
| "agent";
|
||||
|
||||
export type AttentionSeverity = "critical" | "high" | "medium" | "low";
|
||||
|
||||
export interface AttentionSubject {
|
||||
kind: AttentionSubjectKind;
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string | null;
|
||||
identifier: string | null;
|
||||
status: string | null;
|
||||
href: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AttentionDecisionVerb {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
id: string;
|
||||
companyId: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
subject: AttentionSubject;
|
||||
whyNow: string;
|
||||
decisionVerbs: AttentionDecisionVerb[];
|
||||
inlineResolvable: boolean;
|
||||
entryRule: string;
|
||||
exitRule: string;
|
||||
dedupKey: string;
|
||||
dismissalKey: string;
|
||||
severity: AttentionSeverity;
|
||||
rank: number;
|
||||
activityAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
relatedIssue: AttentionSubject | null;
|
||||
}
|
||||
|
||||
export interface AttentionFeed {
|
||||
companyId: string;
|
||||
generatedAt: string;
|
||||
totalCount: number;
|
||||
countsBySourceKind: Record<AttentionSourceKind, number>;
|
||||
items: AttentionItem[];
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
export type { Company } from "./company.js";
|
||||
export type {
|
||||
AttentionDecisionVerb,
|
||||
AttentionFeed,
|
||||
AttentionItem,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
AttentionSubject,
|
||||
AttentionSubjectKind,
|
||||
} from "./attention.js";
|
||||
export type {
|
||||
Environment,
|
||||
EnvironmentDeleteBlastRadius,
|
||||
|
||||
@@ -419,6 +419,7 @@ export type IssueBlockedInboxReason =
|
||||
| "blocked_by_assigned_backlog_issue"
|
||||
| "blocked_by_uninvokable_assignee"
|
||||
| "blocked_by_cancelled_issue"
|
||||
| "dead_in_water"
|
||||
| "blocked_chain_stalled"
|
||||
| "invalid_review_participant"
|
||||
| "in_review_without_action_path"
|
||||
|
||||
@@ -47,6 +47,7 @@ export const issueBlockedInboxReasonSchema = z.enum([
|
||||
"blocked_by_assigned_backlog_issue",
|
||||
"blocked_by_uninvokable_assignee",
|
||||
"blocked_by_cancelled_issue",
|
||||
"dead_in_water",
|
||||
"blocked_chain_stalled",
|
||||
"invalid_review_participant",
|
||||
"in_review_without_action_path",
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
approvals,
|
||||
budgetIncidents,
|
||||
budgetPolicies,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
invites,
|
||||
issueApprovals,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
joinRequests,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { attentionRoutes } from "../routes/attention.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres attention service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("attention service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueApprovals);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(budgetIncidents);
|
||||
await db.delete(budgetPolicies);
|
||||
await db.delete(joinRequests);
|
||||
await db.delete(invites);
|
||||
await db.delete(issueRecoveryActions);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(approvals);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompany(prefix = "ATN") {
|
||||
const companyId = randomUUID();
|
||||
const workerId = randomUUID();
|
||||
const reviewerId = randomUUID();
|
||||
const errorAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: `${prefix} Co`,
|
||||
issuePrefix: prefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: workerId,
|
||||
companyId,
|
||||
name: "Worker",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: reviewerId,
|
||||
companyId,
|
||||
name: "Reviewer",
|
||||
role: "qa",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: errorAgentId,
|
||||
companyId,
|
||||
name: "Broken Agent",
|
||||
role: "engineer",
|
||||
status: "error",
|
||||
errorReason: "adapter config missing",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
return { companyId, workerId, reviewerId, errorAgentId, prefix };
|
||||
}
|
||||
|
||||
async function insertIssue(input: {
|
||||
companyId: string;
|
||||
id?: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority?: string;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
originKind?: string;
|
||||
originId?: string | null;
|
||||
originFingerprint?: string;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
updatedAt?: Date;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
const id = input.id ?? randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
identifier: input.identifier,
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
priority: input.priority ?? "medium",
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: input.assigneeUserId ?? null,
|
||||
originKind: input.originKind ?? "manual",
|
||||
originId: input.originId ?? null,
|
||||
originFingerprint: input.originFingerprint ?? "default",
|
||||
executionState: input.executionState ?? null,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
function pendingUserExecutionState(userId = "board-user") {
|
||||
return {
|
||||
status: "pending",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "user", userId },
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingAgentExecutionState(agentId: string) {
|
||||
return {
|
||||
...pendingUserExecutionState(),
|
||||
currentParticipant: { type: "agent", agentId },
|
||||
};
|
||||
}
|
||||
|
||||
it("returns ranked decision-only items for every active source and excludes non-human or transient rows", async () => {
|
||||
const { companyId, workerId, reviewerId } = await seedCompany("ATN");
|
||||
const baseTime = new Date("2026-07-09T12:00:00.000Z");
|
||||
const interactionIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-1",
|
||||
title: "Needs interaction",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
updatedAt: baseTime,
|
||||
});
|
||||
const recoverySourceIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-2",
|
||||
title: "Needs recovery",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
updatedAt: baseTime,
|
||||
});
|
||||
const agentRecoverySourceIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-21",
|
||||
title: "Agent-owned recovery source",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
updatedAt: baseTime,
|
||||
});
|
||||
const productivitySourceIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-3",
|
||||
title: "Needs productivity review source",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
updatedAt: baseTime,
|
||||
});
|
||||
const agentProductivitySourceIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-31",
|
||||
title: "Agent productivity review source",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
updatedAt: baseTime,
|
||||
});
|
||||
const blockerParentId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-4",
|
||||
title: "Blocked parent",
|
||||
status: "blocked",
|
||||
updatedAt: new Date("2026-07-09T12:04:00.000Z"),
|
||||
});
|
||||
const blockerLeafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-5",
|
||||
title: "Stalled review blocker",
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerId,
|
||||
updatedAt: new Date("2026-07-09T12:05:00.000Z"),
|
||||
});
|
||||
await db.insert(issueRelations).values({
|
||||
companyId,
|
||||
issueId: blockerLeafId,
|
||||
relatedIssueId: blockerParentId,
|
||||
type: "blocks",
|
||||
});
|
||||
const reviewUserIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-6",
|
||||
title: "Human review",
|
||||
status: "in_review",
|
||||
executionState: pendingUserExecutionState(),
|
||||
updatedAt: new Date("2026-07-09T12:06:00.000Z"),
|
||||
});
|
||||
await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-7",
|
||||
title: "Agent review excluded",
|
||||
status: "in_review",
|
||||
executionState: pendingAgentExecutionState(reviewerId),
|
||||
updatedAt: new Date("2026-07-09T12:07:00.000Z"),
|
||||
});
|
||||
|
||||
const pendingApprovalId = randomUUID();
|
||||
await db.insert(approvals).values([
|
||||
{
|
||||
id: pendingApprovalId,
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: { title: "Hire Designer" },
|
||||
createdAt: new Date("2026-07-09T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:01:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "revision_requested",
|
||||
payload: { title: "Revision requested" },
|
||||
createdAt: new Date("2026-07-09T12:02:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:02:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: interactionIssueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Pick a launch date",
|
||||
payload: { version: 1, questions: [] },
|
||||
createdAt: new Date("2026-07-09T12:03:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:03:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: interactionIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Already accepted",
|
||||
payload: { version: 1, prompt: "Already done" },
|
||||
createdAt: new Date("2026-07-09T12:03:30.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:03:30.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const inviteId = randomUUID();
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
companyId,
|
||||
tokenHash: `hash-${inviteId}`,
|
||||
allowedJoinTypes: "both",
|
||||
expiresAt: new Date("2026-07-10T00:00:00.000Z"),
|
||||
});
|
||||
await db.insert(joinRequests).values({
|
||||
id: randomUUID(),
|
||||
inviteId,
|
||||
companyId,
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestIp: "127.0.0.1",
|
||||
requestEmailSnapshot: "new@paperclip.test",
|
||||
createdAt: new Date("2026-07-09T12:04:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:04:00.000Z"),
|
||||
});
|
||||
|
||||
await db.insert(issueRecoveryActions).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
sourceIssueId: recoverySourceIssueId,
|
||||
kind: "missing_disposition",
|
||||
status: "escalated",
|
||||
ownerType: "board",
|
||||
ownerAgentId: null,
|
||||
ownerUserId: null,
|
||||
cause: "missing_disposition",
|
||||
fingerprint: "human-recovery",
|
||||
evidence: {},
|
||||
nextAction: "Choose the final disposition.",
|
||||
createdAt: new Date("2026-07-09T12:05:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:05:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
sourceIssueId: agentRecoverySourceIssueId,
|
||||
kind: "stranded_assigned_issue",
|
||||
status: "active",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: workerId,
|
||||
ownerUserId: null,
|
||||
cause: "stranded",
|
||||
fingerprint: "agent-recovery",
|
||||
evidence: {},
|
||||
nextAction: "Agent should self-heal.",
|
||||
createdAt: new Date("2026-07-09T12:05:30.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:05:30.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-8",
|
||||
title: "Human productivity review",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
parentId: productivitySourceIssueId,
|
||||
assigneeUserId: "board-user",
|
||||
originKind: "issue_productivity_review",
|
||||
originId: productivitySourceIssueId,
|
||||
originFingerprint: `productivity-review:${productivitySourceIssueId}`,
|
||||
updatedAt: new Date("2026-07-09T12:08:00.000Z"),
|
||||
});
|
||||
await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATN-9",
|
||||
title: "Agent productivity review excluded",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
parentId: agentProductivitySourceIssueId,
|
||||
assigneeAgentId: workerId,
|
||||
originKind: "issue_productivity_review",
|
||||
originId: agentProductivitySourceIssueId,
|
||||
originFingerprint: `productivity-review-agent:${agentProductivitySourceIssueId}`,
|
||||
updatedAt: new Date("2026-07-09T12:08:30.000Z"),
|
||||
});
|
||||
|
||||
const exhaustedRunId = randomUUID();
|
||||
const transientRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: exhaustedRunId,
|
||||
companyId,
|
||||
agentId: workerId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "adapter failed",
|
||||
errorCode: "adapter_failed",
|
||||
contextSnapshot: { issueId: reviewUserIssueId },
|
||||
scheduledRetryAttempt: 4,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
createdAt: new Date("2026-07-09T12:09:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:09:00.000Z"),
|
||||
finishedAt: new Date("2026-07-09T12:09:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: transientRunId,
|
||||
companyId,
|
||||
agentId: reviewerId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "will retry",
|
||||
errorCode: "provider_quota",
|
||||
contextSnapshot: { issueId: interactionIssueId },
|
||||
createdAt: new Date("2026-07-09T12:09:30.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:09:30.000Z"),
|
||||
finishedAt: new Date("2026-07-09T12:09:30.000Z"),
|
||||
},
|
||||
]);
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId,
|
||||
runId: exhaustedRunId,
|
||||
agentId: workerId,
|
||||
seq: 1,
|
||||
eventType: "lifecycle",
|
||||
message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
|
||||
payload: { retryReason: "transient_failure", maxAttempts: 4 },
|
||||
createdAt: new Date("2026-07-09T12:09:01.000Z"),
|
||||
});
|
||||
|
||||
const softPolicy85Id = randomUUID();
|
||||
const softPolicy84Id = randomUUID();
|
||||
const hardPolicyId = randomUUID();
|
||||
await db.insert(budgetPolicies).values([
|
||||
{
|
||||
id: softPolicy85Id,
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
},
|
||||
{
|
||||
id: softPolicy84Id,
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "lifetime",
|
||||
amount: 100,
|
||||
},
|
||||
{
|
||||
id: hardPolicyId,
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: workerId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
},
|
||||
]);
|
||||
await db.insert(budgetIncidents).values([
|
||||
{
|
||||
companyId,
|
||||
policyId: softPolicy85Id,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: new Date("2026-07-01T00:00:00.000Z"),
|
||||
windowEnd: new Date("2026-08-01T00:00:00.000Z"),
|
||||
thresholdType: "soft",
|
||||
amountLimit: 100,
|
||||
amountObserved: 85,
|
||||
status: "open",
|
||||
createdAt: new Date("2026-07-09T12:10:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:10:00.000Z"),
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
policyId: softPolicy84Id,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "lifetime",
|
||||
windowStart: new Date("1970-01-01T00:00:00.000Z"),
|
||||
windowEnd: new Date("9999-01-01T00:00:00.000Z"),
|
||||
thresholdType: "soft",
|
||||
amountLimit: 100,
|
||||
amountObserved: 84,
|
||||
status: "open",
|
||||
createdAt: new Date("2026-07-09T12:10:30.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:10:30.000Z"),
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
policyId: hardPolicyId,
|
||||
scopeType: "agent",
|
||||
scopeId: workerId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: new Date("2026-07-01T00:00:00.000Z"),
|
||||
windowEnd: new Date("2026-08-01T00:00:00.000Z"),
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 100,
|
||||
status: "open",
|
||||
createdAt: new Date("2026-07-09T12:11:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:11:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
|
||||
expect(feed.totalCount).toBe(11);
|
||||
expect(feed.countsBySourceKind).toMatchObject({
|
||||
approval: 1,
|
||||
issue_thread_interaction: 1,
|
||||
join_request: 1,
|
||||
recovery_action: 1,
|
||||
productivity_review: 1,
|
||||
blocker_attention: 1,
|
||||
review: 1,
|
||||
failed_run: 1,
|
||||
budget_alert: 2,
|
||||
agent_error_alert: 1,
|
||||
});
|
||||
expect(feed.items.map((item) => item.sourceKind)).toEqual(expect.arrayContaining([
|
||||
"approval",
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
"recovery_action",
|
||||
"productivity_review",
|
||||
"blocker_attention",
|
||||
"review",
|
||||
"failed_run",
|
||||
"budget_alert",
|
||||
"agent_error_alert",
|
||||
]));
|
||||
for (const item of feed.items) {
|
||||
expect(item.dedupKey).toBeTruthy();
|
||||
expect(item.dismissalKey).toBe(`attention:${item.dedupKey}`);
|
||||
expect(item.whyNow).toBeTruthy();
|
||||
expect(item.entryRule).toBeTruthy();
|
||||
expect(item.exitRule).toBeTruthy();
|
||||
expect(item.decisionVerbs.length).toBeGreaterThan(0);
|
||||
expect(item.rank).toBeGreaterThan(0);
|
||||
}
|
||||
expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false);
|
||||
expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false);
|
||||
expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false);
|
||||
expect(feed.items.some((item) =>
|
||||
item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota"
|
||||
)).toBe(false);
|
||||
expect(feed.items.some((item) =>
|
||||
item.sourceKind === "budget_alert" && item.subject.metadata?.observedPercent === 84
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses inbox_dismissals with attention-prefixed dedup keys and resurfaces newer activity", async () => {
|
||||
const { companyId } = await seedCompany("ATD");
|
||||
const approvalId = randomUUID();
|
||||
await db.insert(approvals).values({
|
||||
id: approvalId,
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: { title: "Hire Writer" },
|
||||
createdAt: new Date("2026-07-09T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:00:00.000Z"),
|
||||
});
|
||||
await db.insert(inboxDismissals).values({
|
||||
companyId,
|
||||
userId: "board-user",
|
||||
itemKey: `attention:approval:${approvalId}`,
|
||||
dismissedAt: new Date("2026-07-09T13:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(attentionService(db).list(companyId, { userId: "board-user" }))
|
||||
.resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed
|
||||
await expect(attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }))
|
||||
.resolves.toMatchObject({ totalCount: 2 });
|
||||
|
||||
await db
|
||||
.update(approvals)
|
||||
.set({ updatedAt: new Date("2026-07-09T14:00:00.000Z") })
|
||||
.where(eq(approvals.id, approvalId));
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
expect(feed.items.some((item) => item.dedupKey === `approval:${approvalId}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("serves the route for board users and rejects agent callers", async () => {
|
||||
const { companyId } = await seedCompany("ATR");
|
||||
|
||||
function app(actor: Record<string, unknown>) {
|
||||
const testApp = express();
|
||||
testApp.use(express.json());
|
||||
testApp.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
testApp.use("/api", attentionRoutes(db));
|
||||
testApp.use(errorHandler);
|
||||
return testApp;
|
||||
}
|
||||
|
||||
const board = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
const agent = {
|
||||
type: "agent",
|
||||
source: "agent_key",
|
||||
companyId,
|
||||
agentId: randomUUID(),
|
||||
runId: null,
|
||||
};
|
||||
|
||||
await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200);
|
||||
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
@@ -702,6 +703,64 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it("creates one board-owned recovery action for an assigned todo blocker that is dead in the water", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({
|
||||
blockerAssigneeAgentId: "coder",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const first = await heartbeat.reconcileIssueGraphLiveness();
|
||||
const second = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(first.findings).toBe(1);
|
||||
expect(first.recoveryActionsCreated).toBe(1);
|
||||
expect(first.escalationsCreated).toBe(0);
|
||||
expect(second.findings).toBe(0);
|
||||
expect(second.recoveryActionsCreated).toBe(0);
|
||||
expect(second.escalationsCreated).toBe(0);
|
||||
|
||||
const actions = await db
|
||||
.select()
|
||||
.from(issueRecoveryActions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueRecoveryActions.companyId, companyId),
|
||||
eq(issueRecoveryActions.sourceIssueId, blockerIssueId),
|
||||
),
|
||||
);
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toMatchObject({
|
||||
kind: "dead_in_water",
|
||||
status: "active",
|
||||
ownerType: "board",
|
||||
ownerAgentId: null,
|
||||
previousOwnerAgentId: coderId,
|
||||
returnOwnerAgentId: coderId,
|
||||
cause: "dead_in_water",
|
||||
fingerprint: [
|
||||
"source_scoped_recovery",
|
||||
companyId,
|
||||
blockerIssueId,
|
||||
"dead_in_water",
|
||||
].join(":"),
|
||||
});
|
||||
expect(actions[0]?.evidence).toMatchObject({
|
||||
recoveryCause: "dead_in_water",
|
||||
livenessState: "dead_in_water",
|
||||
sourceIssueId: blockerIssueId,
|
||||
dependentIssueIds: [blockedIssueId],
|
||||
dependentCount: 1,
|
||||
});
|
||||
expect(actions[0]?.nextAction).toContain("nudge its assignee");
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates one bounded escalation for an assigned backlog blocker leaf", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({
|
||||
|
||||
@@ -152,6 +152,116 @@ describe("issue graph liveness classifier", () => {
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects a dead-in-the-water todo blocker with blocked dependents", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue(),
|
||||
issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Bounced unblock work",
|
||||
status: "todo",
|
||||
assigneeAgentId: "blocker-agent",
|
||||
}),
|
||||
],
|
||||
relations: blocks,
|
||||
agents: [
|
||||
agent(),
|
||||
manager,
|
||||
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({
|
||||
issueId: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
state: "dead_in_water",
|
||||
severity: "critical",
|
||||
recoveryIssueId: blockerId,
|
||||
recommendedOwnerAgentId: "blocker-agent",
|
||||
recommendedOwnerCandidates: expect.arrayContaining([
|
||||
{ agentId: "blocker-agent", reason: "stalled_blocker_assignee", sourceIssueId: blockerId },
|
||||
{ agentId: managerId, reason: "assignee_reporting_chain", sourceIssueId: blockerId },
|
||||
]),
|
||||
dependencyPath: [
|
||||
expect.objectContaining({ issueId: blockerId, status: "todo" }),
|
||||
expect.objectContaining({ issueId: blockedId, status: "blocked" }),
|
||||
],
|
||||
incidentKey: `harness_liveness:${companyId}:${blockerId}:dead_in_water:${blockerId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flag dead-in-water candidates with a liveness path, own blockers, or no dependents", () => {
|
||||
const deadCandidate = issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Bounced unblock work",
|
||||
status: "todo",
|
||||
assigneeAgentId: "blocker-agent",
|
||||
});
|
||||
const blockerAgent = agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId });
|
||||
const upstreamBlocker = issue({
|
||||
id: "upstream-1",
|
||||
identifier: "PAP-1705",
|
||||
title: "Upstream dependency",
|
||||
status: "todo",
|
||||
assigneeAgentId: "upstream-agent",
|
||||
});
|
||||
const baseInput = {
|
||||
issues: [issue(), deadCandidate],
|
||||
relations: blocks,
|
||||
agents: [
|
||||
agent(),
|
||||
manager,
|
||||
blockerAgent,
|
||||
agent({ id: "upstream-agent", name: "Upstream Agent", reportsTo: managerId }),
|
||||
],
|
||||
};
|
||||
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
pendingInteractions: [{ companyId, issueId: blockerId, status: "pending" }],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
activeRuns: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "running" }],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
queuedWakeRequests: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "queued" }],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
issues: [issue(), { ...deadCandidate, assigneeUserId: "board-user-1" }],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
issues: [issue(), deadCandidate, upstreamBlocker],
|
||||
relations: [
|
||||
...blocks,
|
||||
{ companyId, blockerIssueId: "upstream-1", blockedIssueId: blockerId },
|
||||
],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
issues: [
|
||||
issue(),
|
||||
{
|
||||
...deadCandidate,
|
||||
status: "in_review",
|
||||
executionState: {
|
||||
currentParticipant: { type: "user", userId: "board-user-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
relations: [],
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects an assigned backlog blocker leaf with no action path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
|
||||
@@ -98,6 +98,105 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
return { companyId, goalId, issueId };
|
||||
}
|
||||
|
||||
async function seedAgentAssignedPlanConfirmationIssue(options?: {
|
||||
status?: string;
|
||||
includePlanTarget?: boolean;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const documentId = randomUUID();
|
||||
const revisionId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Approve an agent plan",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Review an agent-authored plan",
|
||||
status: options?.status ?? "in_review",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "Plan v1",
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: revisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "Plan v1",
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
...(options?.includePlanTarget === false
|
||||
? {}
|
||||
: {
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
revisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, goalId, issueId, agentId, created };
|
||||
}
|
||||
|
||||
it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
@@ -1222,6 +1321,92 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("moves agent-assigned in_review plan confirmations back to todo when accepted", async () => {
|
||||
const { companyId, goalId, issueId, agentId, created } = await seedAgentAssignedPlanConfirmationIssue();
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(accepted.continuationIssue).toEqual({
|
||||
id: issueId,
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
status: "todo",
|
||||
});
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
|
||||
expect(updatedIssue).toMatchObject({
|
||||
id: issueId,
|
||||
status: "todo",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not move accepted plan confirmations when the issue is not in_review", async () => {
|
||||
const { companyId, goalId, issueId, agentId, created } = await seedAgentAssignedPlanConfirmationIssue({
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(accepted.continuationIssue).toBeNull();
|
||||
|
||||
const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
|
||||
expect(updatedIssue).toMatchObject({
|
||||
id: issueId,
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not move non-plan request confirmations out of review", async () => {
|
||||
const { companyId, goalId, issueId, agentId, created } = await seedAgentAssignedPlanConfirmationIssue({
|
||||
includePlanTarget: false,
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(accepted.continuationIssue).toBeNull();
|
||||
|
||||
const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
|
||||
expect(updatedIssue).toMatchObject({
|
||||
id: issueId,
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("expires request confirmations by default when a user comments after creation", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue();
|
||||
const commentId = randomUUID();
|
||||
|
||||
@@ -15,6 +15,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"activity.ts": "/api",
|
||||
"adapters.ts": "/api",
|
||||
"agents.ts": "/api",
|
||||
"attention.ts": "/api",
|
||||
"approvals.ts": "/api",
|
||||
"assets.ts": "/api",
|
||||
"auth.ts": "/api/auth",
|
||||
|
||||
@@ -31,6 +31,7 @@ import { secretRoutes } from "./routes/secrets.js";
|
||||
import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { attentionRoutes } from "./routes/attention.js";
|
||||
import { userProfileRoutes } from "./routes/user-profiles.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
@@ -247,6 +248,7 @@ export async function createApp(
|
||||
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(attentionRoutes(db));
|
||||
api.use(userProfileRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
|
||||
export function attentionRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = attentionService(db);
|
||||
|
||||
router.get("/companies/:companyId/attention", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertBoard(req);
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const includeDismissed = req.query.includeDismissed === "true";
|
||||
const feed = await svc.list(companyId, {
|
||||
userId: req.actor.userId,
|
||||
includeDismissed,
|
||||
});
|
||||
res.json(feed);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { inboxDismissalService, logActivity } from "../services/index.js";
|
||||
|
||||
const inboxDismissalSchema = z.object({
|
||||
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
|
||||
itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"),
|
||||
});
|
||||
|
||||
export function inboxDismissalRoutes(db: Db) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export { secretRoutes } from "./secrets.js";
|
||||
export { costRoutes } from "./costs.js";
|
||||
export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { attentionRoutes } from "./attention.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
|
||||
export { resourceMembershipRoutes } from "./resource-memberships.js";
|
||||
|
||||
@@ -2708,6 +2708,15 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/attention",
|
||||
tags: ["inbox"],
|
||||
summary: "List decision-only attention feed items",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/sidebar-preferences/me",
|
||||
@@ -2765,7 +2774,7 @@ registry.registerPath({
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(z.object({
|
||||
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
|
||||
itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"),
|
||||
})),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
invites,
|
||||
issueApprovals,
|
||||
issueRecoveryActions,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
joinRequests,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AttentionDecisionVerb,
|
||||
AttentionFeed,
|
||||
AttentionItem,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
AttentionSubject,
|
||||
} from "@paperclipai/shared";
|
||||
import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
|
||||
const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [
|
||||
"approval",
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
"recovery_action",
|
||||
"productivity_review",
|
||||
"blocker_attention",
|
||||
"review",
|
||||
"failed_run",
|
||||
"budget_alert",
|
||||
"agent_error_alert",
|
||||
];
|
||||
|
||||
const SEVERITY_RANK: Record<AttentionSeverity, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
||||
const SOURCE_RANK: Record<AttentionSourceKind, number> = {
|
||||
failed_run: 0,
|
||||
recovery_action: 1,
|
||||
blocker_attention: 2,
|
||||
budget_alert: 3,
|
||||
agent_error_alert: 4,
|
||||
approval: 5,
|
||||
issue_thread_interaction: 6,
|
||||
review: 7,
|
||||
productivity_review: 8,
|
||||
join_request: 9,
|
||||
};
|
||||
|
||||
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
|
||||
const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const;
|
||||
const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const;
|
||||
const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const;
|
||||
const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const;
|
||||
|
||||
type IssueSummaryRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type AttentionListOptions = {
|
||||
userId?: string | null;
|
||||
includeDismissed?: boolean;
|
||||
};
|
||||
|
||||
function emptyCounts(): Record<AttentionSourceKind, number> {
|
||||
return Object.fromEntries(ATTENTION_SOURCE_KINDS.map((kind) => [kind, 0])) as Record<AttentionSourceKind, number>;
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | null | undefined): string {
|
||||
if (!value) return new Date(0).toISOString();
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function timestamp(value: Date | string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function isDismissed(
|
||||
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||
dismissalKey: string,
|
||||
activityAt: string,
|
||||
) {
|
||||
const dismissedAt = dismissedAtByKey.get(dismissalKey);
|
||||
return dismissedAt != null && dismissedAt >= timestamp(activityAt);
|
||||
}
|
||||
|
||||
function issueHref(prefix: string, issue: Pick<IssueSummaryRow, "id" | "identifier">) {
|
||||
return `/${prefix}/issues/${issue.identifier ?? issue.id}`;
|
||||
}
|
||||
|
||||
function issueSubject(prefix: string, issue: IssueSummaryRow): AttentionSubject {
|
||||
return {
|
||||
kind: "issue",
|
||||
id: issue.id,
|
||||
companyId: issue.companyId,
|
||||
title: issue.title,
|
||||
identifier: issue.identifier,
|
||||
status: issue.status,
|
||||
href: issueHref(prefix, issue),
|
||||
metadata: {
|
||||
priority: issue.priority,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function itemId(sourceKind: AttentionSourceKind, dedupKey: string) {
|
||||
return `${sourceKind}:${dedupKey}`;
|
||||
}
|
||||
|
||||
function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb[] {
|
||||
return verbs;
|
||||
}
|
||||
|
||||
function createItem(input: Omit<AttentionItem, "id" | "dismissalKey" | "rank">): AttentionItem {
|
||||
return {
|
||||
...input,
|
||||
id: itemId(input.sourceKind, input.dedupKey),
|
||||
dismissalKey: `attention:${input.dedupKey}`,
|
||||
rank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function compareAttentionItems(left: AttentionItem, right: AttentionItem) {
|
||||
const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity];
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
const sourceDiff = SOURCE_RANK[left.sourceKind] - SOURCE_RANK[right.sourceKind];
|
||||
if (sourceDiff !== 0) return sourceDiff;
|
||||
const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return left.dedupKey.localeCompare(right.dedupKey);
|
||||
}
|
||||
|
||||
function betterDuplicate(left: AttentionItem, right: AttentionItem) {
|
||||
return compareAttentionItems(left, right) <= 0 ? left : right;
|
||||
}
|
||||
|
||||
function approvalTitle(type: string, payload: Record<string, unknown>) {
|
||||
const title = typeof payload.title === "string" ? payload.title.trim() : "";
|
||||
if (title) return title;
|
||||
const summary = typeof payload.summary === "string" ? payload.summary.trim() : "";
|
||||
if (summary) return summary;
|
||||
return type.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function interactionLabel(kind: string) {
|
||||
switch (kind) {
|
||||
case "request_confirmation":
|
||||
return "Confirmation requested";
|
||||
case "request_checkbox_confirmation":
|
||||
return "Selection confirmation requested";
|
||||
case "ask_user_questions":
|
||||
return "Questions need answers";
|
||||
case "suggest_tasks":
|
||||
return "Suggested tasks need a decision";
|
||||
default:
|
||||
return "Interaction needs a decision";
|
||||
}
|
||||
}
|
||||
|
||||
function interactionVerbs(kind: string) {
|
||||
if (kind === "ask_user_questions") {
|
||||
return decisionVerbs({
|
||||
id: "respond",
|
||||
label: "Respond",
|
||||
description: "Submit answers to the pending questions.",
|
||||
});
|
||||
}
|
||||
return decisionVerbs(
|
||||
{
|
||||
id: "accept",
|
||||
label: "Accept",
|
||||
description: "Accept the pending interaction.",
|
||||
},
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
description: "Reject the pending interaction and provide a reason when required.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function budgetObservedPercent(amountObserved: number, amountLimit: number) {
|
||||
return amountLimit > 0 ? Math.round((amountObserved / amountLimit) * 10_000) / 100 : 0;
|
||||
}
|
||||
|
||||
async function companyPrefix(db: Db, companyId: string) {
|
||||
const row = await db
|
||||
.select({ issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row?.issuePrefix ?? "PAP";
|
||||
}
|
||||
|
||||
async function dismissedAtByKey(db: Db, companyId: string, userId: string | null | undefined) {
|
||||
if (!userId) return new Map<string, number>();
|
||||
const rows = await db
|
||||
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
|
||||
.from(inboxDismissals)
|
||||
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)));
|
||||
return new Map(rows.map((row) => [row.itemKey, timestamp(row.dismissedAt)]));
|
||||
}
|
||||
|
||||
async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, IssueSummaryRow>();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), isNull(issues.hiddenAt)));
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
|
||||
const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId;
|
||||
return typeof issueId === "string" && issueId.length > 0 ? issueId : null;
|
||||
}
|
||||
|
||||
export function attentionService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, options: AttentionListOptions = {}): Promise<AttentionFeed> => {
|
||||
const prefix = await companyPrefix(db, companyId);
|
||||
const dismissed = await dismissedAtByKey(db, companyId, options.userId);
|
||||
const includeDismissed = options.includeDismissed === true;
|
||||
const collected: AttentionItem[] = [];
|
||||
|
||||
const add = (item: AttentionItem) => {
|
||||
if (!includeDismissed && isDismissed(dismissed, item.dismissalKey, item.activityAt)) return;
|
||||
collected.push(item);
|
||||
};
|
||||
|
||||
const pendingApprovals = await db
|
||||
.select({
|
||||
id: approvals.id,
|
||||
type: approvals.type,
|
||||
status: approvals.status,
|
||||
requestedByAgentId: approvals.requestedByAgentId,
|
||||
requestedByUserId: approvals.requestedByUserId,
|
||||
payload: approvals.payload,
|
||||
createdAt: approvals.createdAt,
|
||||
updatedAt: approvals.updatedAt,
|
||||
})
|
||||
.from(approvals)
|
||||
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
||||
.orderBy(desc(approvals.updatedAt), desc(approvals.id));
|
||||
|
||||
for (const approval of pendingApprovals) {
|
||||
const dedupKey = `approval:${approval.id}`;
|
||||
const title = approvalTitle(approval.type, approval.payload);
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "approval",
|
||||
subject: {
|
||||
kind: "approval",
|
||||
id: approval.id,
|
||||
companyId,
|
||||
title,
|
||||
identifier: null,
|
||||
status: approval.status,
|
||||
href: `/${prefix}/approvals/${approval.id}`,
|
||||
metadata: {
|
||||
type: approval.type,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
requestedByUserId: approval.requestedByUserId,
|
||||
},
|
||||
},
|
||||
whyNow: "Approval is pending a board decision.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "approve", label: "Approve", description: "Approve the request." },
|
||||
{ id: "reject", label: "Reject", description: "Reject the request." },
|
||||
{ id: "request_revision", label: "Request revision", description: "Send the request back for changes." },
|
||||
),
|
||||
inlineResolvable: approval.type !== "request_board_approval",
|
||||
entryRule: "approvals.status = 'pending'",
|
||||
exitRule: "Approval leaves pending status.",
|
||||
dedupKey,
|
||||
severity: "medium",
|
||||
activityAt: toIso(approval.updatedAt),
|
||||
createdAt: toIso(approval.createdAt),
|
||||
updatedAt: toIso(approval.updatedAt),
|
||||
relatedIssue: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const interactionRows = await db
|
||||
.select({
|
||||
id: issueThreadInteractions.id,
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
kind: issueThreadInteractions.kind,
|
||||
status: issueThreadInteractions.status,
|
||||
title: issueThreadInteractions.title,
|
||||
summary: issueThreadInteractions.summary,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
updatedAt: issueThreadInteractions.updatedAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, companyId),
|
||||
inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]),
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id));
|
||||
const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId));
|
||||
|
||||
for (const interaction of interactionRows) {
|
||||
const issue = interactionIssueMap.get(interaction.issueId) ?? null;
|
||||
const dedupKey = `interaction:${interaction.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "issue_thread_interaction",
|
||||
subject: {
|
||||
kind: "interaction",
|
||||
id: interaction.id,
|
||||
companyId,
|
||||
title: interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind),
|
||||
identifier: null,
|
||||
status: interaction.status,
|
||||
href: issue ? `${issueHref(prefix, issue)}#interaction-${interaction.id}` : null,
|
||||
metadata: { kind: interaction.kind, issueId: interaction.issueId },
|
||||
},
|
||||
whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`,
|
||||
decisionVerbs: interactionVerbs(interaction.kind),
|
||||
inlineResolvable: true,
|
||||
entryRule: "issue_thread_interactions.status = 'pending'",
|
||||
exitRule: "Interaction resolves, expires, fails, or is cancelled.",
|
||||
dedupKey,
|
||||
severity: "medium",
|
||||
activityAt: toIso(interaction.updatedAt),
|
||||
createdAt: toIso(interaction.createdAt),
|
||||
updatedAt: toIso(interaction.updatedAt),
|
||||
relatedIssue: issue ? issueSubject(prefix, issue) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
const pendingJoins = await db
|
||||
.select({
|
||||
id: joinRequests.id,
|
||||
requestType: joinRequests.requestType,
|
||||
status: joinRequests.status,
|
||||
requestingUserId: joinRequests.requestingUserId,
|
||||
requestEmailSnapshot: joinRequests.requestEmailSnapshot,
|
||||
agentName: joinRequests.agentName,
|
||||
adapterType: joinRequests.adapterType,
|
||||
createdAt: joinRequests.createdAt,
|
||||
updatedAt: joinRequests.updatedAt,
|
||||
})
|
||||
.from(joinRequests)
|
||||
.innerJoin(invites, eq(joinRequests.inviteId, invites.id))
|
||||
.where(and(
|
||||
eq(joinRequests.companyId, companyId),
|
||||
eq(invites.companyId, companyId),
|
||||
eq(joinRequests.status, "pending_approval"),
|
||||
))
|
||||
.orderBy(desc(joinRequests.updatedAt), desc(joinRequests.id));
|
||||
|
||||
for (const join of pendingJoins) {
|
||||
const label = join.requestType === "agent"
|
||||
? join.agentName ?? "Agent join request"
|
||||
: join.requestEmailSnapshot ?? join.requestingUserId ?? "Human join request";
|
||||
const dedupKey = `join:${join.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "join_request",
|
||||
subject: {
|
||||
kind: "join_request",
|
||||
id: join.id,
|
||||
companyId,
|
||||
title: label,
|
||||
identifier: null,
|
||||
status: join.status,
|
||||
href: `/${prefix}/settings/access`,
|
||||
metadata: {
|
||||
requestType: join.requestType,
|
||||
requestingUserId: join.requestingUserId,
|
||||
adapterType: join.adapterType,
|
||||
},
|
||||
},
|
||||
whyNow: "Join request is pending approval.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "approve", label: "Approve", description: "Approve this join request." },
|
||||
{ id: "reject", label: "Reject", description: "Reject this join request." },
|
||||
),
|
||||
inlineResolvable: true,
|
||||
entryRule: "join_requests.status = 'pending_approval'",
|
||||
exitRule: "Join request is approved or rejected.",
|
||||
dedupKey,
|
||||
severity: "medium",
|
||||
activityAt: toIso(join.updatedAt),
|
||||
createdAt: toIso(join.createdAt),
|
||||
updatedAt: toIso(join.updatedAt),
|
||||
relatedIssue: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const recoveryRows = await db
|
||||
.select()
|
||||
.from(issueRecoveryActions)
|
||||
.where(and(
|
||||
eq(issueRecoveryActions.companyId, companyId),
|
||||
inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_STATUSES]),
|
||||
inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]),
|
||||
))
|
||||
.orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id));
|
||||
const recoveryIssueMap = await issueSummaryMap(
|
||||
db,
|
||||
companyId,
|
||||
recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]),
|
||||
);
|
||||
|
||||
for (const recovery of recoveryRows) {
|
||||
const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null;
|
||||
const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null;
|
||||
const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "recovery_action",
|
||||
subject: {
|
||||
kind: "recovery_action",
|
||||
id: recovery.id,
|
||||
companyId,
|
||||
title: recovery.nextAction,
|
||||
identifier: null,
|
||||
status: recovery.status,
|
||||
href: recoveryIssue ? issueHref(prefix, recoveryIssue) : sourceIssue ? issueHref(prefix, sourceIssue) : null,
|
||||
metadata: {
|
||||
kind: recovery.kind,
|
||||
cause: recovery.cause,
|
||||
ownerType: recovery.ownerType,
|
||||
ownerUserId: recovery.ownerUserId,
|
||||
sourceIssueId: recovery.sourceIssueId,
|
||||
recoveryIssueId: recovery.recoveryIssueId,
|
||||
},
|
||||
},
|
||||
whyNow: recovery.status === "escalated"
|
||||
? "Recovery action escalated to a human owner."
|
||||
: "Recovery action is assigned to a human owner.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "resolve", label: "Resolve", description: "Record the recovery outcome." },
|
||||
{ id: "reassign", label: "Reassign", description: "Move the recovery to another owner." },
|
||||
{ id: "cancel", label: "Cancel", description: "Cancel the recovery action." },
|
||||
),
|
||||
inlineResolvable: false,
|
||||
entryRule: "issue_recovery_actions.status in ('active','escalated') and owner_type in ('user','board')",
|
||||
exitRule: "Recovery action resolves, is cancelled, or moves back to an agent/system owner.",
|
||||
dedupKey,
|
||||
severity: recovery.status === "escalated" ? "high" : "medium",
|
||||
activityAt: toIso(recovery.updatedAt),
|
||||
createdAt: toIso(recovery.createdAt),
|
||||
updatedAt: toIso(recovery.updatedAt),
|
||||
relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
const productivityRows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
originId: issues.originId,
|
||||
originFingerprint: issues.originFingerprint,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
isNull(issues.hiddenAt),
|
||||
isNotNull(issues.assigneeUserId),
|
||||
notInArray(issues.status, [...PRODUCTIVITY_REVIEW_TERMINAL_STATUSES]),
|
||||
))
|
||||
.orderBy(desc(issues.updatedAt), desc(issues.id));
|
||||
const productivitySourceMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId));
|
||||
|
||||
for (const review of productivityRows) {
|
||||
const reviewIssue: IssueSummaryRow = review;
|
||||
const sourceIssue = review.originId ? productivitySourceMap.get(review.originId) ?? null : null;
|
||||
const dedupKey = `productivity_review:${review.originFingerprint ?? review.originId ?? review.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "productivity_review",
|
||||
subject: issueSubject(prefix, reviewIssue),
|
||||
whyNow: "Productivity review is awaiting a human decision.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "resolve", label: "Resolve", description: "Record a productivity review outcome." },
|
||||
{ id: "dismiss", label: "Dismiss", description: "Dismiss this review for now." },
|
||||
{ id: "reassign", label: "Reassign", description: "Move the review to another owner." },
|
||||
),
|
||||
inlineResolvable: false,
|
||||
entryRule: "Open issue_productivity_review issue assigned to a user.",
|
||||
exitRule: "Review issue is done/cancelled or no longer assigned to a user.",
|
||||
dedupKey,
|
||||
severity: review.priority === "critical" ? "critical" : review.priority === "high" ? "high" : "medium",
|
||||
activityAt: toIso(review.updatedAt),
|
||||
createdAt: toIso(review.createdAt),
|
||||
updatedAt: toIso(review.updatedAt),
|
||||
relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true });
|
||||
for (const issue of blockedIssues as Array<IssueSummaryRow & { blockerAttention?: { state?: string; sampleStalledBlockerIdentifier?: string | null; sampleBlockerIdentifier?: string | null } | null }>) {
|
||||
const blockerAttention = issue.blockerAttention;
|
||||
if (blockerAttention?.state !== "stalled") continue;
|
||||
const sample = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier ?? issue.identifier ?? issue.id;
|
||||
const dedupKey = `blocker:${issue.id}:${sample}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "blocker_attention",
|
||||
subject: issueSubject(prefix, issue),
|
||||
whyNow: "Blocked dependency chain is stalled and needs a human to choose the next owner or action.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." },
|
||||
{ id: "reassign", label: "Reassign", description: "Assign the stalled blocker to a live owner." },
|
||||
{ id: "nudge", label: "Nudge", description: "Wake or prompt the current owner." },
|
||||
),
|
||||
inlineResolvable: false,
|
||||
entryRule: "blocked issue has blockerAttention.state = 'stalled'",
|
||||
exitRule: "Blocker chain is no longer stalled or the issue leaves blocked status.",
|
||||
dedupKey,
|
||||
severity: "high",
|
||||
activityAt: toIso(issue.updatedAt),
|
||||
createdAt: toIso(issue.createdAt),
|
||||
updatedAt: toIso(issue.updatedAt),
|
||||
relatedIssue: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const reviewRows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
executionState: issues.executionState,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), isNull(issues.hiddenAt)))
|
||||
.orderBy(desc(issues.updatedAt), desc(issues.id));
|
||||
const reviewIssueIds = reviewRows.map((row) => row.id);
|
||||
const pendingReviewApprovalRows = reviewIssueIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({ issueId: issueApprovals.issueId, approvalId: approvals.id })
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(and(
|
||||
eq(issueApprovals.companyId, companyId),
|
||||
eq(approvals.companyId, companyId),
|
||||
inArray(issueApprovals.issueId, reviewIssueIds),
|
||||
eq(approvals.status, "pending"),
|
||||
));
|
||||
const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId]));
|
||||
|
||||
for (const review of reviewRows) {
|
||||
const state = parseIssueExecutionState(review.executionState);
|
||||
const currentParticipant = state?.status === "pending" ? state.currentParticipant : null;
|
||||
const hasHumanParticipant = currentParticipant?.type === "user";
|
||||
const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null;
|
||||
if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId) continue;
|
||||
const issue: IssueSummaryRow = review;
|
||||
const dedupKey = `review:${review.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
subject: issueSubject(prefix, issue),
|
||||
whyNow: pendingApprovalId
|
||||
? "Issue is in review with a linked pending approval."
|
||||
: hasHumanParticipant
|
||||
? "Issue is in review and the current execution participant is a user."
|
||||
: "Issue is in review and assigned to a user.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "approve", label: "Approve", description: "Approve the review and advance the issue." },
|
||||
{ id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." },
|
||||
),
|
||||
inlineResolvable: false,
|
||||
entryRule: "issues.status = 'in_review' and human reviewer, user assignee, or linked pending approval exists.",
|
||||
exitRule: "Issue leaves in_review or the human review path resolves.",
|
||||
dedupKey,
|
||||
severity: "medium",
|
||||
activityAt: toIso(review.updatedAt),
|
||||
createdAt: toIso(review.createdAt),
|
||||
updatedAt: toIso(review.updatedAt),
|
||||
relatedIssue: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const exhaustedRunRows = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agents.name,
|
||||
status: heartbeatRuns.status,
|
||||
error: heartbeatRuns.error,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
updatedAt: heartbeatRuns.updatedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
exhaustionMessage: heartbeatRunEvents.message,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||
.innerJoin(heartbeatRunEvents, eq(heartbeatRunEvents.runId, heartbeatRuns.id))
|
||||
.where(and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(agents.companyId, companyId),
|
||||
notInArray(agents.status, ["terminated"]),
|
||||
inArray(heartbeatRuns.status, [...FAILED_RUN_STATUSES]),
|
||||
eq(heartbeatRunEvents.companyId, companyId),
|
||||
eq(heartbeatRunEvents.eventType, "lifecycle"),
|
||||
sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`,
|
||||
))
|
||||
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRunEvents.id));
|
||||
|
||||
const latestExhaustedByRunId = new Map<string, (typeof exhaustedRunRows)[number]>();
|
||||
for (const row of exhaustedRunRows) {
|
||||
if (!latestExhaustedByRunId.has(row.id)) latestExhaustedByRunId.set(row.id, row);
|
||||
}
|
||||
const failedRows = [...latestExhaustedByRunId.values()];
|
||||
const failedIssueMap = await issueSummaryMap(
|
||||
db,
|
||||
companyId,
|
||||
failedRows.map((row) => readRunIssueId(row.contextSnapshot)),
|
||||
);
|
||||
for (const run of failedRows) {
|
||||
const issueId = readRunIssueId(run.contextSnapshot);
|
||||
const newer = await db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.agentId, run.agentId),
|
||||
gt(heartbeatRuns.createdAt, run.createdAt),
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId', '') = ${issueId ?? ""}`,
|
||||
))
|
||||
.limit(1);
|
||||
if (newer.length > 0) continue;
|
||||
|
||||
const issue = issueId ? failedIssueMap.get(issueId) ?? null : null;
|
||||
const dedupKey = `run:${run.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "failed_run",
|
||||
subject: {
|
||||
kind: "run",
|
||||
id: run.id,
|
||||
companyId,
|
||||
title: `${run.agentName} run ${run.status}`,
|
||||
identifier: null,
|
||||
status: run.status,
|
||||
href: `/${prefix}/agents/${run.agentId}/runs/${run.id}`,
|
||||
metadata: {
|
||||
agentId: run.agentId,
|
||||
agentName: run.agentName,
|
||||
issueId,
|
||||
errorCode: run.errorCode,
|
||||
error: run.error,
|
||||
retryExhaustedReason: run.exhaustionMessage,
|
||||
},
|
||||
},
|
||||
whyNow: "Run failed after automatic retries were exhausted.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "retry", label: "Retry", description: "Retry the failed run or issue." },
|
||||
{ id: "reassign", label: "Reassign", description: "Move the work to another owner." },
|
||||
{ id: "dismiss", label: "Dismiss", description: "Dismiss this failed-run attention row." },
|
||||
),
|
||||
inlineResolvable: true,
|
||||
entryRule: "latest failed/timed_out run has a Bounded retry exhausted lifecycle event.",
|
||||
exitRule: "A newer run exists for the same issue/agent pair or the row is dismissed.",
|
||||
dedupKey,
|
||||
severity: "high",
|
||||
activityAt: toIso(run.finishedAt ?? run.updatedAt ?? run.createdAt),
|
||||
createdAt: toIso(run.createdAt),
|
||||
updatedAt: toIso(run.updatedAt),
|
||||
relatedIssue: issue ? issueSubject(prefix, issue) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
const budgetOverview = await budgetService(db).overview(companyId);
|
||||
for (const incident of budgetOverview.activeIncidents) {
|
||||
const observedPercent = budgetObservedPercent(incident.amountObserved, incident.amountLimit);
|
||||
if (incident.thresholdType !== "hard" && observedPercent < 85) continue;
|
||||
const dedupKey = `budget:${incident.policyId}:${toIso(incident.windowStart)}:${incident.thresholdType}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "budget_alert",
|
||||
subject: {
|
||||
kind: "budget_incident",
|
||||
id: incident.id,
|
||||
companyId,
|
||||
title: `${incident.scopeName} budget ${incident.thresholdType === "hard" ? "hard stop" : "warning"}`,
|
||||
identifier: null,
|
||||
status: incident.status,
|
||||
href: `/${prefix}/costs`,
|
||||
metadata: {
|
||||
policyId: incident.policyId,
|
||||
scopeType: incident.scopeType,
|
||||
scopeId: incident.scopeId,
|
||||
thresholdType: incident.thresholdType,
|
||||
amountObserved: incident.amountObserved,
|
||||
amountLimit: incident.amountLimit,
|
||||
observedPercent,
|
||||
approvalId: incident.approvalId,
|
||||
approvalStatus: incident.approvalStatus,
|
||||
},
|
||||
},
|
||||
whyNow: incident.thresholdType === "hard"
|
||||
? "Budget hard stop was reached."
|
||||
: "Budget crossed the 85% warning threshold.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "raise_budget_and_resume", label: "Raise budget", description: "Raise the budget and resume paused work." },
|
||||
{ id: "keep_paused", label: "Keep paused", description: "Dismiss or keep the budget stop in place." },
|
||||
),
|
||||
inlineResolvable: true,
|
||||
entryRule: "open budget incident is hard, or soft with observed spend >= 85% of limit.",
|
||||
exitRule: "Budget incident is resolved or dismissed.",
|
||||
dedupKey,
|
||||
severity: incident.thresholdType === "hard" ? "high" : "medium",
|
||||
activityAt: toIso(incident.updatedAt),
|
||||
createdAt: toIso(incident.createdAt),
|
||||
updatedAt: toIso(incident.updatedAt),
|
||||
relatedIssue: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const erroredAgents = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
role: agents.role,
|
||||
status: agents.status,
|
||||
errorReason: agents.errorReason,
|
||||
createdAt: agents.createdAt,
|
||||
updatedAt: agents.updatedAt,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), eq(agents.status, "error")))
|
||||
.orderBy(desc(agents.updatedAt), desc(agents.id));
|
||||
|
||||
for (const agent of erroredAgents) {
|
||||
const dedupKey = `agent_error:${agent.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "agent_error_alert",
|
||||
subject: {
|
||||
kind: "agent",
|
||||
id: agent.id,
|
||||
companyId,
|
||||
title: agent.name,
|
||||
identifier: null,
|
||||
status: agent.status,
|
||||
href: `/${prefix}/agents/${agent.id}`,
|
||||
metadata: { role: agent.role, errorReason: agent.errorReason },
|
||||
},
|
||||
whyNow: "Agent is in error status and needs operator action or dismissal.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "inspect", label: "Inspect", description: "Inspect the agent error." },
|
||||
{ id: "dismiss", label: "Dismiss", description: "Dismiss this alert." },
|
||||
),
|
||||
inlineResolvable: true,
|
||||
entryRule: "agents.status = 'error'",
|
||||
exitRule: "Agent leaves error status or the row is dismissed.",
|
||||
dedupKey,
|
||||
severity: "high",
|
||||
activityAt: toIso(agent.updatedAt),
|
||||
createdAt: toIso(agent.createdAt),
|
||||
updatedAt: toIso(agent.updatedAt),
|
||||
relatedIssue: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const deduped = new Map<string, AttentionItem>();
|
||||
for (const item of collected) {
|
||||
const current = deduped.get(item.dedupKey);
|
||||
deduped.set(item.dedupKey, current ? betterDuplicate(current, item) : item);
|
||||
}
|
||||
|
||||
const items = [...deduped.values()]
|
||||
.sort(compareAttentionItems)
|
||||
.map((item, index) => ({ ...item, rank: index + 1 }));
|
||||
const countsBySourceKind = emptyCounts();
|
||||
for (const item of items) countsBySourceKind[item.sourceKind] += 1;
|
||||
|
||||
return {
|
||||
companyId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalCount: items.length,
|
||||
countsBySourceKind,
|
||||
items,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
export { goalService } from "./goals.js";
|
||||
export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js";
|
||||
export { attentionService } from "./attention.js";
|
||||
export type {
|
||||
WorkTimelineActor,
|
||||
WorkTimelineEdge,
|
||||
|
||||
@@ -197,6 +197,20 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldMoveAcceptedPlanConfirmationToAgentTodo(args: {
|
||||
issue: IssueResolutionContext;
|
||||
interaction: RequestConfirmationLikeInteraction;
|
||||
}) {
|
||||
if (args.interaction.kind !== "request_confirmation") return false;
|
||||
if (args.issue.status !== "in_review") return false;
|
||||
if (!args.issue.assigneeAgentId) return false;
|
||||
|
||||
const target = args.interaction.payload.target;
|
||||
if (!target || target.type !== "issue_document") return false;
|
||||
const targetIssueId = target.issueId ?? args.issue.id;
|
||||
return targetIssueId === args.issue.id && target.key === "plan";
|
||||
}
|
||||
|
||||
function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) {
|
||||
return interaction.payload.supersedeOnUserComment === true;
|
||||
}
|
||||
@@ -845,6 +859,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
throw notFound("Issue not found");
|
||||
}
|
||||
|
||||
const acceptedInteraction = hydrateInteraction(updated) as RequestConfirmationLikeInteraction;
|
||||
let continuationIssue: IssueWakeTarget | null = null;
|
||||
if (shouldReturnAcceptedConfirmationToCreatorAgent({
|
||||
issue: issueContext,
|
||||
@@ -860,6 +875,24 @@ export function issueThreadInteractionService(db: Db) {
|
||||
actorUserId: args.actor.userId ?? null,
|
||||
}, tx);
|
||||
|
||||
if (returnedIssue) {
|
||||
continuationIssue = {
|
||||
id: returnedIssue.id,
|
||||
assigneeAgentId: returnedIssue.assigneeAgentId ?? null,
|
||||
assigneeUserId: returnedIssue.assigneeUserId ?? null,
|
||||
status: returnedIssue.status,
|
||||
};
|
||||
}
|
||||
} else if (shouldMoveAcceptedPlanConfirmationToAgentTodo({
|
||||
issue: issueContext,
|
||||
interaction: acceptedInteraction,
|
||||
})) {
|
||||
const returnedIssue = await issueService(db).update(args.issue.id, {
|
||||
status: "todo",
|
||||
actorAgentId: args.actor.agentId ?? null,
|
||||
actorUserId: args.actor.userId ?? null,
|
||||
}, tx);
|
||||
|
||||
if (returnedIssue) {
|
||||
continuationIssue = {
|
||||
id: returnedIssue.id,
|
||||
@@ -873,7 +906,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
}
|
||||
|
||||
return {
|
||||
interaction: hydrateInteraction(updated),
|
||||
interaction: acceptedInteraction,
|
||||
continuationIssue,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3294,6 +3294,7 @@ async function listIssueBlockedInboxAttentionMap(
|
||||
state: "needs_attention",
|
||||
reason: finding.state as IssueBlockedInboxAttention["reason"],
|
||||
severity: finding.state === "blocked_by_assigned_backlog_issue"
|
||||
|| finding.state === "dead_in_water"
|
||||
|| finding.state === "in_review_without_action_path"
|
||||
? "high"
|
||||
: finding.severity === "critical" ? "critical" : "high",
|
||||
@@ -3315,6 +3316,8 @@ async function listIssueBlockedInboxAttentionMap(
|
||||
return "Assign active owner";
|
||||
case "blocked_by_cancelled_issue":
|
||||
return "Replace blocker";
|
||||
case "dead_in_water":
|
||||
return "Choose disposition";
|
||||
case "invalid_review_participant":
|
||||
return "Repair review participant";
|
||||
case "in_review_without_action_path":
|
||||
|
||||
@@ -8,6 +8,7 @@ export type IssueLivenessState =
|
||||
| "blocked_by_assigned_backlog_issue"
|
||||
| "blocked_by_uninvokable_assignee"
|
||||
| "blocked_by_cancelled_issue"
|
||||
| "dead_in_water"
|
||||
| "invalid_review_participant"
|
||||
| "in_review_without_action_path";
|
||||
|
||||
@@ -361,6 +362,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
|
||||
const blockedDependentsByBlockerIssueId = new Map<string, IssueLivenessIssueInput[]>();
|
||||
const unresolvedBlockers = new Set<string>();
|
||||
const findings: IssueLivenessFinding[] = [];
|
||||
const activeRuns = input.activeRuns ?? [];
|
||||
@@ -387,6 +389,20 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
) {
|
||||
unresolvedBlockers.add(blocker.id);
|
||||
}
|
||||
|
||||
if (
|
||||
blocker &&
|
||||
blocked &&
|
||||
blocker.companyId === relation.companyId &&
|
||||
blocked.companyId === relation.companyId &&
|
||||
blocker.status !== "done" &&
|
||||
blocker.status !== "cancelled" &&
|
||||
blocked.status === "blocked"
|
||||
) {
|
||||
const list = blockedDependentsByBlockerIssueId.get(blocker.id) ?? [];
|
||||
list.push(blocked);
|
||||
blockedDependentsByBlockerIssueId.set(blocker.id, list);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relations of blockersByBlockedIssueId.values()) {
|
||||
@@ -399,6 +415,10 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
});
|
||||
}
|
||||
|
||||
for (const dependents of blockedDependentsByBlockerIssueId.values()) {
|
||||
dependents.sort((left, right) => issueLabel(left).localeCompare(issueLabel(right)));
|
||||
}
|
||||
|
||||
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) ||
|
||||
hasScheduledMonitor(issue, nowMs) ||
|
||||
@@ -408,6 +428,70 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
hasWaitingPath(issue.companyId, issue.id, openRecoveryIssues);
|
||||
}
|
||||
|
||||
function hasOpenBlockers(issue: IssueLivenessIssueInput) {
|
||||
const relations = blockersByBlockedIssueId.get(issue.id) ?? [];
|
||||
return relations.some((relation) => {
|
||||
if (relation.companyId !== issue.companyId) return false;
|
||||
const blocker = issuesById.get(relation.blockerIssueId);
|
||||
return Boolean(
|
||||
blocker &&
|
||||
blocker.companyId === issue.companyId &&
|
||||
blocker.status !== "done" &&
|
||||
blocker.status !== "cancelled",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasReviewerPath(issue: IssueLivenessIssueInput) {
|
||||
const participant = issue.executionState?.currentParticipant;
|
||||
const participantAgentId = readPrincipalAgentId(participant);
|
||||
if (participantAgentId) {
|
||||
const participantAgent = agentsById.get(participantAgentId);
|
||||
return Boolean(
|
||||
participantAgent &&
|
||||
participantAgent.companyId === issue.companyId &&
|
||||
isInvokableAgent(participantAgent, agentsById),
|
||||
);
|
||||
}
|
||||
return principalIsResolvableUser(participant);
|
||||
}
|
||||
|
||||
function deadInWaterFinding(issue: IssueLivenessIssueInput): IssueLivenessFinding | null {
|
||||
if (issue.status !== "todo" || !issue.assigneeAgentId) return null;
|
||||
const assignee = agentsById.get(issue.assigneeAgentId);
|
||||
if (
|
||||
!assignee ||
|
||||
assignee.companyId !== issue.companyId ||
|
||||
!isInvokableAgent(assignee, agentsById)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const dependents = blockedDependentsByBlockerIssueId.get(issue.id) ?? [];
|
||||
if (dependents.length === 0) return null;
|
||||
if (hasExplicitWaitingPath(issue) || hasOpenBlockers(issue) || hasReviewerPath(issue)) return null;
|
||||
|
||||
const ownerCandidates = ownerCandidatesForRecoveryIssue(issue, input.agents, agentsById, {
|
||||
includeStalledAssignee: true,
|
||||
});
|
||||
const dependentLabels = dependents.map(issueLabel);
|
||||
const dependentCopy = dependentLabels.length === 1
|
||||
? dependentLabels[0]!
|
||||
: `${dependentLabels.slice(0, 3).join(", ")}${dependentLabels.length > 3 ? `, and ${dependentLabels.length - 3} more` : ""}`;
|
||||
|
||||
return finding({
|
||||
issue,
|
||||
state: "dead_in_water",
|
||||
reason: `${issueLabel(issue)} is ${issue.status}, blocks ${dependents.length} issue${dependents.length === 1 ? "" : "s"} (${dependentCopy}), and has no interaction, own blocker, active run, queued wake, reviewer, human owner, monitor, or recovery action owning the next step.`,
|
||||
dependencyPath: [issue, ...dependents],
|
||||
recoveryIssue: issue,
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
|
||||
recommendedOwnerCandidates: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Choose a disposition for ${issueLabel(issue)}: nudge its assignee, reassign it, cancel it, or de-prioritize/remove the blocked dependent${dependents.length === 1 ? "" : "s"} (${dependentCopy}).`,
|
||||
blockerIssueId: issue.id,
|
||||
});
|
||||
}
|
||||
|
||||
function reviewFinding(
|
||||
source: IssueLivenessIssueInput,
|
||||
reviewIssue: IssueLivenessIssueInput,
|
||||
@@ -590,6 +674,9 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
}
|
||||
|
||||
for (const issue of input.issues) {
|
||||
const dead = deadInWaterFinding(issue);
|
||||
if (dead) findings.push(dead);
|
||||
|
||||
if (issue.status === "blocked") {
|
||||
if (unresolvedBlockers.has(issue.id)) continue;
|
||||
const chainFinding = firstBlockedChainFinding(issue, issue, [issue], new Set());
|
||||
|
||||
@@ -462,6 +462,36 @@ function livenessRecoveryLeafKey(companyId: string, state: string, leafIssueId:
|
||||
return buildIssueGraphLivenessLeafKey({ companyId, state, leafIssueId });
|
||||
}
|
||||
|
||||
function isDeadInWaterFinding(finding: IssueLivenessFinding) {
|
||||
return finding.state === "dead_in_water";
|
||||
}
|
||||
|
||||
function deadInWaterRecoveryActionFingerprint(finding: IssueLivenessFinding) {
|
||||
return [
|
||||
"source_scoped_recovery",
|
||||
finding.companyId,
|
||||
finding.recoveryIssueId,
|
||||
"dead_in_water",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function buildDeadInWaterRecoveryActionEvidence(finding: IssueLivenessFinding) {
|
||||
const source = finding.dependencyPath[0] ?? null;
|
||||
const dependents = finding.dependencyPath.slice(1);
|
||||
return {
|
||||
sourceIssueId: finding.recoveryIssueId,
|
||||
sourceIdentifier: source?.identifier ?? finding.identifier,
|
||||
sourceStatus: source?.status ?? null,
|
||||
recoveryCause: "dead_in_water",
|
||||
livenessState: finding.state,
|
||||
incidentKey: finding.incidentKey,
|
||||
reason: finding.reason,
|
||||
dependentIssueIds: dependents.map((entry) => entry.issueId),
|
||||
dependentIdentifiers: dependents.map((entry) => entry.identifier).filter(Boolean),
|
||||
dependentCount: dependents.length,
|
||||
};
|
||||
}
|
||||
|
||||
function isUniqueLivenessRecoveryConflict(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const maybe = error as { code?: string; constraint?: string; message?: string };
|
||||
@@ -4086,6 +4116,68 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
return { kind: "created" as const, escalationIssueId: escalation.id };
|
||||
}
|
||||
|
||||
async function ensureDeadInWaterRecoveryAction(input: {
|
||||
finding: IssueLivenessFinding;
|
||||
runId?: string | null;
|
||||
}) {
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, input.finding.recoveryIssueId), eq(issues.companyId, input.finding.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) return { kind: "skipped" as const };
|
||||
|
||||
const action = await recoveryActionsSvc.upsertSourceScoped({
|
||||
companyId: issue.companyId,
|
||||
sourceIssueId: issue.id,
|
||||
recoveryIssueId: null,
|
||||
kind: "dead_in_water",
|
||||
ownerType: "board",
|
||||
ownerAgentId: null,
|
||||
previousOwnerAgentId: issue.assigneeAgentId,
|
||||
returnOwnerAgentId: issue.assigneeAgentId,
|
||||
cause: "dead_in_water",
|
||||
fingerprint: deadInWaterRecoveryActionFingerprint(input.finding),
|
||||
evidence: buildDeadInWaterRecoveryActionEvidence(input.finding),
|
||||
nextAction: input.finding.recommendedAction,
|
||||
wakePolicy: {
|
||||
type: "board_escalation",
|
||||
reason: "dead_in_water",
|
||||
decisionVerbs: ["nudge_assignee", "reassign", "cancel", "deprioritize_dependents"],
|
||||
},
|
||||
monitorPolicy: null,
|
||||
maxAttempts: null,
|
||||
lastAttemptAt: new Date(),
|
||||
});
|
||||
|
||||
if (action.attemptCount === 1) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
agentId: null,
|
||||
runId: input.runId ?? null,
|
||||
action: "issue.recovery_action_opened",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
recoveryActionId: action.id,
|
||||
recoveryActionKind: action.kind,
|
||||
recoveryActionStatus: action.status,
|
||||
ownerType: action.ownerType,
|
||||
source: "recovery.reconcile_issue_graph_liveness",
|
||||
findingState: input.finding.state,
|
||||
incidentKey: input.finding.incidentKey,
|
||||
dependentIssueIds: input.finding.dependencyPath.slice(1).map((entry) => entry.issueId),
|
||||
},
|
||||
});
|
||||
return { kind: "created" as const, recoveryActionId: action.id };
|
||||
}
|
||||
|
||||
return { kind: "existing" as const, recoveryActionId: action.id };
|
||||
}
|
||||
|
||||
async function reconcileResolvedDependencyWakeBackstop(opts?: ResolvedDependencyWakeBackstopOptions) {
|
||||
const result = {
|
||||
checked: 0,
|
||||
@@ -4347,6 +4439,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
cutoff: cutoff.toISOString(),
|
||||
escalationsCreated: 0,
|
||||
existingEscalations: 0,
|
||||
recoveryActionsCreated: 0,
|
||||
existingRecoveryActions: 0,
|
||||
skipped: 0,
|
||||
skippedAutoRecoveryDisabled: 0,
|
||||
skippedOutsideLookback: 0,
|
||||
@@ -4367,6 +4461,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
dependencyWakeIssueIds: [] as string[],
|
||||
issueIds: [] as string[],
|
||||
escalationIssueIds: [] as string[],
|
||||
recoveryActionIds: [] as string[],
|
||||
retiredRecoveryIssueIds: obsoleteRecoveryCleanup.retiredIssueIds,
|
||||
};
|
||||
|
||||
@@ -4396,6 +4491,26 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDeadInWaterFinding(finding)) {
|
||||
const recoveryAction = await ensureDeadInWaterRecoveryAction({
|
||||
finding,
|
||||
runId: opts?.runId ?? null,
|
||||
});
|
||||
if (recoveryAction.kind === "created") {
|
||||
result.recoveryActionsCreated += 1;
|
||||
result.issueIds.push(finding.issueId);
|
||||
result.recoveryActionIds.push(recoveryAction.recoveryActionId);
|
||||
} else if (recoveryAction.kind === "existing") {
|
||||
result.existingRecoveryActions += 1;
|
||||
result.issueIds.push(finding.issueId);
|
||||
result.recoveryActionIds.push(recoveryAction.recoveryActionId);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const escalation = await createIssueGraphLivenessEscalation({
|
||||
finding,
|
||||
runId: opts?.runId ?? null,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||
import { Costs } from "./pages/Costs";
|
||||
import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { WhatNeedsMe } from "./pages/WhatNeedsMe";
|
||||
import { BoardChat } from "./pages/BoardChat";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
@@ -204,6 +205,7 @@ function boardRoutes() {
|
||||
<Route path="board-chat" element={<BoardChat />} />
|
||||
<Route path="artifacts" element={<Artifacts />} />
|
||||
</Route>
|
||||
<Route path="attention" element={<WhatNeedsMe />} />
|
||||
<Route path="inbox" element={<InboxRootRedirect />} />
|
||||
<Route path="inbox/mine" element={<Inbox />} />
|
||||
<Route path="inbox/recent" element={<Inbox />} />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AttentionFeed } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const attentionApi = {
|
||||
/**
|
||||
* Fetch the ranked "What needs me" attention feed for a company. The server
|
||||
* unions every attention source (approvals, interactions, recovery, reviews,
|
||||
* failures, budget…) into one ranked queue with the §0 contract.
|
||||
*/
|
||||
list: (companyId: string, options: { includeDismissed?: boolean } = {}) =>
|
||||
api.get<AttentionFeed>(
|
||||
`/companies/${companyId}/attention${options.includeDismissed ? "?includeDismissed=true" : ""}`,
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
isIssueThreadInteraction,
|
||||
type AskUserQuestionsAnswer,
|
||||
type AskUserQuestionsInteraction,
|
||||
type IssueThreadInteraction,
|
||||
type RequestCheckboxConfirmationInteraction,
|
||||
type RequestConfirmationInteraction,
|
||||
type SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||||
|
||||
interface AttentionInteractionResolverProps {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
interactionId: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
/** Called after a resolution so the parent can refresh the feed. */
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily fetches the full issue-thread interaction referenced by an attention
|
||||
* row and renders the existing {@link IssueThreadInteractionCard} inline, so
|
||||
* confirmations and questions are answerable in-row without leaving the queue
|
||||
* (converged PAP-12628). Reviews never reach here — they deep-link.
|
||||
*/
|
||||
export function AttentionInteractionResolver({
|
||||
companyId,
|
||||
issueId,
|
||||
interactionId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onResolved,
|
||||
}: AttentionInteractionResolverProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: interactions, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.interactions(issueId),
|
||||
queryFn: () => issuesApi.listInteractions(issueId),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
|
||||
const interaction = useMemo<IssueThreadInteraction | null>(() => {
|
||||
const match = (interactions ?? []).find((entry) => entry.id === interactionId);
|
||||
return match && isIssueThreadInteraction(match) ? match : null;
|
||||
}, [interactions, interactionId]);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
onResolved?.();
|
||||
};
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: (input: {
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction;
|
||||
selectedClientKeys?: string[];
|
||||
selectedOptionIds?: string[];
|
||||
}) =>
|
||||
issuesApi.acceptInteraction(issueId, input.interaction.id, {
|
||||
selectedClientKeys: input.selectedClientKeys,
|
||||
selectedOptionIds: input.selectedOptionIds,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (input: { interactionId: string; reason?: string }) =>
|
||||
issuesApi.rejectInteraction(issueId, input.interactionId, input.reason),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const respondMutation = useMutation({
|
||||
mutationFn: (input: { interactionId: string; answers: AskUserQuestionsAnswer[] }) =>
|
||||
issuesApi.respondToInteraction(issueId, input.interactionId, { answers: input.answers }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (input: { interactionId: string }) =>
|
||||
issuesApi.cancelInteraction(issueId, input.interactionId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading decision…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !interaction) {
|
||||
return (
|
||||
<p className="py-3 text-xs text-muted-foreground">
|
||||
This decision is no longer available — it may have been resolved elsewhere.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={(target, selectedClientKeys, selectedOptionIds) =>
|
||||
acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds }).then(() => undefined)
|
||||
}
|
||||
onRejectInteraction={(target, reason) =>
|
||||
rejectMutation.mutateAsync({ interactionId: target.id, reason }).then(() => undefined)
|
||||
}
|
||||
onSubmitInteractionAnswers={(target: AskUserQuestionsInteraction, answers) =>
|
||||
respondMutation.mutateAsync({ interactionId: target.id, answers }).then(() => undefined)
|
||||
}
|
||||
onCancelInteraction={(target: AskUserQuestionsInteraction) =>
|
||||
cancelMutation.mutateAsync({ interactionId: target.id }).then(() => undefined)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { AnchorHTMLAttributes, ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
|
||||
import { AttentionQueueRow } from "./AttentionQueueRow";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act<T>(cb: () => T): T {
|
||||
let result: T | undefined;
|
||||
flushSync(() => {
|
||||
result = cb();
|
||||
});
|
||||
return result as T;
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
act(() =>
|
||||
root?.render(<QueryClientProvider client={client}>{element}</QueryClientProvider>),
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
return {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
sourceKind: "approval",
|
||||
subject: {
|
||||
kind: "approval",
|
||||
id: "approval-1",
|
||||
companyId: "c1",
|
||||
title: "Hire agent: Research Analyst",
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: "/PAP/approvals/approval-1",
|
||||
metadata: {},
|
||||
},
|
||||
whyNow: "Approval is pending a board decision.",
|
||||
decisionVerbs: [],
|
||||
inlineResolvable: true,
|
||||
entryRule: "",
|
||||
exitRule: "",
|
||||
dedupKey: "approval:approval-1",
|
||||
dismissalKey: "attention:approval:approval-1",
|
||||
severity: "high",
|
||||
rank: 0,
|
||||
activityAt: "2026-07-09T12:00:00Z",
|
||||
createdAt: "2026-07-09T12:00:00Z",
|
||||
updatedAt: "2026-07-09T12:00:00Z",
|
||||
relatedIssue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("AttentionQueueRow", () => {
|
||||
it("renders an inline approval resolver when expanded", () => {
|
||||
const el = render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem()}
|
||||
companyId="c1"
|
||||
expanded
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(el.textContent).toContain("Approve");
|
||||
expect(el.textContent).toContain("Request revision");
|
||||
expect(el.textContent).toContain("Reject");
|
||||
// Inline rows show an expand chevron, not an "Open" deep-link.
|
||||
expect(el.textContent).not.toContain("Open");
|
||||
});
|
||||
|
||||
it("does not inline a review — it deep-links instead", () => {
|
||||
const el = render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
sourceKind: "review" as AttentionSourceKind,
|
||||
inlineResolvable: true,
|
||||
subject: {
|
||||
kind: "issue",
|
||||
id: "issue-1",
|
||||
companyId: "c1",
|
||||
title: "PR ready for review",
|
||||
identifier: null,
|
||||
status: "in_review",
|
||||
href: "/PAP/issues/PAP-1",
|
||||
metadata: {},
|
||||
},
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(el.textContent).toContain("Open");
|
||||
// No approval buttons should render for a review row.
|
||||
expect(el.textContent).not.toContain("Request revision");
|
||||
});
|
||||
|
||||
it("fires onDismiss from the row menu action", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const item = buildItem();
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={item}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
// The dropdown trigger + item live in a portal; invoke the handler contract
|
||||
// directly via the rendered menu after opening is environment-flaky in
|
||||
// jsdom, so assert the wiring by locating the trigger exists.
|
||||
const trigger = container?.querySelector('[aria-label="Row actions"]');
|
||||
expect(trigger).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import type { Agent, AttentionItem } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi } from "../api/access";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { isInlineResolvable, severityStyle, sourceMeta } from "../lib/attention";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
|
||||
|
||||
interface AttentionQueueRowProps {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onDismiss: (item: AttentionItem) => void;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
}
|
||||
|
||||
export function AttentionQueueRow({
|
||||
item,
|
||||
companyId,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onDismiss,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
}: AttentionQueueRowProps) {
|
||||
const meta = sourceMeta(item.sourceKind);
|
||||
const severity = severityStyle(item.severity);
|
||||
const Icon = meta.icon;
|
||||
const inline = isInlineResolvable(item);
|
||||
const href = item.subject.href;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-hidden rounded-lg border border-border bg-card",
|
||||
"transition-colors hover:border-border/80",
|
||||
)}
|
||||
data-attention-source={item.sourceKind}
|
||||
data-attention-severity={item.severity}
|
||||
>
|
||||
{/* Severity accent bar */}
|
||||
<span className={cn("absolute inset-y-0 left-0 w-1", severity.accent)} aria-hidden />
|
||||
|
||||
<div className="flex items-start gap-3 py-3 pl-4 pr-3">
|
||||
{/* Expand affordance / source icon */}
|
||||
{inline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="mt-0.5 shrink-0 rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring focus-visible:ring-[3px]"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Collapse decision" : "Expand decision"}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-0.5 shrink-0 p-0.5" aria-hidden>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{meta.label}
|
||||
</span>
|
||||
{item.relatedIssue?.identifier && (
|
||||
<Link
|
||||
to={item.relatedIssue.href ?? "#"}
|
||||
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{item.relatedIssue.identifier}
|
||||
</Link>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{relativeTime(item.activityAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="block truncate text-sm font-medium text-foreground hover:underline"
|
||||
title={item.subject.title ?? undefined}
|
||||
>
|
||||
{item.subject.title ?? meta.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{item.subject.title ?? meta.label}
|
||||
</span>
|
||||
)}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{item.whyNow}</p>
|
||||
</div>
|
||||
|
||||
{/* Deep-link / open for non-inline rows */}
|
||||
{!inline && href && (
|
||||
<Button asChild variant="outline" size="xs" className="shrink-0">
|
||||
<Link to={href}>
|
||||
Open
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Snooze / dismiss */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="Row actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDismiss(item)}>Snooze / dismiss</DropdownMenuItem>
|
||||
{href && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Open source</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inline && expanded && (
|
||||
<div className="border-t border-border/60 bg-muted/20 px-4 py-3">
|
||||
<InlineResolver
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineResolver({
|
||||
item,
|
||||
companyId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
}: {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
}) {
|
||||
if (item.sourceKind === "issue_thread_interaction") {
|
||||
const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id;
|
||||
if (!issueId) {
|
||||
return <p className="text-xs text-muted-foreground">Missing issue reference for this decision.</p>;
|
||||
}
|
||||
return (
|
||||
<AttentionInteractionResolver
|
||||
companyId={companyId}
|
||||
issueId={issueId}
|
||||
interactionId={item.subject.id}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.sourceKind === "approval") {
|
||||
return <ApprovalResolver item={item} companyId={companyId} />;
|
||||
}
|
||||
|
||||
if (item.sourceKind === "join_request") {
|
||||
return <JoinRequestResolver item={item} companyId={companyId} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [note, setNote] = useState("");
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
|
||||
};
|
||||
const approve = useMutation({
|
||||
mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const reject = useMutation({
|
||||
mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const revise = useMutation({
|
||||
mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const pending = approve.isPending || reject.isPending || revise.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Optional decision note…"
|
||||
className="min-h-[64px] text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
||||
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => revise.mutate()} disabled={pending}>
|
||||
{revise.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Request revision
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
|
||||
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JoinRequestResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
|
||||
};
|
||||
const approve = useMutation({
|
||||
mutationFn: () => accessApi.approveJoinRequest(companyId, item.subject.id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const reject = useMutation({
|
||||
mutationFn: () => accessApi.rejectJoinRequest(companyId, item.subject.id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const pending = approve.isPending || reject.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
||||
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
|
||||
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,7 @@ const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
|
||||
configuration_validation: "Configuration Validation",
|
||||
active_run_watchdog: "Active Watchdog",
|
||||
issue_graph_liveness: "Graph Liveness",
|
||||
dead_in_water: "Dead in the Water",
|
||||
};
|
||||
|
||||
const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
|
||||
@@ -118,6 +119,8 @@ const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
|
||||
"The active run has been silent. Recovery is observing without interrupting it.",
|
||||
issue_graph_liveness:
|
||||
"Paperclip detected this task lost a live action path. A recovery owner needs to act.",
|
||||
dead_in_water:
|
||||
"This task is blocking other work and has no live owner, wait, or recovery path.",
|
||||
};
|
||||
|
||||
const STATE_TONE: Record<RecoveryCardCardState, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Inbox,
|
||||
ListChecks,
|
||||
CircleDot,
|
||||
Target,
|
||||
LayoutDashboard,
|
||||
@@ -31,9 +32,11 @@ import { SidebarStarredProjects } from "./SidebarStarredProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { attentionApi } from "../api/attention";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { attentionBadgeCount } from "../lib/attention";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -52,6 +55,13 @@ export function Sidebar() {
|
||||
const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: attentionFeed } = useQuery({
|
||||
queryKey: queryKeys.attention(selectedCompanyId!),
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const attentionCount = attentionBadgeCount(attentionFeed);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
@@ -168,6 +178,13 @@ export function Sidebar() {
|
||||
);
|
||||
})()}
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
<SidebarNavItem
|
||||
to="/attention"
|
||||
label="What needs me"
|
||||
icon={ListChecks}
|
||||
badge={attentionCount}
|
||||
badgeLabel="decisions"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { RoutineDetail, RoutineRunSummary } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RunsSection } from "./operate-sections";
|
||||
import { RoutineDetailContext, type RoutineDetailContextValue } from "./context";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.mock("../RoutineHistoryTab", () => ({
|
||||
RoutineHistoryTab: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../MarkdownEditor", () => ({
|
||||
MarkdownEditor: () => null,
|
||||
}));
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
function makeRoutine(): RoutineDetail {
|
||||
return {
|
||||
id: "routine-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Daily digest",
|
||||
description: null,
|
||||
assigneeAgentId: null,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
latestRevisionId: null,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
responsibleUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-1",
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
createdAt: new Date("2026-07-01T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-01T12:00:00.000Z"),
|
||||
project: null,
|
||||
assignee: null,
|
||||
parentIssue: null,
|
||||
triggers: [],
|
||||
recentRuns: [],
|
||||
activeIssue: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRun(id: string, triggeredAt: string, label: string): RoutineRunSummary {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
routineId: "routine-1",
|
||||
triggerId: `trigger-${id}`,
|
||||
source: "api",
|
||||
status: "succeeded",
|
||||
triggeredAt: new Date(triggeredAt),
|
||||
idempotencyKey: null,
|
||||
triggerPayload: null,
|
||||
dispatchFingerprint: null,
|
||||
routineRevisionId: null,
|
||||
linkedIssueId: null,
|
||||
coalescedIntoRunId: null,
|
||||
failureReason: null,
|
||||
completedAt: new Date(triggeredAt),
|
||||
createdAt: new Date(triggeredAt),
|
||||
updatedAt: new Date(triggeredAt),
|
||||
linkedIssue: null,
|
||||
trigger: {
|
||||
id: `trigger-${id}`,
|
||||
kind: "api",
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ runs }: { runs: RoutineRunSummary[] }) {
|
||||
const value = {
|
||||
routine: makeRoutine(),
|
||||
routineRuns: runs,
|
||||
hasLiveRun: false,
|
||||
activeIssueId: undefined,
|
||||
onOpenRunDialog: vi.fn(),
|
||||
} as unknown as RoutineDetailContextValue;
|
||||
|
||||
return (
|
||||
<RoutineDetailContext.Provider value={value}>
|
||||
<RunsSection />
|
||||
</RoutineDetailContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RunsSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00.000Z"));
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("adds the inbox-style earlier divider when routine runs cross the recent window", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<Harness
|
||||
runs={[
|
||||
makeRun("newer", "2026-07-09T10:00:00.000Z", "Newer execution"),
|
||||
makeRun("older", "2026-07-08T10:00:00.000Z", "Older execution"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Earlier");
|
||||
expect(text.indexOf("Newer execution")).toBeLessThan(text.indexOf("Earlier"));
|
||||
expect(text.indexOf("Earlier")).toBeLessThan(text.indexOf("Older execution"));
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not show the earlier divider when all visible runs are recent", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<Harness
|
||||
runs={[
|
||||
makeRun("newer", "2026-07-09T10:00:00.000Z", "Newer execution"),
|
||||
makeRun("recent", "2026-07-08T13:00:00.000Z", "Recent execution"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Earlier");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Activity as ActivityIcon, Play, SlidersHorizontal } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -25,6 +25,8 @@ const DATE_WINDOW_OPTIONS: { value: string; label: string; ms: number | null }[]
|
||||
{ value: "30d", label: "Last 30d", ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
const RECENT_RUN_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function RunsSection() {
|
||||
const ctx = useRoutineDetail();
|
||||
const { routine, routineRuns, hasLiveRun, activeIssueId, onOpenRunDialog } = ctx;
|
||||
@@ -151,44 +153,66 @@ export function RunsSection() {
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border">
|
||||
{filtered.map((run) => {
|
||||
const label = dedupedTriggerLabel(run.trigger);
|
||||
const title = run.linkedIssue?.title ?? label ?? "Run";
|
||||
return (
|
||||
<EntityRow
|
||||
key={run.id}
|
||||
leading={
|
||||
<>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{run.source}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={run.status === "failed" ? "destructive" : "secondary"}
|
||||
className="shrink-0"
|
||||
>
|
||||
{run.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
identifier={
|
||||
run.linkedIssue
|
||||
? run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)
|
||||
: undefined
|
||||
}
|
||||
title={title}
|
||||
subtitle={runRowSubtitle(run, routine.variables)}
|
||||
reserveSubtitleSpace
|
||||
trailing={
|
||||
<span className="text-xs text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
|
||||
}
|
||||
to={
|
||||
run.linkedIssue
|
||||
? `/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
let previousTimestamp = Number.POSITIVE_INFINITY;
|
||||
const cutoff = Date.now() - RECENT_RUN_WINDOW_MS;
|
||||
return filtered.flatMap((run, index) => {
|
||||
const elements: ReactNode[] = [];
|
||||
const timestamp = new Date(run.triggeredAt).getTime();
|
||||
const showTodayDivider =
|
||||
Number.isFinite(timestamp)
|
||||
&& timestamp < cutoff
|
||||
&& previousTimestamp >= cutoff;
|
||||
if (Number.isFinite(timestamp)) previousTimestamp = timestamp;
|
||||
if (showTodayDivider) {
|
||||
elements.push(
|
||||
<div key={`today-divider-${run.id}-${index}`} className="my-2 flex items-center gap-3 px-4">
|
||||
<div className="flex-1 border-t border-border/80" />
|
||||
<span className="shrink-0 text-(length:--text-micro) font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Earlier
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const label = dedupedTriggerLabel(run.trigger);
|
||||
const title = run.linkedIssue?.title ?? label ?? "Run";
|
||||
elements.push(
|
||||
<EntityRow
|
||||
key={run.id}
|
||||
leading={
|
||||
<>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{run.source}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={run.status === "failed" ? "destructive" : "secondary"}
|
||||
className="shrink-0"
|
||||
>
|
||||
{run.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
identifier={
|
||||
run.linkedIssue
|
||||
? run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)
|
||||
: undefined
|
||||
}
|
||||
title={title}
|
||||
subtitle={runRowSubtitle(run, routine.variables)}
|
||||
reserveSubtitleSpace
|
||||
trailing={
|
||||
<span className="text-xs text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
|
||||
}
|
||||
to={
|
||||
run.linkedIssue
|
||||
? `/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
return elements;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
|
||||
import { attentionBadgeCount, isInlineResolvable, severityStyle, sourceMeta } from "./attention";
|
||||
|
||||
function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
return {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
sourceKind: "approval",
|
||||
subject: { kind: "approval", id: "s1", companyId: "c1", title: "t", identifier: null, status: null, href: null },
|
||||
whyNow: "why",
|
||||
decisionVerbs: [],
|
||||
inlineResolvable: true,
|
||||
entryRule: "",
|
||||
exitRule: "",
|
||||
dedupKey: "d1",
|
||||
dismissalKey: "attention:d1",
|
||||
severity: "medium",
|
||||
rank: 0,
|
||||
activityAt: "2026-07-09T12:00:00Z",
|
||||
createdAt: "2026-07-09T12:00:00Z",
|
||||
updatedAt: "2026-07-09T12:00:00Z",
|
||||
relatedIssue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isInlineResolvable", () => {
|
||||
it("is true for approvals/interactions/join when server flags inlineResolvable", () => {
|
||||
for (const kind of ["approval", "issue_thread_interaction", "join_request"] as AttentionSourceKind[]) {
|
||||
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is false when the server marks a row non-inline (e.g. board approval)", () => {
|
||||
expect(isInlineResolvable(buildItem({ sourceKind: "approval", inlineResolvable: false }))).toBe(false);
|
||||
});
|
||||
|
||||
it("is never inline for reviews even when flagged", () => {
|
||||
expect(isInlineResolvable(buildItem({ sourceKind: "review", inlineResolvable: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("deep-links recovery/failure/budget rows rather than inlining", () => {
|
||||
for (const kind of ["recovery_action", "failed_run", "budget_alert", "blocker_attention"] as AttentionSourceKind[]) {
|
||||
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("attentionBadgeCount", () => {
|
||||
it("counts every queue row as a decision (mentions/unread never enter the feed)", () => {
|
||||
const feed: AttentionFeed = {
|
||||
companyId: "c1",
|
||||
generatedAt: "2026-07-09T12:00:00Z",
|
||||
totalCount: 3,
|
||||
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
|
||||
items: [buildItem({ id: "1" }), buildItem({ id: "2" }), buildItem({ id: "3" })],
|
||||
};
|
||||
expect(attentionBadgeCount(feed)).toBe(3);
|
||||
});
|
||||
|
||||
it("is zero for an empty or missing feed", () => {
|
||||
expect(attentionBadgeCount(null)).toBe(0);
|
||||
expect(attentionBadgeCount(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sourceMeta + severityStyle", () => {
|
||||
it("labels every catalog source kind", () => {
|
||||
const kinds: AttentionSourceKind[] = [
|
||||
"approval",
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
"recovery_action",
|
||||
"productivity_review",
|
||||
"blocker_attention",
|
||||
"review",
|
||||
"failed_run",
|
||||
"budget_alert",
|
||||
"agent_error_alert",
|
||||
];
|
||||
for (const kind of kinds) {
|
||||
expect(sourceMeta(kind).label.length).toBeGreaterThan(0);
|
||||
expect(sourceMeta(kind).icon).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps escalation severity to distinct accents", () => {
|
||||
expect(severityStyle("critical").accent).not.toBe(severityStyle("low").accent);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
DollarSign,
|
||||
Eye,
|
||||
LifeBuoy,
|
||||
MessageSquareQuote,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { AttentionFeed, AttentionItem, AttentionSeverity, AttentionSourceKind } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Source kinds the queue can fully resolve in-row. Everything else deep-links
|
||||
* to its native surface — reviews are *never* inline (converged PAP-12628),
|
||||
* and the remaining state-derived sources (recovery, failures, budget) expose
|
||||
* verbs too rich to safely inline here, so they open their surface.
|
||||
*/
|
||||
export const INLINE_RESOLVABLE_SOURCE_KINDS: ReadonlySet<AttentionSourceKind> = new Set<AttentionSourceKind>([
|
||||
"approval",
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
]);
|
||||
|
||||
export function isInlineResolvable(item: AttentionItem): boolean {
|
||||
return item.inlineResolvable && INLINE_RESOLVABLE_SOURCE_KINDS.has(item.sourceKind);
|
||||
}
|
||||
|
||||
interface SourceMeta {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const SOURCE_META: Record<AttentionSourceKind, SourceMeta> = {
|
||||
approval: { label: "Approval", icon: ShieldCheck },
|
||||
issue_thread_interaction: { label: "Decision requested", icon: MessageSquareQuote },
|
||||
join_request: { label: "Join request", icon: UserPlus },
|
||||
recovery_action: { label: "Recovery", icon: LifeBuoy },
|
||||
productivity_review: { label: "Productivity review", icon: Zap },
|
||||
blocker_attention: { label: "Blocked dependency", icon: Ban },
|
||||
review: { label: "Review", icon: Eye },
|
||||
failed_run: { label: "Failed run", icon: RefreshCw },
|
||||
budget_alert: { label: "Budget", icon: DollarSign },
|
||||
agent_error_alert: { label: "Agent error", icon: AlertTriangle },
|
||||
};
|
||||
|
||||
export function sourceMeta(kind: AttentionSourceKind): SourceMeta {
|
||||
return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " "), icon: AlertTriangle };
|
||||
}
|
||||
|
||||
interface SeverityStyle {
|
||||
/** Left accent bar + dot color. */
|
||||
accent: string;
|
||||
dot: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SEVERITY_STYLE: Record<AttentionSeverity, SeverityStyle> = {
|
||||
critical: { accent: "bg-red-500", dot: "bg-red-500", label: "Critical" },
|
||||
high: { accent: "bg-orange-500", dot: "bg-orange-500", label: "High" },
|
||||
medium: { accent: "bg-yellow-500", dot: "bg-yellow-500", label: "Medium" },
|
||||
low: { accent: "bg-blue-500", dot: "bg-blue-500", label: "Low" },
|
||||
};
|
||||
|
||||
export function severityStyle(severity: AttentionSeverity): SeverityStyle {
|
||||
return SEVERITY_STYLE[severity] ?? SEVERITY_STYLE.low;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decisions-only badge count. Every feed row *is* a pending decision (the
|
||||
* server drops anything without a decision verb into Activity, per the §0
|
||||
* invariant), and mentions/unread never enter the feed — so the row count is
|
||||
* the decisions-only number. `/inbox` keeps its own unread count untouched.
|
||||
*/
|
||||
export function attentionBadgeCount(feed: AttentionFeed | null | undefined): number {
|
||||
return feed?.items.length ?? 0;
|
||||
}
|
||||
@@ -101,6 +101,7 @@ describe("blockedInbox", () => {
|
||||
"blocked_by_assigned_backlog_issue",
|
||||
"blocked_by_cancelled_issue",
|
||||
"blocked_by_uninvokable_assignee",
|
||||
"dead_in_water",
|
||||
"in_review_without_action_path",
|
||||
"invalid_review_participant",
|
||||
"open_recovery_issue",
|
||||
|
||||
@@ -21,6 +21,7 @@ const VARIANT_BY_REASON: Record<IssueBlockedInboxReason, BlockedReasonVariant> =
|
||||
blocked_by_unassigned_issue: "needs_attention",
|
||||
blocked_by_assigned_backlog_issue: "needs_attention",
|
||||
blocked_by_cancelled_issue: "needs_attention",
|
||||
dead_in_water: "needs_attention",
|
||||
in_review_without_action_path: "needs_attention",
|
||||
invalid_review_participant: "needs_attention",
|
||||
open_recovery_issue: "recovery_required",
|
||||
@@ -54,6 +55,7 @@ const REASON_LABELS: Record<IssueBlockedInboxReason, string> = {
|
||||
blocked_by_unassigned_issue: "Unassigned blocker",
|
||||
blocked_by_assigned_backlog_issue: "Parked blocker",
|
||||
blocked_by_cancelled_issue: "Cancelled blocker",
|
||||
dead_in_water: "Dead in the water",
|
||||
in_review_without_action_path: "Review without action path",
|
||||
invalid_review_participant: "Invalid review participant",
|
||||
open_recovery_issue: "Recovery in progress",
|
||||
|
||||
@@ -17,6 +17,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"costs",
|
||||
"usage",
|
||||
"activity",
|
||||
"attention",
|
||||
"inbox",
|
||||
"board-chat",
|
||||
"artifacts",
|
||||
|
||||
@@ -267,6 +267,7 @@ export const queryKeys = {
|
||||
["company-search", companyId, q, scope, limit, offset] as const,
|
||||
},
|
||||
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||
attention: (companyId: string) => ["attention", companyId] 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,140 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Inbox } from "lucide-react";
|
||||
import type { Agent, AttentionItem } from "@paperclipai/shared";
|
||||
import { attentionApi } from "../api/attention";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useInboxDismissals } from "../hooks/useInboxBadge";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { isInlineResolvable } from "../lib/attention";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AttentionQueueRow } from "../components/AttentionQueueRow";
|
||||
|
||||
export function WhatNeedsMe() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [autoExpandDone, setAutoExpandDone] = useState(false);
|
||||
const [clearedIds, setClearedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const { dismiss, dismissedAtByKey } = useInboxDismissals(selectedCompanyId);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "What needs me" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: feed, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.attention(selectedCompanyId!),
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
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]);
|
||||
|
||||
// Client-side guard: the server already filters dismissed rows, but this
|
||||
// keeps a just-dismissed row from flashing back before the refetch lands.
|
||||
const items = useMemo(() => {
|
||||
const list = feed?.items ?? [];
|
||||
return list.filter((item) => {
|
||||
if (clearedIds.has(item.id)) return false;
|
||||
const dismissedAt = dismissedAtByKey.get(item.dismissalKey);
|
||||
return !(dismissedAt != null && dismissedAt >= new Date(item.activityAt).getTime());
|
||||
});
|
||||
}, [feed, clearedIds, dismissedAtByKey]);
|
||||
|
||||
// Auto-expand the topmost inline-capable decision, once, per converged UX.
|
||||
// Rank never reorders while interacting, so we only seed the default once.
|
||||
useEffect(() => {
|
||||
if (autoExpandDone || items.length === 0) return;
|
||||
const topInline = items.find((item) => isInlineResolvable(item));
|
||||
if (topInline) setExpandedId(topInline.id);
|
||||
setAutoExpandDone(true);
|
||||
}, [items, autoExpandDone]);
|
||||
|
||||
const handleDismiss = (item: AttentionItem) => {
|
||||
dismiss(item.dismissalKey);
|
||||
setClearedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
if (expandedId === item.id) setExpandedId(null);
|
||||
};
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="approvals" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-xl font-bold">What needs me</h1>
|
||||
{items.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{items.length} {items.length === 1 ? "decision" : "decisions"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<ZeroState />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
|
||||
onDismiss={handleDismiss}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZeroState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<div className="mb-4 rounded-full bg-green-500/10 p-4">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">You're all caught up</p>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Inbox className="h-4 w-4" />
|
||||
Nothing needs a decision from you right now.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CheckCircle2, Inbox } from "lucide-react";
|
||||
import type { AttentionItem, AttentionSourceKind, AttentionSeverity } from "@paperclipai/shared";
|
||||
import { AttentionQueueRow } from "@/components/AttentionQueueRow";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
|
||||
function item(
|
||||
id: string,
|
||||
sourceKind: AttentionSourceKind,
|
||||
severity: AttentionSeverity,
|
||||
title: string,
|
||||
whyNow: string,
|
||||
overrides: Partial<AttentionItem> = {},
|
||||
): AttentionItem {
|
||||
const now = new Date("2026-07-09T12:00:00Z");
|
||||
return {
|
||||
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`,
|
||||
severity,
|
||||
rank: 0,
|
||||
activityAt: now.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
relatedIssue: {
|
||||
kind: "issue",
|
||||
id: "issue-1000",
|
||||
companyId,
|
||||
title: "Ship the attention queue",
|
||||
identifier: "PAP-1000",
|
||||
status: "in_progress",
|
||||
href: "/PAP/issues/PAP-1000",
|
||||
metadata: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const POPULATED: AttentionItem[] = [
|
||||
item(
|
||||
"recov-1",
|
||||
"recovery_action",
|
||||
"critical",
|
||||
"Run watchdog escalated — agent stalled 40m",
|
||||
"Recovery action escalated and needs a human decision.",
|
||||
{ subject: { kind: "recovery_action", id: "r1", companyId, title: "Run watchdog escalated — agent stalled 40m", identifier: null, status: "escalated", href: "/PAP/issues/PAP-1000", metadata: {} } },
|
||||
),
|
||||
item(
|
||||
"appr-1",
|
||||
"approval",
|
||||
"high",
|
||||
"Hire agent: Research Analyst",
|
||||
"Approval is pending a board decision.",
|
||||
{
|
||||
inlineResolvable: true,
|
||||
subject: { kind: "approval", id: "approval-1", companyId, title: "Hire agent: Research Analyst", identifier: null, status: "pending", href: "/PAP/approvals/approval-1", metadata: { type: "hire_agent" } },
|
||||
relatedIssue: null,
|
||||
decisionVerbs: [
|
||||
{ id: "approve", label: "Approve", description: null },
|
||||
{ id: "reject", label: "Reject", description: null },
|
||||
{ id: "request_revision", label: "Request revision", description: null },
|
||||
],
|
||||
},
|
||||
),
|
||||
item(
|
||||
"intx-1",
|
||||
"issue_thread_interaction",
|
||||
"medium",
|
||||
"Which rollout order should we use?",
|
||||
"Questions need answers on an issue thread.",
|
||||
{
|
||||
inlineResolvable: true,
|
||||
subject: { kind: "interaction", id: "interaction-1", companyId, title: "Which rollout order should we use?", identifier: null, status: "pending", href: "/PAP/issues/PAP-1000#interaction-1", metadata: { kind: "ask_user_questions", issueId: "issue-1000" } },
|
||||
decisionVerbs: [{ id: "respond", label: "Respond", description: null }],
|
||||
},
|
||||
),
|
||||
item(
|
||||
"review-1",
|
||||
"review",
|
||||
"medium",
|
||||
"PR ready for review: attention feed endpoint",
|
||||
"In-review issue is waiting on a human reviewer.",
|
||||
{
|
||||
inlineResolvable: false,
|
||||
decisionVerbs: [
|
||||
{ id: "approve", label: "Approve", description: null },
|
||||
{ id: "request_changes", label: "Request changes", description: null },
|
||||
],
|
||||
},
|
||||
),
|
||||
item(
|
||||
"join-1",
|
||||
"join_request",
|
||||
"medium",
|
||||
"alex@acme.dev wants to join",
|
||||
"Join request is pending approval.",
|
||||
{
|
||||
inlineResolvable: true,
|
||||
subject: { kind: "join_request", id: "join-1", companyId, title: "alex@acme.dev wants to join", identifier: null, status: "pending_approval", href: "/PAP/settings/access", metadata: {} },
|
||||
relatedIssue: null,
|
||||
},
|
||||
),
|
||||
item(
|
||||
"fail-1",
|
||||
"failed_run",
|
||||
"high",
|
||||
"Deploy pipeline failed after 3 retries",
|
||||
"Retries are exhausted; a human action is needed.",
|
||||
{ relatedIssue: null, inlineResolvable: false },
|
||||
),
|
||||
item(
|
||||
"budget-1",
|
||||
"budget_alert",
|
||||
"low",
|
||||
"Company budget crossed 85%",
|
||||
"Budget crossed the 85% threshold.",
|
||||
{ relatedIssue: null, inlineResolvable: false },
|
||||
),
|
||||
];
|
||||
|
||||
function Queue({ items }: { items: AttentionItem[] }) {
|
||||
const firstInline = items.find((i) => i.inlineResolvable && (i.sourceKind === "approval" || i.sourceKind === "join_request"));
|
||||
const [expandedId, setExpandedId] = useState<string | null>(firstInline?.id ?? null);
|
||||
const [cleared, setCleared] = useState<Set<string>>(new Set());
|
||||
const visible = items.filter((i) => !cleared.has(i.id));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4 p-6">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-xl font-bold">What needs me</h1>
|
||||
{visible.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{visible.length} {visible.length === 1 ? "decision" : "decisions"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<div className="mb-4 rounded-full bg-green-500/10 p-4">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">You're all caught up</p>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Inbox className="h-4 w-4" />
|
||||
Nothing needs a decision from you right now.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{visible.map((it) => (
|
||||
<AttentionQueueRow
|
||||
key={it.id}
|
||||
item={it}
|
||||
companyId={companyId}
|
||||
expanded={expandedId === it.id}
|
||||
onToggleExpand={() => setExpandedId((p) => (p === it.id ? null : it.id))}
|
||||
onDismiss={(dismissed) => setCleared((prev) => new Set(prev).add(dismissed.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Queue> = {
|
||||
title: "Pages/What needs me",
|
||||
component: Queue,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Queue>;
|
||||
|
||||
export const Populated: Story = {
|
||||
args: { items: POPULATED },
|
||||
};
|
||||
|
||||
export const ZeroState: Story = {
|
||||
args: { items: [] },
|
||||
};
|
||||
Reference in new issue
Block a user