Fix wake diagnostics low-trust identifier redaction (#9133)
## Thinking Path
> - Paperclip is the open-source app people use to manage AI agents for
work
> - The task/issue lifecycle subsystem tracks when agents wake up, are
suppressed, or are deferred, recording each wake request in
`agent_wakeup_requests` and each defer/suppression event in
`activity_log`
> - When an agent appears stuck or doesn't resume after a dependency
resolves, there is currently no read-only API surface to inspect its
wake history — operators must query the database directly
> - Making wake history queryable via a first-class endpoint lets
operators, support, and monitoring tools diagnose "why didn't this agent
wake up?" without database access
> - This pull request adds `GET /api/issues/:id/diagnostics/wakes`,
returning a bounded 14-day/50-row projection of wake requests and
defer/suppression activity events, with a deterministic `diagnosis`
field and a `likelyReason` inference — including a Case-B inference ("no
wake enqueued because a visible blocker is not done") that reuses the
blocker readiness data from the companion blocker diagnostics endpoint
(see Refs #9114)
> - The benefit is that platform operators can answer "why is this agent
not waking up?" from a safe, read-only HTTP endpoint rather than needing
direct database access, and CI/monitoring can assert expected wake
behavior
## Linked Issues or Issue Description
Refs #9114 (companion blocker diagnostics endpoint, already merged —
this PR extends the same diagnostic surface to wake/activity history)
## What Changed
- **New route** `GET /api/issues/:id/diagnostics/wakes` in
`server/src/routes/issues.ts`: returns a bounded (14-day window, 50-row
cap) projection of `agent_wakeup_requests` rows and wake-relevant
`activity_log` rows (defer/suppression events)
- **Sanitized projection**: raw `payload`, `details`, `error`, and
`triggerDetail` fields are stripped; unknown free-form `source`,
`reason`, and `status` values are projected to `"other"` to prevent
schema bleed
- **Deterministic `diagnosis` and `likelyReason` fields**: includes
Case-B inference ("no wake enqueued — visible blocker not done") that
calls the existing blocker-readiness helper from Slice 1 (#9114) so the
wake surface can explain missing wakes caused by outstanding blockers
- **Auth**: `assertCompanyAccess` + `assertIssueReadAllowed`;
cross-company requests are denied; Case-B blocker inference filters by
caller trust level so hidden (low-trust) blockers are mentioned but not
identified
- **Types in `@paperclipai/shared`**: `IssueWakeDiagnosticsResponse`,
`WakeEvent`, `ActivityEvent` exported from the shared package
- **OpenAPI tag registration** for the new route
- **Skill reference docs** in
`skills/paperclip/references/api-reference.md` documenting the endpoint
contract
- **Test coverage**
(`server/src/__tests__/issue-wake-diagnostics-routes.test.ts`, embedded
Postgres): happy path, empty/null diagnosis, Case-B inference, low-trust
hidden blocker, cross-company denial, raw blob minimization, cap
behaviour, combined blocker+wake test run
## Verification
```bash
# Wake diagnostics tests only
pnpm exec vitest run server/src/__tests__/issue-wake-diagnostics-routes.test.ts
# Wake + blocker diagnostics together (integration)
pnpm exec vitest run server/src/__tests__/issue-blocker-diagnostics-routes.test.ts server/src/__tests__/issue-wake-diagnostics-routes.test.ts
# Type-check shared and server packages
pnpm --filter @paperclipai/shared typecheck
pnpm --filter @paperclipai/server typecheck
# Whitespace / diff check
git diff --check
```
All commands passed locally.
## Risks
- **No schema or migration changes** — this is a read-only projection
over existing tables; no DDL risk.
- **Bounded queries** — 14-day window + 50-row cap limit per call; no
unbounded scans.
- **Auth boundary** — cross-company access is denied at
`assertCompanyAccess`; Case-B inference uses the same per-node trust
filtering as the blocker endpoint so low-trust blockers are acknowledged
but not identified.
- Low overall risk; the endpoint is additive and read-only.
## Model Used
- **Provider:** Anthropic
- **Model:** Claude Sonnet 4.6 (`claude-sonnet-4-6`)
- **Tool use:** yes (file reads, edits, bash execution, Paperclip API
calls)
- **Reasoning mode:** standard (no extended thinking)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
295294d7ce
commit
31a0080e61
|
|
@ -646,6 +646,11 @@ export type {
|
|||
IssueBlockerDiagnosticNode,
|
||||
IssueBlockerDiagnosticsReadiness,
|
||||
IssueBlockerDiagnosticsResponse,
|
||||
IssueWakeDiagnosticActivityRecord,
|
||||
IssueWakeDiagnosticEvent,
|
||||
IssueWakeDiagnosticWakeFailureClass,
|
||||
IssueWakeDiagnosticWakeRequest,
|
||||
IssueWakeDiagnosticsResponse,
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
|
|
|
|||
|
|
@ -293,6 +293,11 @@ export type {
|
|||
IssueBlockerDiagnosticNode,
|
||||
IssueBlockerDiagnosticsReadiness,
|
||||
IssueBlockerDiagnosticsResponse,
|
||||
IssueWakeDiagnosticActivityRecord,
|
||||
IssueWakeDiagnosticEvent,
|
||||
IssueWakeDiagnosticWakeFailureClass,
|
||||
IssueWakeDiagnosticWakeRequest,
|
||||
IssueWakeDiagnosticsResponse,
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
|
|
|
|||
|
|
@ -253,6 +253,60 @@ export interface IssueBlockerDiagnosticsResponse {
|
|||
};
|
||||
}
|
||||
|
||||
export type IssueWakeDiagnosticWakeFailureClass = "failed" | "cancelled" | "skipped";
|
||||
|
||||
export interface IssueWakeDiagnosticWakeRequest {
|
||||
kind: "wake_request";
|
||||
agentId: string | null;
|
||||
source: string;
|
||||
reason: string | null;
|
||||
status: string;
|
||||
coalescedCount: number;
|
||||
runId: string | null;
|
||||
requestedAt: string;
|
||||
claimedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
failureClass: IssueWakeDiagnosticWakeFailureClass | null;
|
||||
}
|
||||
|
||||
export interface IssueWakeDiagnosticActivityRecord {
|
||||
kind: "activity";
|
||||
action: string;
|
||||
entityType: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
createdAt: string;
|
||||
source: string | null;
|
||||
requestedReason: string | null;
|
||||
previousReason: string | null;
|
||||
rootIssueId: string | null;
|
||||
holdId: string | null;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type IssueWakeDiagnosticEvent =
|
||||
| IssueWakeDiagnosticWakeRequest
|
||||
| IssueWakeDiagnosticActivityRecord;
|
||||
|
||||
export interface IssueWakeDiagnosticsResponse {
|
||||
issue: IssueBlockerDiagnosticIssueSummary;
|
||||
diagnosis: string | null;
|
||||
likelyReason: string | null;
|
||||
events: IssueWakeDiagnosticEvent[];
|
||||
wakeRequestCount: number;
|
||||
activityRecordCount: number;
|
||||
truncated: boolean;
|
||||
truncatedSections: {
|
||||
wakeRequests: boolean;
|
||||
activityRecords: boolean;
|
||||
};
|
||||
caps: {
|
||||
maxWakeRequests: number;
|
||||
maxActivityRecords: number;
|
||||
lookbackDays: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
|
||||
|
||||
export type IssueBlockerAttentionReason =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,566 @@
|
|||
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,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueRelations,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres wake diagnostic route tests on this host: ${
|
||||
embeddedPostgresSupport.reason ?? "unsupported environment"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
type CompanyRow = typeof companies.$inferSelect;
|
||||
type AgentRow = typeof agents.$inferSelect;
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
|
||||
function createApp(db: Db, actor: Express.Request["actor"]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function boardActor(company: CompanyRow): Express.Request["actor"] {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [company.id],
|
||||
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
|
||||
isInstanceAdmin: true,
|
||||
source: "local_implicit",
|
||||
};
|
||||
}
|
||||
|
||||
function agentActor(company: CompanyRow, agent: AgentRow, runId: string): Express.Request["actor"] {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId: agent.id,
|
||||
companyId: company.id,
|
||||
runId,
|
||||
source: "agent_jwt",
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCompany(db: Db, label = "Wake Diagnostics") {
|
||||
const nonce = randomUUID().slice(0, 8);
|
||||
const [company] = await db.insert(companies).values({
|
||||
name: `${label} ${nonce}`,
|
||||
issuePrefix: `WD${nonce.slice(0, 4).toUpperCase()}`,
|
||||
defaultResponsibleUserId: "board-user",
|
||||
}).returning();
|
||||
return company!;
|
||||
}
|
||||
|
||||
async function seedAgent(db: Db, companyId: string, permissions: Record<string, unknown> = {}) {
|
||||
const [agent] = await db.insert(agents).values({
|
||||
companyId,
|
||||
name: `Agent ${randomUUID().slice(0, 6)}`,
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions,
|
||||
}).returning();
|
||||
return agent!;
|
||||
}
|
||||
|
||||
async function seedProject(db: Db, companyId: string, name: string) {
|
||||
const [project] = await db.insert(projects).values({
|
||||
companyId,
|
||||
name,
|
||||
status: "in_progress",
|
||||
}).returning();
|
||||
return project!;
|
||||
}
|
||||
|
||||
async function seedIssue(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectId?: string | null;
|
||||
title: string;
|
||||
status?: string;
|
||||
assigneeAgentId?: string | null;
|
||||
parentId?: string | null;
|
||||
},
|
||||
) {
|
||||
const [issue] = await db.insert(issues).values({
|
||||
companyId: input.companyId,
|
||||
projectId: input.projectId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
title: input.title,
|
||||
status: input.status ?? "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
responsibleUserId: "board-user",
|
||||
}).returning();
|
||||
return issue!;
|
||||
}
|
||||
|
||||
async function blockIssue(db: Db, companyId: string, blockerIssueId: string, blockedIssueId: string) {
|
||||
await db.insert(issueRelations).values({
|
||||
companyId,
|
||||
issueId: blockerIssueId,
|
||||
relatedIssueId: blockedIssueId,
|
||||
type: "blocks",
|
||||
});
|
||||
}
|
||||
|
||||
async function attachLowTrustRun(db: Db, fixture: {
|
||||
company: CompanyRow;
|
||||
agent: AgentRow;
|
||||
allowedProject: ProjectRow;
|
||||
root: IssueRow;
|
||||
visibleBlocker: IssueRow;
|
||||
}) {
|
||||
const executionPolicy = {
|
||||
authorizationPolicy: {
|
||||
trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET,
|
||||
companyId: fixture.company.id,
|
||||
projectIds: [fixture.allowedProject.id],
|
||||
rootIssueId: fixture.root.id,
|
||||
issueIds: [fixture.root.id, fixture.visibleBlocker.id],
|
||||
allowedAgentIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
await db.update(agents).set({
|
||||
permissions: {
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
authorizationPolicy: executionPolicy.authorizationPolicy,
|
||||
},
|
||||
}).where(eq(agents.id, fixture.agent.id));
|
||||
fixture.agent.permissions = {
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
authorizationPolicy: executionPolicy.authorizationPolicy,
|
||||
};
|
||||
|
||||
const [run] = await db.insert(heartbeatRuns).values({
|
||||
companyId: fixture.company.id,
|
||||
agentId: fixture.agent.id,
|
||||
status: "running",
|
||||
contextSnapshot: {
|
||||
issueId: fixture.root.id,
|
||||
executionPolicy,
|
||||
},
|
||||
}).returning();
|
||||
await db.update(issues).set({
|
||||
assigneeAgentId: fixture.agent.id,
|
||||
checkoutRunId: run!.id,
|
||||
executionRunId: run!.id,
|
||||
executionPolicy,
|
||||
}).where(eq(issues.id, fixture.root.id));
|
||||
return run!;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issue wake diagnostics route", () => {
|
||||
let db!: Db;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-wake-diagnostics-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("returns recent wake rows newest-first with a deterministic diagnosis", async () => {
|
||||
const company = await seedCompany(db);
|
||||
const agent = await seedAgent(db, company.id);
|
||||
const project = await seedProject(db, company.id, "Core");
|
||||
const issue = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
title: "Wake target",
|
||||
status: "todo",
|
||||
assigneeAgentId: agent.id,
|
||||
});
|
||||
const wakeRunId = randomUUID();
|
||||
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
source: "automation",
|
||||
reason: "issue_blockers_resolved",
|
||||
status: "completed",
|
||||
coalescedCount: 2,
|
||||
payload: { issueId: issue.id, rawMarker: "SHOULD_NOT_LEAK" },
|
||||
runId: wakeRunId,
|
||||
requestedAt: new Date(Date.now() - 10_000),
|
||||
claimedAt: new Date(Date.now() - 9_000),
|
||||
finishedAt: new Date(Date.now() - 1_000),
|
||||
});
|
||||
|
||||
const res = await request(createApp(db, boardActor(company)))
|
||||
.get(`/api/issues/${issue.id}/diagnostics/wakes`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
diagnosis: expect.stringContaining("completed for issue_blockers_resolved"),
|
||||
likelyReason: expect.stringContaining("completed for issue_blockers_resolved"),
|
||||
wakeRequestCount: 1,
|
||||
activityRecordCount: 0,
|
||||
truncated: false,
|
||||
caps: { maxWakeRequests: 50, maxActivityRecords: 50, lookbackDays: 14 },
|
||||
});
|
||||
expect(res.body.events).toHaveLength(1);
|
||||
expect(res.body.events[0]).toMatchObject({
|
||||
kind: "wake_request",
|
||||
agentId: agent.id,
|
||||
runId: wakeRunId,
|
||||
source: "automation",
|
||||
reason: "issue_blockers_resolved",
|
||||
status: "completed",
|
||||
coalescedCount: 2,
|
||||
failureClass: null,
|
||||
});
|
||||
const serialized = JSON.stringify(res.body);
|
||||
expect(serialized).not.toContain("SHOULD_NOT_LEAK");
|
||||
expect(serialized).not.toContain("\"payload\"");
|
||||
expect(serialized).not.toContain("\"details\"");
|
||||
expect(serialized).not.toContain("\"triggerDetail\"");
|
||||
expect(serialized).not.toContain("\"error\"");
|
||||
});
|
||||
|
||||
it("returns null diagnosis for an unblocked issue with no wake history", async () => {
|
||||
const company = await seedCompany(db);
|
||||
const project = await seedProject(db, company.id, "Core");
|
||||
const issue = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
title: "Quiet issue",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const res = await request(createApp(db, boardActor(company)))
|
||||
.get(`/api/issues/${issue.id}/diagnostics/wakes`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.diagnosis).toBeNull();
|
||||
expect(res.body.likelyReason).toBeNull();
|
||||
expect(res.body.events).toEqual([]);
|
||||
expect(res.body.wakeRequestCount).toBe(0);
|
||||
expect(res.body.activityRecordCount).toBe(0);
|
||||
});
|
||||
|
||||
it("infers Case-B never-enqueued blockers-resolved wake from visible blocker state", async () => {
|
||||
const company = await seedCompany(db);
|
||||
const agent = await seedAgent(db, company.id);
|
||||
const project = await seedProject(db, company.id, "Core");
|
||||
const root = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
title: "Blocked root",
|
||||
status: "blocked",
|
||||
assigneeAgentId: agent.id,
|
||||
});
|
||||
const blocker = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
title: "Unfinished blocker",
|
||||
status: "in_progress",
|
||||
});
|
||||
await blockIssue(db, company.id, blocker.id, root.id);
|
||||
|
||||
const res = await request(createApp(db, boardActor(company)))
|
||||
.get(`/api/issues/${root.id}/diagnostics/wakes`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.events).toEqual([]);
|
||||
expect(res.body.diagnosis).toContain("No wake row exists");
|
||||
expect(res.body.diagnosis).toContain("Unfinished blocker");
|
||||
expect(res.body.diagnosis).toContain("in_progress");
|
||||
expect(res.body.diagnosis).toContain("issue_blockers_resolved has not fired");
|
||||
});
|
||||
|
||||
it("omits hidden blocker state from Case-B diagnosis for boundary-scoped agents", async () => {
|
||||
const company = await seedCompany(db);
|
||||
const agent = await seedAgent(db, company.id);
|
||||
const allowedProject = await seedProject(db, company.id, "Allowed");
|
||||
const hiddenProject = await seedProject(db, company.id, "Hidden");
|
||||
const hiddenMarker = `HIDDEN-WAKE-BLOCKER-${randomUUID()}`;
|
||||
const root = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: allowedProject.id,
|
||||
title: "Scoped root",
|
||||
status: "blocked",
|
||||
});
|
||||
const visibleBlocker = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: allowedProject.id,
|
||||
title: "Visible blocker",
|
||||
status: "in_progress",
|
||||
});
|
||||
const hiddenBlocker = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: hiddenProject.id,
|
||||
title: hiddenMarker,
|
||||
status: "cancelled",
|
||||
});
|
||||
await blockIssue(db, company.id, visibleBlocker.id, root.id);
|
||||
await blockIssue(db, company.id, hiddenBlocker.id, root.id);
|
||||
const run = await attachLowTrustRun(db, { company, agent, allowedProject, root, visibleBlocker });
|
||||
|
||||
const res = await request(createApp(db, agentActor(company, agent, run.id)))
|
||||
.get(`/api/issues/${root.id}/diagnostics/wakes`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.diagnosis).toContain("authorization boundary");
|
||||
const serialized = JSON.stringify(res.body);
|
||||
expect(serialized).not.toContain(hiddenBlocker.id);
|
||||
expect(serialized).not.toContain(hiddenMarker);
|
||||
expect(serialized).not.toContain("cancelled");
|
||||
|
||||
const hiddenAgent = await seedAgent(db, company.id);
|
||||
const wakeRunId = randomUUID();
|
||||
const [activityRun] = await db.insert(heartbeatRuns).values({
|
||||
companyId: company.id,
|
||||
agentId: hiddenAgent.id,
|
||||
status: "succeeded",
|
||||
}).returning();
|
||||
const activityRunId = activityRun!.id;
|
||||
const holdId = randomUUID();
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId: company.id,
|
||||
agentId: hiddenAgent.id,
|
||||
source: "automation",
|
||||
reason: "issue_blockers_resolved",
|
||||
status: "completed",
|
||||
coalescedCount: 0,
|
||||
payload: { issueId: root.id },
|
||||
runId: wakeRunId,
|
||||
requestedAt: new Date(Date.now() - 5_000),
|
||||
claimedAt: new Date(Date.now() - 4_000),
|
||||
finishedAt: new Date(Date.now() - 3_000),
|
||||
});
|
||||
await db.insert(activityLog).values({
|
||||
companyId: company.id,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "issue.tree_hold_wakeup_deferred",
|
||||
entityType: "issue",
|
||||
entityId: root.id,
|
||||
agentId: hiddenAgent.id,
|
||||
runId: activityRunId,
|
||||
details: {
|
||||
rootIssueId: root.id,
|
||||
agentId: hiddenAgent.id,
|
||||
holdId,
|
||||
source: "automation",
|
||||
requestedReason: "issue_blockers_resolved",
|
||||
},
|
||||
createdAt: new Date(Date.now() - 1_000),
|
||||
});
|
||||
|
||||
const resWithEvents = await request(createApp(db, agentActor(company, agent, run.id)))
|
||||
.get(`/api/issues/${root.id}/diagnostics/wakes`);
|
||||
|
||||
expect(resWithEvents.status, JSON.stringify(resWithEvents.body)).toBe(200);
|
||||
expect(resWithEvents.body.events).toHaveLength(2);
|
||||
const activityEvent = resWithEvents.body.events.find((event: { kind: string }) => event.kind === "activity");
|
||||
const wakeEvent = resWithEvents.body.events.find((event: { kind: string }) => event.kind === "wake_request");
|
||||
expect(activityEvent).toMatchObject({
|
||||
kind: "activity",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
holdId: null,
|
||||
});
|
||||
expect(wakeEvent).toMatchObject({
|
||||
kind: "wake_request",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
});
|
||||
const serializedWithEvents = JSON.stringify(resWithEvents.body);
|
||||
expect(serializedWithEvents).not.toContain(hiddenBlocker.id);
|
||||
expect(serializedWithEvents).not.toContain(hiddenMarker);
|
||||
expect(serializedWithEvents).not.toContain(hiddenAgent.id);
|
||||
expect(serializedWithEvents).not.toContain(wakeRunId);
|
||||
expect(serializedWithEvents).not.toContain(activityRunId);
|
||||
expect(serializedWithEvents).not.toContain(holdId);
|
||||
});
|
||||
|
||||
it("denies cross-company issue reads", async () => {
|
||||
const companyA = await seedCompany(db, "Company A");
|
||||
const companyB = await seedCompany(db, "Company B");
|
||||
const agentB = await seedAgent(db, companyB.id);
|
||||
const projectA = await seedProject(db, companyA.id, "A");
|
||||
const issueA = await seedIssue(db, {
|
||||
companyId: companyA.id,
|
||||
projectId: projectA.id,
|
||||
title: "Company A issue",
|
||||
status: "blocked",
|
||||
});
|
||||
const [runB] = await db.insert(heartbeatRuns).values({
|
||||
companyId: companyB.id,
|
||||
agentId: agentB.id,
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: issueA.id },
|
||||
}).returning();
|
||||
|
||||
const res = await request(createApp(db, agentActor(companyB, agentB, runB!.id)))
|
||||
.get(`/api/issues/${issueA.id}/diagnostics/wakes`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
});
|
||||
|
||||
it("projects activity records and wake failures without raw blobs", async () => {
|
||||
const company = await seedCompany(db);
|
||||
const agent = await seedAgent(db, company.id);
|
||||
const project = await seedProject(db, company.id, "Core");
|
||||
const issue = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
title: "Held issue",
|
||||
status: "todo",
|
||||
assigneeAgentId: agent.id,
|
||||
});
|
||||
const rawMarker = `RAW-DETAIL-${randomUUID()}`;
|
||||
const [activityRun] = await db.insert(heartbeatRuns).values({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
status: "succeeded",
|
||||
}).returning();
|
||||
const activityRunId = activityRun!.id;
|
||||
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
source: "automation",
|
||||
reason: "unknown-private-reason",
|
||||
status: "failed",
|
||||
payload: { issueId: issue.id, privateValue: rawMarker },
|
||||
error: `secret stack ${rawMarker}`,
|
||||
requestedAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
await db.insert(activityLog).values({
|
||||
companyId: company.id,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "issue.tree_hold_wakeup_deferred",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
agentId: agent.id,
|
||||
runId: activityRunId,
|
||||
details: {
|
||||
rootIssueId: issue.id,
|
||||
holdId: "hold-safe",
|
||||
source: "automation",
|
||||
requestedReason: "issue_commented",
|
||||
triggerDetail: rawMarker,
|
||||
secret: rawMarker,
|
||||
},
|
||||
createdAt: new Date(Date.now() - 1_000),
|
||||
});
|
||||
|
||||
const res = await request(createApp(db, boardActor(company)))
|
||||
.get(`/api/issues/${issue.id}/diagnostics/wakes`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.diagnosis).toContain("deferred by an active issue-tree hold");
|
||||
expect(res.body.events).toHaveLength(2);
|
||||
expect(res.body.events[0]).toMatchObject({
|
||||
kind: "activity",
|
||||
action: "issue.tree_hold_wakeup_deferred",
|
||||
source: "automation",
|
||||
requestedReason: "issue_commented",
|
||||
agentId: agent.id,
|
||||
runId: activityRunId,
|
||||
holdId: "hold-safe",
|
||||
summary: "Wake was deferred because an active issue-tree hold was present.",
|
||||
});
|
||||
expect(res.body.events[1]).toMatchObject({
|
||||
kind: "wake_request",
|
||||
reason: "other",
|
||||
status: "failed",
|
||||
failureClass: "failed",
|
||||
});
|
||||
const serialized = JSON.stringify(res.body);
|
||||
expect(serialized).not.toContain(rawMarker);
|
||||
expect(serialized).not.toContain("\"payload\"");
|
||||
expect(serialized).not.toContain("\"details\"");
|
||||
expect(serialized).not.toContain("\"triggerDetail\"");
|
||||
expect(serialized).not.toContain("\"error\"");
|
||||
});
|
||||
|
||||
it("caps wake output and reports truncation", async () => {
|
||||
const company = await seedCompany(db);
|
||||
const agent = await seedAgent(db, company.id);
|
||||
const project = await seedProject(db, company.id, "Core");
|
||||
const issue = await seedIssue(db, {
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
title: "Noisy issue",
|
||||
status: "todo",
|
||||
assigneeAgentId: agent.id,
|
||||
});
|
||||
const wakeRows = [];
|
||||
for (let index = 0; index < 51; index += 1) {
|
||||
wakeRows.push({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
source: "automation",
|
||||
reason: "issue_commented",
|
||||
status: "completed",
|
||||
payload: { issueId: issue.id },
|
||||
requestedAt: new Date(Date.now() - index * 1_000),
|
||||
});
|
||||
}
|
||||
await db.insert(agentWakeupRequests).values(wakeRows);
|
||||
|
||||
const res = await request(createApp(db, boardActor(company)))
|
||||
.get(`/api/issues/${issue.id}/diagnostics/wakes`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.events).toHaveLength(50);
|
||||
expect(res.body.wakeRequestCount).toBe(50);
|
||||
expect(res.body.truncated).toBe(true);
|
||||
expect(res.body.truncatedSections).toEqual({ wakeRequests: true, activityRecords: false });
|
||||
expect(res.body.diagnosis).toContain("truncated to 50 wake requests");
|
||||
expect(res.body.caps).toEqual({ maxWakeRequests: 50, maxActivityRecords: 50, lookbackDays: 14 });
|
||||
});
|
||||
});
|
||||
|
|
@ -69,6 +69,11 @@ import {
|
|||
type IssueBlockerDiagnosticNode,
|
||||
type IssueBlockerDiagnosticsReadiness,
|
||||
type IssueBlockerDiagnosticsResponse,
|
||||
type IssueWakeDiagnosticActivityRecord,
|
||||
type IssueWakeDiagnosticEvent,
|
||||
type IssueWakeDiagnosticWakeFailureClass,
|
||||
type IssueWakeDiagnosticWakeRequest,
|
||||
type IssueWakeDiagnosticsResponse,
|
||||
type IssueRelationIssueSummary,
|
||||
type IssueWatchdogDiscoveryKind,
|
||||
type SourceTrustMetadata,
|
||||
|
|
@ -135,6 +140,9 @@ import { feedbackService } from "../services/feedback.js";
|
|||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import {
|
||||
ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
|
||||
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
||||
ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
||||
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
||||
readAcceptedPlanConfirmationTarget,
|
||||
} from "../services/issues.js";
|
||||
import { authorizationDeniedDetails } from "../services/authorization.js";
|
||||
|
|
@ -926,6 +934,313 @@ function buildIssueBlockerDiagnosis(input: {
|
|||
return null;
|
||||
}
|
||||
|
||||
const ISSUE_WAKE_DIAGNOSTIC_KNOWN_SOURCES = new Set([
|
||||
"timer",
|
||||
"assignment",
|
||||
"on_demand",
|
||||
"automation",
|
||||
]);
|
||||
|
||||
const ISSUE_WAKE_DIAGNOSTIC_KNOWN_REASONS = new Set([
|
||||
"issue_assigned",
|
||||
"issue_blockers_resolved",
|
||||
"issue_commented",
|
||||
"issue_comment_mentioned",
|
||||
"issue_dependencies_blocked",
|
||||
"issue_tree_hold_active",
|
||||
"missing_issue_comment",
|
||||
"process_lost_retry",
|
||||
"run_liveness_continuation",
|
||||
"heartbeat.disabled",
|
||||
"heartbeat.timer.no_actionable_work",
|
||||
"heartbeat.wakeOnDemand.disabled",
|
||||
]);
|
||||
|
||||
const ISSUE_WAKE_DIAGNOSTIC_KNOWN_STATUSES = new Set([
|
||||
"queued",
|
||||
"claimed",
|
||||
"coalesced",
|
||||
"skipped",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"deferred_issue_execution",
|
||||
]);
|
||||
|
||||
function dateToIso(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function projectWakeDiagnosticSource(value: string | null) {
|
||||
if (!value) return null;
|
||||
return ISSUE_WAKE_DIAGNOSTIC_KNOWN_SOURCES.has(value) ? value : "other";
|
||||
}
|
||||
|
||||
function projectWakeDiagnosticReason(value: string | null) {
|
||||
if (!value) return null;
|
||||
return ISSUE_WAKE_DIAGNOSTIC_KNOWN_REASONS.has(value) ? value : "other";
|
||||
}
|
||||
|
||||
function projectWakeDiagnosticStatus(value: string) {
|
||||
return ISSUE_WAKE_DIAGNOSTIC_KNOWN_STATUSES.has(value) ? value : "other";
|
||||
}
|
||||
|
||||
function wakeFailureClass(
|
||||
status: string,
|
||||
rawError: string | null,
|
||||
): IssueWakeDiagnosticWakeFailureClass | null {
|
||||
if (status === "failed" || rawError) return "failed";
|
||||
if (status === "cancelled") return "cancelled";
|
||||
if (status === "skipped") return "skipped";
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectIssueWakeRequest(row: {
|
||||
agentId: string;
|
||||
source: string;
|
||||
reason: string | null;
|
||||
status: string;
|
||||
coalescedCount: number;
|
||||
runId: string | null;
|
||||
requestedAt: Date | string;
|
||||
claimedAt: Date | string | null;
|
||||
finishedAt: Date | string | null;
|
||||
error: string | null;
|
||||
}, options: { includeInternalIds: boolean }): IssueWakeDiagnosticWakeRequest {
|
||||
const status = projectWakeDiagnosticStatus(row.status);
|
||||
return {
|
||||
kind: "wake_request",
|
||||
agentId: options.includeInternalIds ? row.agentId : null,
|
||||
source: projectWakeDiagnosticSource(row.source) ?? "other",
|
||||
reason: projectWakeDiagnosticReason(row.reason),
|
||||
status,
|
||||
coalescedCount: row.coalescedCount,
|
||||
runId: options.includeInternalIds ? row.runId : null,
|
||||
requestedAt: dateToIso(row.requestedAt)!,
|
||||
claimedAt: dateToIso(row.claimedAt),
|
||||
finishedAt: dateToIso(row.finishedAt),
|
||||
failureClass: wakeFailureClass(status, row.error),
|
||||
};
|
||||
}
|
||||
|
||||
function wakeDiagnosticActivityAction(action: string) {
|
||||
return action === "issue.tree_hold_wakeup_deferred" ? action : "other";
|
||||
}
|
||||
|
||||
function wakeDiagnosticActivityEntityType(entityType: string) {
|
||||
return entityType === "issue" || entityType === "agent_wakeup_request" ? entityType : "other";
|
||||
}
|
||||
|
||||
function projectIssueWakeActivityRecord(
|
||||
row: {
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date | string;
|
||||
},
|
||||
issueId: string,
|
||||
options: { includeInternalIds: boolean },
|
||||
): IssueWakeDiagnosticActivityRecord {
|
||||
const details = row.details && typeof row.details === "object" ? row.details : {};
|
||||
const action = wakeDiagnosticActivityAction(row.action);
|
||||
const rootIssueId = readNonEmptyString(details["rootIssueId"]);
|
||||
const detailIssueId = readNonEmptyString(details["issueId"]);
|
||||
const projectedRootIssueId =
|
||||
rootIssueId === issueId || detailIssueId === issueId || (row.entityType === "issue" && row.entityId === issueId)
|
||||
? issueId
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind: "activity",
|
||||
action,
|
||||
entityType: wakeDiagnosticActivityEntityType(row.entityType),
|
||||
agentId: options.includeInternalIds ? row.agentId ?? readNonEmptyString(details["agentId"]) : null,
|
||||
runId: options.includeInternalIds ? row.runId : null,
|
||||
createdAt: dateToIso(row.createdAt)!,
|
||||
source: projectWakeDiagnosticSource(readNonEmptyString(details["source"])),
|
||||
requestedReason: projectWakeDiagnosticReason(readNonEmptyString(details["requestedReason"])),
|
||||
previousReason: projectWakeDiagnosticReason(readNonEmptyString(details["previousReason"])),
|
||||
rootIssueId: projectedRootIssueId,
|
||||
holdId: options.includeInternalIds ? readNonEmptyString(details["holdId"]) : null,
|
||||
summary: action === "issue.tree_hold_wakeup_deferred"
|
||||
? "Wake was deferred because an active issue-tree hold was present."
|
||||
: "Wake-related activity was recorded.",
|
||||
};
|
||||
}
|
||||
|
||||
function issueWakeDiagnosticEventTimestamp(event: IssueWakeDiagnosticEvent) {
|
||||
const timestamp = event.kind === "wake_request" ? event.requestedAt : event.createdAt;
|
||||
return new Date(timestamp).getTime();
|
||||
}
|
||||
|
||||
function wakeDiagnosticReasonPhrase(reason: string | null) {
|
||||
return reason ? ` for ${reason}` : "";
|
||||
}
|
||||
|
||||
function buildIssueWakeDiagnosis(input: {
|
||||
issue: IssueBlockerDiagnosticIssueSummary;
|
||||
events: IssueWakeDiagnosticEvent[];
|
||||
blockerDiagnostics: IssueBlockerDiagnosticsResponse;
|
||||
truncated: boolean;
|
||||
}) {
|
||||
if (input.truncated) {
|
||||
return `Wake diagnostics for ${blockerDiagnosticLabel(input.issue)} are truncated to ${
|
||||
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS
|
||||
} wake requests and ${ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS} activity records over ${
|
||||
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS
|
||||
} days, so the diagnosis only covers returned records.`;
|
||||
}
|
||||
|
||||
const latest = input.events[0];
|
||||
if (latest?.kind === "activity" && latest.action === "issue.tree_hold_wakeup_deferred") {
|
||||
return `The most recent wake-related activity for ${blockerDiagnosticLabel(
|
||||
input.issue,
|
||||
)} was deferred by an active issue-tree hold.`;
|
||||
}
|
||||
if (latest?.kind === "wake_request") {
|
||||
if (latest.status === "deferred_issue_execution") {
|
||||
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} is deferred${wakeDiagnosticReasonPhrase(
|
||||
latest.reason,
|
||||
)}.`;
|
||||
}
|
||||
if (latest.status === "failed") {
|
||||
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} failed${wakeDiagnosticReasonPhrase(
|
||||
latest.reason,
|
||||
)}; raw error text is withheld.`;
|
||||
}
|
||||
if (latest.status === "skipped" || latest.status === "cancelled" || latest.status === "coalesced") {
|
||||
const coalesced =
|
||||
latest.coalescedCount > 0 ? ` and coalesced ${latest.coalescedCount} additional request(s)` : "";
|
||||
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} was ${latest.status}${wakeDiagnosticReasonPhrase(
|
||||
latest.reason,
|
||||
)}${coalesced}.`;
|
||||
}
|
||||
if (latest.status === "queued" || latest.status === "claimed") {
|
||||
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} is currently ${latest.status}${wakeDiagnosticReasonPhrase(
|
||||
latest.reason,
|
||||
)}.`;
|
||||
}
|
||||
if (latest.status === "completed") {
|
||||
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} completed${wakeDiagnosticReasonPhrase(
|
||||
latest.reason,
|
||||
)}.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.events.length > 0) return null;
|
||||
|
||||
const blockerDiagnostics = input.blockerDiagnostics;
|
||||
if (blockerDiagnostics.truncated) {
|
||||
return `No wake rows are visible for ${blockerDiagnosticLabel(
|
||||
input.issue,
|
||||
)} in the bounded window, and blocker diagnostics are truncated, so no wake cause is inferred.`;
|
||||
}
|
||||
if ((blockerDiagnostics.omittedUnauthorizedBlockerCount ?? 0) > 0) {
|
||||
return `No wake rows are visible for ${blockerDiagnosticLabel(
|
||||
input.issue,
|
||||
)} in the bounded window, and one or more blockers are outside this actor's authorization boundary.`;
|
||||
}
|
||||
if (input.issue.status !== "blocked" || blockerDiagnostics.blockers.length === 0) return null;
|
||||
|
||||
const pendingFinalize = blockerDiagnostics.blockers.find((blocker) => blocker.isPendingFinalize);
|
||||
if (pendingFinalize) {
|
||||
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
|
||||
input.issue,
|
||||
)} is waiting for ${blockerDiagnosticLabel(pendingFinalize)} to finish workspace finalization, so issue_blockers_resolved has not fired.`;
|
||||
}
|
||||
|
||||
const cancelled = blockerDiagnostics.blockers.find((blocker) => blocker.status === "cancelled");
|
||||
if (cancelled) {
|
||||
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
|
||||
input.issue,
|
||||
)} is blocked by ${blockerDiagnosticLabel(cancelled)}, which is cancelled; cancelled blockers do not fire issue_blockers_resolved.`;
|
||||
}
|
||||
|
||||
const unresolved = blockerDiagnostics.blockers.find((blocker) => blocker.isUnresolved);
|
||||
if (unresolved) {
|
||||
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
|
||||
input.issue,
|
||||
)} is blocked by ${blockerDiagnosticLabel(unresolved)}, which is ${unresolved.status}, so issue_blockers_resolved has not fired.`;
|
||||
}
|
||||
|
||||
if (blockerDiagnostics.readiness?.isDependencyReady) {
|
||||
return `No wake row exists for ${blockerDiagnosticLabel(
|
||||
input.issue,
|
||||
)} in the bounded window. All visible blockers are resolved, but the issue is still blocked; this is likely a stale blocker hold or an older wake outside the lookback window.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildIssueWakeDiagnosticsResponse(input: {
|
||||
issue: IssueBlockerDiagnosticReadableIssue;
|
||||
wakeRequests: Array<{
|
||||
agentId: string;
|
||||
source: string;
|
||||
reason: string | null;
|
||||
status: string;
|
||||
coalescedCount: number;
|
||||
runId: string | null;
|
||||
requestedAt: Date | string;
|
||||
claimedAt: Date | string | null;
|
||||
finishedAt: Date | string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
activityRecords: Array<{
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date | string;
|
||||
}>;
|
||||
blockerDiagnostics: IssueBlockerDiagnosticsResponse;
|
||||
truncatedWakeRequests: boolean;
|
||||
truncatedActivityRecords: boolean;
|
||||
includeInternalIds: boolean;
|
||||
}): IssueWakeDiagnosticsResponse {
|
||||
const issue = toIssueBlockerDiagnosticSummary(input.issue);
|
||||
const events: IssueWakeDiagnosticEvent[] = [
|
||||
...input.wakeRequests.map((record) =>
|
||||
projectIssueWakeRequest(record, { includeInternalIds: input.includeInternalIds }),
|
||||
),
|
||||
...input.activityRecords.map((record) =>
|
||||
projectIssueWakeActivityRecord(record, issue.id, { includeInternalIds: input.includeInternalIds }),
|
||||
),
|
||||
].sort((left, right) => issueWakeDiagnosticEventTimestamp(right) - issueWakeDiagnosticEventTimestamp(left));
|
||||
const truncated = input.truncatedWakeRequests || input.truncatedActivityRecords;
|
||||
const diagnosis = buildIssueWakeDiagnosis({
|
||||
issue,
|
||||
events,
|
||||
blockerDiagnostics: input.blockerDiagnostics,
|
||||
truncated,
|
||||
});
|
||||
|
||||
return {
|
||||
issue,
|
||||
diagnosis,
|
||||
likelyReason: diagnosis,
|
||||
events,
|
||||
wakeRequestCount: input.wakeRequests.length,
|
||||
activityRecordCount: input.activityRecords.length,
|
||||
truncated,
|
||||
truncatedSections: {
|
||||
wakeRequests: input.truncatedWakeRequests,
|
||||
activityRecords: input.truncatedActivityRecords,
|
||||
},
|
||||
caps: {
|
||||
maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
||||
maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
||||
lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
|
||||
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
|
||||
|
|
@ -3824,6 +4139,55 @@ export function issueRoutes(
|
|||
res.json(response);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/diagnostics/wakes", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
|
||||
const [wakeDiagnostic, blockerDiagnostic, includeInternalIds] = await Promise.all([
|
||||
svc.getWakeDiagnostics(issue.id),
|
||||
svc.getBlockerDiagnostics(issue.id),
|
||||
actorCanReadCompanyScope(req, issue.companyId),
|
||||
]);
|
||||
const visibleBlockers = await filterIssuesForActor(req, blockerDiagnostic.blockers);
|
||||
const blockerResponse = buildIssueBlockerDiagnosticsResponse({
|
||||
issue,
|
||||
blockers: blockerDiagnostic.blockers,
|
||||
visibleBlockers,
|
||||
readiness: blockerDiagnostic.readiness,
|
||||
truncated: blockerDiagnostic.truncated,
|
||||
});
|
||||
const response = buildIssueWakeDiagnosticsResponse({
|
||||
issue,
|
||||
wakeRequests: wakeDiagnostic.wakeRequests,
|
||||
activityRecords: wakeDiagnostic.activityRecords,
|
||||
blockerDiagnostics: blockerResponse,
|
||||
truncatedWakeRequests: wakeDiagnostic.truncatedWakeRequests,
|
||||
truncatedActivityRecords: wakeDiagnostic.truncatedActivityRecords,
|
||||
includeInternalIds,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
actorType: req.actor.type,
|
||||
wakeRequestCount: response.wakeRequestCount,
|
||||
activityRecordCount: response.activityRecordCount,
|
||||
internalIdsIncluded: includeInternalIds,
|
||||
truncated: response.truncated,
|
||||
},
|
||||
"issue wake diagnostics read",
|
||||
);
|
||||
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
router.get("/issues/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
|
|||
|
|
@ -4963,6 +4963,13 @@ registerCurrentRoute({
|
|||
summary: "Get blocker diagnostics for an issue",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/diagnostics/wakes",
|
||||
tags: ["issues"],
|
||||
summary: "Get wake diagnostics for an issue",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/recovery-actions",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
|
|
@ -101,6 +101,9 @@ const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
|||
export const ISSUE_LIST_DEFAULT_LIMIT = 500;
|
||||
export const ISSUE_LIST_MAX_LIMIT = 1000;
|
||||
export const ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS = 100;
|
||||
export const ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS = 50;
|
||||
export const ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS = 50;
|
||||
export const ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS = 14;
|
||||
const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
|
||||
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
|
||||
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
|
||||
|
|
@ -115,6 +118,25 @@ const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
|
|||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
|
||||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8;
|
||||
const DELETED_ISSUE_COMMENT_BODY = "";
|
||||
const ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS = ["issue.tree_hold_wakeup_deferred"] as const;
|
||||
|
||||
function wakeRequestTargetsIssue(issueId: string) {
|
||||
return sql`(
|
||||
${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}
|
||||
or ${agentWakeupRequests.payload} ->> 'taskId' = ${issueId}
|
||||
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issueId}
|
||||
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issueId}
|
||||
)`;
|
||||
}
|
||||
|
||||
function wakeDiagnosticActivityTargetsIssue(issueId: string) {
|
||||
return sql`(
|
||||
(${activityLog.entityType} = 'issue' and ${activityLog.entityId} = ${issueId})
|
||||
or ${activityLog.details} ->> 'issueId' = ${issueId}
|
||||
or ${activityLog.details} ->> 'rootIssueId' = ${issueId}
|
||||
)`;
|
||||
}
|
||||
|
||||
function assertTransition(from: string, to: string) {
|
||||
if (from === to) return;
|
||||
if (!ALL_ISSUE_STATUSES.includes(to)) {
|
||||
|
|
@ -505,6 +527,27 @@ type IssueBlockerDiagnosticsIssueRow = {
|
|||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
type IssueWakeDiagnosticsWakeRequestRow = {
|
||||
agentId: string;
|
||||
source: string;
|
||||
reason: string | null;
|
||||
status: string;
|
||||
coalescedCount: number;
|
||||
runId: string | null;
|
||||
requestedAt: Date;
|
||||
claimedAt: Date | null;
|
||||
finishedAt: Date | null;
|
||||
error: string | null;
|
||||
};
|
||||
type IssueWakeDiagnosticsActivityRow = {
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
export type IssueDependencyReadiness = {
|
||||
issueId: string;
|
||||
blockerIssueIds: string[];
|
||||
|
|
@ -4912,6 +4955,103 @@ export function issueService(db: Db) {
|
|||
};
|
||||
},
|
||||
|
||||
getWakeDiagnostics: async (
|
||||
issueId: string,
|
||||
opts?: {
|
||||
maxWakeRequests?: number;
|
||||
maxActivityRecords?: number;
|
||||
lookbackDays?: number;
|
||||
},
|
||||
) => {
|
||||
const issue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
const maxWakeRequests = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
opts?.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
||||
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
||||
),
|
||||
);
|
||||
const maxActivityRecords = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
opts?.maxActivityRecords ?? ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
||||
ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
||||
),
|
||||
);
|
||||
const lookbackDays = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
||||
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
||||
),
|
||||
);
|
||||
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const wakeRows = await db
|
||||
.select({
|
||||
agentId: agentWakeupRequests.agentId,
|
||||
source: agentWakeupRequests.source,
|
||||
reason: agentWakeupRequests.reason,
|
||||
status: agentWakeupRequests.status,
|
||||
coalescedCount: agentWakeupRequests.coalescedCount,
|
||||
runId: agentWakeupRequests.runId,
|
||||
requestedAt: agentWakeupRequests.requestedAt,
|
||||
claimedAt: agentWakeupRequests.claimedAt,
|
||||
finishedAt: agentWakeupRequests.finishedAt,
|
||||
error: agentWakeupRequests.error,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, issue.companyId),
|
||||
gte(agentWakeupRequests.requestedAt, since),
|
||||
wakeRequestTargetsIssue(issue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.createdAt))
|
||||
.limit(maxWakeRequests + 1);
|
||||
|
||||
const activityRows = await db
|
||||
.select({
|
||||
action: activityLog.action,
|
||||
entityType: activityLog.entityType,
|
||||
entityId: activityLog.entityId,
|
||||
agentId: activityLog.agentId,
|
||||
runId: activityLog.runId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, issue.companyId),
|
||||
gte(activityLog.createdAt, since),
|
||||
inArray(activityLog.action, [...ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS]),
|
||||
wakeDiagnosticActivityTargetsIssue(issue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.limit(maxActivityRecords + 1);
|
||||
|
||||
return {
|
||||
wakeRequests: wakeRows.slice(0, maxWakeRequests) as IssueWakeDiagnosticsWakeRequestRow[],
|
||||
activityRecords: activityRows.slice(0, maxActivityRecords) as IssueWakeDiagnosticsActivityRow[],
|
||||
truncatedWakeRequests: wakeRows.length > maxWakeRequests,
|
||||
truncatedActivityRecords: activityRows.length > maxActivityRecords,
|
||||
caps: {
|
||||
maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
||||
maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
||||
lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => {
|
||||
const issue = await dbOrTx
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
|
|
|
|||
|
|
@ -230,6 +230,50 @@ Security and bounds:
|
|||
- If blockers are omitted or the result is truncated, `readiness` is `null` and `diagnosis` does not mention hidden blocker ids, statuses, assignees, or reasons.
|
||||
- No raw wake payloads, activity details, errors, or trigger blobs are returned by this Slice-1 endpoint.
|
||||
|
||||
### Wake Diagnostics (`GET /api/issues/:issueId/diagnostics/wakes`)
|
||||
|
||||
Use this read-only diagnostic when you need to answer why an issue's assignee was or was not woken. Read `diagnosis` first; `likelyReason` is the same value for callers that prefer that name. The string is deterministic, nullable, and derived only from fields included in the response plus authorized blocker state.
|
||||
|
||||
The endpoint returns bounded wake/activity events, newest-first across both event kinds:
|
||||
|
||||
```json
|
||||
{
|
||||
"issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null },
|
||||
"diagnosis": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.",
|
||||
"likelyReason": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.",
|
||||
"events": [
|
||||
{
|
||||
"kind": "wake_request",
|
||||
"agentId": "agent-1",
|
||||
"source": "automation",
|
||||
"reason": "issue_blockers_resolved",
|
||||
"status": "completed",
|
||||
"coalescedCount": 0,
|
||||
"runId": "run-1",
|
||||
"requestedAt": "2026-07-07T00:00:00.000Z",
|
||||
"claimedAt": "2026-07-07T00:00:01.000Z",
|
||||
"finishedAt": "2026-07-07T00:00:10.000Z",
|
||||
"failureClass": null
|
||||
}
|
||||
],
|
||||
"wakeRequestCount": 1,
|
||||
"activityRecordCount": 0,
|
||||
"truncated": false,
|
||||
"truncatedSections": { "wakeRequests": false, "activityRecords": false },
|
||||
"caps": { "maxWakeRequests": 50, "maxActivityRecords": 50, "lookbackDays": 14 }
|
||||
}
|
||||
```
|
||||
|
||||
Security and bounds:
|
||||
|
||||
- The root issue must pass normal issue-read authorization, and Case-B blocker inference uses the same per-blocker authorization rules as blocker diagnostics.
|
||||
- Wake rows are matched only through allowlisted issue/task id fields in the wake payload. Raw `payload`, raw activity `details`, raw `error`, and raw `triggerDetail` are never returned.
|
||||
- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`.
|
||||
- Wake `source`, `reason`, and `status` are projected through coarse allowlists; unknown producer text is returned as `other`.
|
||||
- Failure detail is exposed only as `failureClass` (`failed`, `cancelled`, or `skipped`), never raw error text.
|
||||
- Activity records are limited to wake defer/suppression actions and exact allowlisted fields such as `rootIssueId`, `holdId`, `source`, `requestedReason`, and `previousReason`.
|
||||
- Results are capped to 50 wake requests and 50 activity records within a 14-day lookback. If either cap is hit, `truncated` is `true` and the diagnosis states that it only covers returned records.
|
||||
|
||||
### Execution Policy Fields On An Issue
|
||||
|
||||
When an issue has review or approval gates, `GET /api/issues/:issueId` can also include `executionPolicy` and `executionState`:
|
||||
|
|
@ -967,6 +1011,7 @@ Terminal states: `done`, `cancelled`
|
|||
| GET | `/api/issues/:issueId` | Issue details + ancestors |
|
||||
| GET | `/api/issues/:issueId/heartbeat-context` | Compact context for heartbeat: issue state, ancestor summaries, comment cursor |
|
||||
| GET | `/api/issues/:issueId/diagnostics/blockers` | Read-only blocker diagnostic with `diagnosis`, readiness, and bounded anomaly flags |
|
||||
| GET | `/api/issues/:issueId/diagnostics/wakes` | Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference |
|
||||
| POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) |
|
||||
| PATCH | `/api/issues/:issueId` | Update issue (optional `comment` field; `blockedByIssueIds` replaces blocker set) |
|
||||
| POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue