From ac9a883f8b4619fa4e6e70c55303168d53e0e294 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:28:43 -0500 Subject: [PATCH 01/19] Expire ask-user questions superseded by comments (#8799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Issue-thread interactions are how agents ask board users for typed decisions and structured answers inside an issue thread > - Confirmation interactions already become stale when a later board/user comment supersedes the pending decision > - Question interactions had the same workflow risk, because a board/user could answer in a comment while the old question card stayed pending > - This pull request extends the supersede-by-comment lifecycle to ask-user-question interactions and makes that status visible in the UI > - The benefit is agents get a clear continuation signal and users do not see stale question forms after the discussion has moved on ## Linked Issues or Issue Description No exact public GitHub issue was found. Bug report: **Pre-submission checklist** - [x] I have searched existing open and closed issues and this is not a duplicate. - [x] I am on the latest released version of Paperclip, or can reproduce on `master`. - [x] I have confirmed the error originates in Paperclip itself, not in an agent adapter, API provider, or local configuration. **What happened?** Pending `ask_user_questions` interactions could remain open after a later board/user comment changed or answered the request in-thread. That left a stale form visible and kept the interaction in a pending state even though the discussion had moved on. **Expected behavior** Question interactions should follow the same default supersede-on-comment behavior as confirmation interactions, with an explicit expired result that points to the superseding comment. **Steps to reproduce** 1. Create an `ask_user_questions` interaction on an issue. 2. Add a board/user comment created at or after that interaction. 3. Observe that before this change, the question interaction stayed pending instead of expiring as superseded by the comment. **Paperclip version or commit** Current `master` before this PR. **Deployment mode** Self-hosted server or local dev. The bug is in shared issue-thread interaction lifecycle handling. **Installation method** Built from source. **Agent adapter(s) involved** Not adapter-specific. This is a core issue-thread interaction bug. **Database mode** Applies to the normal Paperclip database-backed interaction lifecycle. **Access context** Board user comments supersede agent-created questions. **Relevant logs or output** No crash output. The stale pending interaction was visible in the issue thread state. **Relevant config (if applicable)** None. **Additional context** Confirmation-style interactions already supported this stale-by-comment behavior. This PR brings question interactions into the same lifecycle model. **Privacy checklist** - [x] I have reviewed all pasted output for PII and included no private instance links, local ticket ids, secrets, logs, or screenshots. ## What Changed - Added `supersedeOnUserComment` support to `ask_user_questions` payloads, defaulting it to `true` during interaction creation. - Expire pending question interactions when a later board/user comment supersedes them, including a result with `expirationReason: "superseded_by_comment"` and the superseding `commentId`. - Updated interaction summaries and cards so expired question requests show a clear amber state with a jump link to the comment and correct singular/plural copy. - Updated agent onboarding guidance to describe the new default and how to opt out. - Added shared, server, and UI test coverage for the new lifecycle behavior. ## Verification - `pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/lib/issue-thread-interactions.test.ts --reporter=dot` passed: 5 files, 73 tests. - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` passed. - `pnpm exec vitest run server/src/__tests__/issue-thread-interactions-service.test.ts --reporter=dot && pnpm --filter @paperclipai/server typecheck` passed after the final type-safety cleanup. - Confirmed the branch is rebased on current `origin/master`. - Confirmed the diff does not touch `pnpm-lock.yaml`, `.github/workflows`, or database migrations. ## Risks - Low-to-medium risk: `ask_user_questions` now defaults to expiring after later board/user comments. Existing callers that need questions to stay open through discussion can set `supersedeOnUserComment: false`. - Expired question interactions store an empty `answers` array, so downstream consumers should treat the explicit `expirationReason` as the meaningful outcome. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based coding agent in Paperclip CodexCoder runtime, with terminal and repository tool use. Exact context window is not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/issue-thread-interactions.test.ts | 39 +++ packages/shared/src/types/issue.ts | 3 + packages/shared/src/validators/issue.ts | 3 + .../issue-thread-interaction-routes.test.ts | 10 +- .../issue-thread-interactions-service.test.ts | 227 ++++++++++++++++++ server/src/onboarding-assets/ceo/HEARTBEAT.md | 2 +- .../src/onboarding-assets/default/AGENTS.md | 2 +- .../src/services/issue-thread-interactions.ts | 133 +++++++--- .../IssueThreadInteractionCard.test.tsx | 55 ++++- .../components/IssueThreadInteractionCard.tsx | 18 ++ .../issueThreadInteractionFixtures.ts | 15 ++ ui/src/lib/issue-thread-interactions.test.ts | 28 +++ ui/src/lib/issue-thread-interactions.ts | 6 + 13 files changed, 501 insertions(+), 40 deletions(-) diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index 66ca57ea3a5..fea5c7e5924 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { acceptIssueThreadInteractionSchema, + askUserQuestionsResultSchema, createIssueThreadInteractionSchema, } from "./validators/issue.js"; @@ -103,6 +104,44 @@ describe("issue thread interaction schemas", () => { } }); + it("parses ask_user_questions supersede flags and expired results", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "ask_user_questions", + payload: { + version: 1, + title: "Choose scope", + supersedeOnUserComment: false, + questions: [ + { + id: "scope", + prompt: "Which scope should I use?", + selectionMode: "single", + options: [{ id: "small", label: "Small" }], + }, + ], + }, + }); + + expect(parsed).toMatchObject({ + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + supersedeOnUserComment: false, + }, + }); + + expect(askUserQuestionsResultSchema.parse({ + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId: "11111111-1111-4111-8111-111111111111", + summaryMarkdown: null, + })).toMatchObject({ + expirationReason: "superseded_by_comment", + commentId: "11111111-1111-4111-8111-111111111111", + }); + }); + it("rejects unsafe request_confirmation target hrefs", () => { const base = { kind: "request_confirmation", diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 2cc8d8b7c31..1fed1d34d06 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -760,6 +760,7 @@ export interface AskUserQuestionsPayload { version: 1; title?: string | null; submitLabel?: string | null; + supersedeOnUserComment?: boolean; questions: AskUserQuestionsQuestion[]; } @@ -774,6 +775,8 @@ export interface AskUserQuestionsResult { answers: AskUserQuestionsAnswer[]; cancelled?: true; cancellationReason?: string | null; + expirationReason?: "superseded_by_comment"; + commentId?: string | null; summaryMarkdown?: string | null; } diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index f1e8fcb95e0..b22092ef0d5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -657,6 +657,7 @@ export const askUserQuestionsPayloadSchema = z.object({ version: z.literal(1), title: z.string().trim().max(240).nullable().optional(), submitLabel: z.string().trim().max(120).nullable().optional(), + supersedeOnUserComment: z.boolean().optional(), questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10), }).superRefine((value, ctx) => { const seenQuestionIds = new Set(); @@ -695,6 +696,8 @@ export const askUserQuestionsResultSchema = z.object({ answers: z.array(askUserQuestionsAnswerSchema).max(20), cancelled: z.literal(true).optional(), cancellationReason: z.string().trim().max(4000).nullable().optional(), + expirationReason: z.literal("superseded_by_comment").optional(), + commentId: z.string().uuid().nullable().optional(), summaryMarkdown: z.string().max(20000).nullable().optional(), }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index e383235c969..d3574f79038 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -326,12 +326,14 @@ describe.sequential("issue thread interaction routes", () => { mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([ { id: "interaction-expired", - kind: "request_confirmation", + kind: "ask_user_questions", status: "expired", result: { version: 1, - outcome: "superseded_by_comment", + answers: [], + expirationReason: "superseded_by_comment", commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + summaryMarkdown: null, }, }, ]); @@ -354,10 +356,10 @@ describe.sequential("issue thread interaction routes", () => { action: "issue.thread_interaction_expired", details: expect.objectContaining({ interactionId: "interaction-expired", - interactionKind: "request_confirmation", + interactionKind: "ask_user_questions", source: "issue.interactions.catchup_superseded_by_comment", result: expect.objectContaining({ - outcome: "superseded_by_comment", + expirationReason: "superseded_by_comment", commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", }), }), diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index ba8e529f6ff..5be080a5aaa 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -566,6 +566,233 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { })).rejects.toThrow("Interaction has already been resolved"); }); + it("expires ask_user_questions interactions by default when a user comments after creation", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Question supersede"); + const commentId = randomUUID(); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + + expect(created).toMatchObject({ + kind: "ask_user_questions", + payload: { + supersedeOnUserComment: true, + }, + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + createdAt: new Date(new Date(created.createdAt).getTime() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + kind: "ask_user_questions", + status: "expired", + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId, + summaryMarkdown: null, + }, + resolvedByUserId: "local-board", + }); + }); + + it("keeps ask_user_questions pending when user-comment supersede is explicitly disabled", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Question supersede opt-out"); + + await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + supersedeOnUserComment: false, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(Date.now() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(0); + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + + it("does not supersede ask_user_questions for agent, system, or older user comments", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Question supersede exclusions"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + const createdAtMs = new Date(created.createdAt).getTime(); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs + 1_000), + authorUserId: null, + }, { + agentId: randomUUID(), + })).resolves.toHaveLength(0); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs + 1_000), + authorUserId: null, + }, {})).resolves.toHaveLength(0); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs - 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + })).resolves.toHaveLength(0); + + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + + it("repairs historical ask_user_questions superseded by later user comments idempotently", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Historical question supersede"); + const commentId = randomUUID(); + const createdAt = new Date("2026-05-18T12:00:00.000Z"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + await db + .update(issueThreadInteractions) + .set({ createdAt, updatedAt: createdAt }) + .where(eq(issueThreadInteractions.id, created.id)); + + await db.insert(issueComments).values({ + id: randomUUID(), + companyId, + issueId, + authorType: "system", + body: "System-side progress note.", + createdAt: new Date("2026-05-18T12:00:30.000Z"), + updatedAt: new Date("2026-05-18T12:00:30.000Z"), + }); + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "local-board", + authorType: "user", + body: "Please revise this first.", + createdAt: new Date("2026-05-18T12:01:00.000Z"), + updatedAt: new Date("2026-05-18T12:01:00.000Z"), + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + kind: "ask_user_questions", + status: "expired", + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId, + summaryMarkdown: null, + }, + resolvedByAgentId: null, + resolvedByUserId: "local-board", + }); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + })).resolves.toEqual([]); + }); + it("reuses the existing interaction when the same idempotency key is submitted twice", async () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 655ddc6057d..dcdc6f85df7 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -50,7 +50,7 @@ Status quick guide: - Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. - When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you. - For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, set the source issue to `in_review`, and do not create implementation subtasks until the board/user accepts it. -- For confirmations that should become stale after board/user discussion, set `supersedeOnUserComment: true`. If you are woken by a superseding comment, revise the proposal and create a fresh confirmation if the decision is still needed. +- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you are woken by a superseding comment, revise the question set or proposal and create a fresh interaction if input is still needed. - Use `paperclip-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 3876da6607b..26d11b4a72e 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -11,7 +11,7 @@ You are an agent at Paperclip company. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. - Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`. - Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks. -- Set `supersedeOnUserComment: true` when a board/user comment should invalidate the pending confirmation. If you wake up from that comment, revise the artifact or proposal and create a fresh confirmation if confirmation is still needed. +- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you wake up from a superseding comment, revise the artifact, question set, or proposal and create a fresh interaction if input is still needed. - If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action. - Respect budget, pause/cancel, approval gates, and company boundaries. diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index fd81e5a80f6..ef05fa4bf83 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -82,10 +82,23 @@ type RequestConfirmationLikeInteraction = | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; +const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [ + ...REQUEST_CONFIRMATION_INTERACTION_KINDS, + "ask_user_questions", +] as const; +type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number]; +type UserCommentSupersedableInteraction = + | RequestConfirmationLikeInteraction + | AskUserQuestionsInteraction; + function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind { return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind); } +function isUserCommentSupersedableKind(kind: string): kind is UserCommentSupersedableKind { + return (USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]).includes(kind); +} + function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { if (typeof error !== "object" || error === null) return false; const err = error as { code?: string; constraint?: string; constraint_name?: string }; @@ -181,12 +194,20 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: { return true; } -function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) { +function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) { return interaction.payload.supersedeOnUserComment === true; } function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction { switch (input.kind) { + case "ask_user_questions": + return { + ...input, + payload: { + ...input.payload, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + }, + }; case "request_confirmation": return { ...input, @@ -208,6 +229,24 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C } } +function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentId: string) { + if (row.kind === "ask_user_questions") { + return { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId, + summaryMarkdown: null, + } as const; + } + + return { + version: 1, + outcome: "superseded_by_comment", + commentId, + } as const; +} + function isCommentAtOrAfterInteraction(args: { commentCreatedAt: Date | string; interactionCreatedAt: Date | string; @@ -1134,14 +1173,15 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), + inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )); const superseded = rows.filter((row) => { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; + if (!isUserCommentSupersedableKind(row.kind)) return false; + const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction; return ( - shouldSupersedeRequestConfirmationOnUserComment(interaction) + shouldSupersedeInteractionOnUserComment(interaction) && isCommentAtOrAfterInteraction({ commentCreatedAt: comment.createdAt, interactionCreatedAt: row.createdAt, @@ -1158,11 +1198,7 @@ export function issueThreadInteractionService(db: Db) { .update(issueThreadInteractions) .set({ status: "expired", - result: { - version: 1, - outcome: "superseded_by_comment", - commentId: comment.id, - }, + result: buildSupersededByCommentResult(row, comment.id), resolvedByAgentId: actor.agentId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: now, @@ -1192,7 +1228,7 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), + inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )), db @@ -1218,8 +1254,9 @@ export function issueThreadInteractionService(db: Db) { } >(); for (const row of rows) { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; - if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue; + if (!isUserCommentSupersedableKind(row.kind)) continue; + const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction; + if (!shouldSupersedeInteractionOnUserComment(interaction)) continue; const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({ commentCreatedAt: comment.createdAt, @@ -1238,27 +1275,59 @@ export function issueThreadInteractionService(db: Db) { } } + const rowById = new Map(rows.map((row) => [row.id, row] as const)); for (const { comment, rowIds } of supersededByComment.values()) { - const updatedRows = await db - .update(issueThreadInteractions) - .set({ - status: "expired", - result: { - version: 1, - outcome: "superseded_by_comment", - commentId: comment.id, - }, - resolvedByAgentId: null, - resolvedByUserId: comment.authorUserId, - resolvedAt: now, - updatedAt: now, - }) - .where(and( - inArray(issueThreadInteractions.id, rowIds), - eq(issueThreadInteractions.status, "pending"), - )) - .returning(); - expired.push(...updatedRows.map(hydrateInteraction)); + const commentRows = rowIds + .map((rowId) => rowById.get(rowId)) + .filter((row): row is IssueThreadInteractionRow => Boolean(row)); + const questionRowIds = commentRows + .filter((row) => row.kind === "ask_user_questions") + .map((row) => row.id); + const confirmationRowIds = commentRows + .filter((row) => isRequestConfirmationLikeKind(row.kind)) + .map((row) => row.id); + + if (questionRowIds.length > 0) { + const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions"); + if (!sampleQuestionRow) continue; + const updatedRows = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByCommentResult(sampleQuestionRow, comment.id), + resolvedByAgentId: null, + resolvedByUserId: comment.authorUserId, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + inArray(issueThreadInteractions.id, questionRowIds), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + expired.push(...updatedRows.map(hydrateInteraction)); + } + + if (confirmationRowIds.length > 0) { + const sampleConfirmationRow = commentRows.find((row) => isRequestConfirmationLikeKind(row.kind)); + if (!sampleConfirmationRow) continue; + const updatedRows = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByCommentResult(sampleConfirmationRow, comment.id), + resolvedByAgentId: null, + resolvedByUserId: comment.authorUserId, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + inArray(issueThreadInteractions.id, confirmationRowIds), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + expired.push(...updatedRows.map(hydrateInteraction)); + } } if (expired.length > 0) { diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 1042d96a341..5aebdc8c3a3 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; -import type { ComponentProps, ReactNode } from "react"; +import { act as reactAct, type ComponentProps, type ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; @@ -9,6 +9,7 @@ import { ThemeProvider } from "../context/ThemeContext"; import { TooltipProvider } from "./ui/tooltip"; import { pendingAskUserQuestionsInteraction, + commentExpiredAskUserQuestionsInteraction, commentExpiredRequestConfirmationInteraction, disabledDeclineReasonRequestConfirmationInteraction, failedRequestConfirmationInteraction, @@ -23,6 +24,20 @@ let container: HTMLDivElement | null = null; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + if (typeof reactAct === "function") { + await reactAct(callback); + return; + } + + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; + await new Promise((resolve) => setTimeout(resolve, 0)); +} + vi.mock("@/lib/router", () => ({ Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => ( {children} @@ -174,6 +189,42 @@ describe("IssueThreadInteractionCard", () => { expect(withHandler.textContent).toContain("Cancel question"); }); + it("renders expired question interactions as resolved and non-actionable", () => { + const host = renderCard({ + interaction: commentExpiredAskUserQuestionsInteraction, + onSubmitInteractionAnswers: vi.fn(), + onCancelInteraction: vi.fn(), + }); + + expect(host.textContent).toContain("Questions expired by comment"); + expect(host.textContent).toContain("A later board/user comment superseded this question request."); + expect(host.textContent).not.toContain("Send answers"); + expect(host.textContent).not.toContain("Cancel question"); + + const jumpLink = Array.from(host.querySelectorAll("a")).find((link) => + link.textContent?.includes("Jump to comment"), + ); + expect(jumpLink?.getAttribute("href")).toBe( + "#comment-22222222-2222-4222-8222-222222222222", + ); + }); + + it("uses singular copy for expired single-question interactions", () => { + const [question] = commentExpiredAskUserQuestionsInteraction.payload.questions; + const host = renderCard({ + interaction: { + ...commentExpiredAskUserQuestionsInteraction, + payload: { + ...commentExpiredAskUserQuestionsInteraction.payload, + questions: [question], + }, + }, + }); + + expect(host.textContent).toContain("Question expired by comment"); + expect(host.textContent).not.toContain("Questions expired by comment"); + }); + it("makes child tasks explicit in suggested task trees", () => { const host = renderCard({ interaction: pendingSuggestedTasksInteraction, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 508caf133dc..b26f2bb89e8 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -983,6 +983,24 @@ function AskUserQuestionsCard({

No answer was recorded.

)} + ) : interaction.status === "expired" ? ( +
+
+ + {questions.length === 1 ? "Question expired by comment" : "Questions expired by comment"} +
+

+ A later board/user comment superseded this question request. Create a fresh request if answers are still needed. +

+ {interaction.result?.commentId ? ( + + Jump to comment + + ) : null} +
) : (
{questions.map((question) => { diff --git a/ui/src/fixtures/issueThreadInteractionFixtures.ts b/ui/src/fixtures/issueThreadInteractionFixtures.ts index dc1b0dbcf6b..057341574c4 100644 --- a/ui/src/fixtures/issueThreadInteractionFixtures.ts +++ b/ui/src/fixtures/issueThreadInteractionFixtures.ts @@ -370,6 +370,21 @@ export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInterac }, }); +export const commentExpiredAskUserQuestionsInteraction = createAskUserQuestionsInteraction({ + id: "interaction-questions-expired-comment", + status: "expired", + resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId, + resolvedAt: new Date("2026-04-20T14:25:00.000Z"), + updatedAt: new Date("2026-04-20T14:25:00.000Z"), + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId: "22222222-2222-4222-8222-222222222222", + summaryMarkdown: null, + }, +}); + export const pendingRequestConfirmationInteraction = createRequestConfirmationInteraction({}); export const genericPendingRequestConfirmationInteraction = createRequestConfirmationInteraction({ diff --git a/ui/src/lib/issue-thread-interactions.test.ts b/ui/src/lib/issue-thread-interactions.test.ts index 6e64993bc80..9aa4ace0eff 100644 --- a/ui/src/lib/issue-thread-interactions.test.ts +++ b/ui/src/lib/issue-thread-interactions.test.ts @@ -127,6 +127,34 @@ describe("issue thread interaction helpers", () => { answers: [{ questionId: "question-1", optionIds: ["option-1"] }], }, })).toBe("Answered 1 question"); + + expect(buildIssueThreadInteractionSummary({ + id: "interaction-expired", + companyId: "company-1", + issueId: "issue-1", + kind: "ask_user_questions", + status: "expired", + continuationPolicy: "wake_assignee", + createdAt: "2026-04-06T12:00:00.000Z", + updatedAt: "2026-04-06T12:05:00.000Z", + payload: { + version: 1, + questions: [ + { + id: "question-1", + prompt: "Pick one", + selectionMode: "single", + options: [{ id: "option-1", label: "Option 1" }], + }, + ], + }, + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId: "11111111-1111-4111-8111-111111111111", + }, + })).toBe("Question expired after comment"); }); it("summarizes checkbox confirmation interactions by count", () => { diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts index 5da5b05081b..6d782259ed4 100644 --- a/ui/src/lib/issue-thread-interactions.ts +++ b/ui/src/lib/issue-thread-interactions.ts @@ -158,6 +158,12 @@ export function buildIssueThreadInteractionSummary( if (interaction.status === "cancelled") { return count === 1 ? "Cancelled 1 question" : `Cancelled ${count} questions`; } + if (interaction.status === "expired") { + if (interaction.result?.expirationReason === "superseded_by_comment") { + return count === 1 ? "Question expired after comment" : "Questions expired after comment"; + } + return count === 1 ? "Question expired" : "Questions expired"; + } return count === 1 ? "Asked 1 question" : `Asked ${count} questions`; } From 41059841f19e4fc8149b2080958fb3d63f8164e0 Mon Sep 17 00:00:00 2001 From: Ege Chelebi Date: Wed, 1 Jul 2026 19:25:25 +0100 Subject: [PATCH 02/19] feat(gemini-local): add Gemini 3.1 Pro models to gemini-local adapter (#1602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Thinking path** The gemini_local adapter's model dropdown (packages/adapters/gemini-local/src/index.ts) still lists only Gemini 2.x. Google has since shipped the Gemini 3.1 Pro family, so users can't pick the current flagship from the dashboard and instead hit ModelNotFoundError when they type an ID by hand (#1506). The adapter passes the selected string straight to `gemini --model`, so the fix is to surface the valid 3.1 Pro IDs in the list. **What I did** Added two entries to the `models` array, above the existing 2.x entries: - `gemini-3.1-pro-preview` — Gemini 3.1 Pro (Preview) - `gemini-3.1-pro-preview-customtools` — custom-tools variant, tuned for agentic/tool use **Why it matters** Users can select the current flagship 3.1 Pro (and its custom-tools endpoint) directly, instead of guessing IDs and hitting ModelNotFoundError. **How to verify** Open the gemini_local model dropdown in the dashboard; both entries appear above the 2.5 entries and run against `gemini --model ` without error. **Risks** Minimal — additive, single-file change to a static list; nothing removed. Both IDs are confirmed-valid Google API identifiers. Fixes #1506. --- packages/adapters/gemini-local/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index d1b584ff23b..72597a24f13 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -12,6 +12,8 @@ export const DEFAULT_GEMINI_LOCAL_MODEL = "auto"; export const models = [ { id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" }, + { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" }, + { id: "gemini-3.1-pro-preview-customtools", label: "Gemini 3.1 Pro Preview (Custom Tools)" }, { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, From 8a93a0de4cba3ee37d564c83f58d6b7732b3cd62 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 1 Jul 2026 11:39:39 -0700 Subject: [PATCH 03/19] Implement generated client telemetry types (#8818) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Telemetry is part of the control plane's operational visibility and needs stable event contracts. > - The shared telemetry client accepted first-party event names through a broad string surface, which weakened compile-time guarantees. > - Plugin telemetry still needs a dynamic path because plugin-defined events cannot be enumerated in the core generated type module. > - This pull request vendors generated Paperclip telemetry event and dimension types, closes the first-party event-name union, and keeps plugin telemetry on an explicit dynamic method. > - Review feedback clarified that backend normalization should remain the source of truth, so telemetry helpers now preserve raw categorical values while keeping generated per-event type hints. > - The benefit is stricter first-party telemetry typing without hiding backend normalization signals or changing batching, flushing, schema versioning, sinks, or endpoints. ## Linked Issues or Issue Description No public GitHub issue exists for this internal type-contract maintenance change. ### Problem or motivation The shared telemetry client should reject unregistered first-party event names at compile time, while the plugin telemetry bridge must continue to emit plugin-defined events through the existing batching and envelope path. Helper wrappers should also avoid client-side enum coercion so the backend can detect and record normalization when clients send unexpected categorical values. ### Proposed solution Generate and vendor the accepted Paperclip telemetry event and dimension types, use those types for the first-party `track()` API, keep plugin-defined telemetry on an explicit dynamic method, and let helper wrappers pass raw categorical dimensions through to backend validation. ### Alternatives considered Keeping `track()` open to arbitrary strings would preserve flexibility, but it would not give first-party callers the type safety this change is meant to provide. Enumerating plugin events in core was also ruled out because plugin-defined events are not known to the core package. Client-side enum normalization was removed after review because it duplicates backend validation and can hide misbehaving-client signals. ### Roadmap alignment This is a tightly scoped telemetry contract maintenance change and does not overlap with a roadmap-level core feature. ## What Changed - Vendored the generated Paperclip telemetry event and dimension type module under shared telemetry code. - Closed the first-party telemetry event-name union to generated backend-accepted names plus an explicit `RegisteredPluginEventName = never` extension point. - Added `TelemetryClient.trackDynamic()` for plugin telemetry bridge emission while keeping `track()` closed and typed. - Added JSDoc explaining when to use `track()` versus `trackDynamic()`. - Updated telemetry helper wrappers to type dimensions from each event's generated schema entry while passing raw categorical values through for backend normalization. - Added `trackInteractionResolved()` and updated focused shared/server tests for telemetry event typing, raw pass-through behavior, and plugin telemetry bridging. ## Verification Local verification passed before the latest push: - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm exec vitest run packages/shared/src/telemetry/client-types.test.ts server/src/__tests__/shared-telemetry-events.test.ts server/src/__tests__/plugin-telemetry-bridge.test.ts server/src/__tests__/project-goal-telemetry-routes.test.ts server/src/__tests__/routine-run-telemetry.test.ts server/src/__tests__/issue-telemetry-routes.test.ts` - `git diff --check` Post-push verification completed on head `3d973ffbea6154b19ad208dcffd1374d1b25b654`: - GitHub PR checks passed, including `verify`, build, typecheck/release registry, general test shards, serialized server shards, canary dry run, e2e, and security checks. - Greptile Review passed with 5/5 confidence. - All PR review threads are resolved. ## Risks Low runtime risk. The change is intended to affect TypeScript contracts and helper typing while preserving the existing telemetry enqueue, batching, and backend ingest path. The main intentional behavior shift is that helper wrappers no longer coerce unexpected categorical values on the client; those values reach the backend so backend normalization can record the signal. Private company import source refs still use `hashPrivateRef` when `isPrivate` is true. ## Model Used OpenAI GPT-5 Codex, tool-enabled coding agent. Exact context window was not exposed by the runtime; the agent used repository file access, shell commands, and GitHub CLI operations. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../shared/src/telemetry/client-types.test.ts | 25 ++ packages/shared/src/telemetry/client.ts | 29 +- packages/shared/src/telemetry/events.ts | 115 ++++-- .../generated/paperclip-telemetry.ts | 371 ++++++++++++++++++ packages/shared/src/telemetry/index.ts | 10 + packages/shared/src/telemetry/types.ts | 29 +- .../__tests__/plugin-telemetry-bridge.test.ts | 14 +- .../project-goal-telemetry-routes.test.ts | 2 +- .../__tests__/shared-telemetry-events.test.ts | 61 ++- server/src/services/plugin-host-services.ts | 2 +- 10 files changed, 608 insertions(+), 50 deletions(-) create mode 100644 packages/shared/src/telemetry/client-types.test.ts create mode 100644 packages/shared/src/telemetry/generated/paperclip-telemetry.ts diff --git a/packages/shared/src/telemetry/client-types.test.ts b/packages/shared/src/telemetry/client-types.test.ts new file mode 100644 index 00000000000..3b630edda49 --- /dev/null +++ b/packages/shared/src/telemetry/client-types.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { TelemetryClient } from "./client.js"; + +describe("TelemetryClient event-name types", () => { + it("keeps dynamic telemetry separate from registered events", () => { + const client = new TelemetryClient( + { enabled: false }, + () => ({ + installId: "test-install", + salt: "test-salt", + createdAt: "2026-01-01T00:00:00Z", + firstSeenVersion: "0.0.0", + }), + "0.0.0-test", + ); + + client.track("install.started", {}); + client.trackDynamic("plugin.linear.sync_completed", {}); + + // @ts-expect-error plugin events are intentionally not registered first-party events. + client.track("plugin.linear.sync_completed", {}); + + expect(client).toBeInstanceOf(TelemetryClient); + }); +}); diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index 9c4181cd3a1..178e5f0556e 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto"; import type { TelemetryConfig, + TelemetryDimensions, TelemetryEvent, + TelemetryEventDimensions, TelemetryEventName, TelemetryState, } from "./types.js"; @@ -13,6 +15,11 @@ const DEFAULT_ENDPOINTS = [ const BATCH_SIZE = 50; const SEND_TIMEOUT_MS = 5_000; +type TrackArgs = + keyof TelemetryEventDimensions extends never + ? [dimensions?: TelemetryEventDimensions] + : [dimensions: TelemetryEventDimensions]; + export class TelemetryClient { private queue: TelemetryEvent[] = []; private readonly config: TelemetryConfig; @@ -27,14 +34,32 @@ export class TelemetryClient { this.version = version; } - track(eventName: TelemetryEventName, dimensions?: Record): void { + /** + * Tracks first-party Paperclip telemetry events registered in the generated + * backend event schema. + */ + track(eventName: K, ...args: TrackArgs): void { + const [dimensions] = args; + this.enqueue(eventName, dimensions); + } + + /** + * Tracks plugin telemetry bridge events whose names are built dynamically + * from third-party plugin input. The backend accepts only explicitly + * registered plugin events. + */ + trackDynamic(eventName: string, dimensions?: TelemetryDimensions): void { + this.enqueue(eventName, dimensions); + } + + private enqueue(eventName: string, dimensions?: object): void { if (!this.config.enabled) return; this.getState(); // ensure state is initialised (side-effect: creates state file on first call) this.queue.push({ name: eventName, occurredAt: new Date().toISOString(), - dimensions: dimensions ?? {}, + dimensions: { ...dimensions } as TelemetryDimensions, }); if (this.queue.length >= BATCH_SIZE) { diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 1b347f3cd1f..15315719b56 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -1,91 +1,123 @@ import type { TelemetryClient } from "./client.js"; +import type { EventDimensionsMap } from "./generated/paperclip-telemetry.js"; + +type RawDimension = T | (string & {}); + +function asEventDimension(value: RawDimension): T { + return value as T; +} export function trackInstallStarted(client: TelemetryClient): void { - client.track("install.started"); + client.track("install.started", {}); } export function trackInstallCompleted( client: TelemetryClient, - dims: { adapterType: string }, + dims: { adapterType: RawDimension }, ): void { - client.track("install.completed", { adapter_type: dims.adapterType }); + client.track("install.completed", { + adapter_type: asEventDimension(dims.adapterType), + }); } export function trackCompanyImported( client: TelemetryClient, - dims: { sourceType: string; sourceRef: string; isPrivate: boolean }, + dims: { + sourceType: RawDimension; + sourceRef: string; + isPrivate: boolean; + }, ): void { const ref = dims.isPrivate ? client.hashPrivateRef(dims.sourceRef) : dims.sourceRef; client.track("company.imported", { - source_type: dims.sourceType, + source_type: asEventDimension(dims.sourceType), source_ref: ref, source_ref_hashed: dims.isPrivate, }); } export function trackProjectCreated(client: TelemetryClient): void { - client.track("project.created"); + client.track("project.created", {}); } export function trackRoutineCreated(client: TelemetryClient): void { - client.track("routine.created"); + client.track("routine.created", {}); } export function trackRoutineRun( client: TelemetryClient, - dims: { source: string; status: string }, + dims: { + source: RawDimension; + status: RawDimension; + }, ): void { client.track("routine.run", { - source: dims.source, - status: dims.status, + source: asEventDimension(dims.source), + status: asEventDimension(dims.status), }); } export function trackGoalCreated( client: TelemetryClient, - dims?: { goalLevel?: string | null }, + dims?: { goalLevel?: RawDimension | null }, ): void { - client.track("goal.created", dims?.goalLevel ? { goal_level: dims.goalLevel } : undefined); + client.track("goal.created", { + goal_level: dims?.goalLevel ? asEventDimension(dims.goalLevel) : "other", + }); } export function trackAgentCreated( client: TelemetryClient, - dims: { agentRole: string; agentId?: string }, + dims: { + agentRole: RawDimension; + agentId: string; + }, ): void { client.track("agent.created", { - agent_role: dims.agentRole, - ...(dims.agentId ? { agent_id: dims.agentId } : {}), + agent_role: asEventDimension(dims.agentRole), + agent_id: dims.agentId, }); } export function trackSkillImported( client: TelemetryClient, - dims: { sourceType: string; skillRef?: string | null }, + dims: { + sourceType: RawDimension; + skillRef?: string | null; + }, ): void { client.track("skill.imported", { - source_type: dims.sourceType, + source_type: asEventDimension(dims.sourceType), ...(dims.skillRef ? { skill_ref: dims.skillRef } : {}), }); } export function trackAgentFirstHeartbeat( client: TelemetryClient, - dims: { agentRole: string; agentId?: string }, + dims: { + agentRole: RawDimension; + agentId: string; + }, ): void { client.track("agent.first_heartbeat", { - agent_role: dims.agentRole, - ...(dims.agentId ? { agent_id: dims.agentId } : {}), + agent_role: asEventDimension(dims.agentRole), + agent_id: dims.agentId, }); } export function trackAgentTaskCompleted( client: TelemetryClient, - dims: { agentRole: string; agentId?: string; adapterType?: string; model?: string }, + dims: { + agentRole: RawDimension; + agentId: string; + adapterType: RawDimension; + model?: string; + }, ): void { client.track("agent.task_completed", { - agent_role: dims.agentRole, - ...(dims.agentId ? { agent_id: dims.agentId } : {}), - ...(dims.adapterType ? { adapter_type: dims.adapterType } : {}), + agent_role: asEventDimension(dims.agentRole), + agent_id: dims.agentId, + adapter_type: asEventDimension(dims.adapterType), ...(dims.model ? { model: dims.model } : {}), }); } @@ -96,3 +128,38 @@ export function trackErrorHandlerCrash( ): void { client.track("error.handler_crash", { error_code: dims.errorCode }); } + +export function trackInteractionResolved( + client: TelemetryClient, + dims: { + interactionKind: RawDimension; + status: RawDimension; + resolvedByKind: RawDimension; + resolutionReason?: RawDimension | null; + createdByKind?: RawDimension | null; + creatorAgentRole?: RawDimension | null; + continuationPolicy?: RawDimension | null; + targetType?: RawDimension | null; + optionCount?: number; + selectedOptionCount?: number; + questionCount?: number; + answeredQuestionCount?: number; + createdTaskCount?: number; + }, +): void { + client.track("interaction.resolved", { + interaction_kind: asEventDimension(dims.interactionKind), + status: asEventDimension(dims.status), + resolved_by_kind: asEventDimension(dims.resolvedByKind), + ...(dims.resolutionReason ? { resolution_reason: asEventDimension(dims.resolutionReason) } : {}), + ...(dims.createdByKind ? { created_by_kind: asEventDimension(dims.createdByKind) } : {}), + ...(dims.creatorAgentRole ? { creator_agent_role: asEventDimension(dims.creatorAgentRole) } : {}), + ...(dims.continuationPolicy ? { continuation_policy: asEventDimension(dims.continuationPolicy) } : {}), + ...(dims.targetType ? { target_type: asEventDimension(dims.targetType) } : {}), + ...(dims.optionCount === undefined ? {} : { option_count: dims.optionCount }), + ...(dims.selectedOptionCount === undefined ? {} : { selected_option_count: dims.selectedOptionCount }), + ...(dims.questionCount === undefined ? {} : { question_count: dims.questionCount }), + ...(dims.answeredQuestionCount === undefined ? {} : { answered_question_count: dims.answeredQuestionCount }), + ...(dims.createdTaskCount === undefined ? {} : { created_task_count: dims.createdTaskCount }), + }); +} diff --git a/packages/shared/src/telemetry/generated/paperclip-telemetry.ts b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts new file mode 100644 index 00000000000..05ef53b9c4c --- /dev/null +++ b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts @@ -0,0 +1,371 @@ +// GENERATED — DO NOT EDIT. + +export interface PaperclipAgentCreatedDimensions { +agent_id: string +agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other") +} + +export interface PaperclipAgentFirstHeartbeatDimensions { +agent_id: string +agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other") +} + +export interface PaperclipAgentTaskCompletedDimensions { +adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "other") +agent_id: string +agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other") +model?: string +} + +export interface PaperclipCompanyImportedDimensions { +source_type: ("local_path" | "github" | "url" | "catalog" | "skills_sh" | "unknown") +source_ref?: string +source_ref_hashed?: boolean +} + +export interface PaperclipErrorHandlerCrashDimensions { +error_code: string +} + +export interface PaperclipGoalCreatedDimensions { +goal_level: ("company" | "team" | "agent" | "task" | "other") +} + +export interface PaperclipInstallCompletedDimensions { +adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "other") +} + +export interface PaperclipInstallStartedDimensions { + +} + +export interface PaperclipInteractionResolvedDimensions { +interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmation" | "request_checkbox_confirmation" | "other") +status: ("accepted" | "rejected" | "answered" | "cancelled" | "expired" | "failed" | "other") +resolution_reason?: ("accepted" | "rejected" | "stale_target" | "superseded_by_comment" | "expired" | "cancelled" | "other") +resolved_by_kind: ("user" | "agent" | "system" | "other") +created_by_kind?: ("agent" | "user" | "other") +creator_agent_role?: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other") +continuation_policy?: ("none" | "wake_assignee" | "wake_assignee_on_accept" | "other") +target_type?: ("issue_document" | "custom" | "none" | "other") +option_count?: number +selected_option_count?: number +question_count?: number +answered_question_count?: number +created_task_count?: number +skipped_task_count?: number +has_reason?: boolean +resolution_latency_seconds?: number +interaction_id?: string +created_by_agent_id?: string +source_run_id?: string +} + +export interface PaperclipProjectCreatedDimensions { + +} + +export interface PaperclipRoutineCreatedDimensions { + +} + +export interface PaperclipRoutineRunDimensions { +source: ("schedule" | "manual" | "api" | "webhook" | "other") +status: ("received" | "coalesced" | "skipped" | "issue_created" | "completed" | "failed" | "other") +} + +export interface PaperclipSkillImportedDimensions { +source_type: ("local_path" | "github" | "url" | "catalog" | "skills_sh" | "unknown") +skill_ref?: string +} + +export type PaperclipEventName = + | "agent.created" + | "agent.first_heartbeat" + | "agent.task_completed" + | "company.imported" + | "error.handler_crash" + | "goal.created" + | "install.completed" + | "install.started" + | "interaction.resolved" + | "project.created" + | "routine.created" + | "routine.run" + | "skill.imported"; + +export interface EventDimensionsMap { + "agent.created": PaperclipAgentCreatedDimensions; + "agent.first_heartbeat": PaperclipAgentFirstHeartbeatDimensions; + "agent.task_completed": PaperclipAgentTaskCompletedDimensions; + "company.imported": PaperclipCompanyImportedDimensions; + "error.handler_crash": PaperclipErrorHandlerCrashDimensions; + "goal.created": PaperclipGoalCreatedDimensions; + "install.completed": PaperclipInstallCompletedDimensions; + "install.started": PaperclipInstallStartedDimensions; + "interaction.resolved": PaperclipInteractionResolvedDimensions; + "project.created": PaperclipProjectCreatedDimensions; + "routine.created": PaperclipRoutineCreatedDimensions; + "routine.run": PaperclipRoutineRunDimensions; + "skill.imported": PaperclipSkillImportedDimensions; +} + +export const PAPERCLIP_EVENTS = { + "agent.created": "agent.created", + "agent.first_heartbeat": "agent.first_heartbeat", + "agent.task_completed": "agent.task_completed", + "company.imported": "company.imported", + "error.handler_crash": "error.handler_crash", + "goal.created": "goal.created", + "install.completed": "install.completed", + "install.started": "install.started", + "interaction.resolved": "interaction.resolved", + "project.created": "project.created", + "routine.created": "routine.created", + "routine.run": "routine.run", + "skill.imported": "skill.imported", +} as const; + +export const PAPERCLIP_ENUM_DESCRIPTIONS = { + "agent.created": { + "agent_role": { + "ceo": "Agent configured for company leadership and board coordination work.", + "cto": "Agent configured for technical leadership, architecture, and engineering coordination.", + "cmo": "Agent configured for marketing leadership work.", + "cfo": "Agent configured for finance leadership work.", + "security": "Agent configured for security review, risk, or policy work.", + "engineer": "Agent configured for general software engineering work.", + "designer": "Agent configured for product, visual, or experience design work.", + "pm": "Agent configured for product management or planning work.", + "qa": "Agent configured for quality assurance, testing, or validation work.", + "devops": "Agent configured for infrastructure, deployment, or operations work.", + "researcher": "Agent configured for research and information-gathering work.", + "general": "Agent configured as a general-purpose worker without a more specific role.", + "other": "Fallback when the agent role is unknown or not represented by the tracked enum." + } + }, + "agent.first_heartbeat": { + "agent_role": { + "ceo": "Agent configured for company leadership and board coordination work.", + "cto": "Agent configured for technical leadership, architecture, and engineering coordination.", + "cmo": "Agent configured for marketing leadership work.", + "cfo": "Agent configured for finance leadership work.", + "security": "Agent configured for security review, risk, or policy work.", + "engineer": "Agent configured for general software engineering work.", + "designer": "Agent configured for product, visual, or experience design work.", + "pm": "Agent configured for product management or planning work.", + "qa": "Agent configured for quality assurance, testing, or validation work.", + "devops": "Agent configured for infrastructure, deployment, or operations work.", + "researcher": "Agent configured for research and information-gathering work.", + "general": "Agent configured as a general-purpose worker without a more specific role.", + "other": "Fallback when the agent role is unknown or not represented by the tracked enum." + } + }, + "agent.task_completed": { + "adapter_type": { + "process": "Agent runtime uses a local process adapter.", + "http": "Agent runtime uses a generic HTTP adapter.", + "acpx_local": "Agent runtime uses the local ACPX adapter.", + "claude_local": "Agent runtime uses the local Claude adapter.", + "codex_local": "Agent runtime uses the local Codex adapter.", + "cursor_cloud": "Agent runtime uses the Cursor cloud adapter.", + "gemini_local": "Agent runtime uses the local Gemini adapter.", + "hermes_gateway": "Agent runtime uses the Hermes gateway adapter.", + "hermes_local": "Agent runtime uses the local Hermes adapter.", + "opencode_local": "Agent runtime uses the local OpenCode adapter.", + "pi_local": "Agent runtime uses the local Pi adapter.", + "cursor": "Agent runtime uses the Cursor adapter.", + "openclaw_gateway": "Agent runtime uses the OpenClaw gateway adapter.", + "grok_local": "Agent runtime uses the local Grok adapter.", + "other": "Fallback when the adapter type is unknown or not represented by the tracked enum." + }, + "agent_role": { + "ceo": "Agent configured for company leadership and board coordination work.", + "cto": "Agent configured for technical leadership, architecture, and engineering coordination.", + "cmo": "Agent configured for marketing leadership work.", + "cfo": "Agent configured for finance leadership work.", + "security": "Agent configured for security review, risk, or policy work.", + "engineer": "Agent configured for general software engineering work.", + "designer": "Agent configured for product, visual, or experience design work.", + "pm": "Agent configured for product management or planning work.", + "qa": "Agent configured for quality assurance, testing, or validation work.", + "devops": "Agent configured for infrastructure, deployment, or operations work.", + "researcher": "Agent configured for research and information-gathering work.", + "general": "Agent configured as a general-purpose worker without a more specific role.", + "other": "Fallback when the agent role is unknown or not represented by the tracked enum." + } + }, + "company.imported": { + "source_type": { + "local_path": "Import source came from a filesystem path on the operator's machine.", + "github": "Import source came from a GitHub repository or GitHub-backed reference.", + "url": "Import source came from a direct URL.", + "catalog": "Import source came from a Paperclip catalog entry.", + "skills_sh": "Import source came from a Skills.sh-compatible source.", + "unknown": "Source type could not be classified." + } + }, + "goal.created": { + "goal_level": { + "company": "Goal applies at company scope.", + "team": "Goal applies at team or group scope.", + "agent": "Goal applies to a specific agent.", + "task": "Goal applies to task-level work.", + "other": "Fallback when the goal level is unknown or not represented by the tracked enum." + } + }, + "install.completed": { + "adapter_type": { + "process": "Agent runtime uses a local process adapter.", + "http": "Agent runtime uses a generic HTTP adapter.", + "acpx_local": "Agent runtime uses the local ACPX adapter.", + "claude_local": "Agent runtime uses the local Claude adapter.", + "codex_local": "Agent runtime uses the local Codex adapter.", + "cursor_cloud": "Agent runtime uses the Cursor cloud adapter.", + "gemini_local": "Agent runtime uses the local Gemini adapter.", + "hermes_gateway": "Agent runtime uses the Hermes gateway adapter.", + "hermes_local": "Agent runtime uses the local Hermes adapter.", + "opencode_local": "Agent runtime uses the local OpenCode adapter.", + "pi_local": "Agent runtime uses the local Pi adapter.", + "cursor": "Agent runtime uses the Cursor adapter.", + "openclaw_gateway": "Agent runtime uses the OpenClaw gateway adapter.", + "grok_local": "Agent runtime uses the local Grok adapter.", + "other": "Fallback when the adapter type is unknown or not represented by the tracked enum." + } + }, + "interaction.resolved": { + "interaction_kind": { + "suggest_tasks": "Board-facing interaction that proposes concrete subtasks for acceptance.", + "ask_user_questions": "Board-facing interaction that asks structured questions and stores answers.", + "request_confirmation": "Board-facing interaction that asks for a single accept or reject decision.", + "request_checkbox_confirmation": "Board-facing interaction that asks the board to select options and confirm.", + "other": "Fallback when the interaction kind is unknown or not represented by the tracked enum." + }, + "status": { + "accepted": "Interaction was accepted.", + "rejected": "Interaction was rejected.", + "answered": "Ask-user-questions interaction was answered.", + "cancelled": "Interaction was cancelled before acceptance or answer.", + "expired": "Interaction expired because the bound target became stale or was superseded.", + "failed": "Interaction resolution attempted but failed.", + "other": "Fallback when the terminal status is unknown or not represented by the tracked enum." + }, + "resolution_reason": { + "accepted": "Stored result outcome says the interaction was accepted.", + "rejected": "Stored result outcome says the interaction was rejected.", + "stale_target": "Bound target, such as an issue document revision, was no longer current.", + "superseded_by_comment": "A later user or board comment superseded the pending confirmation.", + "expired": "Interaction expired for a generic expiration reason.", + "cancelled": "Interaction was explicitly cancelled.", + "other": "Fallback when the resolution reason is unknown or not represented by the tracked enum." + }, + "resolved_by_kind": { + "user": "A board or human user resolved the interaction.", + "agent": "An agent resolved the interaction.", + "system": "Paperclip resolved the interaction automatically.", + "other": "Fallback when the resolver kind is unknown or not represented by the tracked enum." + }, + "created_by_kind": { + "agent": "Interaction was created by an agent.", + "user": "Interaction was created by a board or human user.", + "other": "Fallback when the creator kind is unknown or not represented by the tracked enum." + }, + "creator_agent_role": { + "ceo": "Agent configured for company leadership and board coordination work.", + "cto": "Agent configured for technical leadership, architecture, and engineering coordination.", + "cmo": "Agent configured for marketing leadership work.", + "cfo": "Agent configured for finance leadership work.", + "security": "Agent configured for security review, risk, or policy work.", + "engineer": "Agent configured for general software engineering work.", + "designer": "Agent configured for product, visual, or experience design work.", + "pm": "Agent configured for product management or planning work.", + "qa": "Agent configured for quality assurance, testing, or validation work.", + "devops": "Agent configured for infrastructure, deployment, or operations work.", + "researcher": "Agent configured for research and information-gathering work.", + "general": "Agent configured as a general-purpose worker without a more specific role.", + "other": "Fallback when the agent role is unknown or not represented by the tracked enum." + }, + "continuation_policy": { + "none": "Resolving the interaction does not automatically wake the issue assignee.", + "wake_assignee": "Resolving the interaction wakes or returns the issue to the assignee.", + "wake_assignee_on_accept": "Accepting the interaction wakes or returns the issue to the assignee.", + "other": "Fallback when the continuation policy is unknown or not represented by the tracked enum." + }, + "target_type": { + "issue_document": "Interaction is bound to a specific issue document revision.", + "custom": "Interaction is bound to a custom target.", + "none": "Interaction has no bound target.", + "other": "Fallback when the target type is unknown or not represented by the tracked enum." + } + }, + "routine.run": { + "source": { + "schedule": "Routine was triggered by a scheduled trigger.", + "manual": "Routine was triggered manually by a user or agent action.", + "api": "Routine was triggered through an API request.", + "webhook": "Routine was triggered by a webhook.", + "other": "Fallback when the source is unknown or not represented by the tracked enum." + }, + "status": { + "received": "Routine run was accepted for processing.", + "coalesced": "A live execution already existed and the run was coalesced into it.", + "skipped": "A live execution already existed and concurrency policy skipped the run.", + "issue_created": "Routine dispatch created a new issue and queued the agent wakeup.", + "completed": "Routine run completed without needing a new issue.", + "failed": "Routine dispatch failed and the run was finalized as failed.", + "other": "Fallback when the status is unknown or not represented by the tracked enum." + } + }, + "skill.imported": { + "source_type": { + "local_path": "Import source came from a filesystem path on the operator's machine.", + "github": "Import source came from a GitHub repository or GitHub-backed reference.", + "url": "Import source came from a direct URL.", + "catalog": "Import source came from a Paperclip catalog entry.", + "skills_sh": "Import source came from a Skills.sh-compatible source.", + "unknown": "Source type could not be classified." + } + } +} as const; + +export const SCHEMA_VERSION = "1" as const; + +export interface PaperclipTelemetryEvent { +name: K +occurredAt: string +dimensions: EventDimensionsMap[K] +} + +export type AnyPaperclipTelemetryEvent = { + [K in PaperclipEventName]: PaperclipTelemetryEvent +}[PaperclipEventName]; + +export interface PaperclipTelemetryBatch { +app: "paperclip" +schemaVersion: typeof SCHEMA_VERSION +installId: string +version?: string +events: AnyPaperclipTelemetryEvent[] +} + +export function makeEvent( + name: K, + dimensions: EventDimensionsMap[K], + occurredAt: string +): PaperclipTelemetryEvent { + return { name, occurredAt, dimensions }; +} + +export function makeBatch( + installId: string, + events: readonly AnyPaperclipTelemetryEvent[], + version?: string +): PaperclipTelemetryBatch { + return { + app: "paperclip", + schemaVersion: SCHEMA_VERSION, + installId, + ...(version === undefined ? {} : { version }), + events: [...events] + }; +} diff --git a/packages/shared/src/telemetry/index.ts b/packages/shared/src/telemetry/index.ts index f80de29c3bb..16b5d8de5d2 100644 --- a/packages/shared/src/telemetry/index.ts +++ b/packages/shared/src/telemetry/index.ts @@ -14,11 +14,21 @@ export { trackAgentFirstHeartbeat, trackAgentTaskCompleted, trackErrorHandlerCrash, + trackInteractionResolved, } from "./events.js"; export type { TelemetryConfig, TelemetryState, TelemetryEvent, TelemetryEventEnvelope, + TelemetryDimensions, + TelemetryDimensionValue, + TelemetryEventDimensions, TelemetryEventName, + RegisteredPluginEventName, } from "./types.js"; +export type { + AnyPaperclipTelemetryEvent, + EventDimensionsMap, + PaperclipEventName, +} from "./generated/paperclip-telemetry.js"; diff --git a/packages/shared/src/telemetry/types.ts b/packages/shared/src/telemetry/types.ts index d3552d0d216..b5c2aef21b0 100644 --- a/packages/shared/src/telemetry/types.ts +++ b/packages/shared/src/telemetry/types.ts @@ -1,3 +1,8 @@ +import type { + EventDimensionsMap, + PaperclipEventName, +} from "./generated/paperclip-telemetry.js"; + export interface TelemetryState { installId: string; salt: string; @@ -12,11 +17,14 @@ export interface TelemetryConfig { schemaVersion?: string; } +export type TelemetryDimensionValue = string | number | boolean; +export type TelemetryDimensions = Record; + /** Per-event object inside the backend envelope */ export interface TelemetryEvent { name: string; occurredAt: string; - dimensions: Record; + dimensions: TelemetryDimensions; } /** Full payload sent to the backend ingest endpoint */ @@ -28,17 +36,8 @@ export interface TelemetryEventEnvelope { events: TelemetryEvent[]; } -export type TelemetryEventName = - | "install.started" - | "install.completed" - | "company.imported" - | "project.created" - | "routine.created" - | "routine.run" - | "goal.created" - | "agent.created" - | "skill.imported" - | "agent.first_heartbeat" - | "agent.task_completed" - | "error.handler_crash" - | `plugin.${string}`; +export type RegisteredPluginEventName = never; +export type TelemetryEventName = PaperclipEventName | RegisteredPluginEventName; + +export type TelemetryEventDimensions = + K extends keyof EventDimensionsMap ? EventDimensionsMap[K] : never; diff --git a/server/src/__tests__/plugin-telemetry-bridge.test.ts b/server/src/__tests__/plugin-telemetry-bridge.test.ts index 367663d9bb3..2b3b4836278 100644 --- a/server/src/__tests__/plugin-telemetry-bridge.test.ts +++ b/server/src/__tests__/plugin-telemetry-bridge.test.ts @@ -26,8 +26,8 @@ describe("plugin telemetry bridge", () => { }); it("prefixes plugin telemetry events before forwarding them to the telemetry client", async () => { - const track = vi.fn(); - mockGetTelemetryClient.mockReturnValue({ track }); + const trackDynamic = vi.fn(); + mockGetTelemetryClient.mockReturnValue({ trackDynamic }); const services = buildHostServices( {} as never, @@ -46,14 +46,14 @@ describe("plugin telemetry bridge", () => { dimensions: { attempts: 2, success: true }, }); - expect(track).toHaveBeenCalledWith("plugin.linear.sync_completed", { + expect(trackDynamic).toHaveBeenCalledWith("plugin.linear.sync_completed", { attempts: 2, success: true, }); }); it("rejects invalid bare telemetry event names before prefixing", async () => { - mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockGetTelemetryClient.mockReturnValue({ trackDynamic: vi.fn() }); const services = buildHostServices( {} as never, @@ -92,6 +92,9 @@ describe("plugin telemetry bridge", () => { }); it("passes telemetry requests through when the plugin declares the capability", async () => { + const trackDynamic = vi.fn(); + mockGetTelemetryClient.mockReturnValue({ trackDynamic }); + const services = buildHostServices( {} as never, "plugin-record-id", @@ -110,5 +113,8 @@ describe("plugin telemetry bridge", () => { }); expect(mockGetTelemetryClient).toHaveBeenCalledTimes(1); + expect(trackDynamic).toHaveBeenCalledWith("plugin.linear.sync_completed", { + source: "manual", + }); }); }); diff --git a/server/src/__tests__/project-goal-telemetry-routes.test.ts b/server/src/__tests__/project-goal-telemetry-routes.test.ts index 7b5b97bc1bc..a0c12d799c3 100644 --- a/server/src/__tests__/project-goal-telemetry-routes.test.ts +++ b/server/src/__tests__/project-goal-telemetry-routes.test.ts @@ -150,7 +150,7 @@ describe("project and goal telemetry routes", () => { .send({ name: "Telemetry project" }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created"); + expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created", {}); }); it("emits telemetry when a goal is created", async () => { diff --git a/server/src/__tests__/shared-telemetry-events.test.ts b/server/src/__tests__/shared-telemetry-events.test.ts index 1d6483d2e88..8150910ae90 100644 --- a/server/src/__tests__/shared-telemetry-events.test.ts +++ b/server/src/__tests__/shared-telemetry-events.test.ts @@ -3,9 +3,10 @@ import { trackAgentCreated, trackAgentFirstHeartbeat, trackAgentTaskCompleted, + trackInteractionResolved, trackInstallCompleted, } from "@paperclipai/shared/telemetry"; -import type { TelemetryClient } from "@paperclipai/shared/telemetry"; +import type { EventDimensionsMap, TelemetryClient } from "@paperclipai/shared/telemetry"; function createClient(): TelemetryClient { return { @@ -14,6 +15,10 @@ function createClient(): TelemetryClient { } as unknown as TelemetryClient; } +function runtimeValue(value: string): T { + return value as T; +} + describe("shared telemetry agent events", () => { it("includes agent_id for agent.created", () => { const client = createClient(); @@ -29,16 +34,30 @@ describe("shared telemetry agent events", () => { }); }); + it("passes an unrecognized agent role through for backend normalization", () => { + const client = createClient(); + + trackAgentCreated(client, { + agentRole: runtimeValue("coder"), + agentId: "44444444-4444-4444-8444-444444444444", + }); + + expect(client.track).toHaveBeenCalledWith("agent.created", { + agent_role: "coder", + agent_id: "44444444-4444-4444-8444-444444444444", + }); + }); + it("includes agent_id for agent.first_heartbeat", () => { const client = createClient(); trackAgentFirstHeartbeat(client, { - agentRole: "coder", + agentRole: "engineer", agentId: "22222222-2222-4222-8222-222222222222", }); expect(client.track).toHaveBeenCalledWith("agent.first_heartbeat", { - agent_role: "coder", + agent_role: "engineer", agent_id: "22222222-2222-4222-8222-222222222222", }); }); @@ -49,11 +68,13 @@ describe("shared telemetry agent events", () => { trackAgentTaskCompleted(client, { agentRole: "qa", agentId: "33333333-3333-4333-8333-333333333333", + adapterType: "codex_local", }); expect(client.track).toHaveBeenCalledWith("agent.task_completed", { agent_role: "qa", agent_id: "33333333-3333-4333-8333-333333333333", + adapter_type: "codex_local", }); }); @@ -70,4 +91,38 @@ describe("shared telemetry agent events", () => { expect.objectContaining({ agent_id: expect.any(String) }), ); }); + + it("passes interaction.resolved enum dimensions through for backend normalization", () => { + const client = createClient(); + + trackInteractionResolved(client, { + interactionKind: runtimeValue( + "single_confirmation", + ), + status: "accepted", + resolvedByKind: runtimeValue("operator"), + resolutionReason: "accepted", + createdByKind: "agent", + creatorAgentRole: runtimeValue("coder"), + continuationPolicy: runtimeValue( + "wake_everyone", + ), + targetType: "issue_document", + optionCount: 2, + selectedOptionCount: 1, + }); + + expect(client.track).toHaveBeenCalledWith("interaction.resolved", { + interaction_kind: "single_confirmation", + status: "accepted", + resolved_by_kind: "operator", + resolution_reason: "accepted", + created_by_kind: "agent", + creator_agent_role: "coder", + continuation_policy: "wake_everyone", + target_type: "issue_document", + option_count: 2, + selected_option_count: 1, + }); + }); }); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 4309e1e3c05..5284af6ee6e 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1294,7 +1294,7 @@ export function buildHostServices( } const telemetryClient = getTelemetryClient(); if (!telemetryClient) return; - telemetryClient.track(`plugin.${pluginKey}.${eventName}`, params.dimensions); + telemetryClient.trackDynamic(`plugin.${pluginKey}.${eventName}`, params.dimensions); }, }, From be8ae528c4919b151aa0e425a2a81149c18b7141 Mon Sep 17 00:00:00 2001 From: Evyatar Bluzer Date: Thu, 2 Jul 2026 03:14:57 +0700 Subject: [PATCH 04/19] fix(a11y): add aria-pressed to date preset and day-of-week toggle buttons (#1913) ## Thinking Path > - Paperclip orchestrates ai-agents for zero-human companies > - But humans want to watch the agents and oversee their work > - Human users interact with the dashboard UI to monitor costs, schedules, and agent behavior > - The UI has toggle button groups where you click one option and other become unselected (like date range presets, day-of-week selectors) > - Visually the active button look different (filled vs outlined), but screen reader users hear no difference - all buttons sound same > - The `aria-pressed` attribute is what screen readers need to announce which toggle is active and which is not > - I searched entire UI codebase for `aria-pressed` and found zero usage anywhere > - This pull request adds `aria-pressed={isActive}` to two toggle button groups: date range presets in Costs page and day-of-week selector in ScheduleEditor > - Now screen reader users can tell which button is currently selected without relying on visual styling only ## Problem The app has button groups that work like toggles - you click one button to select it and the others become unselected. Visually this work fine because the active button change to a different variant (filled vs outlined). But for screen reader users, ALL buttons sound exactly the same - just "button, 7 days", "button, 30 days", etc with no way to tell which one is currently active. I searched the entire UI codebase for `aria-pressed` and found zero results. This attribute is what screen readers need to announce "7 days, pressed" vs "30 days, not pressed" for toggle button groups. ## What I changed Added `aria-pressed={isActive}` to two toggle button groups: 1. **Costs.tsx** - Date range preset buttons (7d, 30d, 90d, Custom). Screen reader now announce which date range is selected. 2. **ScheduleEditor.tsx** - Day of week selector (Mon, Tue, Wed...). Screen reader now announce which day is selected for weekly schedule. ## How to test 1. Go to Costs page, use VoiceOver (Cmd+F5 on Mac) 2. Tab through the date preset buttons 3. Active button should announce "pressed", others "not pressed" 4. Same for ScheduleEditor - create/edit trigger with weekly preset, tab through day buttons 2 files, 2 lines added. --- ui/src/components/ScheduleEditor.tsx | 1 + ui/src/pages/Costs.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/ui/src/components/ScheduleEditor.tsx b/ui/src/components/ScheduleEditor.tsx index 2d991d5bf6b..12331ae4589 100644 --- a/ui/src/components/ScheduleEditor.tsx +++ b/ui/src/components/ScheduleEditor.tsx @@ -368,6 +368,7 @@ export function ScheduleEditor({ variant={dayOfWeek === d.value ? "default" : "outline"} size="sm" className="h-7 px-2 text-xs" + aria-pressed={dayOfWeek === d.value} onClick={() => { setDayOfWeek(d.value); emitChange(preset, hour, minute, d.value, dayOfMonth, customCron); diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 5d3a81e8257..c78f8f54a6a 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -554,6 +554,7 @@ export function Costs() { variant={preset === key ? "secondary" : "ghost"} size="sm" onClick={() => setPreset(key)} + aria-pressed={preset === key} > {PRESET_LABELS[key]} From d68c34f2cc90181c540dd4aec8c8b01726d31d16 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 1 Jul 2026 13:35:58 -0700 Subject: [PATCH 05/19] Fix managed workspace branch coherence recovery (#8826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Managed issue workspaces are part of the control-plane runtime boundary: the server records which git worktree and branch an agent run is allowed to use. > - Existing reuse checks validated the worktree path and cleanliness, but did not fully validate that the actual checked-out branch still matched the recorded execution workspace branch. > - That gap let an agent run switch a managed worktree onto a publishing branch without updating the execution workspace record, then later reuse or finalize the workspace as though it were coherent. > - The runtime needs a bounded repair path for provably safe mismatches and a hard validation failure for dirty, divergent, or unrecorded branch transitions. > - This pull request adds branch coherence to managed git worktree validation, records explicit recovery evidence, and prevents finalize success when a run silently changes branches. > - The benefit is that branch drift becomes either safely repaired or visibly recoverable instead of silently corrupting managed workspace state. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. Bug report: - What happened: a managed agent workspace could be recorded for one branch while the underlying git worktree was actually checked out on another branch. Reuse and finalization could still treat the workspace as healthy. - Expected behavior: managed git worktrees should verify the actual branch against the recorded execution workspace branch. Safe same-HEAD clean mismatches may be repaired, while dirty, divergent, or unrecorded branch transitions should fail into explicit workspace validation recovery. - Reproduction outline: create a runtime-managed issue worktree, switch its checkout to another branch without updating the execution workspace record, then attempt reuse or run finalization. - Deployment mode: local/self-hosted Paperclip server using managed git workspaces. - Related public work: Refs #7644 and #7579. Related but not duplicate: #8275 and #5851. ## What Changed - Added managed git worktree branch inspection, formatted validation evidence, and safe same-HEAD repair logic to the workspace runtime service. - Validated recorded managed workspace branch state before reuse and during heartbeat setup. - Added finalization-time branch guards so runs that silently switch branches fail with `workspace_validation_failed` instead of recording a successful finalize. - Added recovery fingerprints and evidence for `git_worktree_branch_incoherence`, including manual-repair next actions for unsafe branch drift. - Documented branch coherence as part of runtime-created git worktree workspace coherence. - Added focused tests for safe branch repair, dirty/divergent recovery evidence, heartbeat setup validation, and finalize failure/success paths. ## Verification - `pnpm install --frozen-lockfile` - `git diff --check origin/master...HEAD` - `pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts server/src/__tests__/issue-recovery-actions.test.ts server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts` - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` Notes: - An initial full `pnpm test:run` attempt hit a transient `socket hang up` in one `plugin-routes-authz` case. The exact case passed when rerun directly, the full `plugin-routes-authz` file passed, and the subsequent full `pnpm test:run` passed. - `pnpm build` still emits existing Vite CSS pseudo-element and chunk-size warnings unrelated to this change. ## Risks - This intentionally changes behavior for managed runs that switch branches without recording the transition: they now fail during workspace validation/finalization instead of silently proceeding. - The automatic repair path is intentionally narrow. It only repairs clean branch mismatches when both branches point at the same commit; dirty or divergent worktrees require manual recovery. - Recovery fingerprints now include workspace-validation evidence, so duplicate recovery-action grouping is more precise for branch-incoherence failures. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 Codex CLI/API coding agent, with shell/git/test execution and reasoning mode enabled. ## 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 - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Cody Co-authored-by: Cody --- doc/execution-semantics.md | 2 + ...eartbeat-workspace-finalize-branch.test.ts | 427 ++++++++++++++++ .../heartbeat-workspace-session.test.ts | 45 ++ .../__tests__/issue-recovery-actions.test.ts | 95 ++++ .../src/__tests__/workspace-runtime.test.ts | 359 ++++++++++++-- server/src/services/heartbeat.ts | 151 +++++- server/src/services/recovery/service.ts | 37 +- server/src/services/workspace-runtime.ts | 468 ++++++++++++++++-- 8 files changed, 1520 insertions(+), 64 deletions(-) create mode 100644 server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 500f9c89bef..d4266b93959 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -311,6 +311,8 @@ The state `projectWorkspaceId` plus `executionWorkspaceId` without `projectId` i Workspace incoherence feeds into the same non-terminal liveness and stranded assigned-work model as a disappeared run. The recovery path should first fail or reject the incoherent wake, then either repair and requeue one bounded continuation for the same assignee or surface an explicit recovery action. It must not leave an agent-owned `in_progress` issue healthy solely because a wake record exists that would invoke the adapter in the wrong cwd, a non-git directory where git is required, an unrelated project workspace, or an unrecoverable missing worktree. +For runtime-created `git_worktree` execution workspaces, branch coherence is part of workspace coherence. The persisted execution workspace branch is the recorded branch for future dispatch. Reusing that workspace must verify that the worktree is still registered and that `HEAD` is on the recorded branch. Successful run finalization must perform the same check before recording `workspace_finalize=succeeded`; if the run switched to a publishing/PR branch without updating the execution workspace record, finalization records a failed workspace finalize and the run fails with the expected and actual branch. A branch change is sanctioned only when a control-plane path updates the execution workspace record before finalization, or when publishing work happens in a separate worktree and the managed issue worktree remains on its recorded branch. + ### Explicit recovery actions An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item. diff --git a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts new file mode 100644 index 00000000000..031d70f9803 --- /dev/null +++ b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts @@ -0,0 +1,427 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { and, asc, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agentRuntimeState, + agentTaskSessions, + agentWakeupRequests, + agents, + companies, + companySkills, + createDb, + documentRevisions, + documents, + environmentLeases, + environments, + executionWorkspaces, + heartbeatRunEvents, + heartbeatRuns, + issueComments, + issueDocuments, + issuePlanDecompositions, + issues, + projects, + projectWorkspaces, + workspaceOperations, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { heartbeatService } from "../services/heartbeat.ts"; +import { instanceSettingsService } from "../services/instance-settings.ts"; + +const execFileAsync = promisify(execFile); + +const adapterExecute = vi.hoisted(() => + vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + summary: "Finalization branch guard test run.", + provider: "test", + model: "test-model", + })), +); + +vi.mock("../adapters/index.js", () => ({ + getServerAdapter: () => ({ + type: "codex_local", + execute: adapterExecute, + supportsLocalAgentJwt: false, + }), + findActiveServerAdapter: () => ({ + type: "codex_local", + execute: adapterExecute, + supportsLocalAgentJwt: false, + }), + listAdapterModelProfiles: async () => [], + runningProcesses: new Map(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat workspace finalize branch tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +type Db = ReturnType; +type Heartbeat = ReturnType; + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }); +} + +async function createGitRepo() { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-finalize-branch-repo-")); + await runGit(repoRoot, ["init"]); + await runGit(repoRoot, ["config", "user.email", "paperclip-test@example.com"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await writeFile(path.join(repoRoot, "README.md"), "finalization branch guard\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "initial"]); + return repoRoot; +} + +async function waitForRunToFinish(heartbeat: Heartbeat, runId: string, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const run = await heartbeat.getRun(runId); + if (run && run.status !== "queued" && run.status !== "running") return run; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return heartbeat.getRun(runId); +} + +async function waitForHeartbeatIdle(db: Db, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns); + if (!runs.some((run) => run.status === "queued" || run.status === "running")) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function waitForRuntimeStateLastRun(db: Db, agentId: string, runId: string, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = await db + .select({ lastRunId: agentRuntimeState.lastRunId }) + .from(agentRuntimeState) + .where(eq(agentRuntimeState.agentId, agentId)) + .then((rows) => rows[0] ?? null); + if (state?.lastRunId === runId) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +function readAdapterWorkspace(input: unknown) { + const context = (input as { context?: Record }).context ?? {}; + const workspace = context.paperclipWorkspace as Record | undefined; + const cwd = typeof workspace?.cwd === "string" ? workspace.cwd : null; + const branchName = typeof workspace?.branchName === "string" ? workspace.branchName : null; + const executionWorkspaceId = + typeof context.executionWorkspaceId === "string" ? context.executionWorkspaceId : null; + if (!cwd || !branchName || !executionWorkspaceId) { + throw new Error("Adapter input is missing the realized execution workspace context"); + } + return { cwd, branchName, executionWorkspaceId }; +} + +async function seedRunTarget(db: Db, repoRoot: string) { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const issueId = randomUUID(); + const agentId = randomUUID(); + + await instanceSettingsService(db).updateExperimental({ + enableIsolatedWorkspaces: true, + }); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace Finalize Branch Guard", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + cwd: repoRoot, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + projectWorkspaceId, + title: "Publish without drifting managed workspace", + status: "in_progress", + workMode: "standard", + priority: "medium", + assigneeAgentId: agentId, + identifier: `PAP-${issueId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + createdAt: new Date(), + updatedAt: new Date(), + }); + + return { companyId, projectId, projectWorkspaceId, issueId, agentId }; +} + +async function wakeIssue(heartbeat: Heartbeat, agentId: string, issueId: string) { + return heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId }, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_commented", + skipIssueComment: true, + }, + }); +} + +async function listFinalizeOperations(db: Db, runId: string) { + return db + .select() + .from(workspaceOperations) + .where(and( + eq(workspaceOperations.heartbeatRunId, runId), + eq(workspaceOperations.phase, "workspace_finalize"), + )) + .orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.createdAt)); +} + +describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + const tempRoots: string[] = []; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-finalize-branch-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await waitForHeartbeatIdle(db); + adapterExecute.mockReset(); + adapterExecute.mockImplementation(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + summary: "Finalization branch guard test run.", + provider: "test", + model: "test-model", + })); + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined); + } + await db.delete(issuePlanDecompositions); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(agentTaskSessions); + await db.delete(environmentLeases); + await db.delete(activityLog); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(issueComments); + await db.delete(issues); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agentWakeupRequests); + await db.delete(agentRuntimeState); + await db.delete(agents); + await db.delete(workspaceOperations); + await db.delete(executionWorkspaces); + await db.delete(environments); + await db.delete(companySkills); + await db.delete(companies); + }); + + afterAll(async () => { + await db.$client.end(); + await tempDb?.cleanup(); + }); + + it("fails a successful adapter run when the managed worktree branch drift was not recorded", async () => { + const repoRoot = await createGitRepo(); + tempRoots.push(repoRoot); + const { agentId, issueId } = await seedRunTarget(db, repoRoot); + const publishBranch = `publish-${issueId.slice(0, 8)}`; + let recordedBranch: string | null = null; + let executionWorkspaceId: string | null = null; + + adapterExecute.mockImplementationOnce(async (input) => { + const workspace = readAdapterWorkspace(input); + recordedBranch = workspace.branchName; + executionWorkspaceId = workspace.executionWorkspaceId; + await runGit(workspace.cwd, ["checkout", "-b", publishBranch]); + await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId)); + return { + exitCode: 0, + signal: null, + timedOut: false, + summary: "Adapter completed after switching to a publish branch.", + provider: "test", + model: "test-model", + }; + }); + + const heartbeat = heartbeatService(db); + const run = await wakeIssue(heartbeat, agentId, issueId); + expect(run).not.toBeNull(); + + const finishedRun = await waitForRunToFinish(heartbeat, run!.id); + expect(finishedRun).toMatchObject({ + status: "failed", + errorCode: "workspace_validation_failed", + error: expect.stringContaining("Record a sanctioned execution-workspace branch transition"), + }); + const workspaceValidation = (finishedRun?.resultJson as Record | null)?.workspaceValidation; + expect(workspaceValidation).toMatchObject({ + reason: "git_worktree_branch_mismatch_after_run", + fingerprint: expect.stringMatching(/^workspace_finalize_branch_mismatch:v1:sha256:/), + issueId, + persistedExecutionWorkspaceId: executionWorkspaceId, + managedGitWorktreeBranch: expect.objectContaining({ + executionWorkspaceId, + reasonCode: "branch_mismatch", + expectedBranchName: recordedBranch, + actualBranchName: publishBranch, + }), + }); + await waitForRuntimeStateLastRun(db, agentId, run!.id); + expect(adapterExecute).toHaveBeenCalledTimes(1); + + const finalizeOps = await listFinalizeOperations(db, run!.id); + expect(finalizeOps).toHaveLength(1); + expect(finalizeOps[0]).toMatchObject({ + status: "failed", + executionWorkspaceId, + stderrExcerpt: expect.stringContaining("Managed git worktree branch check failed"), + }); + expect(finalizeOps[0]?.metadata).toMatchObject({ + managedGitWorktreeBranch: { + executionWorkspaceId, + valid: false, + reasonCode: "branch_mismatch", + expectedBranchName: recordedBranch, + actualBranchName: publishBranch, + }, + }); + }, 20_000); + + it("allows a successful adapter run when the branch transition is recorded before finalization", async () => { + const repoRoot = await createGitRepo(); + tempRoots.push(repoRoot); + const { agentId, issueId } = await seedRunTarget(db, repoRoot); + const publishBranch = `publish-${issueId.slice(0, 8)}`; + let executionWorkspaceId: string | null = null; + + adapterExecute.mockImplementationOnce(async (input) => { + const workspace = readAdapterWorkspace(input); + executionWorkspaceId = workspace.executionWorkspaceId; + await runGit(workspace.cwd, ["checkout", "-b", publishBranch]); + await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId)); + await db + .update(executionWorkspaces) + .set({ + branchName: publishBranch, + updatedAt: new Date(), + }) + .where(eq(executionWorkspaces.id, workspace.executionWorkspaceId)); + return { + exitCode: 0, + signal: null, + timedOut: false, + summary: "Adapter completed after recording a branch transition.", + provider: "test", + model: "test-model", + }; + }); + + const heartbeat = heartbeatService(db); + const run = await wakeIssue(heartbeat, agentId, issueId); + expect(run).not.toBeNull(); + + const finishedRun = await waitForRunToFinish(heartbeat, run!.id); + expect(finishedRun).toMatchObject({ + status: "succeeded", + errorCode: null, + error: null, + }); + await waitForRuntimeStateLastRun(db, agentId, run!.id); + expect(adapterExecute).toHaveBeenCalledTimes(1); + + const finalizedWorkspace = await db + .select({ branchName: executionWorkspaces.branchName }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, executionWorkspaceId!)) + .then((rows) => rows[0] ?? null); + expect(finalizedWorkspace?.branchName).toBe(publishBranch); + + const finalizeOps = await listFinalizeOperations(db, run!.id); + expect(finalizeOps).toHaveLength(1); + expect(finalizeOps[0]).toMatchObject({ + status: "succeeded", + executionWorkspaceId, + }); + expect(finalizeOps[0]?.metadata).toMatchObject({ + managedGitWorktreeBranch: { + executionWorkspaceId, + valid: true, + reasonCode: null, + expectedBranchName: publishBranch, + actualBranchName: publishBranch, + }, + }); + }, 20_000); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index cdce55dd7ba..63456956b32 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -365,6 +365,51 @@ describe("assertGitSensitiveAdapterWorkspaceValid", () => { ); }); + it("rejects a git worktree persisted workspace when the checked-out branch differs from the recorded branch", async () => { + const repoRoot = await createGitCheckout({ withRemote: false }); + const worktreeParent = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-branch-worktree-")); + const worktreePath = path.join(worktreeParent, "workspace"); + const recordedBranch = "PAP-1-recorded-branch"; + const actualBranch = "PAP-1-push-pr-head"; + try { + await runGit(repoRoot, ["config", "user.email", "test@example.com"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await fs.writeFile(path.join(repoRoot, "README.md"), "initial\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + await runGit(repoRoot, ["worktree", "add", "-b", recordedBranch, worktreePath, "HEAD"]); + await runGit(worktreePath, ["checkout", "-b", actualBranch]); + + const input = buildWorkspaceValidationInput(); + await expectWorkspaceValidationFailure( + buildWorkspaceValidationInput({ + resolvedWorkspace: buildResolvedWorkspace({ cwd: worktreePath }), + executionWorkspace: { + ...input.executionWorkspace, + strategy: "git_worktree", + baseCwd: repoRoot, + cwd: worktreePath, + branchName: recordedBranch, + worktreePath, + }, + persistedExecutionWorkspace: { + ...input.persistedExecutionWorkspace!, + strategyType: "git_worktree", + cwd: worktreePath, + providerType: "git_worktree", + providerRef: worktreePath, + branchName: recordedBranch, + }, + }), + "git_worktree_branch_mismatch", + `expected git worktree branch "${recordedBranch}"`, + ); + } finally { + await fs.rm(repoRoot, { recursive: true, force: true }); + await fs.rm(worktreeParent, { recursive: true, force: true }); + } + }); + it("rejects a workspace-linked issue when adapter cwd has no git metadata", async () => { const input = buildWorkspaceValidationInput(); const cwd = "/tmp/paperclip-workspace-without-git-metadata"; diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index dd15e7c0cdd..54a740c1cf2 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -379,6 +379,101 @@ describeEmbeddedPostgres("issue recovery actions", () => { }); }); + it("deduplicates workspace-incoherence recovery actions by the typed workspace fingerprint", async () => { + const { companyId, coderId, sourceIssue } = await seedCompany(); + const enqueueWakeup = vi.fn(async () => null); + const recovery = recoveryService(db, { enqueueWakeup }); + const workspaceFingerprint = `workspace_incoherence:v1:sha256:${"a".repeat(64)}`; + const workspaceValidation = { + reason: "git_worktree_branch_incoherence", + fingerprint: workspaceFingerprint, + sourceIssueId: sourceIssue.id, + sourceIdentifier: sourceIssue.identifier, + executionWorkspaceId: "execution-workspace-1", + expectedBranch: "PAP-1-expected", + actualBranch: "PAP-1-publish", + cleanliness: "dirty", + provenance: { + expectedBranchExists: true, + actualBranchExists: true, + expectedHeadSha: "1111111111111111111111111111111111111111", + actualHeadSha: "2222222222222222222222222222222222222222", + sameHead: false, + }, + safeRepair: { + eligible: false, + attempted: false, + succeeded: false, + reason: "worktree is not clean", + }, + }; + const firstLatestRun = { + id: randomUUID(), + agentId: coderId, + status: "failed", + error: "workspace branch mismatch", + errorCode: "workspace_validation_failed", + contextSnapshot: {}, + livenessState: "failed", + resultJson: { workspaceValidation }, + } as const; + const secondLatestRun = { + ...firstLatestRun, + id: randomUUID(), + }; + + await recovery.escalateStrandedAssignedIssue({ + issue: sourceIssue, + previousStatus: "in_progress", + latestRun: firstLatestRun, + comment: "Workspace failed validation.", + recoveryCause: "workspace_validation_failed", + }); + await recovery.escalateStrandedAssignedIssue({ + issue: sourceIssue, + previousStatus: "in_progress", + latestRun: secondLatestRun, + comment: "Workspace failed validation.", + recoveryCause: "workspace_validation_failed", + }); + + const actionRows = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.sourceIssueId, sourceIssue.id)); + expect(actionRows).toHaveLength(1); + expect(actionRows[0]).toMatchObject({ + companyId, + kind: "workspace_validation", + cause: "workspace_validation_failed", + status: "active", + attemptCount: 2, + fingerprint: expect.stringContaining(workspaceFingerprint), + evidence: expect.objectContaining({ + latestRunId: secondLatestRun.id, + latestRunErrorCode: "workspace_validation_failed", + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + fingerprint: workspaceFingerprint, + sourceIssueId: sourceIssue.id, + executionWorkspaceId: "execution-workspace-1", + expectedBranch: "PAP-1-expected", + actualBranch: "PAP-1-publish", + cleanliness: "dirty", + }), + }), + nextAction: expect.stringContaining("git worktree branch incoherence"), + wakePolicy: expect.objectContaining({ + type: "manual_repair_required", + reason: "workspace_validation_failed", + }), + }); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id)); + expect(comments.filter((comment) => comment.body.includes(`Recovery action: \`${actionRows[0]?.id}\``))).toHaveLength(1); + expect(enqueueWakeup).not.toHaveBeenCalled(); + }); + it("keeps the source issue blocked when source-scoped wakeup is claimed synchronously", async () => { const { companyId, managerId, coderId, sourceIssue } = await seedCompany(); await db.update(agents).set({ status: "paused" }).where(eq(agents.id, managerId)); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 4f066822ddf..4c8ac909806 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -754,8 +754,9 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath); }); - it("rejects reusing a linked worktree whose branch drifted from the expected issue branch", async () => { + it("repairs a clean linked worktree whose branch drifted from the expected issue branch", async () => { const repoRoot = await createTempRepo(); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); const initial = await realizeExecutionWorkspace({ base: { @@ -786,34 +787,52 @@ describe("realizeExecutionWorkspace", () => { await runGit(initial.cwd, ["checkout", "-b", "unexpected-branch"]); - await expect( - realizeExecutionWorkspace({ - base: { - baseCwd: repoRoot, - source: "project_primary", - projectId: "project-1", - workspaceId: "workspace-1", - repoUrl: null, - repoRef: "HEAD", + const repaired = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", }, - config: { - workspaceStrategy: { - type: "git_worktree", - branchTemplate: "{{issue.identifier}}-{{slug}}", - }, - }, - issue: { - id: "issue-1", - identifier: "PAP-447", - title: "Add Worktree Support", - }, - agent: { - id: "agent-1", - name: "Codex Coder", - companyId: "company-1", - }, - }), - ).rejects.toThrow(/not a reusable git worktree \(worktree HEAD is on "unexpected-branch" instead of "PAP-447-add-worktree-support"\)\./); + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Add Worktree Support", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + recorder, + }); + + expect(repaired.created).toBe(false); + expect(repaired.cwd).toBe(initial.cwd); + await expect(readGit(initial.cwd, ["branch", "--show-current"])).resolves.toBe("PAP-447-add-worktree-support"); + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "worktree_prepare", + command: "git checkout PAP-447-add-worktree-support", + metadata: expect.objectContaining({ + branchIncoherenceRepair: true, + expectedBranchName: "PAP-447-add-worktree-support", + actualBranchName: "unexpected-branch", + sourceIssueId: "issue-1", + fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/), + }), + }), + ]), + ); }); it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => { @@ -2057,6 +2076,290 @@ describe("realizeExecutionWorkspace", () => { expect(actualHead).toBe(expectedHead); }, 15_000); + it("repairs a clean persisted git worktree branch mismatch when both branches point at the same commit", async () => { + const repoRoot = await createTempRepo(); + const expectedBranch = "PAP-454-repair-clean-branch-mismatch"; + const actualBranch = "PAP-454-publish-head"; + const realWorktreeRoot = path.join(repoRoot, ".paperclip", "real-worktrees"); + const symlinkedWorktreeRoot = path.join(repoRoot, ".paperclip", "worktrees"); + const realWorktreePath = path.join(realWorktreeRoot, expectedBranch); + const worktreePath = path.join(symlinkedWorktreeRoot, expectedBranch); + await fs.mkdir(realWorktreeRoot, { recursive: true }); + await runGit(repoRoot, ["branch", expectedBranch]); + await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, realWorktreePath, "HEAD"]); + await fs.symlink(realWorktreeRoot, symlinkedWorktreeRoot, "dir"); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + const restored = await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-1", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: expectedBranch, + }, + issue: { + id: "issue-1", + identifier: "PAP-454", + title: "Repair clean branch mismatch", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + recorder, + }); + + expect(restored?.cwd).toBe(worktreePath); + await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(expectedBranch); + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "worktree_prepare", + command: "git checkout PAP-454-repair-clean-branch-mismatch", + metadata: expect.objectContaining({ + branchIncoherenceRepair: true, + expectedBranchName: expectedBranch, + actualBranchName: actualBranch, + sourceIssueId: "issue-1", + executionWorkspaceId: "execution-workspace-1", + fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/), + }), + }), + ]), + ); + }, 15_000); + + it("rejects dirty persisted git worktree branch incoherence with bounded recovery evidence", async () => { + const repoRoot = await createTempRepo(); + const expectedBranch = "PAP-455-reject-dirty-branch-mismatch"; + const actualBranch = "PAP-455-publish-head"; + const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch); + await fs.mkdir(path.dirname(worktreePath), { recursive: true }); + await runGit(repoRoot, ["branch", expectedBranch]); + await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, worktreePath, "HEAD"]); + await fs.writeFile(path.join(worktreePath, "untracked.txt"), "not safe to switch\n", "utf8"); + + await expect(ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-2", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: expectedBranch, + }, + issue: { + id: "issue-2", + identifier: "PAP-455", + title: "Reject dirty branch mismatch", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/), + sourceIssueId: "issue-2", + sourceIdentifier: "PAP-455", + executionWorkspaceId: "execution-workspace-2", + expectedBranch, + actualBranch, + cleanliness: "dirty", + provenance: expect.objectContaining({ + expectedBranchExists: true, + actualBranchExists: true, + sameHead: true, + }), + safeRepair: expect.objectContaining({ + eligible: false, + attempted: false, + succeeded: false, + reason: "worktree is not clean", + }), + }), + }, + }); + }, 15_000); + + it("routes non-reusable persisted git worktrees through workspace validation recovery", async () => { + const repoRoot = await createTempRepo(); + const expectedBranch = "PAP-455-not-registered-worktree"; + const detachedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch); + await fs.mkdir(path.dirname(detachedWorktreePath), { recursive: true }); + await execFileAsync("git", ["clone", repoRoot, detachedWorktreePath]); + await runGit(detachedWorktreePath, ["checkout", "-B", expectedBranch]); + + await expect(ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-not-registered", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: detachedWorktreePath, + providerRef: detachedWorktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: expectedBranch, + }, + issue: { + id: "issue-not-registered", + identifier: "PAP-455", + title: "Reject unregistered persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: { + reason: "git_worktree_not_reusable", + reasonCode: "not_registered", + worktreePath: detachedWorktreePath, + executionWorkspaceId: "execution-workspace-not-registered", + }, + }, + }); + }, 15_000); + + it("rejects an existing persisted git worktree when the checked-out branch changed to a different commit", async () => { + const repoRoot = await createTempRepo(); + + const initial = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-456", + title: "Keep persisted branch coherent", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + const actualBranch = "PAP-456-push-pr-head"; + await runGit(initial.cwd, ["checkout", "-b", actualBranch]); + await fs.writeFile(path.join(initial.cwd, "publish.txt"), "publish\n", "utf8"); + await runGit(initial.cwd, ["add", "publish.txt"]); + await runGit(initial.cwd, ["commit", "-m", "Add publish branch work"]); + + await expect(ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-3", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: initial.cwd, + providerRef: initial.worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: initial.branchName, + }, + issue: { + id: "issue-3", + identifier: "PAP-456", + title: "Keep persisted branch coherent", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/), + sourceIssueId: "issue-3", + sourceIdentifier: "PAP-456", + executionWorkspaceId: "execution-workspace-3", + expectedBranch: initial.branchName, + actualBranch, + cleanliness: "clean", + provenance: expect.objectContaining({ + expectedBranchExists: true, + actualBranchExists: true, + sameHead: false, + }), + safeRepair: expect.objectContaining({ + eligible: false, + attempted: false, + succeeded: false, + reason: "expected branch and current HEAD differ", + }), + }), + }, + }); + }, 15_000); + it("does not reuse a missing persisted local filesystem workspace", async () => { const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-base-")); const missingCwd = path.join(baseCwd, "missing-workspace"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5c6a2b17d15..63afaca38c1 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -98,6 +98,8 @@ import { cleanupExecutionWorkspaceArtifacts, ensurePersistedExecutionWorkspaceAvailable, ensureRuntimeServicesForRun, + formatManagedGitWorktreeBranchInspection, + inspectManagedGitWorktreeBranch, persistAdapterManagedRuntimeServices, realizeExecutionWorkspace, releaseRuntimeServicesForRun, @@ -1153,8 +1155,21 @@ async function ensureManagedProjectWorkspace(input: { } } -function isWorkspaceValidationFailure(error: unknown): error is WorkspaceValidationFailure { - return error instanceof WorkspaceValidationFailure; +type WorkspaceValidationFailureLike = WorkspaceValidationFailure | { + code: typeof WORKSPACE_VALIDATION_FAILURE_CODE; + resultJson: Record; +}; + +function isWorkspaceValidationFailure(error: unknown): error is WorkspaceValidationFailureLike { + if (error instanceof WorkspaceValidationFailure) return true; + const maybe = error as { code?: unknown; resultJson?: unknown } | null; + return Boolean( + maybe && + maybe.code === WORKSPACE_VALIDATION_FAILURE_CODE && + maybe.resultJson && + typeof maybe.resultJson === "object" && + !Array.isArray(maybe.resultJson), + ); } function isWorkspaceValidationFailedRun( @@ -1163,6 +1178,38 @@ function isWorkspaceValidationFailedRun( return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE; } +function stableStringifyForFingerprint(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringifyForFingerprint(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const rec = value as Record; + return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyForFingerprint(rec[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function fingerprintFinalizeWorkspaceBranchValidation(input: { + issueId: string | null; + executionWorkspaceId: string; + inspection: ReturnType; +}) { + const digest = createHash("sha256") + .update(stableStringifyForFingerprint({ + version: 1, + reason: "git_worktree_branch_mismatch_after_run", + issueId: input.issueId, + executionWorkspaceId: input.executionWorkspaceId, + worktreePath: input.inspection.worktreePath ? path.resolve(input.inspection.worktreePath) : null, + repoRoot: input.inspection.repoRoot ? path.resolve(input.inspection.repoRoot) : null, + expectedBranchName: input.inspection.expectedBranchName, + actualBranchName: input.inspection.actualBranchName, + reasonCode: input.inspection.reasonCode, + })) + .digest("hex"); + return `workspace_finalize_branch_mismatch:v1:sha256:${digest}`; +} + function isConfigurationIncompleteFailure(error: unknown): error is ConfigurationIncompleteFailure { return error instanceof ConfigurationIncompleteFailure; } @@ -1386,6 +1433,27 @@ export async function assertGitSensitiveAdapterWorkspaceValid(input: { `Issue ${issue.identifier ?? issue.id} expected a git workspace for ${input.adapterType}, but "${effectiveCwd}" has no .git metadata.`, ); } + + const expectedManagedBranchName = + readNonEmptyString(input.persistedExecutionWorkspace?.branchName) ?? + readNonEmptyString(input.executionWorkspace.branchName); + if ( + input.persistedExecutionWorkspace?.strategyType === "git_worktree" && + effectiveCwd && + expectedManagedBranchName + ) { + const inspection = await inspectManagedGitWorktreeBranch({ + worktreePath: effectiveCwd, + expectedBranchName: expectedManagedBranchName, + }); + if (!inspection.valid) { + fail( + "git_worktree_branch_mismatch", + `Issue ${issue.identifier ?? issue.id} expected git worktree branch "${expectedManagedBranchName}" at "${effectiveCwd}", but ${inspection.reason ?? "the checked-out branch could not be verified"}.`, + { managedGitWorktreeBranch: formatManagedGitWorktreeBranchInspection(inspection) }, + ); + } + } } const heartbeatRunProcessGroupIdColumn = @@ -9946,6 +10014,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ? await ensurePersistedExecutionWorkspaceAvailable({ base: executionWorkspaceBase, workspace: { + id: existingExecutionWorkspace.id, mode: existingExecutionWorkspace.mode, strategyType: existingExecutionWorkspace.strategyType, cwd: existingExecutionWorkspace.cwd, @@ -10665,11 +10734,81 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ); } let adapterFinalizeOutcome: "succeeded" | "failed" | null = null; + const inspectFinalizeWorkspaceBranch = async () => { + const workspaceRecord = persistedExecutionWorkspace?.id + ? await executionWorkspacesSvc.getById(persistedExecutionWorkspace.id) + : persistedExecutionWorkspace; + if (workspaceRecord?.strategyType !== "git_worktree") return null; + + const worktreePath = + readNonEmptyString(workspaceRecord.providerRef) ?? + readNonEmptyString(workspaceRecord.cwd) ?? + readNonEmptyString(executionWorkspace.worktreePath) ?? + readNonEmptyString(executionWorkspace.cwd); + const expectedBranchName = + readNonEmptyString(workspaceRecord.branchName) ?? + readNonEmptyString(executionWorkspace.branchName); + if (!worktreePath || !expectedBranchName) return null; + + const inspection = await inspectManagedGitWorktreeBranch({ + worktreePath, + expectedBranchName, + }); + return { workspaceRecord, inspection }; + }; const recordWorkspaceFinalize = async ( status: "succeeded" | "failed", metadata?: Record, ) => { if (adapterFinalizeOutcome) return; + let finalizeBranchMetadata: Record | null = null; + if (status === "succeeded") { + const branchInspection = await inspectFinalizeWorkspaceBranch(); + if (branchInspection) { + const managedGitWorktreeBranch = formatManagedGitWorktreeBranchInspection(branchInspection.inspection); + finalizeBranchMetadata = { + executionWorkspaceId: branchInspection.workspaceRecord.id, + ...managedGitWorktreeBranch, + }; + if (!branchInspection.inspection.valid) { + const workspaceValidationFingerprint = fingerprintFinalizeWorkspaceBranchValidation({ + issueId: issueRef?.id ?? null, + executionWorkspaceId: branchInspection.workspaceRecord.id, + inspection: managedGitWorktreeBranch, + }); + await workspaceOperationRecorder.recordOperation({ + phase: "workspace_finalize", + cwd: executionWorkspace.cwd, + metadata: { + adapterType: agent.adapterType, + executionTargetKind: executionTarget?.kind ?? "local", + ...metadata, + managedGitWorktreeBranch: finalizeBranchMetadata, + }, + run: async () => ({ + status: "failed", + stderr: `Managed git worktree branch check failed: ${branchInspection.inspection.reason ?? "unknown branch mismatch"}\n`, + }), + }); + adapterFinalizeOutcome = "failed"; + throw new WorkspaceValidationFailure( + `Execution workspace ${branchInspection.workspaceRecord.id} expected git worktree branch "${branchInspection.inspection.expectedBranchName}" at "${branchInspection.inspection.worktreePath}", but ${branchInspection.inspection.reason ?? "the checked-out branch could not be verified"}. Record a sanctioned execution-workspace branch transition or restore the workspace branch before completing the run.`, + { + workspaceValidation: { + reason: "git_worktree_branch_mismatch_after_run", + fingerprint: workspaceValidationFingerprint, + adapterType: agent.adapterType, + issueId: issueRef?.id ?? null, + issueIdentifier: issueRef?.identifier ?? null, + persistedExecutionWorkspaceId: branchInspection.workspaceRecord.id, + executionWorkspaceCwd: executionWorkspace.cwd, + managedGitWorktreeBranch: finalizeBranchMetadata, + }, + }, + ); + } + } + } await workspaceOperationRecorder.recordOperation({ phase: "workspace_finalize", cwd: executionWorkspace.cwd, @@ -10677,6 +10816,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) adapterType: agent.adapterType, executionTargetKind: executionTarget?.kind ?? "local", ...metadata, + ...(finalizeBranchMetadata ? { managedGitWorktreeBranch: finalizeBranchMetadata } : {}), }, run: async () => ({ status }), }); @@ -11201,8 +11341,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) // A missing secret/env binding is a known pre-dispatch configuration gap, // not an opaque setup crash. Surface it with its own errorCode so the // recovery path routes it to a human owner instead of looping retries. + const workspaceValidationSetupFailure = isWorkspaceValidationFailure(outerErr) ? outerErr : null; const configurationIncompleteSetupFailure = isConfigurationIncompleteFailure(outerErr) ? outerErr : null; - const setupFailureErrorCode = configurationIncompleteSetupFailure?.code ?? "setup_failed"; + const setupFailureErrorCode = + workspaceValidationSetupFailure?.code ?? configurationIncompleteSetupFailure?.code ?? "setup_failed"; logger.error({ err: outerErr, runId }, "heartbeat execution setup failed"); const setupFailureAgent = await getAgent(run.agentId).catch(() => null); const setupFailureWrite = await setRunStatusIfRunning(runId, "failed", { @@ -11213,7 +11355,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", { errorCode: setupFailureErrorCode, errorMessage: message, - resultJson: configurationIncompleteSetupFailure?.resultJson ?? null, + resultJson: + workspaceValidationSetupFailure?.resultJson ?? configurationIncompleteSetupFailure?.resultJson ?? null, }), } : {}), }).catch(() => ({ run: null, updated: false as const })); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index b98c428a2d0..fa577ee791c 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -113,7 +113,9 @@ type RecoveryWakeup = ( type LatestIssueRun = Pick< typeof heartbeatRuns.$inferSelect, "id" | "agentId" | "status" | "error" | "errorCode" | "contextSnapshot" | "livenessState" -> | null; +> & { + resultJson?: unknown; +} | null; type SuccessfulLatestIssueRun = NonNullable & { status: "succeeded" }; type StrandedRecoveryCause = @@ -130,6 +132,16 @@ type SuccessfulRunHandoffRecoveryEvidence = { maxHandoffAttempts: number; }; +function readWorkspaceValidationPayload(latestRun: LatestIssueRun): Record | null { + const payload = parseObject(parseObject(latestRun?.resultJson).workspaceValidation); + return Object.keys(payload).length > 0 ? payload : null; +} + +function readWorkspaceValidationFingerprint(latestRun: LatestIssueRun): string | null { + const payload = readWorkspaceValidationPayload(latestRun); + return readNonEmptyString(payload?.fingerprint); +} + type WatchdogDecisionActor = | { type: "board"; userId?: string | null; runId?: string | null } | { type: "agent"; agentId?: string | null; runId?: string | null } @@ -497,6 +509,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) errorCode: heartbeatRuns.errorCode, contextSnapshot: heartbeatRuns.contextSnapshot, livenessState: heartbeatRuns.livenessState, + resultJson: heartbeatRuns.resultJson, }) .from(heartbeatRuns) .where( @@ -2246,7 +2259,20 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) function strandedRecoveryActionFingerprint(input: { issue: typeof issues.$inferSelect; recoveryCause: StrandedRecoveryCause; + latestRun: LatestIssueRun; }) { + if (input.recoveryCause === "workspace_validation_failed") { + const workspaceFingerprint = readWorkspaceValidationFingerprint(input.latestRun); + if (workspaceFingerprint) { + return [ + "source_scoped_recovery", + input.issue.companyId, + input.issue.id, + input.recoveryCause, + workspaceFingerprint, + ].join(":"); + } + } return [ "source_scoped_recovery", input.issue.companyId, @@ -2263,6 +2289,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; }) { const context = parseObject(input.latestRun?.contextSnapshot); + const workspaceValidation = input.recoveryCause === "workspace_validation_failed" + ? readWorkspaceValidationPayload(input.latestRun) + : null; return { sourceIssueId: input.issue.id, sourceIdentifier: input.issue.identifier, @@ -2278,6 +2307,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) missingDisposition: input.successfulRunHandoffEvidence?.missingDisposition ?? null, handoffAttempt: input.successfulRunHandoffEvidence?.handoffAttempt ?? null, maxHandoffAttempts: input.successfulRunHandoffEvidence?.maxHandoffAttempts ?? null, + ...(workspaceValidation ? { workspaceValidation } : {}), }; } @@ -2303,6 +2333,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) fingerprint: strandedRecoveryActionFingerprint({ issue: input.issue, recoveryCause, + latestRun: input.latestRun, }), evidence: buildStrandedRecoveryActionEvidence({ issue: input.issue, @@ -2314,7 +2345,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) nextAction: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON ? "Choose and record a valid issue disposition without copying transcript content." : recoveryCause === "workspace_validation_failed" - ? "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution." + ? readWorkspaceValidationPayload(input.latestRun)?.reason === "git_worktree_branch_incoherence" + ? "Repair the source issue git worktree branch incoherence, or choose a new execution workspace, before resuming adapter execution." + : "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution." : recoveryCause === "configuration_incomplete" ? "Bind the missing secret(s) named in the run failure to the agent/project/routine env before resuming adapter execution." : "Restore a live execution path, fix the runtime/adapter failure, or record an intentional manual resolution.", diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 5b29ffcfbbf..d7e87effde9 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -70,6 +70,17 @@ export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput { baseRefSha?: string | null; } +export class WorkspaceRuntimeValidationFailure extends Error { + code = "workspace_validation_failed" as const; + resultJson: Record; + + constructor(message: string, resultJson: Record) { + super(message); + this.name = "WorkspaceRuntimeValidationFailure"; + this.resultJson = resultJson; + } +} + export interface RuntimeServiceRef { id: string; companyId: string; @@ -640,6 +651,238 @@ async function remoteExists(repoRoot: string, remote: string): Promise .catch(() => false); } +const GIT_WORKTREE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence"; + +type GitWorktreeCleanliness = "clean" | "dirty" | "unknown"; + +type GitWorktreeBranchIncoherenceEvidence = { + reason: typeof GIT_WORKTREE_BRANCH_INCOHERENCE_REASON; + fingerprint: string; + sourceIssueId: string | null; + sourceIdentifier: string | null; + executionWorkspaceId: string | null; + worktreePath: string; + repoRoot: string; + expectedBranch: string; + actualBranch: string | null; + cleanliness: GitWorktreeCleanliness; + statusEntryCount: number | null; + provenance: { + expectedBranchRef: string; + actualBranchRef: string | null; + registeredBranchRef: string | null; + registeredPathFound: boolean; + registeredBranchMatchesHead: boolean; + expectedBranchExists: boolean; + actualBranchExists: boolean | null; + expectedHeadSha: string | null; + actualHeadSha: string | null; + sameHead: boolean; + }; + safeRepair: { + eligible: boolean; + attempted: boolean; + succeeded: boolean; + reason: string; + }; +}; + +function formatBranchForMessage(branch: string | null | undefined) { + return branch && branch.length > 0 ? branch : ""; +} + +function fingerprintWorkspaceBranchIncoherence(input: { + sourceIssueId: string | null; + executionWorkspaceId: string | null; + worktreePath: string; + expectedBranch: string; + actualBranch: string | null; + cleanliness: GitWorktreeCleanliness; + expectedHeadSha: string | null; + actualHeadSha: string | null; +}) { + const digest = createHash("sha256") + .update(stableStringify({ + version: 1, + reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON, + sourceIssueId: input.sourceIssueId, + executionWorkspaceId: input.executionWorkspaceId, + worktreePath: path.resolve(input.worktreePath), + expectedBranch: input.expectedBranch, + actualBranch: input.actualBranch, + cleanliness: input.cleanliness, + expectedHeadSha: input.expectedHeadSha, + actualHeadSha: input.actualHeadSha, + })) + .digest("hex"); + return `workspace_incoherence:v1:sha256:${digest}`; +} + +async function inspectGitWorktreeBranchIncoherence(input: { + repoRoot: string; + worktreePath: string; + expectedBranchName: string; + actualBranchName: string | null; + sourceIssue: ExecutionWorkspaceIssueRef | null; + executionWorkspaceId?: string | null; +}): Promise { + const status = await runGit( + ["status", "--porcelain", "--untracked-files=all"], + input.worktreePath, + ).catch(() => null); + const statusLines = status === null + ? null + : status.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const cleanliness: GitWorktreeCleanliness = + status === null ? "unknown" : status.trim().length > 0 ? "dirty" : "clean"; + const expectedHeadSha = await runGit( + ["rev-parse", "--verify", `refs/heads/${input.expectedBranchName}^{commit}`], + input.repoRoot, + ).catch(() => null); + const actualHeadSha = await runGit(["rev-parse", "HEAD"], input.worktreePath).catch(() => null); + const actualBranchExists = input.actualBranchName + ? await localBranchExists(input.repoRoot, input.actualBranchName) + : null; + const registered = await findRegisteredGitWorktreeByPath(input.repoRoot, input.worktreePath); + const actualBranchRef = input.actualBranchName ? `refs/heads/${input.actualBranchName}` : null; + const registeredBranchRef = registered?.branch ?? null; + const registeredBranchMatchesHead = Boolean(registered && registeredBranchRef === actualBranchRef); + const sameHead = Boolean(expectedHeadSha && actualHeadSha && expectedHeadSha === actualHeadSha); + const expectedBranchExists = Boolean(expectedHeadSha); + const eligible = cleanliness === "clean" && expectedBranchExists && sameHead && registeredBranchMatchesHead; + const safeRepairReason = eligible + ? "clean worktree and expected branch points at the current HEAD" + : cleanliness !== "clean" + ? "worktree is not clean" + : !registered + ? "worktree path is not registered" + : !registeredBranchMatchesHead + ? "registered worktree branch does not match HEAD" + : !expectedBranchExists + ? "expected branch does not exist" + : !sameHead + ? "expected branch and current HEAD differ" + : "safe repair could not be proven"; + const fingerprint = fingerprintWorkspaceBranchIncoherence({ + sourceIssueId: input.sourceIssue?.id ?? null, + executionWorkspaceId: input.executionWorkspaceId ?? null, + worktreePath: input.worktreePath, + expectedBranch: input.expectedBranchName, + actualBranch: input.actualBranchName, + cleanliness, + expectedHeadSha, + actualHeadSha, + }); + + return { + reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON, + fingerprint, + sourceIssueId: input.sourceIssue?.id ?? null, + sourceIdentifier: input.sourceIssue?.identifier ?? null, + executionWorkspaceId: input.executionWorkspaceId ?? null, + worktreePath: path.resolve(input.worktreePath), + repoRoot: path.resolve(input.repoRoot), + expectedBranch: input.expectedBranchName, + actualBranch: input.actualBranchName, + cleanliness, + statusEntryCount: statusLines?.length ?? null, + provenance: { + expectedBranchRef: `refs/heads/${input.expectedBranchName}`, + actualBranchRef, + registeredBranchRef, + registeredPathFound: Boolean(registered), + registeredBranchMatchesHead, + expectedBranchExists, + actualBranchExists, + expectedHeadSha, + actualHeadSha, + sameHead, + }, + safeRepair: { + eligible, + attempted: false, + succeeded: false, + reason: safeRepairReason, + }, + }; +} + +function branchIncoherenceValidationFailure(evidence: GitWorktreeBranchIncoherenceEvidence) { + return new WorkspaceRuntimeValidationFailure( + `Execution workspace git worktree expected branch "${evidence.expectedBranch}" but found "${formatBranchForMessage(evidence.actualBranch)}" at "${evidence.worktreePath}". Safe repair ${evidence.safeRepair.succeeded ? "succeeded" : "was not completed"}: ${evidence.safeRepair.reason}.`, + { + workspaceValidation: evidence, + }, + ); +} + +async function ensureGitWorktreeBranchCoherent(input: { + repoRoot: string; + worktreePath: string; + expectedBranchName: string | null; + sourceIssue: ExecutionWorkspaceIssueRef | null; + executionWorkspaceId?: string | null; + actualBranchName?: string | null; + recorder?: WorkspaceOperationRecorder | null; +}) { + const expectedBranchName = input.expectedBranchName?.trim(); + if (!expectedBranchName) return; + + const currentBranch = input.actualBranchName !== undefined + ? input.actualBranchName + : await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath).catch(() => null); + if (currentBranch === expectedBranchName) return; + + const evidence = await inspectGitWorktreeBranchIncoherence({ + repoRoot: input.repoRoot, + worktreePath: input.worktreePath, + expectedBranchName, + actualBranchName: currentBranch, + sourceIssue: input.sourceIssue, + executionWorkspaceId: input.executionWorkspaceId ?? null, + }); + + if (!evidence.safeRepair.eligible) { + throw branchIncoherenceValidationFailure(evidence); + } + + evidence.safeRepair.attempted = true; + try { + await recordGitOperation(input.recorder, { + phase: "worktree_prepare", + args: ["checkout", expectedBranchName], + cwd: input.worktreePath, + metadata: { + repoRoot: input.repoRoot, + worktreePath: input.worktreePath, + expectedBranchName, + actualBranchName: currentBranch, + branchIncoherenceRepair: true, + fingerprint: evidence.fingerprint, + sourceIssueId: evidence.sourceIssueId, + executionWorkspaceId: evidence.executionWorkspaceId, + }, + successMessage: `Repaired clean git worktree branch mismatch at ${input.worktreePath}: checked out ${expectedBranchName}\n`, + failureLabel: `git checkout ${expectedBranchName}`, + }); + } catch (error) { + evidence.safeRepair.succeeded = false; + evidence.safeRepair.reason = `safe checkout failed: ${error instanceof Error ? error.message : String(error)}`; + throw branchIncoherenceValidationFailure(evidence); + } + + const repairedBranch = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath) + .catch(() => null); + if (repairedBranch !== expectedBranchName) { + evidence.safeRepair.succeeded = false; + evidence.safeRepair.reason = `checkout completed but HEAD is ${formatBranchForMessage(repairedBranch)}`; + throw branchIncoherenceValidationFailure(evidence); + } + + evidence.safeRepair.succeeded = true; + evidence.safeRepair.reason = "clean worktree checked out the recorded branch"; +} + // Resolve the authoritative base ref for a fresh worktree. A configured local // branch is mapped to its `origin/` counterpart so unpushed local // divergence never leaks into the task branch; remote-tracking refs, SHAs, and @@ -751,6 +994,22 @@ type GitWorktreeListEntry = { branch: string | null; }; +export type ManagedGitWorktreeBranchInspection = { + valid: boolean; + reason: string | null; + reasonCode: + | "missing_worktree" + | "not_a_git_checkout" + | "not_registered" + | "wrong_repository_root" + | "branch_mismatch" + | null; + repoRoot: string | null; + worktreePath: string; + expectedBranchName: string | null; + actualBranchName: string | null; +}; + function parseGitWorktreeListPorcelain(raw: string): GitWorktreeListEntry[] { const entries: GitWorktreeListEntry[] = []; let current: Partial = {}; @@ -803,6 +1062,19 @@ async function findRegisteredGitWorktreeByBranch(repoRoot: string, branchName: s return null; } +async function findRegisteredGitWorktreeByPath(repoRoot: string, worktreePath: string): Promise { + const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null); + if (!raw) return null; + + const expectedPath = await resolvePathForWorktreeComparison(worktreePath); + for (const entry of parseGitWorktreeListPorcelain(raw)) { + if (await resolvePathForWorktreeComparison(entry.worktree) === expectedPath) { + return entry; + } + } + return null; +} + async function isGitCheckout(cwd: string): Promise { return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null)); } @@ -846,6 +1118,11 @@ async function directoryExists(value: string) { return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); } +async function resolvePathForWorktreeComparison(value: string): Promise { + const resolved = path.resolve(value); + return fs.realpath(resolved).then((realPath) => path.resolve(realPath)).catch(() => resolved); +} + async function listLinkedGitWorktreePaths(repoRoot: string): Promise> { const output = await runGit(["worktree", "list", "--porcelain"], repoRoot); const paths = new Set(); @@ -853,47 +1130,132 @@ async function listLinkedGitWorktreePaths(repoRoot: string): Promise if (!line.startsWith("worktree ")) continue; const worktree = line.slice("worktree ".length).trim(); if (!worktree) continue; - paths.add(path.resolve(worktree)); + paths.add(await resolvePathForWorktreeComparison(worktree)); } return paths; } +export async function inspectManagedGitWorktreeBranch(input: { + worktreePath: string; + expectedBranchName: string | null | undefined; + repoRoot?: string | null; +}): Promise { + const worktreePath = await resolvePathForWorktreeComparison(input.worktreePath); + const expectedBranchName = asString(input.expectedBranchName, "").trim() || null; + const base = { + worktreePath, + expectedBranchName, + actualBranchName: null, + }; + + if (!await directoryExists(worktreePath)) { + return { + ...base, + valid: false, + reason: `worktree path "${worktreePath}" does not exist`, + reasonCode: "missing_worktree", + repoRoot: input.repoRoot ? path.resolve(input.repoRoot) : null, + }; + } + + const repoRoot = input.repoRoot + ? path.resolve(input.repoRoot) + : await resolveGitOwnerRepoRoot(worktreePath).catch(() => null); + if (!repoRoot) { + return { + ...base, + valid: false, + reason: "path is not a git checkout", + reasonCode: "not_a_git_checkout", + repoRoot: null, + }; + } + + const listedWorktrees = await listLinkedGitWorktreePaths(repoRoot).catch(() => null); + if (!listedWorktrees?.has(worktreePath)) { + return { + ...base, + valid: false, + reason: "path is not registered in `git worktree list`", + reasonCode: "not_registered", + repoRoot, + }; + } + + const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], worktreePath).catch(() => null); + if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== worktreePath) { + return { + ...base, + valid: false, + reason: "git resolves this path to a different repository root", + reasonCode: "wrong_repository_root", + repoRoot, + }; + } + + const actualBranchName = await runGit( + ["symbolic-ref", "--quiet", "--short", "HEAD"], + worktreePath, + ).catch(() => null); + if (expectedBranchName && actualBranchName !== expectedBranchName) { + return { + ...base, + valid: false, + reason: `worktree HEAD is on "${actualBranchName ?? ""}" instead of "${expectedBranchName}"`, + reasonCode: "branch_mismatch", + repoRoot, + actualBranchName, + }; + } + + return { + ...base, + valid: true, + reason: null, + reasonCode: null, + repoRoot, + actualBranchName, + }; +} + async function validateLinkedGitWorktree(input: { repoRoot: string; worktreePath: string; expectedBranchName: string | null; -}): Promise<{ valid: true } | { valid: false; reason: string }> { - const resolvedWorktreePath = path.resolve(input.worktreePath); - const listedWorktrees = await listLinkedGitWorktreePaths(input.repoRoot); - if (!listedWorktrees.has(resolvedWorktreePath)) { - return { - valid: false, - reason: "path is not registered in `git worktree list`", - }; +}): Promise< + | { valid: true } + | { + valid: false; + reason: string; + reasonCode: Exclude; + actualBranchName?: string | null; } - - const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], resolvedWorktreePath).catch(() => null); - if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== resolvedWorktreePath) { - return { - valid: false, - reason: "git resolves this path to a different repository root", - }; - } - - if (input.expectedBranchName) { - const currentBranch = await runGit( - ["symbolic-ref", "--quiet", "--short", "HEAD"], - resolvedWorktreePath, - ).catch(() => null); - if (currentBranch !== input.expectedBranchName) { - return { +> { + const inspection = await inspectManagedGitWorktreeBranch({ + repoRoot: input.repoRoot, + worktreePath: input.worktreePath, + expectedBranchName: input.expectedBranchName, + }); + return inspection.valid + ? { valid: true } + : { valid: false, - reason: `worktree HEAD is on "${currentBranch ?? ""}" instead of "${input.expectedBranchName}"`, + reason: inspection.reason ?? "unknown git worktree mismatch", + reasonCode: inspection.reasonCode ?? "not_a_git_checkout", + actualBranchName: inspection.actualBranchName, }; - } - } +} - return { valid: true }; +export function formatManagedGitWorktreeBranchInspection(input: ManagedGitWorktreeBranchInspection) { + return { + valid: input.valid, + reason: input.reason, + reasonCode: input.reasonCode, + repoRoot: input.repoRoot, + worktreePath: input.worktreePath, + expectedBranchName: input.expectedBranchName, + actualBranchName: input.actualBranchName, + }; } function terminateChildProcess(child: ChildProcess) { @@ -1328,11 +1690,28 @@ export async function realizeExecutionWorkspace(input: { } async function validateReusableWorktree(reusablePath: string) { - return await validateLinkedGitWorktree({ + const validation = await validateLinkedGitWorktree({ repoRoot, worktreePath: reusablePath, expectedBranchName: branchName, }).catch(() => null); + if (validation && !validation.valid && validation.reasonCode === "branch_mismatch") { + await ensureGitWorktreeBranchCoherent({ + repoRoot, + worktreePath: reusablePath, + expectedBranchName: branchName, + actualBranchName: validation.actualBranchName ?? null, + sourceIssue: input.issue, + executionWorkspaceId: null, + recorder: input.recorder ?? null, + }); + return await validateLinkedGitWorktree({ + repoRoot, + worktreePath: reusablePath, + expectedBranchName: branchName, + }).catch(() => null); + } + return validation; } const existingWorktree = await directoryExists(worktreePath); @@ -1431,6 +1810,7 @@ export async function realizeExecutionWorkspace(input: { export async function ensurePersistedExecutionWorkspaceAvailable(input: { base: ExecutionWorkspaceInput; workspace: { + id?: string | null; mode: string | null | undefined; strategyType: string | null | undefined; cwd: string | null | undefined; @@ -1481,6 +1861,34 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { if (await directoryExists(cwd)) { const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null; const reuseWorktreePath = realized.worktreePath ?? cwd; + if (await isGitCheckout(reuseWorktreePath)) { + await ensureGitWorktreeBranchCoherent({ + repoRoot, + worktreePath: reuseWorktreePath, + expectedBranchName: realized.branchName, + sourceIssue: input.issue, + executionWorkspaceId: input.workspace.id ?? null, + recorder: input.recorder ?? null, + }); + } + const validation = await validateLinkedGitWorktree({ + repoRoot, + worktreePath: reuseWorktreePath, + expectedBranchName: realized.branchName, + }); + if (!validation.valid) { + throw new WorkspaceRuntimeValidationFailure( + `Persisted git worktree "${reuseWorktreePath}" is not reusable (${validation.reason}).`, + { + workspaceValidation: { + reason: "git_worktree_not_reusable", + reasonCode: validation.reasonCode, + worktreePath: reuseWorktreePath, + executionWorkspaceId: input.workspace.id ?? null, + }, + }, + ); + } const baseRefreshWarnings = reuseBaseRef ? await refreshRemoteTrackingBaseRef(repoRoot, reuseBaseRef) : []; From 3522b1c9be3a90485800ec15698e57aba75cbf8a Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 1 Jul 2026 13:43:11 -0700 Subject: [PATCH 06/19] Emit interaction resolved telemetry (#8824) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Issue-thread interactions are how agents ask users or the board for decisions and structured input > - Product telemetry needs to understand when those interactions resolve without exposing private interaction content > - Resolution currently happens through several service paths, so telemetry needs to be emitted consistently from the terminal transitions > - The interaction service should describe the resolved interaction, while the telemetry backend owns unknown-value normalization for dimensions > - This pull request emits `interaction.resolved` after successful database writes and removes redundant client-side normalization from the service > - The benefit is aggregate-safe telemetry for interaction completion behavior without leaking raw IDs, answer text, rejection reasons, or document content ## Linked Issues or Issue Description No public GitHub issue exists for this internal telemetry follow-up. Feature context: - Problem/motivation: Paperclip needs aggregate product telemetry for issue-thread interaction resolution outcomes while preserving privacy boundaries around user answers and internal identifiers. - Proposed solution: Emit `interaction.resolved` once from terminal interaction resolution paths, passing runtime dimensions through the shared telemetry helper while preserving aggregate-safe counts and ID/free-text omission. - Alternatives considered: Normalizing interaction dimensions in the interaction service duplicated telemetry backend responsibility and made unknown-value handling inconsistent across telemetry clients. - Roadmap alignment: This is a focused telemetry instrumentation follow-up that builds on the generated telemetry event types from #8818. ## What Changed - Wires `interaction.resolved` telemetry into terminal issue-thread interaction resolution paths after successful database writes. - Passes raw interaction kind, status, continuation policy, resolution reason, target type, and creator agent role values to the shared telemetry helper instead of maintaining service-local allowlists. - Preserves resolver classification, target `none` derivation for non-confirmation interactions, non-negative aggregate counts, raw ID omission, and free-text omission. - Logs telemetry failures without blocking interaction resolution. - Adds service-level tests for accepted, rejected, answered, stale-target expiry, superseded-comment expiry, and raw creator-role pass-through payloads. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/issue-thread-interactions-telemetry.test.ts server/src/__tests__/shared-telemetry-events.test.ts` - `pnpm typecheck` - GitHub PR checks on the latest head commit are green, including `verify`, build, e2e, general tests, serialized server suites, security scans, and Greptile Review. - Security code review completed before this branch update. ## Risks - Low operational risk: telemetry is emitted after successful persistence and telemetry failures are logged without blocking the user-visible interaction flow. - Main behavioral risk is duplicate or missing telemetry from a resolution path; the focused tests cover the terminal resolution variants. - Telemetry dimension normalization now depends on the shared telemetry backend path instead of the interaction service, so backend normalization must remain the source of truth for unknown or empty dimension values. - The existing PR branch name contains an internal task id because this update continues an already-open PR branch instead of opening a replacement PR. ## Model Used OpenAI GPT-5 Codex coding agent, API-based coding environment with shell, repository, and GitHub CLI tool use. Context window size was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/telemetry/events.ts | 2 + ...ssue-thread-interactions-telemetry.test.ts | 505 ++++++++++++++++++ .../__tests__/shared-telemetry-events.test.ts | 2 + .../src/services/issue-thread-interactions.ts | 181 ++++++- 4 files changed, 683 insertions(+), 7 deletions(-) create mode 100644 server/src/__tests__/issue-thread-interactions-telemetry.test.ts diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 15315719b56..88f50aae0ae 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -145,6 +145,7 @@ export function trackInteractionResolved( questionCount?: number; answeredQuestionCount?: number; createdTaskCount?: number; + skippedTaskCount?: number; }, ): void { client.track("interaction.resolved", { @@ -161,5 +162,6 @@ export function trackInteractionResolved( ...(dims.questionCount === undefined ? {} : { question_count: dims.questionCount }), ...(dims.answeredQuestionCount === undefined ? {} : { answered_question_count: dims.answeredQuestionCount }), ...(dims.createdTaskCount === undefined ? {} : { created_task_count: dims.createdTaskCount }), + ...(dims.skippedTaskCount === undefined ? {} : { skipped_task_count: dims.skippedTaskCount }), }); } diff --git a/server/src/__tests__/issue-thread-interactions-telemetry.test.ts b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts new file mode 100644 index 00000000000..0fe45ac507e --- /dev/null +++ b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts @@ -0,0 +1,505 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + agents, + companies, + createDb, + documentRevisions, + documents, + goals, + issueComments, + issueDocuments, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; + +const telemetryMocks = vi.hoisted(() => ({ + track: vi.fn(), + getTelemetryClient: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: telemetryMocks.getTelemetryClient, +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => { + let db!: ReturnType; + let interactionsSvc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-interaction-telemetry-"); + db = createDb(tempDb.connectionString); + interactionsSvc = issueThreadInteractionService(db); + }, 20_000); + + beforeEach(() => { + telemetryMocks.track.mockClear(); + telemetryMocks.getTelemetryClient.mockReturnValue({ + track: telemetryMocks.track, + hashPrivateRef: vi.fn((value: string) => `hashed:${value}`), + }); + }); + + afterEach(async () => { + await db.delete(issueThreadInteractions); + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issues); + await db.delete(goals); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedIssue(title = "Interaction telemetry") { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(goals).values({ + id: goalId, + companyId, + title, + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title, + status: "in_progress", + priority: "medium", + }); + + return { companyId, goalId, issueId }; + } + + async function seedAgent(companyId: string, role: string) { + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: role, + role, + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + return agentId; + } + + function expectNoRawInteractionIds(dimensions: Record) { + expect(dimensions).not.toHaveProperty("interaction_id"); + expect(dimensions).not.toHaveProperty("created_by_agent_id"); + expect(dimensions).not.toHaveProperty("source_run_id"); + } + + function lastInteractionResolvedDimensions() { + expect(telemetryMocks.track).toHaveBeenCalledWith("interaction.resolved", expect.any(Object)); + const calls = telemetryMocks.track.mock.calls.filter((call) => call[0] === "interaction.resolved"); + return calls.at(-1)?.[1] as Record; + } + + it("emits accepted suggested-task telemetry with created and skipped task counts", async () => { + const { companyId, goalId, issueId } = await seedIssue("Accept suggested tasks telemetry"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + { + clientKey: "sibling", + title: "Create the sibling follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedClientKeys: ["root"], + }, { + userId: "local-board", + }); + + const dimensions = lastInteractionResolvedDimensions(); + expect(dimensions).toMatchObject({ + interaction_kind: "suggest_tasks", + status: "accepted", + resolved_by_kind: "user", + resolution_reason: "accepted", + created_by_kind: "user", + continuation_policy: "wake_assignee", + target_type: "none", + created_task_count: 1, + skipped_task_count: 2, + }); + expectNoRawInteractionIds(dimensions); + }); + + it("emits accepted checkbox telemetry with raw role, target, and counts", async () => { + const { companyId, goalId, issueId } = await seedIssue("Accept checkbox telemetry"); + const creatorAgentId = await seedAgent(companyId, "Backend Engineer"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_checkbox_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Which files should be deleted?", + options: [ + { id: "file-a", label: "a.txt" }, + { id: "file-b", label: "b.txt" }, + ], + target: { + type: "custom", + key: "cleanup-plan", + }, + }, + }, { + agentId: creatorAgentId, + }); + + await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedOptionIds: ["file-b"], + }, { + userId: "local-board", + }); + + const dimensions = lastInteractionResolvedDimensions(); + expect(dimensions).toMatchObject({ + interaction_kind: "request_checkbox_confirmation", + status: "accepted", + resolved_by_kind: "user", + resolution_reason: "accepted", + created_by_kind: "agent", + creator_agent_role: "Backend Engineer", + continuation_policy: "wake_assignee", + target_type: "custom", + option_count: 2, + selected_option_count: 1, + }); + expectNoRawInteractionIds(dimensions); + }); + + it("emits rejected confirmation telemetry and omits creator_agent_role for user-created interactions", async () => { + const { companyId, issueId } = await seedIssue("Reject confirmation telemetry"); + const resolverAgentId = await seedAgent(companyId, "SecurityEngineer"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Approve this?", + }, + }, { + userId: "local-board", + }); + + await interactionsSvc.rejectInteraction({ + id: issueId, + companyId, + }, created.id, { + reason: "Needs edits before approval.", + }, { + agentId: resolverAgentId, + }); + + const dimensions = lastInteractionResolvedDimensions(); + expect(dimensions).toMatchObject({ + interaction_kind: "request_confirmation", + status: "rejected", + resolved_by_kind: "agent", + resolution_reason: "rejected", + created_by_kind: "user", + continuation_policy: "none", + target_type: "none", + }); + expect(dimensions).not.toHaveProperty("creator_agent_role"); + expect(dimensions).not.toHaveProperty("reason"); + expectNoRawInteractionIds(dimensions); + }); + + it("emits answered question telemetry with system resolver and raw creator role", async () => { + const { companyId, issueId } = await seedIssue("Answer question telemetry"); + const creatorAgentId = await seedAgent(companyId, "Wizard"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + questions: [ + { + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + required: true, + options: [{ id: "phase-1", label: "Phase 1" }], + }, + { + id: "extras", + prompt: "Optional extras", + selectionMode: "multi", + options: [{ id: "docs", label: "Docs" }], + }, + ], + }, + }, { + agentId: creatorAgentId, + }); + await db + .update(issueThreadInteractions) + .set({ continuationPolicy: "" }) + .where(eq(issueThreadInteractions.id, created.id)); + + await interactionsSvc.answerQuestions({ + id: issueId, + companyId, + }, created.id, { + answers: [{ questionId: "scope", optionIds: ["phase-1"] }], + summaryMarkdown: "Do not emit this free text.", + }, {}); + + const dimensions = lastInteractionResolvedDimensions(); + expect(dimensions).toMatchObject({ + interaction_kind: "ask_user_questions", + status: "answered", + resolved_by_kind: "system", + created_by_kind: "agent", + creator_agent_role: "Wizard", + target_type: "none", + question_count: 2, + answered_question_count: 1, + }); + expect(dimensions).not.toHaveProperty("continuation_policy"); + expect(dimensions).not.toHaveProperty("summaryMarkdown"); + expect(dimensions).not.toHaveProperty("answers"); + expectNoRawInteractionIds(dimensions); + }); + + it("emits expired question telemetry with zero answered question count", async () => { + const { companyId, issueId } = await seedIssue("Expired question telemetry"); + const commentId = randomUUID(); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + questions: [ + { + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + required: true, + options: [{ id: "phase-1", label: "Phase 1" }], + }, + ], + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + createdAt: new Date(new Date(created.createdAt).getTime() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + const dimensions = lastInteractionResolvedDimensions(); + expect(dimensions).toMatchObject({ + interaction_kind: "ask_user_questions", + status: "expired", + resolved_by_kind: "user", + resolution_reason: "superseded_by_comment", + created_by_kind: "user", + continuation_policy: "wake_assignee", + target_type: "none", + question_count: 1, + answered_question_count: 0, + }); + expectNoRawInteractionIds(dimensions); + }); + + it("emits expired stale-target telemetry without stale target identifiers", async () => { + const { companyId, issueId } = await seedIssue("Stale target telemetry"); + const documentId = randomUUID(); + const revisionId = randomUUID(); + + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "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: "v1", + }); + + await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Approve the plan?", + target: { + type: "issue_document", + issueId, + documentId, + key: "plan", + revisionId, + revisionNumber: 1, + }, + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireStaleRequestConfirmationsForIssueDocument({ + id: issueId, + companyId, + }, null, {}); + + expect(expired).toHaveLength(1); + const dimensions = lastInteractionResolvedDimensions(); + expect(dimensions).toMatchObject({ + interaction_kind: "request_confirmation", + status: "expired", + resolved_by_kind: "system", + resolution_reason: "stale_target", + created_by_kind: "user", + continuation_policy: "none", + target_type: "issue_document", + }); + expect(dimensions).not.toHaveProperty("staleTarget"); + expect(dimensions).not.toHaveProperty("revisionId"); + expectNoRawInteractionIds(dimensions); + }); + + it("emits superseded expiration telemetry without comment identifiers", async () => { + const { companyId, issueId } = await seedIssue("Superseded telemetry"); + const commentId = randomUUID(); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Approve this plan?", + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + createdAt: new Date(new Date(created.createdAt).getTime() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + const dimensions = lastInteractionResolvedDimensions(); + expect(dimensions).toMatchObject({ + interaction_kind: "request_confirmation", + status: "expired", + resolved_by_kind: "user", + resolution_reason: "superseded_by_comment", + created_by_kind: "user", + target_type: "none", + }); + expect(dimensions).not.toHaveProperty("commentId"); + expectNoRawInteractionIds(dimensions); + }); +}); diff --git a/server/src/__tests__/shared-telemetry-events.test.ts b/server/src/__tests__/shared-telemetry-events.test.ts index 8150910ae90..19341fdd203 100644 --- a/server/src/__tests__/shared-telemetry-events.test.ts +++ b/server/src/__tests__/shared-telemetry-events.test.ts @@ -110,6 +110,7 @@ describe("shared telemetry agent events", () => { targetType: "issue_document", optionCount: 2, selectedOptionCount: 1, + skippedTaskCount: 3, }); expect(client.track).toHaveBeenCalledWith("interaction.resolved", { @@ -123,6 +124,7 @@ describe("shared telemetry agent events", () => { target_type: "issue_document", option_count: 2, selected_option_count: 1, + skipped_task_count: 3, }); }); }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index ef05fa4bf83..62550db0dd9 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util"; import { and, asc, eq, inArray, isNotNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + agents, documents, heartbeatRuns, issueComments, @@ -9,6 +10,7 @@ import { issueThreadInteractions, issues, } from "@paperclipai/db"; +import { trackInteractionResolved } from "@paperclipai/shared/telemetry"; import type { AcceptIssueThreadInteraction, AskUserQuestionsAnswer, @@ -39,6 +41,7 @@ import { suggestTasksResultSchema, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; +import { getTelemetryClient } from "../telemetry.js"; import { issueService, runWorkspaceIsFinalized } from "./issues.js"; type InteractionActor = { @@ -247,6 +250,151 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI } as const; } +function resolveActorKind(interaction: Pick) { + if (interaction.resolvedByAgentId) return "agent"; + if (interaction.resolvedByUserId) return "user"; + return "system"; +} + +function resolveCreatorKind(interaction: Pick) { + if (interaction.createdByAgentId) return "agent"; + if (interaction.createdByUserId) return "user"; + return undefined; +} + +function deriveTargetType(interaction: IssueThreadInteraction) { + if (interaction.kind !== "request_confirmation" && interaction.kind !== "request_checkbox_confirmation") { + return "none"; + } + return interaction.payload.target?.type ?? "none"; +} + +function deriveResolutionReason(interaction: IssueThreadInteraction) { + switch (interaction.status) { + case "accepted": + return "accepted"; + case "rejected": + return "rejected"; + case "cancelled": + return "cancelled"; + case "expired": { + if (interaction.kind === "ask_user_questions") { + return interaction.result?.expirationReason ?? "expired"; + } + if (interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation") { + return interaction.result?.outcome ?? "expired"; + } + return "expired"; + } + default: + return undefined; + } +} + +function nonNegativeInteger(value: number) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} + +function buildInteractionResolvedCounts(interaction: IssueThreadInteraction, args?: { + createdTaskCount?: number; +}) { + switch (interaction.kind) { + case "suggest_tasks": + return { + createdTaskCount: nonNegativeInteger(args?.createdTaskCount ?? 0), + skippedTaskCount: nonNegativeInteger(interaction.result?.skippedClientKeys?.length ?? 0), + }; + case "request_checkbox_confirmation": + return { + optionCount: nonNegativeInteger(interaction.payload.options.length), + selectedOptionCount: nonNegativeInteger(interaction.result?.selectedOptionIds?.length ?? 0), + }; + case "ask_user_questions": + return { + questionCount: nonNegativeInteger(interaction.payload.questions.length), + answeredQuestionCount: nonNegativeInteger(interaction.result?.answers?.length ?? 0), + }; + default: + return {}; + } +} + +async function fetchCreatorAgentRoleById( + db: Pick, + interactions: readonly IssueThreadInteraction[], +) { + const creatorAgentIds = [...new Set(interactions + .map((interaction) => interaction.createdByAgentId) + .filter((value): value is string => Boolean(value)))]; + if (creatorAgentIds.length === 0) return new Map(); + + const rows = await db + .select({ + id: agents.id, + role: agents.role, + }) + .from(agents) + .where(inArray(agents.id, creatorAgentIds)); + + return new Map(rows.map((row) => [row.id, row.role] as const)); +} + +async function emitInteractionResolvedTelemetry( + db: Pick, + interaction: IssueThreadInteraction, + args?: { createdTaskCount?: number; creatorRoleByAgentId?: ReadonlyMap }, +) { + const telemetryClient = getTelemetryClient(); + if (!telemetryClient) return; + + try { + let roleByAgentId = args?.creatorRoleByAgentId ?? new Map(); + if (!args?.creatorRoleByAgentId) { + try { + roleByAgentId = await fetchCreatorAgentRoleById(db, [interaction]); + } catch (error) { + console.error("[paperclip] Failed to load interaction.resolved creator role", error); + } + } + const creatorAgentRole = interaction.createdByAgentId + ? roleByAgentId.get(interaction.createdByAgentId) ?? undefined + : undefined; + + trackInteractionResolved(telemetryClient, { + interactionKind: interaction.kind, + status: interaction.status, + resolvedByKind: resolveActorKind(interaction), + resolutionReason: deriveResolutionReason(interaction), + createdByKind: resolveCreatorKind(interaction), + creatorAgentRole, + continuationPolicy: interaction.continuationPolicy, + targetType: deriveTargetType(interaction), + ...buildInteractionResolvedCounts(interaction, { + createdTaskCount: args?.createdTaskCount, + }), + }); + } catch (error) { + console.error("[paperclip] Failed to emit interaction.resolved telemetry", error); + } +} + +async function emitResolvedInteractionsTelemetry( + db: Pick, + interactions: readonly IssueThreadInteraction[], +) { + if (interactions.length === 0 || !getTelemetryClient()) return; + let roleByAgentId = new Map(); + try { + roleByAgentId = await fetchCreatorAgentRoleById(db, interactions); + } catch (error) { + console.error("[paperclip] Failed to load interaction.resolved creator roles", error); + } + await Promise.all(interactions.map((interaction) => + emitInteractionResolvedTelemetry(db, interaction, { creatorRoleByAgentId: roleByAgentId }) + )); +} + function isCommentAtOrAfterInteraction(args: { commentCreatedAt: Date | string; interactionCreatedAt: Date | string; @@ -557,7 +705,9 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { throw conflict("Interaction has already been resolved"); } await touchIssue(db, args.row.issueId); - return hydrateInteraction(updated); + const expired = hydrateInteraction(updated); + await emitInteractionResolvedTelemetry(db, expired); + return expired; } export function issueThreadInteractionService(db: Db) { @@ -654,7 +804,7 @@ export function issueThreadInteractionService(db: Db) { : undefined; const now = new Date(); - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const [updated] = await tx .update(issueThreadInteractions) .set({ @@ -727,6 +877,8 @@ export function issueThreadInteractionService(db: Db) { continuationIssue, }; }); + await emitInteractionResolvedTelemetry(db, result.interaction); + return result; } async function rejectRequestConfirmation(args: { @@ -774,7 +926,9 @@ export function issueThreadInteractionService(db: Db) { throw conflict("Interaction has already been resolved"); } await touchIssue(db, args.issue.id); - return hydrateInteraction(updated); + const rejected = hydrateInteraction(updated); + await emitInteractionResolvedTelemetry(db, rejected); + return rejected; } return { @@ -1086,8 +1240,12 @@ export function issueThreadInteractionService(db: Db) { current.updatedAt = updated.updatedAt; }); + const accepted = hydrateInteraction(current); + await emitInteractionResolvedTelemetry(db, accepted, { + createdTaskCount: createdWakeTargets.length, + }); return { - interaction: hydrateInteraction(current), + interaction: accepted, createdIssues: createdWakeTargets, }; }, @@ -1157,7 +1315,9 @@ export function issueThreadInteractionService(db: Db) { } await touchIssue(db, issue.id); - return hydrateInteraction(updated); + const rejected = hydrateInteraction(updated); + await emitInteractionResolvedTelemetry(db, rejected); + return rejected; }, expireRequestConfirmationsSupersededByComment: async ( @@ -1214,6 +1374,7 @@ export function issueThreadInteractionService(db: Db) { if (expired.length > 0) { await touchIssue(db, issue.id); + await emitResolvedInteractionsTelemetry(db, expired); } return expired; }, @@ -1332,6 +1493,7 @@ export function issueThreadInteractionService(db: Db) { if (expired.length > 0) { await touchIssue(db, issue.id); + await emitResolvedInteractionsTelemetry(db, expired); } return expired; }, @@ -1407,6 +1569,7 @@ export function issueThreadInteractionService(db: Db) { if (expired.length > 0) { await touchIssue(db, issue.id); + await emitResolvedInteractionsTelemetry(db, expired); } return expired; }, @@ -1465,7 +1628,9 @@ export function issueThreadInteractionService(db: Db) { } await touchIssue(db, issue.id); - return hydrateInteraction(updated); + const answered = hydrateInteraction(updated); + await emitInteractionResolvedTelemetry(db, answered); + return answered; }, cancelQuestions: async ( @@ -1520,7 +1685,9 @@ export function issueThreadInteractionService(db: Db) { } await touchIssue(db, issue.id); - return hydrateInteraction(updated); + const cancelled = hydrateInteraction(updated); + await emitInteractionResolvedTelemetry(db, cancelled); + return cancelled; }, }; } From de837e268381eaa0e9b72fe8718de40f5ac66973 Mon Sep 17 00:00:00 2001 From: Evyatar Bluzer Date: Thu, 2 Jul 2026 04:32:49 +0700 Subject: [PATCH 07/19] refactor(a11y): issues view toggle aria attributes and goal tree expand buttons (#1939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path Two interactive UI controls were missing WAI-ARIA attributes, so screen-reader users couldn't perceive their state. The IssuesList view-mode toggle already had `title` tooltips but no `aria-label`/`aria-pressed` and its container had no `role="group"`; the GoalTree expand/collapse chevron announced only "button". Approach: attributes only, no logic/render changes, reusing the pattern already shipped on the Agents page toggle. Per review feedback, GoalTree uses a **stable** `aria-label` (`` `${goal.title} subtree` ``) with `aria-expanded` for state, rather than a dynamic label that double-announces state. ## Issue _No existing tracking issue — described inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR"._ **What happened** Two components expose buttons with no accessible name or state, making them unusable via screen reader: (1) the `IssuesList` view-mode toggle doesn't convey which view is active; (2) the `GoalTree` expand/collapse chevrons have no name and no expanded/collapsed state. **Expected behavior** Both controls announce their purpose and current state to assistive technology. **Steps to reproduce** Enable VoiceOver, open the Issues page and Tab to the view-mode toggle, then open the Goals page with nested goals and Tab to a tree chevron — each control announces only "button", with no name and no pressed/expanded state. ## What Changed **`ui/src/components/IssuesList.tsx`** — `role="group"` + `aria-label="View mode"` on the container; `aria-label` ("List view"/"Board view") and `aria-pressed` on each button. **`ui/src/components/GoalTree.tsx`** — stable `aria-label` (`` `${goal.title} subtree` ``) and `aria-expanded` on the chevron button. 2 files, ARIA attributes only, no behavioral change. ## Verification 1. Issues page → toggle announces "List view, pressed" / "Board view, not pressed", grouped as "View mode". 2. Goals page with nested goals → each chevron announces " subtree" with expanded/collapsed state. 3. Manual VoiceOver pass; no visual/behavioral change for sighted users. ## Risks Minimal — additive HTML attributes with no impact on logic, rendering, or state. Worst case is a suboptimal announcement string, trivially adjusted. ## Model Used Original change human-authored by @bluzername. Two follow-up commits (stable `aria-label` refinement; removal of a stray tooling file) applied via maintainer edit; the refinement was drafted with Claude Opus 4.8. ## Checklist - [x] I searched the GitHub PR list (open + recently closed) for similar/duplicate PRs before opening — none found. --------- Co-authored-by: Andrew Aymeloglu --- ui/src/components/GoalTree.tsx | 2 ++ ui/src/components/IssuesList.tsx | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/src/components/GoalTree.tsx b/ui/src/components/GoalTree.tsx index 116b1668f29..6c0de3cccd8 100644 --- a/ui/src/components/GoalTree.tsx +++ b/ui/src/components/GoalTree.tsx @@ -35,6 +35,8 @@ function GoalNode({ goal, children, allGoals, depth, goalLink, onSelect }: GoalN e.stopPropagation(); setExpanded(!expanded); }} + aria-label={`${goal.title} subtree`} + aria-expanded={expanded} > {/* View mode toggle */} -
+
@@ -1400,6 +1402,8 @@ export function IssuesList({ className={`p-1.5 transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`} onClick={() => updateView({ viewMode: "board" })} title="Board view" + aria-label="Board view" + aria-pressed={viewState.viewMode === "board"} > From fb2b760915853ce9588437988babbfdbc427128e Mon Sep 17 00:00:00 2001 From: scotttong Date: Wed, 1 Jul 2026 17:45:30 -0700 Subject: [PATCH 08/19] fix(issues): attribute agent-authored comments instead of rendering them as "Board" (#8833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Task/issue threads render each comment as a chat bubble; the author determines whether it shows as a left-aligned agent bubble (name + icon) or a right-aligned "Board" bubble > - Comments posted by an agent from a local execution environment are written with a non-human author id (`local-board`/system), so they were mis-rendered as blue "Board" bubbles instead of being attributed to the authoring agent > - This misattribution is confusing (it looks like the human board said something an agent actually said) and it can drive false wake/reconciliation behavior on the affected threads > - This pull request adds server-side attribution derivation (lossless run-id join first, then an explicit run-log post marker), persists the derived agent so the read path stops re-scanning run logs, and stops the client from labeling agent-derived comments "Board" > - The benefit is agent comments render as the correct agent, genuine human board comments are never reattributed, and reads get cheaper after a one-time persistence ## Linked Issues or Issue Description **What happened?** In a task/issue comment thread, comments authored by an agent from a local execution environment are stored with a non-human author id (`local-board`/system). The UI renders these as right-aligned blue "Board" bubbles, implying a human board member authored them. The mislabeling is also a wake/reconciliation hazard: an agent comment that reads as "Board" can look like human board input. **Expected behavior** Such comments should render as the authoring agent (left-aligned bubble with agent name + icon). Genuine human/board comments must continue to render as "Board" and must never be reattributed to an agent. **Steps to reproduce** 1. Have an agent post a comment on an issue from a local execution environment (author id `local-board`). 2. Open the issue comment thread in the UI. 3. Observe the agent's comment rendered as a right-aligned blue "Board" bubble instead of the authoring agent. **Root cause** The read path did not resolve the authoring agent for these comments, and the client fell back to a "Board" label for the `local-board` author. ## What Changed - **Server derivation (`server/src/services/issues.ts`):** - Resolve the authoring agent from the comment's run id first (`createdByRunId`/`derivedCreatedByRunId` → `heartbeatRuns.agentId`) — lossless when present. - Second tier `run_log_comment_post`: read the run log lazily (only for still-unresolved comments) to match the explicit `comment id:` post marker. - **Guard:** never reattribute a comment whose author maps to a genuine user profile. Only the non-human sentinel (`local-board`, which is itself a `user` row) and authors absent from the `user` table are eligible. - Pure timing-overlap tiers are intentionally **not** used (Option A) — an agent comment and a human board comment posted during the same run are indistinguishable rows, so any timing guess risks mislabeling a real human comment. - **Persistence (`packages/db/src/migrations/0126_issue_comment_derived_attribution.sql`, `packages/db/src/schema/issue_comments.ts`):** add stored `derived_*` attribution columns and write the resolved agent back with a single bulk `UPDATE ... FROM (VALUES ...)`, so reads stop recomputing from run logs. Migration is additive (new nullable columns) with a batched, idempotent backfill of the lossless run-id tier over historical rows. - **Types (`packages/shared/src/types/issue.ts`):** expose the persisted attribution fields and the `IssueCommentDerivedAuthorSource` union. - **Client (`ui/src/lib/issue-chat-messages.test.ts`):** the message builder already prefers a resolved agent id (`authorAgentId ?? runAgentId ?? derivedAuthorAgentId`), so once the server persists the derived agent the bubble renders as the agent automatically — no client code change needed. Adds a regression guard confirming a genuine board comment with no derived agent is still rendered as "Board". - **Tests:** derivation + message-building tests, including assertions that genuine board/user comments are **not** reattributed. ## Verification - `cd server && npx vitest run issues-service` — 94 tests pass: run-id resolution, no-attribution on timing overlap alone (Option A), multi-run ambiguity, same-agent multi-run, and the genuine-user guard. Exercises the real persistence path (bulk UPDATE) against the test DB. - `cd ui && npx vitest run issue-chat-messages` — 27 tests pass; client no longer labels agent-derived comments "Board", and a genuine board comment with no derived agent is not re-labeled. - `cd server && npm run typecheck` — passes (exit 0). - Manual: on a thread containing old agent-authored comments, the blue "Board" bubbles render as the authoring agent; a genuine board comment on the same thread still renders as "Board". ## Risks - **Mis-reattributing a genuine board comment made during an agent run** → mitigated by the human-profile guard (only `local-board`/system authors are eligible) and by dropping pure timing tiers (Option A): only the lossless run-id join and the explicit run-log post marker attribute history. - **Backfill volume / run-log reads** → the migration backfill is batched (5000 rows/loop) and results are persisted so reads stop recomputing; the read-path persistence is a single bulk UPDATE rather than per-comment round-trips. Migration adds only nullable columns (no destructive change). - The persistence/backfill has **not** been run against any production database as part of opening this PR. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, via Claude Code with tool use. ## 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 — related open PRs (#6006 narrow attribution run scan, #4729 attribution roll-up, #7014 reaped-run attribution) address different attribution paths; none fix the `local-board` "Board" bubble rendering this PR targets. Supersedes #8832 (same change; branch renamed to drop an internal ticket id per CONTRIBUTING → Branch Naming) - [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 and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Claude Opus 4.8 --- ...0126_issue_comment_derived_attribution.sql | 69 +++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/issue_comments.ts | 8 + packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 18 +- server/src/__tests__/issues-service.test.ts | 147 +++++++++- server/src/services/issues.ts | 273 ++++++++++++++---- ui/src/lib/issue-chat-messages.test.ts | 34 +++ 9 files changed, 501 insertions(+), 57 deletions(-) create mode 100644 packages/db/src/migrations/0126_issue_comment_derived_attribution.sql diff --git a/packages/db/src/migrations/0126_issue_comment_derived_attribution.sql b/packages/db/src/migrations/0126_issue_comment_derived_attribution.sql new file mode 100644 index 00000000000..564c2ad7a67 --- /dev/null +++ b/packages/db/src/migrations/0126_issue_comment_derived_attribution.sql @@ -0,0 +1,69 @@ +ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_created_by_run_id" uuid;--> statement-breakpoint +ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_source" text;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_author_agent_id_agents_id_fk' + ) THEN + ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_author_agent_id_agents_id_fk" FOREIGN KEY ("derived_author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk' + ) THEN + ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("derived_created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +-- Backfill agent attribution for historical non-human ("Board") comments so old +-- threads stop rendering as blue board bubbles and the read path stops +-- re-scanning run logs. Two SQL-computable tiers, both guarded to NEVER touch a +-- comment whose author maps to a genuine user profile. The log-marker tier is +-- handled lazily on read (it needs object-storage log bodies). +-- +-- Tier `run_id`: the comment's own authoring run resolves to an agent (lossless). +-- Batched to keep lock/WAL footprint bounded on large histories. +DO $$ +DECLARE + affected integer; +BEGIN + LOOP + WITH batch AS ( + SELECT c.id AS comment_id, hr.agent_id, hr.id AS run_id + FROM issue_comments c + JOIN heartbeat_runs hr ON hr.id = c.created_by_run_id + WHERE c.author_agent_id IS NULL + AND c.derived_author_agent_id IS NULL + AND c.author_user_id IS NOT NULL + -- Only the non-human board sentinel or non-`user` authors are + -- eligible. `local-board` IS a row in "user" (the implicit board + -- admin), so it must be allowed explicitly; genuine signups are not. + AND (c.author_user_id = 'local-board' + OR NOT EXISTS (SELECT 1 FROM "user" u WHERE u.id = c.author_user_id)) + LIMIT 5000 + ) + UPDATE issue_comments c + SET derived_author_agent_id = b.agent_id, + derived_created_by_run_id = b.run_id, + derived_author_source = 'run_id' + FROM batch b + WHERE c.id = b.comment_id; + GET DIAGNOSTICS affected = ROW_COUNT; + EXIT WHEN affected = 0; + END LOOP; +END $$;--> statement-breakpoint +-- Option A: the pure run-window TIMING tiers are intentionally NOT applied. +-- Because agents post through the `local-board` subprocess, an agent comment and +-- a genuine human board comment are indistinguishable rows, so any timing-overlap +-- guess mis-attributes human board comments that merely coincided with an agent +-- run (e.g. a human board reply typed while an agent run was in flight). +-- Only the lossless `run_id` backfill above (and the read-path `run_log_comment_post` +-- tier) attribute history; everything else stays "Board". +-- +-- This statement also reverts any attribution a PRIOR revision of this migration +-- persisted via the timing tiers, so re-applying / upgrading is idempotent. +UPDATE issue_comments +SET derived_author_agent_id = NULL, + derived_created_by_run_id = NULL, + derived_author_source = NULL +WHERE derived_author_source IN ('run_window_unique', 'run_window_agent_unique'); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5d52de43059..fa5b9832c99 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -883,6 +883,13 @@ "when": 1782440100000, "tag": "0125_environment_custom_image_templates", "breakpoints": true + }, + { + "idx": 126, + "version": "7", + "when": 1782526400000, + "tag": "0126_issue_comment_derived_attribution", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/issue_comments.ts b/packages/db/src/schema/issue_comments.ts index 8eb9e708239..006f049cbe2 100644 --- a/packages/db/src/schema/issue_comments.ts +++ b/packages/db/src/schema/issue_comments.ts @@ -1,5 +1,6 @@ import type { IssueCommentAuthorType, + IssueCommentDerivedAuthorSource, IssueCommentMetadata, IssueCommentPresentation, SourceTrustMetadata, @@ -20,6 +21,13 @@ export const issueComments = pgTable( authorUserId: text("author_user_id"), authorType: text("author_type").$type(), createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + // Persisted result of best-effort agent-attribution derivation for comments + // authored by a non-human sentinel (e.g. `local-board`). Populated once by a + // backfill migration and lazily on read so the load path stops re-scanning + // run logs. + derivedAuthorAgentId: uuid("derived_author_agent_id").references(() => agents.id, { onDelete: "set null" }), + derivedCreatedByRunId: uuid("derived_created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + derivedAuthorSource: text("derived_author_source").$type(), body: text("body").notNull(), presentation: jsonb("presentation").$type(), metadata: jsonb("metadata").$type(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b61d9c57de0..8674779f43a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -659,6 +659,7 @@ export type { IssueExecutionStagePrincipal, IssueExecutionDecision, IssueComment, + IssueCommentDerivedAuthorSource, IssueCommentMetadata, IssueCommentMetadataSection, IssueCommentMetadataRow, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 13f972d940e..60a60195570 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -324,6 +324,7 @@ export type { IssueReviewRequest, IssueExecutionDecision, IssueComment, + IssueCommentDerivedAuthorSource, IssueCommentMetadata, IssueCommentMetadataSection, IssueCommentMetadataRow, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 1fed1d34d06..c676cafb3b8 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -603,6 +603,22 @@ export interface Issue { updatedAt: Date; } +/** + * Where a comment's derived (non-stored-author) agent attribution came from, + * in descending confidence: + * - `run_id`: comment carries a `createdByRunId`/`derivedCreatedByRunId` whose + * run resolves directly to an agent (lossless). + * - `run_log_comment_post`: a run log within the comment window contains the + * `comment id: {id}` post marker (lossless: the run recorded posting it). + * + * Only lossless signals are used. Pure run-window timing overlap is NOT a + * source — it cannot distinguish an agent comment from a human board comment + * that coincided with a run (Option A). + */ +export type IssueCommentDerivedAuthorSource = + | "run_id" + | "run_log_comment_post"; + export interface IssueComment { id: string; companyId: string; @@ -613,7 +629,7 @@ export interface IssueComment { createdByRunId?: string | null; derivedAuthorAgentId?: string | null; derivedCreatedByRunId?: string | null; - derivedAuthorSource?: "run_log_comment_post" | null; + derivedAuthorSource?: IssueCommentDerivedAuthorSource | null; body: string; presentation: IssueCommentPresentation | null; metadata: IssueCommentMetadata | null; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 8064953df4d..0dbc66b68f5 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -94,14 +94,81 @@ describe("deriveIssueCommentRunLogAttribution", () => { }); }); - it("does not rewrite comments without exact run-log proof", () => { + it("resolves directly from the comment's own run id without reading logs", () => { + const commentId = randomUUID(); + const runId = randomUUID(); + const agentId = randomUUID(); + + const derived = deriveIssueCommentRunLogAttribution( + [ + { + id: commentId, + authorAgentId: null, + authorUserId: "local-board", + createdByRunId: runId, + createdAt: new Date("2026-05-11T18:55:40.090Z"), + }, + ], + [ + { + runId, + agentId, + createdAt: new Date("2026-05-11T18:51:56.246Z"), + startedAt: new Date("2026-05-11T18:51:56.257Z"), + finishedAt: new Date("2026-05-11T18:55:45.600Z"), + logContent: "", + }, + ], + ); + + expect(derived.get(commentId)).toEqual({ + derivedAuthorAgentId: agentId, + derivedCreatedByRunId: runId, + derivedAuthorSource: "run_id", + }); + }); + + it("does NOT attribute on run-window overlap alone — timing is not a lossless signal (option A)", () => { + // A human board comment can land inside an agent's run window; since both are + // stored as `local-board`, a timing-only guess would mis-attribute it. So a + // single overlapping run with no run-id and no log marker stays unresolved. + const commentId = randomUUID(); + const runId = randomUUID(); + const agentId = randomUUID(); + + const derived = deriveIssueCommentRunLogAttribution( + [ + { + id: commentId, + authorAgentId: null, + authorUserId: "local-board", + createdByRunId: null, + createdAt: new Date("2026-05-11T18:55:40.090Z"), + }, + ], + [ + { + runId, + agentId, + createdAt: new Date("2026-05-11T18:51:56.246Z"), + startedAt: new Date("2026-05-11T18:51:56.257Z"), + finishedAt: new Date("2026-05-11T18:55:45.600Z"), + logContent: "posted results without echoing the comment id", + }, + ], + ); + + expect(derived.has(commentId)).toBe(false); + }); + + it("does not guess when multiple agent runs overlap and no log proves the author", () => { const commentId = randomUUID(); const derived = deriveIssueCommentRunLogAttribution( [ { id: commentId, authorAgentId: null, - authorUserId: "user-1", + authorUserId: "local-board", createdByRunId: null, createdAt: new Date("2026-05-11T18:55:40.090Z"), }, @@ -113,7 +180,81 @@ describe("deriveIssueCommentRunLogAttribution", () => { createdAt: new Date("2026-05-11T18:51:56.246Z"), startedAt: new Date("2026-05-11T18:51:56.257Z"), finishedAt: new Date("2026-05-11T18:55:45.600Z"), - logContent: "posted results without echoing the comment id", + logContent: "no comment id here", + }, + { + runId: randomUUID(), + agentId: randomUUID(), + createdAt: new Date("2026-05-11T18:54:00.000Z"), + startedAt: new Date("2026-05-11T18:54:00.000Z"), + finishedAt: new Date("2026-05-11T18:56:00.000Z"), + logContent: "also nothing", + }, + ], + ); + + expect(derived.has(commentId)).toBe(false); + }); + + it("does NOT attribute on same-agent run-window overlap alone (option A)", () => { + // Even when every overlapping run is the same agent, timing alone cannot + // prove the comment was the agent's vs a human board comment during the run. + const commentId = randomUUID(); + const agentId = randomUUID(); + + const derived = deriveIssueCommentRunLogAttribution( + [ + { + id: commentId, + authorAgentId: null, + authorUserId: "local-board", + createdByRunId: null, + createdAt: new Date("2026-06-29T17:41:59.916Z"), + }, + ], + [ + { + runId: randomUUID(), + agentId, + createdAt: new Date("2026-06-29T17:41:26.116Z"), + startedAt: new Date("2026-06-29T17:41:26.116Z"), + finishedAt: new Date("2026-06-29T17:46:33.794Z"), + logContent: "no comment id here", + }, + { + runId: randomUUID(), + agentId, + createdAt: new Date("2026-06-29T17:40:09.531Z"), + startedAt: new Date("2026-06-29T17:40:09.531Z"), + finishedAt: new Date("2026-06-29T17:46:33.794Z"), + logContent: "also nothing", + }, + ], + ); + + expect(derived.has(commentId)).toBe(false); + }); + + it("never reattributes a comment that already has a stored agent author", () => { + const commentId = randomUUID(); + const derived = deriveIssueCommentRunLogAttribution( + [ + { + id: commentId, + authorAgentId: randomUUID(), + authorUserId: null, + createdByRunId: null, + createdAt: new Date("2026-05-11T18:55:40.090Z"), + }, + ], + [ + { + runId: randomUUID(), + agentId: randomUUID(), + createdAt: new Date("2026-05-11T18:51:56.246Z"), + startedAt: new Date("2026-05-11T18:51:56.257Z"), + finishedAt: new Date("2026-05-11T18:55:45.600Z"), + logContent: "", }, ], ); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e3442c17d9d..686caa0bffa 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -6,6 +6,7 @@ import { activityLog, agentWakeupRequests, agents, + authUsers, approvals, assets, companies, @@ -37,6 +38,7 @@ import type { AcceptedPlanDecomposition, IssueComment, IssueCommentAuthorType, + IssueCommentDerivedAuthorSource, IssueCommentMetadata, IssueCommentPresentation, IssueBlockerAttention, @@ -101,6 +103,11 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; +// Non-human author sentinels that agents post under. These ARE eligible for +// agent-attribution derivation even though `local-board` is also materialized +// as a row in the `user` table (it is the implicit board admin). Genuine human +// users — real signups with their own ids — are never reattributed. +const NON_HUMAN_SENTINEL_AUTHOR_USER_IDS = new Set(["local-board"]); const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000; @@ -169,32 +176,65 @@ type IssueCommentRunLogAttributionRun = { createdAt: Date | string; startedAt?: Date | string | null; finishedAt?: Date | string | null; + // Best-effort run log text. May be empty when logs were not read for a tier + // that does not need them (run-id / run-window-unique); only the + // `run_log_comment_post` tier consults this. logContent: string; }; +type DerivedIssueCommentAttribution = { + derivedAuthorAgentId: string; + derivedCreatedByRunId: string; + derivedAuthorSource: IssueCommentDerivedAuthorSource; +}; + +/** + * Best-effort agent attribution for comments whose stored author is a non-human + * sentinel (e.g. `local-board`). Callers MUST pre-filter `comments` to drop any + * comment whose `authorUserId` maps to a genuine user profile so a real board / + * user comment is never reattributed. + * + * Only LOSSLESS signals are used — a comment is reattributed solely when a run + * provably authored it. Pure run-window timing overlap is intentionally NOT a + * signal: because agents post through the `local-board` subprocess, an agent + * comment and a genuine human board comment are indistinguishable rows, so any + * timing-based guess mis-attributes human board comments that merely coincided + * with an agent run (Option A). + * + * Tiers, in descending confidence (first match wins per comment): + * 1. `run_id` — the comment's own `createdByRunId` resolves to an agent run + * (lossless: that run authored the comment). + * 2. `run_log_comment_post` — an overlapping run log contains the explicit + * `comment id: {id}` post marker (lossless: the run recorded posting it). + */ export function deriveIssueCommentRunLogAttribution( comments: readonly IssueCommentRunLogAttributionCandidate[], runs: readonly IssueCommentRunLogAttributionRun[], ) { - const derivedByCommentId = new Map(); + const derivedByCommentId = new Map(); + const runById = new Map(runs.map((run) => [run.runId, run] as const)); for (const comment of comments) { - if (comment.authorAgentId || !comment.authorUserId || comment.createdByRunId) continue; + if (comment.authorAgentId || !comment.authorUserId) continue; + + // Tier 1: the comment carries the run that authored it. Lossless even when + // the author was recorded as the `local-board` sentinel. + if (comment.createdByRunId) { + const ownRun = runById.get(comment.createdByRunId); + if (ownRun?.agentId) { + derivedByCommentId.set(comment.id, { + derivedAuthorAgentId: ownRun.agentId, + derivedCreatedByRunId: ownRun.runId, + derivedAuthorSource: "run_id", + }); + continue; + } + } + const commentCreatedAtMs = toTimestampMs(comment.createdAt); if (commentCreatedAtMs === null) continue; - let bestMatch: - | { - runId: string; - agentId: string; - distanceMs: number; - } - | null = null; - + const overlappingRuns: Array<{ run: IssueCommentRunLogAttributionRun; runEndMs: number }> = []; for (const run of runs) { const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt); const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt); @@ -205,24 +245,30 @@ export function deriveIssueCommentRunLogAttribution( ) { continue; } - if (!run.logContent.includes(`comment id: ${comment.id}`)) continue; - - const distanceMs = Math.abs(runEndMs - commentCreatedAtMs); - if (!bestMatch || distanceMs < bestMatch.distanceMs) { - bestMatch = { - runId: run.runId, - agentId: run.agentId, - distanceMs, - }; - } + overlappingRuns.push({ run, runEndMs }); } - if (!bestMatch) continue; - derivedByCommentId.set(comment.id, { - derivedAuthorAgentId: bestMatch.agentId, - derivedCreatedByRunId: bestMatch.runId, - derivedAuthorSource: "run_log_comment_post", - }); + // Tier 2: an overlapping run log explicitly recorded posting this comment. + let bestLogMatch: { runId: string; agentId: string; distanceMs: number } | null = null; + for (const { run, runEndMs } of overlappingRuns) { + if (!run.logContent.includes(`comment id: ${comment.id}`)) continue; + const distanceMs = Math.abs(runEndMs - commentCreatedAtMs); + if (!bestLogMatch || distanceMs < bestLogMatch.distanceMs) { + bestLogMatch = { runId: run.runId, agentId: run.agentId, distanceMs }; + } + } + if (bestLogMatch) { + derivedByCommentId.set(comment.id, { + derivedAuthorAgentId: bestLogMatch.agentId, + derivedCreatedByRunId: bestLogMatch.runId, + derivedAuthorSource: "run_log_comment_post", + }); + continue; + } + + // No lossless signal — leave unresolved. A pure run-window timing overlap is + // deliberately NOT enough to reattribute (it cannot tell an agent comment + // from a human board comment that happened during the run). } return derivedByCommentId; @@ -3442,6 +3488,39 @@ export function issueService(db: Db) { return content; } + // Persist a resolved attribution so subsequent reads stop re-scanning run + // logs (and old "Board" threads stay fixed durably). Best-effort: a write + // failure must never break the read path. The `IS NULL` guard keeps this + // idempotent and avoids clobbering a value another reader just stored. + async function persistDerivedIssueCommentAttribution( + derivedByCommentId: ReadonlyMap, + ) { + if (derivedByCommentId.size === 0) return; + // One bulk `UPDATE ... FROM (VALUES ...)` so the read path is never blocked + // on N sequential round-trips for a large legacy thread. The `IS NULL` guard + // keeps this idempotent and avoids clobbering a value another reader just + // stored. Best-effort: a write failure must never break the read path. + const rows = [...derivedByCommentId].map( + ([commentId, derived]) => + sql`(${commentId}::uuid, ${derived.derivedAuthorAgentId}::uuid, ${derived.derivedCreatedByRunId}::uuid, ${derived.derivedAuthorSource}::text)`, + ); + try { + await db.execute(sql` + UPDATE ${issueComments} AS c + SET derived_author_agent_id = v.agent_id, + derived_created_by_run_id = v.run_id, + derived_author_source = v.source + FROM (VALUES ${sql.join(rows, sql`, `)}) AS v(comment_id, agent_id, run_id, source) + WHERE c.id = v.comment_id AND c.derived_author_agent_id IS NULL + `); + } catch (err) { + logger.warn( + { err, commentIds: [...derivedByCommentId.keys()] }, + "failed to persist derived issue-comment attribution", + ); + } + } + async function enrichCommentsWithDerivedAgentAttribution< T extends { id: string; @@ -3450,20 +3529,55 @@ export function issueService(db: Db) { authorAgentId?: string | null; authorUserId?: string | null; createdByRunId?: string | null; + derivedAuthorAgentId?: string | null; createdAt: Date | string; }, >(comments: readonly T[]) { - const candidates = comments.filter((comment) => + // Candidates: a non-human author, no stored agent, and not already resolved + // by a previous read / the backfill migration. + const preliminary = comments.filter((comment) => !comment.authorAgentId && !!comment.authorUserId - && !comment.createdByRunId, + && !comment.derivedAuthorAgentId, ); - if (candidates.length === 0) return comments; + if (preliminary.length === 0) return comments; const companyId = comments[0]?.companyId ?? null; const issueId = comments[0]?.issueId ?? null; if (!companyId || !issueId) return comments; + // Guard: never reattribute a comment whose author maps to a genuine user + // profile. Only the non-human sentinels agents post under (e.g. + // `local-board`) are eligible — even though `local-board` is itself a row in + // the `user` table, so a plain "exists in user table" check would wrongly + // exclude every mis-attributed agent comment. + const nonSentinelAuthorUserIds = [ + ...new Set( + preliminary + .map((comment) => comment.authorUserId) + .filter((id): id is string => !!id && !NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(id)), + ), + ]; + const genuineUserIds = nonSentinelAuthorUserIds.length + ? new Set( + ( + await db + .select({ id: authUsers.id }) + .from(authUsers) + .where(inArray(authUsers.id, nonSentinelAuthorUserIds)) + ).map((row) => row.id), + ) + : new Set(); + // `preliminary` already guarantees a truthy `authorUserId`, so only the two + // "not a genuine user" arms are live: the explicit non-human sentinel, or an + // author id absent from the `user` table. + const candidates = preliminary.filter( + (comment) => + NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(comment.authorUserId!) + || !genuineUserIds.has(comment.authorUserId!), + ); + if (candidates.length === 0) return comments; + const minCommentCreatedAtMs = candidates.reduce((min, comment) => { const timestamp = toTimestampMs(comment.createdAt); if (timestamp === null) return min; @@ -3481,6 +3595,13 @@ export function issueService(db: Db) { maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS, ).toISOString(); + // The runs the comments' own `createdByRunId` point at — fetched + // unconditionally so the lossless run-id tier resolves even when a run is + // not otherwise associated with the issue. + const ownRunIds = [ + ...new Set(candidates.map((comment) => comment.createdByRunId).filter((id): id is string => !!id)), + ]; + const runs = await db .select({ runId: heartbeatRuns.id, @@ -3497,36 +3618,82 @@ export function issueService(db: Db) { and( eq(heartbeatRuns.companyId, companyId), or( - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, - sql`exists ( - select 1 - from ${activityLog} - where ${activityLog.companyId} = ${companyId} - and ${activityLog.entityType} = 'issue' - and ${activityLog.entityId} = ${issueId} - and ${activityLog.runId} = ${heartbeatRuns.id} - )`, + and( + or( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + sql`exists ( + select 1 + from ${activityLog} + where ${activityLog.companyId} = ${companyId} + and ${activityLog.entityType} = 'issue' + and ${activityLog.entityId} = ${issueId} + and ${activityLog.runId} = ${heartbeatRuns.id} + )`, + ), + sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`, + sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`, + ), + ownRunIds.length > 0 ? inArray(heartbeatRuns.id, ownRunIds) : sql`false`, ), - sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`, - sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`, ), ) .orderBy(desc(heartbeatRuns.createdAt)); if (runs.length === 0) return comments; - const runsWithLogs: Array<(typeof runs)[number] & { logContent: string }> = []; - for (let index = 0; index < runs.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) { - const batch = runs.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS); - const batchWithLogs = await Promise.all(batch.map(async (run) => ({ - ...run, - logContent: await readRunLogText(run), - }))); - runsWithLogs.push(...batchWithLogs); + // Pass 1: resolve the run-id tier, which never reads log bodies. Most + // comments resolve here, so we avoid object-storage reads entirely. + const runsWithoutLogs = runs.map((run) => ({ ...run, logContent: "" })); + const derivedByCommentId = new Map( + deriveIssueCommentRunLogAttribution(candidates, runsWithoutLogs), + ); + + // Pass 2: for comments still unresolved after the run-id tier, read the logs + // of any run whose window overlaps such a comment, to look for the explicit + // `comment id:` post marker. The marker is a lossless signal regardless of + // how many runs overlap, so we do not short-circuit on the single-run case. + const unresolved = candidates.filter((comment) => !derivedByCommentId.has(comment.id)); + if (unresolved.length > 0) { + const runIdsToRead = new Set(); + for (const run of runs) { + const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt); + const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt); + if (runStartMs === null || runEndMs === null) continue; + for (const comment of unresolved) { + const commentCreatedAtMs = toTimestampMs(comment.createdAt); + if (commentCreatedAtMs === null) continue; + if ( + commentCreatedAtMs >= runStartMs + && commentCreatedAtMs <= runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS + ) { + runIdsToRead.add(run.runId); + break; + } + } + } + + if (runIdsToRead.size > 0) { + const runsToRead = runs.filter((run) => runIdsToRead.has(run.runId)); + const logByRunId = new Map(); + for (let index = 0; index < runsToRead.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) { + const batch = runsToRead.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS); + await Promise.all( + batch.map(async (run) => { + logByRunId.set(run.runId, await readRunLogText(run)); + }), + ); + } + const runsWithLogs = runs.map((run) => ({ ...run, logContent: logByRunId.get(run.runId) ?? "" })); + for (const [commentId, derived] of deriveIssueCommentRunLogAttribution(unresolved, runsWithLogs)) { + derivedByCommentId.set(commentId, derived); + } + } } - const derivedByCommentId = deriveIssueCommentRunLogAttribution(candidates, runsWithLogs); + if (derivedByCommentId.size === 0) return comments; + await persistDerivedIssueCommentAttribution(derivedByCommentId); + return comments.map((comment) => { const derived = derivedByCommentId.get(comment.id); return derived ? { ...comment, ...derived } : comment; diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index de7849a8253..1f8a5959c13 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -462,6 +462,40 @@ describe("buildIssueChatMessages", () => { }); }); + it("does not reattribute a genuine board/user comment that has no derived agent", () => { + const agentMap = new Map([["agent-1", createAgent("agent-1", "Claude")]]); + const messages = buildIssueChatMessages({ + comments: [ + createComment({ + authorUserId: "local-board", + authorType: "user", + // No agent ever resolved for this comment — a real board action. + derivedAuthorAgentId: null, + derivedCreatedByRunId: null, + runId: null, + runAgentId: null, + }), + ], + timelineEvents: [], + linkedRuns: [], + liveRuns: [], + agentMap, + currentUserId: "user-1", + userLabelMap: new Map([["local-board", "Board"]]), + }); + + expect(messages[0]).toMatchObject({ + role: "user", + metadata: { + custom: { + authorType: "user", + authorAgentId: null, + authorUserId: "local-board", + }, + }, + }); + }); + it("renders a comment as agent-authored when runAgentId is set from activity log", () => { const agentMap = new Map([["agent-1", createAgent("agent-1", "Claude")]]); const messages = buildIssueChatMessages({ From 2eba718befc22b2feaec28d9f5ddaab5a946289f Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 1 Jul 2026 21:38:01 -0700 Subject: [PATCH 09/19] Fix sandbox bridge credentials and stalled review recovery (#8844) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The local adapter and heartbeat recovery systems decide whether an agent has a real control-plane mutation path. > - Sandboxed local adapters split execution between the trusted host process and the sandbox shell/tool surface. > - A host-side adapter can still reach Paperclip while the sandbox shell surface cannot, which leaves agents thinking no endpoint or credentials are configured even though the host can still post comments. > - Execution-policy review stages can also remain pending after a reviewer run finishes without recording a decision. > - This pull request makes the sandbox bridge available to the actual shell mutation surface and adds bounded recovery for terminal-but-still-pending review participants. > - The benefit is that agents get a real reachable Paperclip API path where they need it, and stalled review stages become visible recovery work instead of silently drifting. ## Linked Issues or Issue Description No exact public GitHub issue matched this combined failure. I searched for exact and related terms including `cannot reach the Paperclip control plane`, `execution_review_participant_recovery`, `sandbox callback bridge`, `review participant in_review`, and `control plane sandbox`. Related public issues: - Refs #8482 for `in_review` liveness invariant recovery. - Refs #863 for prior agent API-key reachability confusion. - Refs #248 for the broader sandboxed agent execution model. Bug summary: - What happened: a sandboxed local-adapter run could have host-side Paperclip access while the sandbox Bash/tool surface lacked a reachable API endpoint or usable run credentials. Separately, a reviewer run could finish while its execution-review stage remained pending, leaving the source issue in `in_review` with no decision and no live participant run. - Expected behavior: the mutation surface that agents actually use should receive a run-scoped Paperclip bridge, and pending review participants should get one bounded normal-model recovery wake before moving to explicit blocked/source-scoped recovery. - Steps to reproduce: run a sandbox-backed local adapter that needs Bash/curl/tooling to call Paperclip from inside the sandbox, or finish an execution-policy reviewer run without submitting the pending review decision. - Deployment mode: local/authenticated private development instance with sandbox-backed local adapters. ## What Changed - Changed sandbox callback bridge startup so bridge credentials are passed through the sandbox runner environment instead of embedded in the visible `nohup env ...` command string. - Added adapter-utils coverage proving the sandbox shell can call Paperclip through the bridge, forwards the host run JWT with `X-Paperclip-Run-Id`, and does not leak host or bridge tokens into stdout/stderr, runner command text, or runtime files. - Added one bounded execution-review participant recovery path for terminal reviewer runs whose `executionState` remains pending. - Escalated exhausted or non-invokable review participant recovery to blocked/source-scoped recovery with dedicated evidence, activity, and next-action text. - Documented the mutation-surface reachability contract in `doc/execution-semantics.md` and updated the Paperclip skill authentication guidance for sandbox bridge env vars. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/execution-target-sandbox.test.ts` - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts --no-file-parallelism --maxWorkers=1` - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check` - `curl -fsS $PAPERCLIP_API_URL/api/health` returned `status: ok` on the local instance. ## Risks - Medium behavioral risk: more `in_review` issues with terminal-but-pending reviewer runs will now be retried once and then blocked explicitly instead of remaining quiet. - Low sandbox bridge risk: credential delivery moved from command text to the runner environment, which is less leaky but depends on sandbox providers honoring the env payload for startup commands. - No database migration is included. - Full repo build and CI were not run locally before opening the PR; targeted server/adapter tests and typechecks passed. ## Model Used OpenAI GPT-5 via the Codex local agent, with repository tool use and shell-based code execution. The runtime did not expose a precise context-window value to the agent. ## 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 - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 6 + .../src/execution-target-sandbox.test.ts | 131 +++- .../src/sandbox-callback-bridge.ts | 4 +- server/src/__tests__/file-resources.test.ts | 7 +- .../heartbeat-process-recovery.test.ts | 571 +++++++++++++++++- server/src/services/heartbeat.ts | 209 ++++++- server/src/services/recovery/service.ts | 269 ++++++++- skills/paperclip/SKILL.md | 2 +- 8 files changed, 1142 insertions(+), 57 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index d4266b93959..929f5a80235 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -307,6 +307,10 @@ A workspace-coherent adapter path means: - the effective cwd exists or is provider-reachable, according to the workspace provider - when the adapter or workspace strategy relies on git state, the cwd is git-valid for the selected workspace: it resolves to the expected repository root, required base refs or branch metadata can be resolved, and runtime-created worktrees are still registered or explicitly recoverable +Adapter-backed liveness also requires control-plane reachability from the agent's actual mutation surface, not just from the host adapter process. If the agent is expected to use Bash, shell tools, runtime helpers, or in-sandbox command execution to update issues, create comments, upload artifacts, or submit review decisions, the `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY` visible to that surface must route to Paperclip successfully. + +For sandbox-backed local adapters, Paperclip may satisfy that contract with a run-scoped in-sandbox bridge. The host adapter keeps the real run JWT on the host side, injects only the bridge URL/token into the sandbox tool environment, and forwards allowed Paperclip API requests with the run id attached. The bridge credentials are execution plumbing, not user-facing context: they must not be written into prompts, visible comments, issue documents, restored workspace files, or durable logs. Agents and skills must use the env vars available in Bash/curl rather than assuming that the host's localhost API URL is reachable from browser or web-extraction tools inside the sandbox. + The state `projectWorkspaceId` plus `executionWorkspaceId` without `projectId` is invalid for project-scoped execution. Paperclip may treat it as recoverable only when it can derive exactly one owning project from the execution workspace, project workspace, or source issue in the same company and then repair the persisted state before delivery. If the owning project is missing, ambiguous, or cross-company, the queued adapter run must not be counted as a live path. Workspace incoherence feeds into the same non-terminal liveness and stranded assigned-work model as a disappeared run. The recovery path should first fail or reject the incoherent wake, then either repair and requeue one bounded continuation for the same assignee or surface an explicit recovery action. It must not leave an agent-owned `in_progress` issue healthy solely because a wake record exists that would invoke the adapter in the wrong cwd, a non-git directory where git is required, an unrelated project workspace, or an unrecoverable missing worktree. @@ -398,6 +402,8 @@ Agent-assigned `in_review` with no typed participant is only healthy when one of An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely. +When an execution-policy review stage has a pending agent participant, the participant's run is part of the review path only while it is live or queued. If that participant run reaches a terminal state while `executionState.status` remains `pending`, no decision has been recorded. Paperclip should queue one bounded normal-model recovery wake for the same participant when the agent is invokable and no other review path exists. If that recovery run also finishes while the stage remains pending, or the participant cannot be invoked, Paperclip must move the source issue to an explicit blocked/recovery path instead of leaving `in_review` to drift silently. + ### Issue monitors An issue monitor is a one-shot deferred action path for agent-owned issues in `in_progress` or `in_review`. diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 92f964bcd1f..0e7fac90fbb 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -17,6 +17,7 @@ import { type AdapterSandboxExecutionTarget, } from "./execution-target.js"; import { runChildProcess } from "./server-utils.js"; +import { shellQuote } from "./ssh.js"; describe("sandbox adapter execution targets", () => { const cleanupDirs: string[] = []; @@ -60,6 +61,20 @@ describe("sandbox adapter execution targets", () => { }; } + async function readRuntimeTextFiles(rootDir: string): Promise { + const entries = await readdir(rootDir, { withFileTypes: true }).catch(() => []); + const contents: string[] = []; + for (const entry of entries) { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + contents.push(...await readRuntimeTextFiles(entryPath)); + } else if (entry.isFile()) { + contents.push(await readFile(entryPath, "utf8").catch(() => "")); + } + } + return contents; + } + it("executes through the provider-neutral runner without a remote spec", async () => { const runner = { execute: vi.fn(async () => ({ @@ -449,6 +464,120 @@ describe("sandbox adapter execution targets", () => { } }); + it("exposes the Paperclip bridge to the sandbox shell surface", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-shell-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude"); + await mkdir(runtimeRootDir, { recursive: true }); + + const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = []; + const apiServer = createServer((req, res) => { + requests.push({ + method: req.method ?? "GET", + url: req.url ?? "/", + auth: req.headers.authorization ?? null, + runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null, + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve, reject) => { + apiServer.once("error", reject); + apiServer.listen(0, "127.0.0.1", () => resolve()); + }); + const address = apiServer.address(); + if (!address || typeof address === "string") { + throw new Error("Expected the bridge shell test API server to listen on a TCP port."); + } + + const delegateRunner = createLocalSandboxRunner(); + const runner = { + execute: vi.fn(async (input: Parameters[0]) => delegateRunner.execute(input)), + }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd, + runner, + timeoutMs: 30_000, + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-shell", + target, + runtimeRootDir, + adapterKey: "claude", + hostApiToken: "real-run-jwt", + hostApiUrl: `http://127.0.0.1:${address.port}`, + }); + try { + expect(bridge).not.toBeNull(); + const shellProbe = [ + "const url = `${process.env.PAPERCLIP_API_URL}/api/agents/me`;", + "fetch(url, { headers: { authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`, accept: 'application/json' } })", + " .then(async (response) => {", + " const body = await response.json();", + " process.stdout.write(JSON.stringify({", + " status: response.status,", + " body,", + " bridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE,", + " }));", + " })", + " .catch((error) => {", + " console.error(error instanceof Error ? error.stack : String(error));", + " process.exit(1);", + " });", + ].join("\n"); + + const result = await runAdapterExecutionTargetShellCommand( + "run-bridge-shell", + target, + `${shellQuote(process.execPath)} -e ${shellQuote(shellProbe)}`, + { + cwd: remoteCwd, + env: bridge!.env, + timeoutSec: 15, + graceSec: 5, + onLog: async () => {}, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + status: 200, + body: { ok: true }, + bridgeMode: "queue_v1", + }); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("real-run-jwt"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain(bridge!.env.PAPERCLIP_API_KEY); + const runnerCommandText = JSON.stringify( + runner.execute.mock.calls.map(([call]) => ({ + command: call.command, + args: call.args, + })), + ); + expect(runnerCommandText).not.toContain("real-run-jwt"); + expect(runnerCommandText).not.toContain(bridge!.env.PAPERCLIP_API_KEY); + const runtimeFiles = (await readRuntimeTextFiles(runtimeRootDir)).join("\n"); + expect(runtimeFiles).not.toContain("real-run-jwt"); + expect(runtimeFiles).not.toContain(bridge!.env.PAPERCLIP_API_KEY); + expect(requests).toEqual([{ + method: "GET", + url: "/api/agents/me", + auth: "Bearer real-run-jwt", + runId: "run-bridge-shell", + }]); + } finally { + await bridge?.stop(); + await new Promise((resolve) => apiServer.close(() => resolve())); + } + }); + it("uses the effective adapter timeout when starting the sandbox callback bridge", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-timeout-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index c673e80efd4..6531aacfe97 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -914,8 +914,7 @@ export async function startSandboxCallbackBridgeServer(input: { [ `mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`, `rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`, - `nohup env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` + - `${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` + + `nohup ${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` + `>> ${shellQuote(directories.logFile)} 2>&1 < /dev/null &`, "pid=$!", `printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`, @@ -925,6 +924,7 @@ export async function startSandboxCallbackBridgeServer(input: { cwd: input.remoteCwd, env: { [SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE, + ...env, }, timeoutMs, }); diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index 8064f4bcf08..3eddcf8d7b6 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -63,7 +63,8 @@ async function seedGraph(db: Db, input: { projectSourceType?: string; targetProjectSourceType?: string; }): Promise { - const suffix = crypto.randomUUID().slice(0, 8); + const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 12); + const prefixSuffix = suffix.toUpperCase(); const companyId = crypto.randomUUID(); const otherCompanyId = crypto.randomUUID(); const goalId = crypto.randomUUID(); @@ -79,8 +80,8 @@ async function seedGraph(db: Db, input: { const otherIssueId = crypto.randomUUID(); await db.insert(companies).values([ - { id: companyId, name: `Company ${suffix}`, issuePrefix: `F${suffix.slice(0, 4).toUpperCase()}` }, - { id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${suffix.slice(0, 4).toUpperCase()}` }, + { id: companyId, name: `Company ${suffix}`, issuePrefix: `F${prefixSuffix}` }, + { id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${prefixSuffix}` }, ]); await db.insert(goals).values([ { id: goalId, companyId, title: "Goal", level: "company", status: "active" }, diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index af0c89a8bdb..2d7b3b016ef 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -644,7 +644,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { async function seedStrandedIssueFixture(input: { status: "todo" | "in_progress"; runStatus: "failed" | "timed_out" | "cancelled" | "succeeded"; - retryReason?: "assignment_recovery" | "issue_continuation_needed" | null; + retryReason?: "assignment_recovery" | "issue_continuation_needed" | "execution_review_participant_recovery" | null; runSource?: string | null; assignToUser?: boolean; activePauseHold?: boolean; @@ -769,6 +769,103 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { return { companyId, agentId, runId, wakeupRequestId, issueId, rootIssueId }; } + async function seedInReviewParticipantRunFixture(input?: { + wakeReason?: string; + retryReason?: string | null; + }) { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const wakeupRequestId = randomUUID(); + const issueId = randomUUID(); + const stageId = randomUUID(); + const now = new Date("2026-03-19T00:00:00.000Z"); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const wakeReason = input?.wakeReason ?? "execution_review_requested"; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexReviewer", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(agentWakeupRequests).values({ + id: wakeupRequestId, + companyId, + agentId, + source: "automation", + triggerDetail: "system", + reason: wakeReason, + payload: { + issueId, + ...(input?.retryReason ? { retryReason: input.retryReason } : {}), + }, + status: "queued", + runId, + requestedAt: now, + updatedAt: now, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + wakeupRequestId, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason, + ...(input?.retryReason ? { retryReason: input.retryReason } : {}), + }, + updatedAt: now, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Review participant stayed pending", + status: "in_review", + priority: "medium", + assigneeAgentId: agentId, + assigneeUserId: null, + executionRunId: runId, + executionAgentNameKey: "codexreviewer", + executionLockedAt: now, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + executionState: { + status: "pending", + currentStageId: stageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId, userId: null }, + returnAssignee: { type: "agent", agentId, userId: null }, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }); + + return { companyId, agentId, runId, wakeupRequestId, issueId, stageId }; + } + async function seedAssignedTodoNoRunFixture(input?: { agentStatus?: "paused" | "idle" | "running"; }) { @@ -816,10 +913,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { agentId: string; issueId: string; runId: string; - previousStatus: "todo" | "in_progress"; - retryReason?: "assignment_recovery" | "issue_continuation_needed" | null; + previousStatus: "todo" | "in_progress" | "in_review"; + retryReason?: "assignment_recovery" | "issue_continuation_needed" | "execution_review_participant_recovery" | null; cause?: string; kind?: string; + previousOwnerAgentId?: string | null; + returnOwnerAgentId?: string | null; }) { const action = await waitForValue(async () => db.select().from(issueRecoveryActions).where( @@ -839,8 +938,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { status: "active", ownerType: "agent", ownerAgentId: input.agentId, - previousOwnerAgentId: input.agentId, - returnOwnerAgentId: input.agentId, + previousOwnerAgentId: input.previousOwnerAgentId ?? input.agentId, + returnOwnerAgentId: input.returnOwnerAgentId ?? input.agentId, cause: input.cause ?? "stranded_assigned_issue", attemptCount: 1, maxAttempts: null, @@ -851,9 +950,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { latestRunId: input.runId, retryReason: input.retryReason ?? null, }); - expect(action.nextAction).toContain( - input.kind === "missing_disposition" ? "valid issue disposition" : "Restore a live execution path", - ); + if (input.cause === "execution_review_participant_recovery") { + expect(action.nextAction).toContain("failed review participant path"); + } else { + expect(action.nextAction).toContain( + input.kind === "missing_disposition" ? "valid issue disposition" : "Restore a live execution path", + ); + } const recoveryIssues = await db .select() @@ -2554,6 +2657,458 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } }); + it("re-enqueues an already stranded execution-review participant during reconciliation", async () => { + const { agentId, issueId, runId, wakeupRequestId, stageId } = await seedInReviewParticipantRunFixture(); + const finishedAt = new Date("2026-03-19T00:05:00.000Z"); + await db + .update(heartbeatRuns) + .set({ + status: "succeeded", + startedAt: new Date("2026-03-19T00:00:00.000Z"), + finishedAt, + updatedAt: finishedAt, + }) + .where(eq(heartbeatRuns.id, runId)); + await db + .update(agentWakeupRequests) + .set({ + status: "completed", + finishedAt, + updatedAt: finishedAt, + }) + .where(eq(agentWakeupRequests.id, wakeupRequestId)); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.reviewParticipantRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + const retryRun = runs.find((row) => row.id !== runId); + expect(["queued", "running"]).toContain(retryRun?.status); + expect(retryRun).toMatchObject({ + retryOfRunId: runId, + }); + expect(retryRun?.contextSnapshot).toMatchObject({ + issueId, + taskId: issueId, + wakeReason: "execution_review_participant_recovery", + retryReason: "execution_review_participant_recovery", + source: "issue.execution_review_recovery", + retryOfRunId: runId, + currentStageId: stageId, + currentStageType: "review", + reviewRecoveryInstruction: expect.stringContaining("Submit the review decision now"), + }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); + }); + + it("re-enqueues a stranded execution-review participant when another agent has the latest issue run", async () => { + const { companyId, agentId, issueId, runId, wakeupRequestId, stageId } = + await seedInReviewParticipantRunFixture(); + const otherAgentId = randomUUID(); + const otherRunId = randomUUID(); + const finishedAt = new Date("2026-03-19T00:05:00.000Z"); + + await db + .update(heartbeatRuns) + .set({ + status: "succeeded", + startedAt: new Date("2026-03-19T00:00:00.000Z"), + finishedAt, + updatedAt: finishedAt, + }) + .where(eq(heartbeatRuns.id, runId)); + await db + .update(agentWakeupRequests) + .set({ + status: "completed", + finishedAt, + updatedAt: finishedAt, + }) + .where(eq(agentWakeupRequests.id, wakeupRequestId)); + + await db.insert(agents).values({ + id: otherAgentId, + companyId, + name: "CodexImplementor", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: otherRunId, + companyId, + agentId: otherAgentId, + invocationSource: "automation", + triggerDetail: "system", + status: "succeeded", + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_commented", + }, + startedAt: new Date("2026-03-19T00:10:00.000Z"), + finishedAt: new Date("2026-03-19T00:15:00.000Z"), + createdAt: new Date(Date.now() + 1_000), + updatedAt: new Date("2026-03-19T00:15:00.000Z"), + }); + + const heartbeat = heartbeatService(db); + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.reviewParticipantRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const retryRun = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)) + .then((runs) => + runs.find((row) => + row.id !== runId && + (row.contextSnapshot as Record | null)?.retryReason === + "execution_review_participant_recovery" + ) ?? null + ); + expect(retryRun).toMatchObject({ + retryOfRunId: runId, + }); + expect(retryRun?.contextSnapshot).toMatchObject({ + issueId, + currentStageId: stageId, + currentStageType: "review", + }); + }); + + it("re-enqueues a stranded execution-review participant when another agent has a queued issue wake", async () => { + const { companyId, agentId, issueId, runId, wakeupRequestId } = + await seedInReviewParticipantRunFixture(); + const otherAgentId = randomUUID(); + const otherWakeId = randomUUID(); + const finishedAt = new Date("2026-03-19T00:05:00.000Z"); + + await db + .update(heartbeatRuns) + .set({ + status: "succeeded", + startedAt: new Date("2026-03-19T00:00:00.000Z"), + finishedAt, + updatedAt: finishedAt, + }) + .where(eq(heartbeatRuns.id, runId)); + await db + .update(agentWakeupRequests) + .set({ + status: "completed", + finishedAt, + updatedAt: finishedAt, + }) + .where(eq(agentWakeupRequests.id, wakeupRequestId)); + + await db.insert(agents).values({ + id: otherAgentId, + companyId, + name: "CodexImplementor", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(agentWakeupRequests).values({ + id: otherWakeId, + companyId, + agentId: otherAgentId, + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId }, + status: "queued", + }); + + const heartbeat = heartbeatService(db); + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.reviewParticipantRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const wakeups = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.agentId, agentId)); + expect(wakeups.some((wakeup) => + wakeup.reason === "execution_review_participant_recovery" && + wakeup.status !== "skipped" + )).toBe(true); + }); + + it("retries a pending execution-review participant when another agent has an active issue run", async () => { + const { companyId, agentId, issueId, runId } = await seedInReviewParticipantRunFixture(); + const otherAgentId = randomUUID(); + const otherRunId = randomUUID(); + await db.insert(agents).values({ + id: otherAgentId, + companyId, + name: "CodexImplementor", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: otherRunId, + companyId, + agentId: otherAgentId, + invocationSource: "automation", + triggerDetail: "system", + status: "running", + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_commented", + }, + startedAt: new Date("2026-03-19T00:01:00.000Z"), + updatedAt: new Date("2026-03-19T00:01:00.000Z"), + }); + + const heartbeat = heartbeatService(db); + await heartbeat.resumeQueuedRuns(); + const reviewRecoveryRun = await waitForValue(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return runs.find((row) => + (row.contextSnapshot as Record | null)?.retryReason === + "execution_review_participant_recovery" + ) ?? null; + }, 8_000); + + expect(reviewRecoveryRun).toMatchObject({ + companyId, + agentId, + retryOfRunId: runId, + }); + }); + + it("does not immediately recover a generic on-demand run used for an in-review agent API update", async () => { + const { agentId, issueId, runId } = await seedInReviewParticipantRunFixture({ + wakeReason: "manual", + }); + const heartbeat = heartbeatService(db); + + await heartbeat.resumeQueuedRuns(); + const settledRun = await waitForRunToSettle(heartbeat, runId, 8_000); + expect(settledRun?.status).toBe("succeeded"); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs.some((row) => + (row.contextSnapshot as Record | null)?.retryReason === + "execution_review_participant_recovery" + )).toBe(false); + + const issue = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("in_review"); + expect(issue?.assigneeAgentId).toBe(agentId); + }); + + it("retries a pending execution-review participant once before blocking with a recovery action", async () => { + const { companyId, agentId, issueId, runId, stageId } = await seedInReviewParticipantRunFixture(); + const heartbeat = heartbeatService(db); + + await heartbeat.resumeQueuedRuns(); + const reviewRecoveryRun = await waitForValue(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return runs.find((row) => + (row.contextSnapshot as Record | null)?.retryReason === + "execution_review_participant_recovery" && + row.status !== "queued" && + row.status !== "running" + ) ?? null; + }, 8_000); + expect(reviewRecoveryRun).toBeTruthy(); + expect(reviewRecoveryRun).toMatchObject({ + companyId, + agentId, + retryOfRunId: runId, + status: "succeeded", + }); + expect(reviewRecoveryRun?.contextSnapshot).toMatchObject({ + issueId, + taskId: issueId, + wakeReason: "execution_review_participant_recovery", + retryReason: "execution_review_participant_recovery", + source: "issue.execution_review_recovery", + retryOfRunId: runId, + currentStageId: stageId, + currentStageType: "review", + reviewRecoveryInstruction: expect.stringContaining("Submit the review decision now"), + }); + expect(reviewRecoveryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); + + const sourceIssue = await waitForValue(async () => { + const row = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + return row?.status === "blocked" ? row : null; + }, 8_000); + expect(sourceIssue).toMatchObject({ + status: "blocked", + assigneeAgentId: agentId, + executionRunId: null, + }); + + const recoveryAction = await expectSourceScopedStrandedRecoveryAction({ + companyId, + agentId, + issueId, + runId: reviewRecoveryRun!.id, + previousStatus: "in_review", + retryReason: "execution_review_participant_recovery", + cause: "execution_review_participant_recovery", + }); + expect(recoveryAction.evidence).toMatchObject({ + latestRunId: reviewRecoveryRun?.id, + latestRunStatus: "succeeded", + latestRunErrorCode: null, + recoveryCause: "execution_review_participant_recovery", + }); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + const recoveryComment = comments.find((comment) => + comment.body.includes("pending execution-review participant once") && + comment.body.includes(`Recovery action: \`${recoveryAction.id}\``), + ); + expect(recoveryComment).toBeTruthy(); + + const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)); + expect(activity.some((event) => + (event.details as Record | null)?.source === + "recovery.reconcile_execution_review_participant", + )).toBe(true); + }); + + it("blocks failed execution-review recovery under the reviewer when the source assignee differs", async () => { + const { companyId, agentId, issueId, runId, wakeupRequestId, stageId } = + await seedInReviewParticipantRunFixture({ + wakeReason: "execution_review_participant_recovery", + retryReason: "execution_review_participant_recovery", + }); + const sourceAssigneeAgentId = randomUUID(); + const finishedAt = new Date("2026-03-19T00:05:00.000Z"); + + await db.insert(agents).values({ + id: sourceAssigneeAgentId, + companyId, + name: "CodexImplementor", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db + .update(issues) + .set({ + assigneeAgentId: sourceAssigneeAgentId, + executionState: { + status: "pending", + currentStageId: stageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId, userId: null }, + returnAssignee: { type: "agent", agentId: sourceAssigneeAgentId, userId: null }, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }) + .where(eq(issues.id, issueId)); + await db + .update(heartbeatRuns) + .set({ + status: "failed", + startedAt: new Date("2026-03-19T00:00:00.000Z"), + finishedAt, + updatedAt: finishedAt, + errorCode: "adapter_failed", + error: "review recovery failed before submitting a decision", + }) + .where(eq(heartbeatRuns.id, runId)); + await db + .update(agentWakeupRequests) + .set({ + status: "failed", + claimedAt: new Date("2026-03-19T00:00:00.000Z"), + finishedAt, + updatedAt: finishedAt, + error: "review recovery failed before submitting a decision", + }) + .where(eq(agentWakeupRequests.id, wakeupRequestId)); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.reviewParticipantRequeued).toBe(0); + expect(result.escalated).toBe(1); + expect(result.issueIds).toEqual([issueId]); + + const sourceIssue = await waitForValue(async () => { + const row = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + return row?.status === "blocked" ? row : null; + }); + expect(sourceIssue).toMatchObject({ + status: "blocked", + assigneeAgentId: agentId, + }); + + const recoveryAction = await expectSourceScopedStrandedRecoveryAction({ + companyId, + agentId, + issueId, + runId, + previousStatus: "in_review", + retryReason: "execution_review_participant_recovery", + cause: "execution_review_participant_recovery", + previousOwnerAgentId: sourceAssigneeAgentId, + returnOwnerAgentId: sourceAssigneeAgentId, + }); + expect(recoveryAction.evidence).toMatchObject({ + latestRunId: runId, + latestRunStatus: "failed", + latestRunErrorCode: "adapter_failed", + recoveryCause: "execution_review_participant_recovery", + }); + }); + it.each([ ["failed", "adapter_failed"], ["failed", "process_lost"], diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 63afaca38c1..70e54654948 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -279,6 +279,9 @@ const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed"; const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed"; const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete"; const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete"; +const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery"; +const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = "execution_review_participant_recovery"; +const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participant_recovery"; const GITHUB_PR_WORKFLOW_SKILL_KEY = "paperclipai/bundled/software-development/github-pr-workflow"; const GITHUB_PR_WORKFLOW_SKILL_SLUG = "github-pr-workflow"; const PUSH_CAPABILITY_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const; @@ -2035,7 +2038,10 @@ function summarizeRunFailureForIssueComment( function didAutomaticRecoveryFail( latestRun: Pick | null, - expectedRetryReason: "assignment_recovery" | "issue_continuation_needed", + expectedRetryReason: + | "assignment_recovery" + | "issue_continuation_needed" + | typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON, ) { if (!latestRun) return false; @@ -2049,6 +2055,27 @@ function didAutomaticRecoveryFail( ); } +function isExecutionReviewParticipantRecoveryRun( + run: Pick | null, +) { + if (!run) return false; + const context = parseObject(run.contextSnapshot); + return readNonEmptyString(context.retryReason) === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON; +} + +function isExecutionReviewParticipantRecoveryEligibleRun( + run: Pick | null, +) { + if (!run) return false; + const context = parseObject(run.contextSnapshot); + const wakeReason = readNonEmptyString(context.wakeReason); + return ( + wakeReason === "execution_review_requested" || + wakeReason === "execution_approval_requested" || + isExecutionReviewParticipantRecoveryRun(run) + ); +} + function normalizeLedgerBillingType(value: unknown): BillingType { const raw = readNonEmptyString(value); switch (raw) { @@ -2397,6 +2424,7 @@ export function shouldResetTaskSessionForWake( if ( wakeReason === "issue_assigned" || wakeReason === "execution_review_requested" || + wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON || wakeReason === "execution_approval_requested" || wakeReason === "execution_changes_requested" || // PF-4: timer-driven wakes are exploratory ("any new work?"). They do not @@ -2509,6 +2537,9 @@ export function describeSessionResetReason( const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); if (wakeReason === "issue_assigned") return "wake reason is issue_assigned"; if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested"; + if (wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON) { + return `wake reason is ${EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON}`; + } if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested"; if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested"; // PF-4: paired with shouldResetTaskSessionForWake — keep the reason wording @@ -11458,6 +11489,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ); } + function buildExecutionReviewParticipantRecoveryComment(input: { + latestRun: Pick | null | undefined; + }) { + const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); + return ( + "Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " + + `or live reviewer run.${failureSummary ?? ""} ` + + "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " + + "restore the review stage, or record an intentional manual resolution." + ); + } + async function releaseIssueExecutionAndPromote( run: typeof heartbeatRuns.$inferSelect, options: { suppressImmediateRecovery?: boolean } = {}, @@ -11839,6 +11882,149 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; } + const findExistingExecutionPath = (agentId?: string | null) => + tx + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, issue.companyId), + inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, + sql`${heartbeatRuns.id} <> ${run.id}`, + agentId ? eq(heartbeatRuns.agentId, agentId) : sql`true`, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + + const issueHasScheduledMonitor = + issue.monitorNextCheckAt instanceof Date && + issue.monitorNextCheckAt.getTime() > Date.now(); + const executionState = parseIssueExecutionState(issue.executionState); + const currentParticipant = executionState?.status === "pending" + ? executionState.currentParticipant + : null; + const issueNeedsReviewParticipantRecovery = + issue.status === "in_review" && + !issue.assigneeUserId && + currentParticipant?.type === "agent" && + currentParticipant.agentId === run.agentId && + isExecutionReviewParticipantRecoveryEligibleRun(run) && + HEARTBEAT_RUN_TERMINAL_STATUSES.includes( + run.status as (typeof HEARTBEAT_RUN_TERMINAL_STATUSES)[number], + ); + + if (issueNeedsReviewParticipantRecovery) { + const existingReviewParticipantExecutionPath = await findExistingExecutionPath(currentParticipant.agentId); + if ( + options.suppressImmediateRecovery || + existingReviewParticipantExecutionPath || + issueHasScheduledMonitor || + await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc) + ) { + return { kind: "released" as const }; + } + + if (issue.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery) { + return { + kind: "blocked_recovery_in_place" as const, + issue, + previousStatus: issue.status, + }; + } + + const shouldBlockReviewRecovery = + !recoveryAgentInvokable || + !recoveryAgent || + isExecutionReviewParticipantRecoveryRun(run); + if (shouldBlockReviewRecovery) { + return { + kind: "blocked" as const, + issue, + previousStatus: issue.status, + comment: buildExecutionReviewParticipantRecoveryComment({ latestRun: run }), + recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE, + recoveryOwnerAgentId: currentParticipant.agentId, + }; + } + + const now = new Date(); + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + companyId: issue.companyId, + agentId: recoveryAgent.id, + source: "automation", + triggerDetail: "system", + reason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON, + payload: withRecoveryModelProfileHint({ + issueId: issue.id, + retryOfRunId: run.id, + retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON, + currentStageId: executionState?.currentStageId ?? null, + currentStageType: executionState?.currentStageType ?? null, + }, "normal_model"), + status: "queued", + requestedByActorType: "system", + requestedByActorId: null, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + const queuedRun = await tx + .insert(heartbeatRuns) + .values({ + companyId: issue.companyId, + agentId: recoveryAgent.id, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + wakeupRequestId: wakeupRequest.id, + contextSnapshot: withRecoveryModelProfileHint({ + issueId: issue.id, + taskId: issue.id, + wakeReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON, + retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON, + source: "issue.execution_review_recovery", + retryOfRunId: run.id, + currentStageId: executionState?.currentStageId ?? null, + currentStageType: executionState?.currentStageType ?? null, + reviewRecoveryInstruction: + "The previous reviewer run ended while this execution-review stage was still pending. Submit the review decision now, or mark the issue blocked with the exact unblock action.", + }, "normal_model"), + sessionIdBefore: recoverySessionBefore, + retryOfRunId: run.id, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: queuedRun.id, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + await tx + .update(issues) + .set({ + executionRunId: queuedRun.id, + executionAgentNameKey: recoveryAgentNameKey, + executionLockedAt: now, + updatedAt: now, + }) + .where(eq(issues.id, issue.id)); + + return { + kind: "queued_recovery" as const, + run: queuedRun, + }; + } + const issueNeedsImmediateRecovery = (issue.status === "todo" || issue.status === "in_progress") && !issue.assigneeUserId && @@ -11852,19 +12038,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return { kind: "released" as const }; } - const existingExecutionPath = await tx - .select({ id: heartbeatRuns.id }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, issue.companyId), - inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, - sql`${heartbeatRuns.id} <> ${run.id}`, - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); + const existingExecutionPath = await findExistingExecutionPath(); if (existingExecutionPath) { return { kind: "released" as const }; } @@ -11987,7 +12161,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (promotionResult?.kind === "blocked") { await recovery.escalateStrandedAssignedIssue({ issue: promotionResult.issue, - previousStatus: promotionResult.previousStatus as "todo" | "in_progress", + previousStatus: promotionResult.previousStatus as "todo" | "in_progress" | "in_review", latestRun: run, comment: promotionResult.comment, recoveryCause: @@ -11995,7 +12169,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ? WORKSPACE_VALIDATION_RECOVERY_CAUSE : promotionResult.recoveryCause === CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE ? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE + : promotionResult.recoveryCause === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE + ? EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE : undefined, + recoveryOwnerAgentId: promotionResult.recoveryOwnerAgentId, }); return; } @@ -12003,7 +12180,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (promotionResult?.kind === "blocked_recovery_in_place") { await recovery.escalateStrandedRecoveryIssueInPlace({ issue: promotionResult.issue, - previousStatus: promotionResult.previousStatus as "todo" | "in_progress", + previousStatus: promotionResult.previousStatus as "todo" | "in_progress" | "in_review", latestRun: run, }); return; diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index fa577ee791c..607c8e8f7ce 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, @@ -37,6 +37,7 @@ import { instanceSettingsService } from "../instance-settings.js"; import { issueRecoveryActionService } from "../issue-recovery-actions.js"; import { issueTreeControlService } from "../issue-tree-control.js"; import { TERMINAL_HEARTBEAT_RUN_STATUSES, issueService } from "../issues.js"; +import { parseIssueExecutionState } from "../issue-execution-policy.js"; import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js"; import { getRunLogStore } from "../run-log-store.js"; import { @@ -72,6 +73,7 @@ const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024; const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery; const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; +const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON = "execution_review_participant_recovery"; const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", @@ -122,8 +124,11 @@ type StrandedRecoveryCause = | "stranded_assigned_issue" | "workspace_validation_failed" | "configuration_incomplete" + | "execution_review_participant_recovery" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON; +type StrandedPreviousStatus = "todo" | "in_progress" | "in_review"; + type SuccessfulRunHandoffRecoveryEvidence = { sourceRunId: string | null; correctiveRunId: string; @@ -175,9 +180,29 @@ function summarizeRunFailureForIssueComment(run: LatestIssueRun) { return null; } +function buildExecutionReviewParticipantRecoveryComment(latestRun: LatestIssueRun) { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); + return ( + "Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " + + `or live reviewer run.${failureSummary ?? ""} ` + + "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " + + "restore the review stage, or record an intentional manual resolution." + ); +} + +function buildExecutionReviewParticipantUnavailableComment(latestRun: LatestIssueRun) { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); + return ( + "Paperclip cannot continue the pending execution-review participant because the participant is not invokable " + + `and the review stage has no completed decision or live reviewer run.${failureSummary ?? ""} ` + + "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " + + "restore the review stage, or record an intentional manual resolution." + ); +} + function didAutomaticRecoveryFail( latestRun: LatestIssueRun, - expectedRetryReason: "assignment_recovery" | "issue_continuation_needed", + expectedRetryReason: "assignment_recovery" | "issue_continuation_needed" | typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, ) { if (!latestRun) return false; @@ -189,6 +214,11 @@ function didAutomaticRecoveryFail( ); } +function isTerminalIssueRun(latestRun: LatestIssueRun) { + if (!latestRun) return false; + return TERMINAL_HEARTBEAT_RUN_STATUSES.has(latestRun.status); +} + const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set([ "adapter_failed", "codex_transient_upstream", @@ -523,6 +553,35 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .then((rows) => rows[0] ?? null); } + async function getLatestIssueRunForAgent( + companyId: string, + issueId: string, + agentId: string, + ): Promise { + return db + .select({ + id: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + contextSnapshot: heartbeatRuns.contextSnapshot, + livenessState: heartbeatRuns.livenessState, + resultJson: heartbeatRuns.resultJson, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + eq(heartbeatRuns.agentId, agentId), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + ), + ) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + async function summarizeRecentContinuationRetries( companyId: string, issueId: string, @@ -571,7 +630,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return { consecutive, latestFinishedAt }; } - async function hasActiveExecutionPath(companyId: string, issueId: string) { + async function hasActiveExecutionPath(companyId: string, issueId: string, agentId?: string | null) { const [run, deferredWake] = await Promise.all([ db .select({ id: heartbeatRuns.id }) @@ -581,6 +640,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + agentId ? eq(heartbeatRuns.agentId, agentId) : sql`true`, ), ) .limit(1) @@ -593,6 +653,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, "deferred_issue_execution"), sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`, + agentId ? eq(agentWakeupRequests.agentId, agentId) : sql`true`, ), ) .limit(1) @@ -618,7 +679,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .then((rows) => Boolean(rows[0])); } - async function hasQueuedIssueWake(companyId: string, issueId: string) { + async function hasQueuedIssueWake(companyId: string, issueId: string, agentId?: string | null) { return db .select({ id: agentWakeupRequests.id }) .from(agentWakeupRequests) @@ -627,6 +688,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, "queued"), sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`, + agentId ? eq(agentWakeupRequests.agentId, agentId) : sql`true`, ), ) .limit(1) @@ -677,10 +739,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function enqueueStrandedIssueRecovery(input: { issueId: string; agentId: string; - reason: "issue_assignment_recovery" | "issue_continuation_needed"; - retryReason: "assignment_recovery" | "issue_continuation_needed"; + reason: "issue_assignment_recovery" | "issue_continuation_needed" | typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON; + retryReason: "assignment_recovery" | "issue_continuation_needed" | typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON; source: string; retryOfRunId?: string | null; + extraContext?: Record; }) { const queued = await deps.enqueueWakeup(input.agentId, { source: "automation", @@ -689,6 +752,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) payload: withRecoveryModelProfileHint({ issueId: input.issueId, ...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}), + ...(input.extraContext ?? {}), }, "normal_model"), requestedByActorType: "system", requestedByActorId: null, @@ -699,6 +763,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) retryReason: input.retryReason, source: input.source, ...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}), + ...(input.extraContext ?? {}), }, "normal_model"), }); @@ -2052,8 +2117,12 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ].join("\n"); } - async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) { + async function resolveStrandedIssueRecoveryOwnerAgentId( + issue: typeof issues.$inferSelect, + preferredOwnerAgentId?: string | null, + ) { const candidateIds: string[] = []; + if (preferredOwnerAgentId) candidateIds.push(preferredOwnerAgentId); if (issue.assigneeAgentId) { const assignee = await getAgent(issue.assigneeAgentId); if (assignee?.reportsTo) candidateIds.push(assignee.reportsTo); @@ -2091,7 +2160,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) function buildStrandedIssueRecoveryDescription(input: { issue: typeof issues.$inferSelect; latestRun: LatestIssueRun; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedPreviousStatus; prefix: string; recoveryCause?: StrandedRecoveryCause; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; @@ -2134,9 +2203,26 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "unknown"; const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); + const isReviewParticipantRecovery = input.recoveryCause === "execution_review_participant_recovery"; + const detectedInvariant = isReviewParticipantRecovery + ? "execution_review_participant_recovery" + : "stranded_assigned_issue"; + const requiredAction = isReviewParticipantRecovery + ? [ + "- Inspect the latest reviewer run and the pending execution-review stage.", + "- Fix the reviewer runtime, restore the source issue to `in_review` with a live participant, or record an intentional manual resolution.", + "- When the source issue has a live review path or has been intentionally resolved, mark this recovery issue done.", + ] + : [ + "- Inspect the latest run and source issue state.", + "- Fix the runtime/adapter problem, reassign the source issue, or convert the source issue into a clear manual-review state.", + "- When the source issue has a live execution path or has been intentionally resolved, mark this recovery issue done.", + ]; return [ - "Paperclip exhausted automatic recovery for an assigned issue and created this explicit recovery task.", + isReviewParticipantRecovery + ? "Paperclip exhausted automatic recovery for a pending execution-review participant and created this explicit recovery task." + : "Paperclip exhausted automatic recovery for an assigned issue and created this explicit recovery task.", "", "## Source", "", @@ -2144,7 +2230,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) `- Previous source status: \`${input.previousStatus}\``, `- Latest retry run: ${runLink}`, `- Latest retry status: \`${input.latestRun?.status ?? "unknown"}\``, - `- Detected invariant: \`stranded_assigned_issue\``, + `- Detected invariant: \`${detectedInvariant}\``, `- Retry reason: \`${retryReason}\``, failureSummary ? `- Failure: ${failureSummary.trim()}` : "- Failure: none recorded", "", @@ -2154,16 +2240,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) "", "## Required Action", "", - "- Inspect the latest run and source issue state.", - "- Fix the runtime/adapter problem, reassign the source issue, or convert the source issue into a clear manual-review state.", - "- When the source issue has a live execution path or has been intentionally resolved, mark this recovery issue done.", + ...requiredAction, ].join("\n"); } async function ensureStrandedIssueRecoveryIssue(input: { issue: typeof issues.$inferSelect; latestRun: LatestIssueRun; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedPreviousStatus; recoveryCause?: StrandedRecoveryCause; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; }) { @@ -2284,7 +2368,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) function buildStrandedRecoveryActionEvidence(input: { issue: typeof issues.$inferSelect; latestRun: LatestIssueRun; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedPreviousStatus; recoveryCause: StrandedRecoveryCause; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; }) { @@ -2314,12 +2398,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function ensureSourceScopedStrandedRecoveryAction(input: { issue: typeof issues.$inferSelect; latestRun: LatestIssueRun; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedPreviousStatus; recoveryCause?: StrandedRecoveryCause; + recoveryOwnerAgentId?: string | null; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; }) { const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue"; - const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId(input.issue); + const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId( + input.issue, + input.recoveryOwnerAgentId, + ); const now = new Date(); const action = await recoveryActionsSvc.upsertSourceScoped({ companyId: input.issue.companyId, @@ -2350,6 +2438,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) : "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution." : recoveryCause === "configuration_incomplete" ? "Bind the missing secret(s) named in the run failure to the agent/project/routine env before resuming adapter execution." + : recoveryCause === "execution_review_participant_recovery" + ? "Repair the failed review participant path, restore the source issue to in_review with a live reviewer, or record an intentional manual resolution." : "Restore a live execution path, fix the runtime/adapter failure, or record an intentional manual resolution.", wakePolicy: recoveryCause === "workspace_validation_failed" || recoveryCause === "configuration_incomplete" ? { @@ -2414,7 +2504,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) function buildRecoveryIssueInPlaceEscalationComment(input: { issue: typeof issues.$inferSelect; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedPreviousStatus; latestRun: LatestIssueRun; prefix: string; }) { @@ -2441,7 +2531,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function escalateStrandedRecoveryIssueInPlace(input: { issue: typeof issues.$inferSelect; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedPreviousStatus; latestRun: LatestIssueRun; }) { const updated = await issuesSvc.update(input.issue.id, { status: "blocked" }); @@ -2574,10 +2664,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function escalateStrandedAssignedIssue(input: { issue: typeof issues.$inferSelect; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedPreviousStatus; latestRun: LatestIssueRun; comment?: string; recoveryCause?: StrandedRecoveryCause; + recoveryOwnerAgentId?: string | null; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; }) { if (isStrandedIssueRecoveryIssue(input.issue)) { @@ -2594,6 +2685,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) previousStatus: input.previousStatus, latestRun: input.latestRun, recoveryCause, + recoveryOwnerAgentId: input.recoveryOwnerAgentId, successfulRunHandoffEvidence: input.successfulRunHandoffEvidence, }); const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id); @@ -2697,6 +2789,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ? "recovery.reconcile_workspace_validation_failed" : input.recoveryCause === "configuration_incomplete" ? "recovery.reconcile_configuration_incomplete" + : input.recoveryCause === "execution_review_participant_recovery" + ? "recovery.reconcile_execution_review_participant" : "recovery.reconcile_stranded_assigned_issue", recoveryCause: input.recoveryCause ?? "stranded_assigned_issue", latestRunId: input.latestRun?.id ?? null, @@ -2750,8 +2844,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .where( and( isNull(issues.assigneeUserId), - inArray(issues.status, ["todo", "in_progress"]), - sql`${issues.assigneeAgentId} is not null`, + inArray(issues.status, ["todo", "in_progress", "in_review"]), + or( + sql`${issues.assigneeAgentId} is not null`, + eq(issues.status, "in_review"), + ), ), ); @@ -2763,6 +2860,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) successfulContinuationObserved: 0, orphanBlockersAssigned: 0, successfulRunHandoffEscalated: 0, + reviewParticipantRequeued: 0, escalated: 0, waitingOnReviewResolved: 0, recentProgressExempted: 0, @@ -2771,19 +2869,36 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) }; for (const issue of candidates) { - const agentId = issue.assigneeAgentId; + const executionState = issue.status === "in_review" + ? parseIssueExecutionState(issue.executionState) + : null; + const pendingExecutionState = executionState?.status === "pending" ? executionState : null; + const currentParticipant = pendingExecutionState + ? pendingExecutionState.currentParticipant + : null; + const participantAgentId = currentParticipant?.type === "agent" ? currentParticipant.agentId : null; + const agentId = issue.status === "in_review" && participantAgentId + ? participantAgentId + : issue.assigneeAgentId; if (!agentId) { result.skipped += 1; continue; } const agent = await getAgent(agentId); - if (!agent || agent.companyId !== issue.companyId || !(await isAgentInvokable(agent))) { + const agentInvokable = agent && agent.companyId === issue.companyId + ? await isAgentInvokable(agent) + : false; + if (issue.status !== "in_review" && !agentInvokable) { result.skipped += 1; continue; } - if (await hasActiveExecutionPath(issue.companyId, issue.id)) { + if (await hasActiveExecutionPath( + issue.companyId, + issue.id, + issue.status === "in_review" ? agentId : null, + )) { result.skipped += 1; continue; } @@ -2802,7 +2917,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (isStrandedIssueRecoveryIssue(issue) && isUnsuccessfulTerminalIssueRun(latestRun)) { const updated = await escalateStrandedRecoveryIssueInPlace({ issue, - previousStatus: issue.status as "todo" | "in_progress", + previousStatus: issue.status as StrandedPreviousStatus, latestRun, }); if (updated) { @@ -2814,6 +2929,108 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) continue; } + if (issue.status === "in_review") { + if (!participantAgentId || !pendingExecutionState) { + result.skipped += 1; + continue; + } + const participantLatestRun = await getLatestIssueRunForAgent( + issue.companyId, + issue.id, + participantAgentId, + ); + + if (!participantLatestRun || !isTerminalIssueRun(participantLatestRun)) { + if (!agentInvokable) { + const updated = await escalateStrandedAssignedIssue({ + issue, + previousStatus: "in_review", + latestRun: participantLatestRun, + comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun), + recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, + recoveryOwnerAgentId: participantAgentId, + }); + if (updated) { + result.escalated += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + } else { + result.skipped += 1; + } + continue; + } + + if (!agentInvokable) { + const updated = await escalateStrandedAssignedIssue({ + issue, + previousStatus: "in_review", + latestRun: participantLatestRun, + comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun), + recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, + recoveryOwnerAgentId: participantAgentId, + }); + if (updated) { + result.escalated += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + continue; + } + + if (didAutomaticRecoveryFail(participantLatestRun, EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON)) { + const updated = await escalateStrandedAssignedIssue({ + issue, + previousStatus: "in_review", + latestRun: participantLatestRun, + comment: buildExecutionReviewParticipantRecoveryComment(participantLatestRun), + recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, + recoveryOwnerAgentId: participantAgentId, + }); + if (updated) { + result.escalated += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + continue; + } + + if (await hasQueuedIssueWake(issue.companyId, issue.id, participantAgentId)) { + result.skipped += 1; + continue; + } + + if (await isInvocationBudgetBlocked(issue, participantAgentId)) { + result.skipped += 1; + continue; + } + + const queued = await enqueueStrandedIssueRecovery({ + issueId: issue.id, + agentId: participantAgentId, + reason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, + retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, + source: "issue.execution_review_recovery", + retryOfRunId: participantLatestRun.id, + extraContext: { + currentStageId: pendingExecutionState.currentStageId ?? null, + currentStageType: pendingExecutionState.currentStageType ?? null, + reviewRecoveryInstruction: + "The previous reviewer run ended while this execution-review stage was still pending. Submit the review decision now, or mark the issue blocked with the exact unblock action.", + }, + }); + if (queued) { + result.reviewParticipantRequeued += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + continue; + } + if (issue.status === "todo") { if (!latestRun) { if (await hasQueuedIssueWake(issue.companyId, issue.id)) { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 7f9e2d6bc5a..86bd26a6205 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -19,7 +19,7 @@ In Paperclip, **task** and **issue** refer to the same work item. The UI may use ## Authentication -Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For sandbox-backed local adapters, the Bash/tool environment may receive `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY` for a run-scoped bridge instead of the host API directly; use those exact env vars from Bash/curl and do not assume the host port is reachable from browser or web tools. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL, and never paste the API key or bridge token into prompts, comments, documents, restored workspace files, or logs. Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides. From b4815bf964a7cfd18bc5bd8f380323831205557d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 1 Jul 2026 23:57:31 -0700 Subject: [PATCH 10/19] Scope environment custom images to instance environments (#8850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environments are now managed as instance-level runtime resources rather than per-company rows > - The custom environment image setup tables were introduced with their own `company_id` columns and route query parameters > - That split made one saved environment image state depend on an extra company context even though the environment itself is the durable owner > - It also made saved-environment probes harder because applying the active custom image template could require a company context when no secret-backed config needed one > - This pull request scopes custom image templates and setup sessions directly to the saved environment > - The benefit is that reusable environment images follow the same instance-scoped model as environments while secret resolution still uses company context only when secrets require it ## Linked Issues or Issue Description No matching public GitHub issue was found. Bug report: ### What happened? saved environment custom-image routes and persistence required a `companyId` even though environments are instance-scoped, and saved sandbox probes did not opt into active custom-image template application unless a company context was present. ### Expected behavior custom-image templates and setup sessions should be owned by the saved environment, and saved sandbox probes should apply the active template while still requiring a company context only for secret-backed runtime config. ### Steps to reproduce 1. Configure an instance-scoped sandbox environment with custom-image setup support. 2. Start or inspect a custom-image session or template for that saved environment. 3. Probe the saved environment without a custom-image-specific `companyId` query parameter. ### Paperclip version or commit current `master` after the environment custom-image template migration. ### Deployment mode Local dev (pnpm dev) or authenticated local Paperclip instance. ### Installation method Built from source (pnpm dev / pnpm build). ### Agent adapter(s) involved Not adapter-specific (core bug). ### Database mode Embedded PGlite/Postgres dev database. ### Access context Board human operator. ### Privacy checklist No logs, secrets, tokens, private URLs, or local machine paths are included. Duplicate search performed: - `gh search prs "environment custom image companyId repo:paperclipai/paperclip" --state open --limit 20` - `gh search prs "custom image environment scoped repo:paperclipai/paperclip" --state open --limit 20` - `gh search issues "environment custom image repo:paperclipai/paperclip" --state open --limit 20` The returned results were unrelated adapter, Docker, auth, or stale-workspace items. ## What Changed - Removed redundant `company_id` columns from environment custom-image templates and setup sessions. - Added migration `0127_environment_custom_images_instance_scoped` to collapse duplicate active rows per environment before dropping the old company-scoped indexes/columns. - Updated custom-image services, route handlers, shared validators, and UI API/query keys to use environment-scoped custom-image state. - Kept runtime secret resolution company-aware only when secret refs or bindings require a company context. - Made saved sandbox environment probes opt into active custom-image template application. - Updated DB, shared, server, and UI tests for the new environment-scoped contract. ## Verification - `pnpm --filter @paperclipai/db run check:migrations` - `pnpm exec vitest run packages/db/src/environment-custom-images-schema.test.ts packages/shared/src/environment-custom-images.test.ts server/src/__tests__/environment-custom-image-routes.test.ts server/src/__tests__/environment-custom-images-service.test.ts server/src/__tests__/environment-routes.test.ts ui/src/pages/CompanyEnvironments.test.tsx` - `pnpm -r typecheck` - `pnpm test:run` before rebasing onto latest `master`; after the rebase only the migration number changed, and the migration check plus focused suite, typecheck, and build were rerun. - `pnpm build` ## Risks - Migration safety: the migration supersedes duplicate active templates per environment and fails duplicate active setup sessions before adding environment-only unique indexes. Operators with duplicate historical active rows should review which active template is kept. - Behavior shift: plugin custom-image setup calls now receive `companyId: "instance"` when no secret binding determines a concrete company context. - Secret-backed configs still require an explicit or uniquely inferable company context; environments with secret bindings spread across multiple companies continue to fail fast. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex via the `codex_local` adapter, GPT-5-based coding model with tool-enabled repository inspection, editing, testing, git, and GitHub CLI access. Exact context-window metadata was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../environment-custom-images-schema.test.ts | 24 +-- ...ironment_custom_images_instance_scoped.sql | 106 ++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + ...environment_custom_image_setup_sessions.ts | 25 +-- .../environment_custom_image_templates.ts | 18 +- .../src/environment-custom-images.test.ts | 7 +- .../validators/environment-custom-images.ts | 2 - .../environment-custom-image-routes.test.ts | 28 +-- .../environment-custom-images-service.test.ts | 137 ++++++++++--- .../src/__tests__/environment-routes.test.ts | 44 ++++ server/src/routes/environments.ts | 82 +------- server/src/services/environment-config.ts | 38 ++-- .../environment-custom-image-runtime.ts | 3 - .../src/services/environment-custom-images.ts | 191 +++++++++++++----- server/src/services/environment-probe.ts | 7 +- ui/src/api/environments.ts | 16 +- ui/src/lib/queryKeys.ts | 4 +- ui/src/pages/CompanyEnvironments.test.tsx | 5 +- ui/src/pages/CompanyEnvironments.tsx | 13 +- 19 files changed, 497 insertions(+), 260 deletions(-) create mode 100644 packages/db/src/migrations/0127_environment_custom_images_instance_scoped.sql diff --git a/packages/db/src/environment-custom-images-schema.test.ts b/packages/db/src/environment-custom-images-schema.test.ts index a3761a9e325..040b58df129 100644 --- a/packages/db/src/environment-custom-images-schema.test.ts +++ b/packages/db/src/environment-custom-images-schema.test.ts @@ -14,33 +14,33 @@ function indexWhere(table: Parameters[0], indexName: stri } describe("environment customImage schema", () => { - it("scopes template lookup and active uniqueness by company and environment", () => { + it("scopes template lookup and active uniqueness by environment", () => { expect(indexColumns( environmentCustomImageTemplates, - "environment_custom_image_templates_company_environment_status_idx", - )).toEqual(["company_id", "environment_id", "status"]); + "environment_custom_image_templates_environment_status_idx", + )).toEqual(["environment_id", "status"]); expect(indexColumns( environmentCustomImageTemplates, - "environment_custom_image_templates_company_environment_active_uq", - )).toEqual(["company_id", "environment_id"]); + "environment_custom_image_templates_environment_active_uq", + )).toEqual(["environment_id"]); expect(indexWhere( environmentCustomImageTemplates, - "environment_custom_image_templates_company_environment_active_uq", + "environment_custom_image_templates_environment_active_uq", )).toBeDefined(); }); - it("scopes setup-session lookup and active uniqueness by company and environment", () => { + it("scopes setup-session lookup and active uniqueness by environment", () => { expect(indexColumns( environmentCustomImageSetupSessions, - "environment_custom_image_setup_sessions_company_environment_status_idx", - )).toEqual(["company_id", "environment_id", "status"]); + "environment_custom_image_setup_sessions_environment_status_idx", + )).toEqual(["environment_id", "status"]); expect(indexColumns( environmentCustomImageSetupSessions, - "environment_custom_image_setup_sessions_company_environment_active_uq", - )).toEqual(["company_id", "environment_id"]); + "environment_custom_image_setup_sessions_environment_active_uq", + )).toEqual(["environment_id"]); expect(indexWhere( environmentCustomImageSetupSessions, - "environment_custom_image_setup_sessions_company_environment_active_uq", + "environment_custom_image_setup_sessions_environment_active_uq", )).toBeDefined(); }); }); diff --git a/packages/db/src/migrations/0127_environment_custom_images_instance_scoped.sql b/packages/db/src/migrations/0127_environment_custom_images_instance_scoped.sql new file mode 100644 index 00000000000..060606af8d0 --- /dev/null +++ b/packages/db/src/migrations/0127_environment_custom_images_instance_scoped.sql @@ -0,0 +1,106 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "environment_custom_image_templates" + WHERE "status" = 'active' + GROUP BY "environment_id" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Cannot migrate environment custom image templates to environment scope while multiple active templates exist for the same environment. Revoke or supersede the extra active templates before retrying.'; + END IF; +END $$; +--> statement-breakpoint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "environment_custom_image_setup_sessions" + WHERE "status" IN ('starting', 'waiting_for_user', 'capturing') + GROUP BY "environment_id" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Cannot migrate environment custom image setup sessions to environment scope while multiple active sessions exist for the same environment. Finish or cancel the extra sessions before retrying.'; + END IF; +END $$; +--> statement-breakpoint +UPDATE "environment_custom_image_templates" +SET + "metadata" = jsonb_set( + COALESCE("metadata", '{}'::jsonb), + '{setupRpcCompanyId}', + to_jsonb("company_id"::text), + true + ), + "updated_at" = now() +WHERE "company_id" IS NOT NULL + AND COALESCE("metadata" ->> 'setupRpcCompanyId', '') = ''; +--> statement-breakpoint +UPDATE "environment_custom_image_setup_sessions" +SET + "metadata" = jsonb_set( + COALESCE("metadata", '{}'::jsonb), + '{setupRpcCompanyId}', + to_jsonb("company_id"::text), + true + ), + "updated_at" = now() +WHERE "company_id" IS NOT NULL + AND COALESCE("metadata" ->> 'setupRpcCompanyId', '') = ''; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_status_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_templates_company_provider_status_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_active_uq"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_templates_company_last_used_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_status_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_active_uq"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_template_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_promoted_template_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_expires_idx"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" + DROP CONSTRAINT IF EXISTS "environment_custom_image_templates_company_id_companies_id_fk"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + DROP CONSTRAINT IF EXISTS "environment_custom_image_setup_sessions_company_id_companies_id_fk"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" DROP COLUMN IF EXISTS "company_id"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" DROP COLUMN IF EXISTS "company_id"; +--> statement-breakpoint +CREATE INDEX "environment_custom_image_templates_environment_status_idx" + ON "environment_custom_image_templates" USING btree ("environment_id", "status"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_templates_environment_provider_status_idx" + ON "environment_custom_image_templates" USING btree ("environment_id", "provider", "status"); +--> statement-breakpoint +CREATE UNIQUE INDEX "environment_custom_image_templates_environment_active_uq" + ON "environment_custom_image_templates" USING btree ("environment_id") + WHERE "status" = 'active'; +--> statement-breakpoint +CREATE INDEX "environment_custom_image_templates_last_used_idx" + ON "environment_custom_image_templates" USING btree ("last_used_at"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_environment_status_idx" + ON "environment_custom_image_setup_sessions" USING btree ("environment_id", "status"); +--> statement-breakpoint +CREATE UNIQUE INDEX "environment_custom_image_setup_sessions_environment_active_uq" + ON "environment_custom_image_setup_sessions" USING btree ("environment_id") + WHERE "status" IN ('starting', 'waiting_for_user', 'capturing'); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_template_idx" + ON "environment_custom_image_setup_sessions" USING btree ("template_id"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_promoted_template_idx" + ON "environment_custom_image_setup_sessions" USING btree ("promoted_template_id"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_expires_idx" + ON "environment_custom_image_setup_sessions" USING btree ("expires_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index fa5b9832c99..9cac3b4213d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -890,6 +890,13 @@ "when": 1782526400000, "tag": "0126_issue_comment_derived_attribution", "breakpoints": true + }, + { + "idx": 127, + "version": "7", + "when": 1782526500000, + "tag": "0127_environment_custom_images_instance_scoped", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/environment_custom_image_setup_sessions.ts b/packages/db/src/schema/environment_custom_image_setup_sessions.ts index e88af26b899..d7a30bf9cb4 100644 --- a/packages/db/src/schema/environment_custom_image_setup_sessions.ts +++ b/packages/db/src/schema/environment_custom_image_setup_sessions.ts @@ -5,7 +5,6 @@ import type { EnvironmentCustomImageSetupSessionStatus, } from "@paperclipai/shared"; import { agents } from "./agents.js"; -import { companies } from "./companies.js"; import { environmentLeases } from "./environment_leases.js"; import { environmentCustomImageTemplates } from "./environment_custom_image_templates.js"; import { environments } from "./environments.js"; @@ -14,7 +13,6 @@ export const environmentCustomImageSetupSessions = pgTable( "environment_custom_image_setup_sessions", { id: uuid("id").primaryKey().defaultRandom(), - companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }), templateId: uuid("template_id").references(() => environmentCustomImageTemplates.id, { onDelete: "set null" }), promotedTemplateId: uuid("promoted_template_id") @@ -36,26 +34,17 @@ export const environmentCustomImageSetupSessions = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ - companyEnvironmentStatusIdx: index("environment_custom_image_setup_sessions_company_environment_status_idx").on( - table.companyId, + environmentStatusIdx: index("environment_custom_image_setup_sessions_environment_status_idx").on( table.environmentId, table.status, ), - companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_setup_sessions_company_environment_active_uq") - .on(table.companyId, table.environmentId) + environmentActiveUq: uniqueIndex("environment_custom_image_setup_sessions_environment_active_uq") + .on(table.environmentId) .where(sql`${table.status} IN ('starting', 'waiting_for_user', 'capturing')`), - companyTemplateIdx: index("environment_custom_image_setup_sessions_company_template_idx").on( - table.companyId, - table.templateId, - ), - companyPromotedTemplateIdx: index("environment_custom_image_setup_sessions_company_promoted_template_idx").on( - table.companyId, - table.promotedTemplateId, - ), - companyExpiresIdx: index("environment_custom_image_setup_sessions_company_expires_idx").on( - table.companyId, - table.expiresAt, - ), + templateIdx: index("environment_custom_image_setup_sessions_template_idx").on(table.templateId), + promotedTemplateIdx: index("environment_custom_image_setup_sessions_promoted_template_idx") + .on(table.promotedTemplateId), + expiresIdx: index("environment_custom_image_setup_sessions_expires_idx").on(table.expiresAt), providerLeaseIdx: index("environment_custom_image_setup_sessions_provider_lease_idx").on( table.provider, table.providerLeaseId, diff --git a/packages/db/src/schema/environment_custom_image_templates.ts b/packages/db/src/schema/environment_custom_image_templates.ts index aadd8080ba4..589906cfa77 100644 --- a/packages/db/src/schema/environment_custom_image_templates.ts +++ b/packages/db/src/schema/environment_custom_image_templates.ts @@ -11,14 +11,12 @@ import { } from "drizzle-orm/pg-core"; import type { EnvironmentCustomImageTemplateStatus } from "@paperclipai/shared"; import { agents } from "./agents.js"; -import { companies } from "./companies.js"; import { environments } from "./environments.js"; export const environmentCustomImageTemplates = pgTable( "environment_custom_image_templates", { id: uuid("id").primaryKey().defaultRandom(), - companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }), provider: text("provider").notNull(), templateKind: text("template_kind").notNull().default("unknown"), @@ -37,23 +35,19 @@ export const environmentCustomImageTemplates = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ - companyEnvironmentStatusIdx: index("environment_custom_image_templates_company_environment_status_idx").on( - table.companyId, + environmentStatusIdx: index("environment_custom_image_templates_environment_status_idx").on( table.environmentId, table.status, ), - companyProviderStatusIdx: index("environment_custom_image_templates_company_provider_status_idx").on( - table.companyId, + environmentProviderStatusIdx: index("environment_custom_image_templates_environment_provider_status_idx").on( + table.environmentId, table.provider, table.status, ), - companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_templates_company_environment_active_uq") - .on(table.companyId, table.environmentId) + environmentActiveUq: uniqueIndex("environment_custom_image_templates_environment_active_uq") + .on(table.environmentId) .where(sql`${table.status} = 'active'`), supersededByIdx: index("environment_custom_image_templates_superseded_by_idx").on(table.supersededByTemplateId), - companyLastUsedIdx: index("environment_custom_image_templates_company_last_used_idx").on( - table.companyId, - table.lastUsedAt, - ), + lastUsedIdx: index("environment_custom_image_templates_last_used_idx").on(table.lastUsedAt), }), ); diff --git a/packages/shared/src/environment-custom-images.test.ts b/packages/shared/src/environment-custom-images.test.ts index add76996c94..e102d95e5c5 100644 --- a/packages/shared/src/environment-custom-images.test.ts +++ b/packages/shared/src/environment-custom-images.test.ts @@ -11,16 +11,14 @@ import { startEnvironmentCustomImageSetupSessionSchema, } from "./validators/environment-custom-images.js"; -const companyId = "11111111-1111-4111-8111-111111111111"; const environmentId = "22222222-2222-4222-8222-222222222222"; const templateId = "33333333-3333-4333-8333-333333333333"; const sessionId = "44444444-4444-4444-8444-444444444444"; describe("environment customImage validators", () => { - it("requires company and environment scope on templates and setup sessions", () => { + it("requires environment scope on templates and setup sessions", () => { const template = environmentCustomImageTemplateSchema.parse({ id: templateId, - companyId, environmentId, provider: "daytona", templateKind: "snapshot", @@ -38,12 +36,10 @@ describe("environment customImage validators", () => { updatedAt: "2026-06-25T12:00:00.000Z", }); - expect(template.companyId).toBe(companyId); expect(template.environmentId).toBe(environmentId); const session = environmentCustomImageSetupSessionSchema.parse({ id: sessionId, - companyId, environmentId, templateId, promotedTemplateId: null, @@ -69,7 +65,6 @@ describe("environment customImage validators", () => { updatedAt: "2026-06-25T12:00:00.000Z", }); - expect(session.companyId).toBe(companyId); expect(session.environmentId).toBe(environmentId); }); diff --git a/packages/shared/src/validators/environment-custom-images.ts b/packages/shared/src/validators/environment-custom-images.ts index 523f1d21556..bd1b76a6c11 100644 --- a/packages/shared/src/validators/environment-custom-images.ts +++ b/packages/shared/src/validators/environment-custom-images.ts @@ -32,7 +32,6 @@ export type EnvironmentCustomImageSetupConnectionSummary = export const environmentCustomImageTemplateSchema = z.object({ id: z.string().uuid(), - companyId: z.string().uuid(), environmentId: z.string().uuid(), provider: providerKeySchema, templateKind: environmentCustomImageTemplateKindSchema, @@ -54,7 +53,6 @@ export type EnvironmentCustomImageTemplate = export const environmentCustomImageSetupSessionSchema = z.object({ id: z.string().uuid(), - companyId: z.string().uuid(), environmentId: z.string().uuid(), templateId: z.string().uuid().nullable(), promotedTemplateId: z.string().uuid().nullable(), diff --git a/server/src/__tests__/environment-custom-image-routes.test.ts b/server/src/__tests__/environment-custom-image-routes.test.ts index 3d90806ea9e..6d3268c78fb 100644 --- a/server/src/__tests__/environment-custom-image-routes.test.ts +++ b/server/src/__tests__/environment-custom-image-routes.test.ts @@ -120,7 +120,6 @@ function createEnvironment(overrides: Record = {}) { function createTemplate(overrides: Record = {}) { return { id: "template-1", - companyId: "company-1", environmentId: "env-1", provider: "daytona", templateKind: "snapshot", @@ -143,7 +142,6 @@ function createTemplate(overrides: Record = {}) { function createSession(overrides: Record = {}) { return { id: "session-1", - companyId: "company-1", environmentId: "env-1", templateId: "template-1", promotedTemplateId: null, @@ -282,13 +280,12 @@ describe("environment customImage setup routes", () => { it("starts a setup session, returns the live payload, and logs redacted details", async () => { const res = await request(createApp(boardActor())) - .post("/api/environments/env-1/custom-image-setup-sessions?companyId=company-1") + .post("/api/environments/env-1/custom-image-setup-sessions") .send({ ttlSeconds: 3600 }); expect(res.status).toBe(201); expect(res.body.connectionPayload.command).toContain("203.0.113.10"); expect(mockEnvironmentCustomImageService.startSetupSession).toHaveBeenCalledWith({ - companyId: "company-1", environmentId: "env-1", templateId: null, ttlSeconds: 3600, @@ -320,7 +317,7 @@ describe("environment customImage setup routes", () => { it("denies agent API key actors before customImage state or payloads are read", async () => { const app = createApp(agentActor()); const start = await request(app) - .post("/api/environments/env-1/custom-image-setup-sessions?companyId=company-1") + .post("/api/environments/env-1/custom-image-setup-sessions") .send({}); const status = await request(app) .get("/api/environment-custom-image-setup-sessions/session-1"); @@ -346,18 +343,17 @@ describe("environment customImage setup routes", () => { expect(mockEnvironmentCustomImageService.refreshSetupSession).not.toHaveBeenCalled(); }); - it("denies single-company fallback when the board actor is not a member", async () => { + it("does not require a company fallback to start setup", async () => { mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-2"]); - const res = await request(createApp(boardActor({ - companyIds: ["company-1"], - isInstanceAdmin: false, - }))) + const res = await request(createApp(boardActor())) .post("/api/environments/env-1/custom-image-setup-sessions") .send({}); - expect(res.status).toBe(403); - expect(mockEnvironmentCustomImageService.startSetupSession).not.toHaveBeenCalled(); + expect(res.status).toBe(201); + expect(mockEnvironmentCustomImageService.startSetupSession).toHaveBeenCalledWith(expect.objectContaining({ + environmentId: "env-1", + })); }); it("finishes and promotes a template while logging redacted template details", async () => { @@ -395,22 +391,20 @@ describe("environment customImage setup routes", () => { expect(loggedActivityJson()).not.toContain("lease-secret"); }); - it("rolls back and disables active templates through company-scoped routes", async () => { + it("rolls back and disables active templates through instance-scoped routes", async () => { const app = createApp(boardActor()); const rollback = await request(app) - .post("/api/environments/env-1/custom-image-template/rollback?companyId=company-1") + .post("/api/environments/env-1/custom-image-template/rollback") .send({}); const disable = await request(app) - .delete("/api/environments/env-1/custom-image-template?companyId=company-1&deleteProviderTemplate=true"); + .delete("/api/environments/env-1/custom-image-template?deleteProviderTemplate=true"); expect(rollback.status).toBe(200); expect(disable.status).toBe(200); expect(mockEnvironmentCustomImageService.rollbackTemplate).toHaveBeenCalledWith({ - companyId: "company-1", environmentId: "env-1", }); expect(mockEnvironmentCustomImageService.disableTemplate).toHaveBeenCalledWith({ - companyId: "company-1", environmentId: "env-1", deleteProviderTemplate: true, }); diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index c3cf2c10a07..883e4c7f32d 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -157,12 +157,10 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { async function seed() { const companyId = randomUUID(); - const otherCompanyId = randomUUID(); const environmentId = randomUUID(); - await db.insert(companies).values([ + await db.insert(companies).values( { id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` }, - { id: otherCompanyId, name: "Other", issuePrefix: `B${otherCompanyId.slice(0, 4)}` }, - ]); + ); await db.insert(environments).values({ id: environmentId, name: `Fake ${environmentId.slice(0, 8)}`, @@ -184,16 +182,15 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { manifestJson: pluginManifest(), status: "ready", }); - return { companyId, otherCompanyId, environmentId }; + return { companyId, environmentId }; } it("starts, refreshes, finishes, refreshes again, and rolls back setup sessions", async () => { - const { companyId, environmentId } = await seed(); + const { environmentId } = await seed(); const workerManager = createWorkerManager(); const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); const started = await service.startSetupSession({ - companyId, environmentId, actor: { userId: "user-1" }, ttlSeconds: 600, @@ -221,25 +218,69 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { }); const refresh = await service.startSetupSession({ - companyId, environmentId, actor: { userId: "user-1" }, }); const replacement = await service.finishSetupSession({ sessionId: refresh.session.id }); expect(replacement.template.id).not.toBe(promoted.template.id); - const rollback = await service.rollbackTemplate({ companyId, environmentId }); + const rollback = await service.rollbackTemplate({ environmentId }); expect(rollback.activeTemplate.id).toBe(promoted.template.id); expect(rollback.supersededTemplate.id).toBe(replacement.template.id); }); - it("revokes the active template before deleting the provider template", async () => { + it("reuses the setup provider company context across lifecycle calls", async () => { const { companyId, environmentId } = await seed(); const workerManager = createWorkerManager(); const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); const started = await service.startSetupSession({ - companyId, + environmentId, + actor: { userId: "user-1" }, + secretContextCompanyId: companyId, + }); + expect(started.session.metadata).toMatchObject({ + setupRpcCompanyId: companyId, + }); + + await service.refreshSetupSession({ + sessionId: started.session.id, + includeConnectionPayload: true, + }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + + const lifecycleCalls = workerManager.call.mock.calls + .filter(([, method]) => [ + "environmentStartInteractiveSetup", + "environmentGetInteractiveSetup", + "environmentCaptureTemplate", + "environmentCancelInteractiveSetup", + ].includes(method)) + .map(([, method, params]) => ({ + method, + companyId: (params as Record).companyId, + })); + + expect(lifecycleCalls).toEqual([ + { method: "environmentStartInteractiveSetup", companyId }, + { method: "environmentGetInteractiveSetup", companyId }, + { method: "environmentCaptureTemplate", companyId }, + { method: "environmentCancelInteractiveSetup", companyId }, + ]); + expect(promoted.session.metadata).toMatchObject({ + setupRpcCompanyId: companyId, + }); + expect(promoted.template.metadata).toMatchObject({ + setupRpcCompanyId: companyId, + }); + }); + + it("revokes the active template before deleting the provider template", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" }, }); @@ -258,7 +299,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { }); const disabled = await service.disableTemplate({ - companyId, environmentId, deleteProviderTemplate: true, }); @@ -276,13 +316,53 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { ); }); + it("does not send provider template refs to a different current provider", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + environmentId, + actor: { userId: "user-1" }, + }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + const deleteCallsBefore = workerManager.call.mock.calls + .filter(([, method]) => method === "environmentDeleteTemplate") + .length; + + await db.update(environments) + .set({ + config: { + provider: "other-plugin", + image: "other:base", + reuseLease: false, + }, + }) + .where(eq(environments.id, environmentId)); + + await expect(service.disableTemplate({ + environmentId, + deleteProviderTemplate: true, + })).rejects.toThrow("Environment customImage provider changed"); + + const deleteCallsAfter = workerManager.call.mock.calls + .filter(([, method]) => method === "environmentDeleteTemplate") + .length; + const [templateRow] = await db + .select({ status: environmentCustomImageTemplates.status }) + .from(environmentCustomImageTemplates) + .where(eq(environmentCustomImageTemplates.id, promoted.template.id)); + + expect(deleteCallsAfter).toBe(deleteCallsBefore); + expect(templateRow?.status).toBe("active"); + }); + it("cancels and times out setup sessions without changing the active template", async () => { - const { companyId, environmentId } = await seed(); + const { environmentId } = await seed(); const workerManager = createWorkerManager(); const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); const started = await service.startSetupSession({ - companyId, environmentId, actor: { userId: "user-1" }, }); @@ -291,10 +371,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { reason: "user_cancelled", }); expect(cancelled.status).toBe("cancelled"); - expect(await service.getActiveTemplate({ companyId, environmentId })).toBeNull(); + expect(await service.getActiveTemplate({ environmentId })).toBeNull(); const expired = await service.startSetupSession({ - companyId, environmentId, actor: { userId: "user-1" }, ttlSeconds: 60, @@ -308,21 +387,32 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { expect(timedOut?.status).toBe("timed_out"); }); - it("rejects templates from another company or environment", async () => { - const { companyId, otherCompanyId, environmentId } = await seed(); + it("rejects templates from another environment", async () => { + const { environmentId } = await seed(); + const otherEnvironmentId = randomUUID(); + await db.insert(environments).values({ + id: otherEnvironmentId, + name: `Other ${otherEnvironmentId.slice(0, 8)}`, + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:base", + reuseLease: false, + }, + envVars: {}, + }); const workerManager = createWorkerManager(); const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); const [otherTemplate] = await db.insert(environmentCustomImageTemplates).values({ - companyId: otherCompanyId, - environmentId, + environmentId: otherEnvironmentId, provider: "fake-plugin", templateKind: "snapshot", - templateRef: "snapshot-other-company", + templateRef: "snapshot-other-environment", status: "active", }).returning(); await expect(service.startSetupSession({ - companyId, environmentId, templateId: otherTemplate!.id, actor: { userId: "user-1" }, @@ -342,7 +432,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { expect(fallback.config).toMatchObject({ image: "fake:base" }); await db.insert(environmentCustomImageTemplates).values({ - companyId, environmentId, provider: "fake-plugin", templateKind: "snapshot", @@ -380,7 +469,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { const environment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); await db.insert(environmentCustomImageTemplates).values({ - companyId, environmentId, provider: "fake-plugin", templateKind: "snapshot", @@ -414,7 +502,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); const started = await service.startSetupSession({ - companyId, environmentId, actor: { userId: "user-1" }, }); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index a45b298012d..d830c8cdaf7 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1346,6 +1346,7 @@ describe("environment routes", () => { expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { companyId: null, pluginWorkerManager: undefined, + applyCustomImageTemplate: false, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1444,6 +1445,7 @@ describe("environment routes", () => { expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { companyId: null, pluginWorkerManager: undefined, + applyCustomImageTemplate: true, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1460,6 +1462,48 @@ describe("environment routes", () => { ); }); + it("probes saved sandbox environments with the active custom image template without company context", async () => { + const environment = { + ...createEnvironment(), + id: "env-sandbox", + name: "Daytona Sandbox", + driver: "sandbox" as const, + config: { + provider: "daytona", + image: "ubuntu:24.04", + reuseLease: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockProbeEnvironment.mockResolvedValue({ + ok: true, + driver: "sandbox", + summary: "Connected to Daytona sandbox.", + details: { + provider: "daytona", + snapshot: "captured-template", + }, + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/environments/${environment.id}/probe`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.driver).toBe("sandbox"); + expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { + companyId: null, + pluginWorkerManager: undefined, + applyCustomImageTemplate: true, + }); + }); + it("probes unsaved provider config without persisting secrets", async () => { mockProbeEnvironment.mockResolvedValue({ ok: true, diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index b1f18879109..2ac76b93935 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -71,17 +71,6 @@ export function environmentRoutes( assertBoardOrgAccess(req); } - function assertCustomImageCompanyAccess(req: Request, companyId: string) { - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowedCompanies = req.actor.companyIds ?? []; - if (!allowedCompanies.includes(companyId)) { - throw forbidden("User does not have access to this company"); - } - } - function canReadFullInstanceEnvironment(req: Request) { return req.actor.type === "board" && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin); @@ -139,47 +128,6 @@ export function environmentRoutes( ); } - async function logEnvironmentCustomImageActivity(input: { - actor: ReturnType; - companyId: string; - action: string; - entityId: string; - details: Record; - }) { - await logActivity(db, { - companyId: input.companyId, - actorType: input.actor.actorType, - actorId: input.actor.actorId, - agentId: input.actor.agentId, - runId: input.actor.runId, - action: input.action, - entityType: "environment", - entityId: input.entityId, - details: input.details, - }); - } - - async function resolveCustomImageCompanyId(req: Request): Promise { - const queryCompanyId = - typeof req.query.companyId === "string" && req.query.companyId.trim().length > 0 - ? req.query.companyId.trim() - : null; - if (queryCompanyId) { - assertCustomImageCompanyAccess(req, queryCompanyId); - return queryCompanyId; - } - if (req.actor.type === "board" && req.actor.companyIds?.length === 1) { - return req.actor.companyIds[0]!; - } - const companyIds = await instanceSettings.listCompanyIds(); - if (companyIds.length === 1 && companyIds[0]) { - const companyId = companyIds[0]; - assertCustomImageCompanyAccess(req, companyId); - return companyId; - } - throw unprocessable("companyId query parameter is required for environment customImage setup."); - } - async function resolveEnvironmentSecretContextCompanyId( req: Request, environmentId: string, @@ -339,9 +287,7 @@ export function environmentRoutes( router.get("/environments/:environmentId/custom-image-template", async (req, res) => { assertCanAccessInstanceEnvironments(req); - const companyId = await resolveCustomImageCompanyId(req); const overview = await customImages.getOverview({ - companyId, environmentId: req.params.environmentId as string, }); res.json(overview); @@ -352,11 +298,10 @@ export function environmentRoutes( validate(startEnvironmentCustomImageSetupSessionSchema), async (req, res) => { assertCanAccessInstanceEnvironments(req); - const companyId = await resolveCustomImageCompanyId(req); + const environmentId = req.params.environmentId as string; const actor = getActorInfo(req); const result = await customImages.startSetupSession({ - companyId, - environmentId: req.params.environmentId as string, + environmentId, templateId: req.body.templateId ?? null, ttlSeconds: req.body.ttlSeconds ?? null, actor: { @@ -364,9 +309,8 @@ export function environmentRoutes( agentId: actor.agentId, }, }); - await logEnvironmentCustomImageActivity({ + await logInstanceEnvironmentActivity({ actor, - companyId, action: "environment.custom_image_setup.started", entityId: result.session.environmentId, details: setupSessionActivityDetails(result.session), @@ -382,7 +326,6 @@ export function environmentRoutes( res.status(404).json({ error: "Environment customImage setup session not found" }); return; } - assertCustomImageCompanyAccess(req, session.companyId); const result = await customImages.refreshSetupSession({ sessionId: session.id, includeConnectionPayload: true, @@ -400,15 +343,13 @@ export function environmentRoutes( res.status(404).json({ error: "Environment customImage setup session not found" }); return; } - assertCustomImageCompanyAccess(req, session.companyId); const actor = getActorInfo(req); const result = await customImages.finishSetupSession({ sessionId: session.id, metadata: req.body.metadata, }); - await logEnvironmentCustomImageActivity({ + await logInstanceEnvironmentActivity({ actor, - companyId: session.companyId, action: "environment.custom_image_setup.finished", entityId: result.session.environmentId, details: { @@ -430,15 +371,13 @@ export function environmentRoutes( res.status(404).json({ error: "Environment customImage setup session not found" }); return; } - assertCustomImageCompanyAccess(req, session.companyId); const actor = getActorInfo(req); const cancelled = await customImages.cancelSetupSession({ sessionId: session.id, reason: req.body.reason ?? null, }); - await logEnvironmentCustomImageActivity({ + await logInstanceEnvironmentActivity({ actor, - companyId: session.companyId, action: "environment.custom_image_setup.cancelled", entityId: cancelled.environmentId, details: setupSessionActivityDetails(cancelled), @@ -449,15 +388,12 @@ export function environmentRoutes( router.post("/environments/:environmentId/custom-image-template/rollback", async (req, res) => { assertCanAccessInstanceEnvironments(req); - const companyId = await resolveCustomImageCompanyId(req); const actor = getActorInfo(req); const result = await customImages.rollbackTemplate({ - companyId, environmentId: req.params.environmentId as string, }); - await logEnvironmentCustomImageActivity({ + await logInstanceEnvironmentActivity({ actor, - companyId, action: "environment.custom_image_template.rolled_back", entityId: req.params.environmentId as string, details: { @@ -470,16 +406,13 @@ export function environmentRoutes( router.delete("/environments/:environmentId/custom-image-template", async (req, res) => { assertCanAccessInstanceEnvironments(req); - const companyId = await resolveCustomImageCompanyId(req); const actor = getActorInfo(req); const template = await customImages.disableTemplate({ - companyId, environmentId: req.params.environmentId as string, deleteProviderTemplate: req.query.deleteProviderTemplate === "true", }); - await logEnvironmentCustomImageActivity({ + await logInstanceEnvironmentActivity({ actor, - companyId, action: "environment.custom_image_template.disabled", entityId: req.params.environmentId as string, details: templateActivityDetails(template), @@ -715,6 +648,7 @@ export function environmentRoutes( const probe = await probeEnvironment(db, environment, { companyId: companyIdForSecrets, pluginWorkerManager: options.pluginWorkerManager, + applyCustomImageTemplate: environment.driver === "sandbox", }); await logInstanceEnvironmentActivity({ actor, diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 2dfbb36228f..05878ed2768 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -556,7 +556,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: { export async function resolveEnvironmentDriverConfigForRuntime( db: Db, - companyId: string, + companyId: string | null, environment: Pick & Partial>, context?: { issueId?: string | null; @@ -576,6 +576,9 @@ export async function resolveEnvironmentDriverConfigForRuntime( } if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) { + if (!companyId) { + throw unprocessable("Runtime secret resolution requires a companyId context"); + } return { driver: "ssh", config: { @@ -600,22 +603,31 @@ export async function resolveEnvironmentDriverConfigForRuntime( if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") { const schema = await getSandboxProviderConfigSchema(db, parsed.config.provider); - const runtimeConfig = await resolveConfigSecretRefsForRuntime({ - db, - companyId, - config: parsed.config as Record, - schema, - context: { - consumerId: environmentId!, - issueId: context?.issueId ?? null, - heartbeatRunId: context?.heartbeatRunId ?? null, - }, - }) as SandboxEnvironmentConfig; + let runtimeConfig = parsed.config; + if (companyId) { + runtimeConfig = await resolveConfigSecretRefsForRuntime({ + db, + companyId, + config: parsed.config as Record, + schema, + context: { + consumerId: environmentId!, + issueId: context?.issueId ?? null, + heartbeatRunId: context?.heartbeatRunId ?? null, + }, + }) as SandboxEnvironmentConfig; + } else { + for (const path of collectSecretRefPaths(schema)) { + const current = readConfigValueAtPath(parsed.config as Record, path); + if (typeof current === "string" && isUuidSecretRef(current.trim())) { + throw unprocessable("Runtime secret resolution requires a companyId context"); + } + } + } return { driver: "sandbox", config: environmentId && (context?.issueId || context?.heartbeatRunId || context?.applyCustomImageTemplate) ? await resolveActiveEnvironmentCustomImageTemplateForRuntime(db, { - companyId, environmentId, baseConfig: parsed.config, runtimeConfig, diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 0f938c12448..9c56321cd43 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -114,7 +114,6 @@ export function applyCustomImageTemplateToSandboxConfig( export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate { return { id: row.id, - companyId: row.companyId, environmentId: row.environmentId, provider: row.provider, templateKind: readEnvironmentCustomImageTemplateKind(row.templateKind), @@ -136,7 +135,6 @@ export function environmentCustomImageTemplateFromRow(row: TemplateRow): Environ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( db: Db, input: { - companyId: string; environmentId: string; baseConfig: SandboxEnvironmentConfig; runtimeConfig: SandboxEnvironmentConfig; @@ -147,7 +145,6 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( .select() .from(environmentCustomImageTemplates) .where(and( - eq(environmentCustomImageTemplates.companyId, input.companyId), eq(environmentCustomImageTemplates.environmentId, input.environmentId), eq(environmentCustomImageTemplates.provider, input.baseConfig.provider), eq(environmentCustomImageTemplates.status, "active"), diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index a044d14e7fd..ae3b2f0c6ad 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -31,6 +31,7 @@ import { resolveSandboxProviderSecretRefPaths, stripSandboxProviderEnvelope, } from "./environment-config.js"; +import { secretService } from "./secrets.js"; import { resolvePluginExecuteRpcTimeoutMs, resolvePluginSandboxProviderDriverByKey, @@ -49,6 +50,8 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js"; const ACTIVE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const; const DEFAULT_SETUP_TTL_SECONDS = 60 * 60; const DEFAULT_CONNECTION_EXPIRES_IN_MINUTES = 15; +const SETUP_RPC_COMPANY_ID_METADATA_KEY = "setupRpcCompanyId"; +const SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY = "sourceEnvironmentConfigFingerprint"; type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect; @@ -72,7 +75,6 @@ export interface EnvironmentCustomImageSetupCleanupResult { function toSession(row: SetupSessionRow): EnvironmentCustomImageSetupSession { return { id: row.id, - companyId: row.companyId, environmentId: row.environmentId, templateId: row.templateId ?? null, promotedTemplateId: row.promotedTemplateId ?? null, @@ -130,6 +132,42 @@ function normalizeProviderMetadata(metadata: Record | null | un return redactEnvironmentCustomImageValue(metadata); } +function metadataRecord(metadata: Record | null | undefined): Record { + return metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}; +} + +function normalizeSetupRpcCompanyId(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readSetupRpcCompanyId(metadata: Record | null | undefined): string | null { + return normalizeSetupRpcCompanyId(metadataRecord(metadata)[SETUP_RPC_COMPANY_ID_METADATA_KEY]); +} + +function persistedSetupMetadata(metadata: Record | null | undefined): Record { + const record = metadataRecord(metadata); + const result: Record = {}; + const setupRpcCompanyId = normalizeSetupRpcCompanyId(record[SETUP_RPC_COMPANY_ID_METADATA_KEY]); + if (setupRpcCompanyId) { + result[SETUP_RPC_COMPANY_ID_METADATA_KEY] = setupRpcCompanyId; + } + const fingerprint = readString(record[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY]); + if (fingerprint) { + result[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY] = fingerprint; + } + return result; +} + +function mergeSetupSessionMetadata( + existing: Record | null | undefined, + providerMetadata: Record | null | undefined, +): Record | null { + const provider = normalizeProviderMetadata(providerMetadata) ?? {}; + const persisted = persistedSetupMetadata(existing); + const merged = { ...provider, ...persisted }; + return Object.keys(merged).length > 0 ? merged : null; +} + function normalizePersistedStatus( status: string, fallback: EnvironmentCustomImageSetupSessionStatus = "failed", @@ -179,10 +217,9 @@ function sourceTemplateFromConfig( async function resolveActiveTemplate( db: Db, - input: { companyId: string; environmentId: string; provider?: string | null }, + input: { environmentId: string; provider?: string | null }, ): Promise { const conditions = [ - eq(environmentCustomImageTemplates.companyId, input.companyId), eq(environmentCustomImageTemplates.environmentId, input.environmentId), eq(environmentCustomImageTemplates.status, "active"), ]; @@ -203,6 +240,7 @@ export function environmentCustomImageService( options: { pluginWorkerManager?: PluginWorkerManager } = {}, ) { const environments = environmentService(db); + const secrets = secretService(db); async function getTemplateById(id: string): Promise { const row = await db @@ -223,14 +261,12 @@ export function environmentCustomImageService( } async function getActiveSetupSession(input: { - companyId: string; environmentId: string; }): Promise { const row = await db .select() .from(environmentCustomImageSetupSessions) .where(and( - eq(environmentCustomImageSetupSessions.companyId, input.companyId), eq(environmentCustomImageSetupSessions.environmentId, input.environmentId), inArray(environmentCustomImageSetupSessions.status, [...ACTIVE_SETUP_STATUSES]), )) @@ -240,14 +276,12 @@ export function environmentCustomImageService( } async function getLatestSetupSession(input: { - companyId: string; environmentId: string; }): Promise { const row = await db .select() .from(environmentCustomImageSetupSessions) .where(and( - eq(environmentCustomImageSetupSessions.companyId, input.companyId), eq(environmentCustomImageSetupSessions.environmentId, input.environmentId), )) .orderBy(desc(environmentCustomImageSetupSessions.createdAt)) @@ -261,8 +295,25 @@ export function environmentCustomImageService( return environment; } + async function resolveSecretContextCompanyId( + environmentId: string, + explicitCompanyId?: string | null, + ): Promise { + if (explicitCompanyId) return explicitCompanyId; + const bindingCompanyIds = await secrets.listBindingCompanyIdsForTarget({ + targetType: "environment", + targetId: environmentId, + }); + if (bindingCompanyIds.length > 1) { + throw conflict("Environment secret bindings span multiple companies and require explicit companyId context."); + } + return bindingCompanyIds[0] ?? null; + } + async function resolveSetupProvider(input: { - companyId: string; + secretContextCompanyId?: string | null; + storedRpcCompanyId?: string | null; + storedProvider?: string | null; environment: Environment; requireCapture?: boolean; requireDelete?: boolean; @@ -270,9 +321,16 @@ export function environmentCustomImageService( if (!options.pluginWorkerManager) { throw unprocessable("Environment customImage setup requires a running plugin worker manager."); } + const storedRpcCompanyId = normalizeSetupRpcCompanyId(input.storedRpcCompanyId); + const secretContextCompanyId = storedRpcCompanyId + ? (storedRpcCompanyId === "instance" ? null : storedRpcCompanyId) + : await resolveSecretContextCompanyId( + input.environment.id, + input.secretContextCompanyId, + ); const parsed = await resolveEnvironmentDriverConfigForRuntime( db, - input.companyId, + secretContextCompanyId, input.environment, { issueId: null, heartbeatRunId: null }, ); @@ -280,6 +338,12 @@ export function environmentCustomImageService( throw unprocessable("Environment customImage setup is only supported for sandbox environments."); } const provider = parsed.config.provider; + const storedProvider = readString(input.storedProvider); + if (storedProvider && provider !== storedProvider) { + throw conflict( + `Environment customImage provider changed from "${storedProvider}" to "${provider}". Switch the environment back before continuing this customImage lifecycle operation.`, + ); + } const resolved = await resolvePluginSandboxProviderDriverByKey({ db, driverKey: provider, @@ -300,6 +364,7 @@ export function environmentCustomImageService( } return { provider, + rpcCompanyId: storedRpcCompanyId ?? secretContextCompanyId ?? "instance", pluginId: resolved.plugin.id, driver: resolved.driver, runtimeConfig: parsed.config, @@ -308,17 +373,20 @@ export function environmentCustomImageService( } async function callProviderStart(input: { - companyId: string; environment: Environment; sessionId: string; expiresAt: Date; sourceTemplateRef: string | null; sourceTemplateKind: EnvironmentCustomImageTemplateKind | null; + secretContextCompanyId?: string | null; }): Promise { - const provider = await resolveSetupProvider({ companyId: input.companyId, environment: input.environment }); + const provider = await resolveSetupProvider({ + secretContextCompanyId: input.secretContextCompanyId, + environment: input.environment, + }); return await options.pluginWorkerManager!.call(provider.pluginId, "environmentStartInteractiveSetup", { driverKey: provider.provider, - companyId: input.companyId, + companyId: provider.rpcCompanyId, environmentId: input.environment.id, issueId: null, config: provider.driverConfig, @@ -338,10 +406,14 @@ export function environmentCustomImageService( includeConnectionPayload: boolean; }): Promise { const environment = await requireEnvironment(input.session.environmentId); - const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment }); + const provider = await resolveSetupProvider({ + environment, + storedProvider: input.session.provider, + storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata), + }); return await options.pluginWorkerManager!.call(provider.pluginId, "environmentGetInteractiveSetup", { driverKey: provider.provider, - companyId: input.session.companyId, + companyId: provider.rpcCompanyId, environmentId: environment.id, issueId: null, config: provider.driverConfig, @@ -361,13 +433,14 @@ export function environmentCustomImageService( }): Promise { const environment = await requireEnvironment(input.session.environmentId); const provider = await resolveSetupProvider({ - companyId: input.session.companyId, environment, + storedProvider: input.session.provider, + storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata), requireCapture: true, }); return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCaptureTemplate", { driverKey: provider.provider, - companyId: input.session.companyId, + companyId: provider.rpcCompanyId, environmentId: environment.id, issueId: null, config: provider.driverConfig, @@ -388,10 +461,14 @@ export function environmentCustomImageService( reason: string | null; }): Promise { const environment = await requireEnvironment(input.session.environmentId); - const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment }); + const provider = await resolveSetupProvider({ + environment, + storedProvider: input.session.provider, + storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata), + }); return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCancelInteractiveSetup", { driverKey: provider.provider, - companyId: input.session.companyId, + companyId: provider.rpcCompanyId, environmentId: environment.id, issueId: null, config: provider.driverConfig, @@ -404,26 +481,37 @@ export function environmentCustomImageService( })); } + async function resolveTemplateDeleteProvider(template: EnvironmentCustomImageTemplate) { + if (!template.templateRef) { + throw unprocessable("Cannot delete an environment customImage template without a provider template ref."); + } + const environment = await requireEnvironment(template.environmentId); + return await resolveSetupProvider({ + environment, + storedProvider: template.provider, + storedRpcCompanyId: readSetupRpcCompanyId(template.metadata), + requireDelete: true, + }); + } + async function callProviderDeleteTemplate(input: { template: EnvironmentCustomImageTemplate; + provider: Awaited>; reason: string | null; }) { - if (!input.template.templateRef) { + const templateRef = input.template.templateRef; + if (!templateRef) { throw unprocessable("Cannot delete an environment customImage template without a provider template ref."); } const environment = await requireEnvironment(input.template.environmentId); - const provider = await resolveSetupProvider({ - companyId: input.template.companyId, - environment, - requireDelete: true, - }); + const provider = input.provider; return await options.pluginWorkerManager!.call(provider.pluginId, "environmentDeleteTemplate", { driverKey: provider.provider, - companyId: input.template.companyId, + companyId: provider.rpcCompanyId, environmentId: environment.id, issueId: null, config: provider.driverConfig, - templateRef: input.template.templateRef, + templateRef, templateKind: input.template.templateKind as PluginEnvironmentTemplateRefKind, metadata: input.template.metadata ?? undefined, reason: input.reason, @@ -434,7 +522,7 @@ export function environmentCustomImageService( } async function updateSessionFromProvider( - sessionId: string, + session: Pick, providerSession: PluginEnvironmentInteractiveSetupSession, fallbackStatus: EnvironmentCustomImageSetupSessionStatus = "failed", ): Promise { @@ -447,11 +535,11 @@ export function environmentCustomImageService( status, connectionSummary: normalizeConnectionSummary(providerSession.connectionSummary), expiresAt: providerSession.expiresAt ? new Date(providerSession.expiresAt) : undefined, - metadata: normalizeProviderMetadata(providerSession.metadata), + metadata: mergeSetupSessionMetadata(session.metadata, providerSession.metadata), failureReason: status === "failed" ? "Provider setup session failed or is missing." : null, updatedAt: now, }) - .where(eq(environmentCustomImageSetupSessions.id, sessionId)) + .where(eq(environmentCustomImageSetupSessions.id, session.id)) .returning() .then((rows) => rows[0] ?? null); if (!row) throw notFound("Environment customImage setup session not found"); @@ -509,7 +597,6 @@ export function environmentCustomImageService( return { getOverview: async (input: { - companyId: string; environmentId: string; }): Promise => { await requireEnvironment(input.environmentId); @@ -522,7 +609,6 @@ export function environmentCustomImageService( }, getActiveTemplate: async (input: { - companyId: string; environmentId: string; provider?: string | null; }): Promise => resolveActiveTemplate(db, input), @@ -530,15 +616,18 @@ export function environmentCustomImageService( getSessionById, startSetupSession: async (input: { - companyId: string; environmentId: string; templateId?: string | null; ttlSeconds?: number | null; actor: { userId?: string | null; agentId?: string | null }; + secretContextCompanyId?: string | null; now?: Date; }): Promise => { const environment = await requireEnvironment(input.environmentId); - const provider = await resolveSetupProvider({ companyId: input.companyId, environment }); + const provider = await resolveSetupProvider({ + secretContextCompanyId: input.secretContextCompanyId, + environment, + }); const activeSession = await getActiveSetupSession(input); if (activeSession) { throw conflict("An environment customImage setup session is already active for this environment."); @@ -546,7 +635,6 @@ export function environmentCustomImageService( const selectedTemplate = input.templateId ? await getTemplateById(input.templateId) : await resolveActiveTemplate(db, { - companyId: input.companyId, environmentId: input.environmentId, provider: provider.provider, }); @@ -556,13 +644,12 @@ export function environmentCustomImageService( if ( selectedTemplate && ( - selectedTemplate.companyId !== input.companyId || selectedTemplate.environmentId !== input.environmentId || selectedTemplate.provider !== provider.provider || selectedTemplate.status !== "active" ) ) { - throw unprocessable("Setup template must be the active template for this company and environment."); + throw unprocessable("Setup template must be the active template for this environment."); } const source = selectedTemplate ? { @@ -579,9 +666,12 @@ export function environmentCustomImageService( const expiresAt = addSeconds(now, ttlSeconds); const sessionId = randomUUID(); const fingerprint = fingerprintEnvironmentSandboxProviderConfig(provider.runtimeConfig); + const setupMetadata = { + [SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY]: fingerprint, + [SETUP_RPC_COMPANY_ID_METADATA_KEY]: provider.rpcCompanyId, + }; await db.insert(environmentCustomImageSetupSessions).values({ id: sessionId, - companyId: input.companyId, environmentId: input.environmentId, templateId: selectedTemplate?.id ?? null, provider: provider.provider, @@ -590,23 +680,21 @@ export function environmentCustomImageService( startedByAgentId: input.actor.agentId ?? null, baseTemplateRef: source.sourceTemplateRef, expiresAt, - metadata: { - sourceEnvironmentConfigFingerprint: fingerprint, - }, + metadata: setupMetadata, createdAt: now, updatedAt: now, }); try { const providerSession = await callProviderStart({ - companyId: input.companyId, environment, sessionId, expiresAt, sourceTemplateRef: source.sourceTemplateRef, sourceTemplateKind: source.sourceTemplateKind, + secretContextCompanyId: input.secretContextCompanyId, }); - const session = await updateSessionFromProvider(sessionId, providerSession); + const session = await updateSessionFromProvider({ id: sessionId, metadata: setupMetadata }, providerSession); return { session, connectionPayload: providerSession.connectionPayload ?? null, @@ -644,7 +732,7 @@ export function environmentCustomImageService( includeConnectionPayload: input.includeConnectionPayload ?? false, }); return { - session: await updateSessionFromProvider(session.id, providerSession), + session: await updateSessionFromProvider(session, providerSession), connectionPayload: providerSession.connectionPayload ?? null, }; }, @@ -666,7 +754,6 @@ export function environmentCustomImageService( } await markSessionStatus({ sessionId: session.id, status: "capturing" }); const currentActive = await resolveActiveTemplate(db, { - companyId: session.companyId, environmentId: session.environmentId, provider: session.provider, }); @@ -680,8 +767,9 @@ export function environmentCustomImageService( }) : null; const provider = await resolveSetupProvider({ - companyId: session.companyId, environment, + storedProvider: session.provider, + storedRpcCompanyId: readSetupRpcCompanyId(session.metadata), requireCapture: true, }); const runtimeConfigBinding = templateConfigBindingFromDriver({ @@ -699,7 +787,6 @@ export function environmentCustomImageService( updatedAt: now, }) .where(and( - eq(environmentCustomImageTemplates.companyId, session.companyId), eq(environmentCustomImageTemplates.environmentId, session.environmentId), eq(environmentCustomImageTemplates.provider, session.provider), eq(environmentCustomImageTemplates.status, "active"), @@ -709,7 +796,6 @@ export function environmentCustomImageService( .insert(environmentCustomImageTemplates) .values({ id: templateId, - companyId: session.companyId, environmentId: session.environmentId, provider: session.provider, templateKind: readTemplateKind(captured.templateKind), @@ -723,6 +809,7 @@ export function environmentCustomImageService( metadata: normalizeProviderMetadata({ ...(captured.metadata ?? {}), ...(input.metadata ? { userMetadata: input.metadata } : {}), + ...persistedSetupMetadata(session.metadata), [ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding, }), createdAt: now, @@ -790,7 +877,6 @@ export function environmentCustomImageService( }, rollbackTemplate: async (input: { - companyId: string; environmentId: string; now?: Date; }): Promise<{ @@ -804,7 +890,6 @@ export function environmentCustomImageService( .select() .from(environmentCustomImageTemplates) .where(and( - eq(environmentCustomImageTemplates.companyId, input.companyId), eq(environmentCustomImageTemplates.environmentId, input.environmentId), eq(environmentCustomImageTemplates.provider, active.provider), eq(environmentCustomImageTemplates.status, "superseded"), @@ -843,7 +928,6 @@ export function environmentCustomImageService( }, disableTemplate: async (input: { - companyId: string; environmentId: string; deleteProviderTemplate?: boolean; now?: Date; @@ -851,6 +935,9 @@ export function environmentCustomImageService( await requireEnvironment(input.environmentId); const active = await resolveActiveTemplate(db, input); if (!active) throw notFound("Active environment customImage template not found"); + const deleteProvider = input.deleteProviderTemplate + ? await resolveTemplateDeleteProvider(active) + : null; const now = input.now ?? new Date(); const row = await db .update(environmentCustomImageTemplates) @@ -860,8 +947,8 @@ export function environmentCustomImageService( .then((rows) => rows[0] ?? null); if (!row) throw notFound("Active environment customImage template not found"); const template = environmentCustomImageTemplateFromRow(row); - if (input.deleteProviderTemplate) { - await callProviderDeleteTemplate({ template, reason: "disabled" }); + if (deleteProvider) { + await callProviderDeleteTemplate({ template, provider: deleteProvider, reason: "disabled" }); } return template; }, diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index 8c0e39b31a2..eaac5b22e8f 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -18,12 +18,15 @@ export async function probeEnvironment( companyId?: string | null; pluginWorkerManager?: PluginWorkerManager; resolvedConfig?: ParsedEnvironmentConfig; + applyCustomImageTemplate?: boolean; } = {}, ): Promise { const resolvedCompanyId = options.companyId ?? null; const parsed = options.resolvedConfig ?? ( - resolvedCompanyId - ? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment) + resolvedCompanyId || options.applyCustomImageTemplate === true + ? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, { + applyCustomImageTemplate: options.applyCustomImageTemplate === true, + }) : parseEnvironmentDriverConfig(environment) ); diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 0d1a99b80f8..7cfe039cb9e 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -67,17 +67,14 @@ export const environmentsApi = { config?: Record; metadata?: Record | null; }) => api.post(`/companies/${companyId}/environments/probe-config`, body), - customImageTemplate: (environmentId: string, companyId: string) => - api.get( - `/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}`, - ), + customImageTemplate: (environmentId: string) => + api.get(`/environments/${environmentId}/custom-image-template`), startCustomImageSetupSession: ( environmentId: string, - companyId: string, body: StartEnvironmentCustomImageSetupSession = {}, ) => api.post( - `/environments/${environmentId}/custom-image-setup-sessions?companyId=${encodeURIComponent(companyId)}`, + `/environments/${environmentId}/custom-image-setup-sessions`, body, ), customImageSetupSession: (sessionId: string) => @@ -100,17 +97,16 @@ export const environmentsApi = { `/environment-custom-image-setup-sessions/${sessionId}/cancel`, body, ), - rollbackCustomImageTemplate: (environmentId: string, companyId: string) => + rollbackCustomImageTemplate: (environmentId: string) => api.post( - `/environments/${environmentId}/custom-image-template/rollback?companyId=${encodeURIComponent(companyId)}`, + `/environments/${environmentId}/custom-image-template/rollback`, {}, ), disableCustomImageTemplate: ( environmentId: string, - companyId: string, options: { deleteProviderTemplate?: boolean } = {}, ) => api.delete( - `/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}&deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`, + `/environments/${environmentId}/custom-image-template?deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`, ), }; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index d4ca9c06dff..2fbc9a92245 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -161,8 +161,8 @@ export const queryKeys = { environments: { list: (companyId: string) => ["environments", companyId] as const, capabilities: (companyId: string) => ["environment-capabilities", companyId] as const, - customImageTemplate: (companyId: string, environmentId: string) => - ["environments", companyId, environmentId, "custom-image-template"] as const, + customImageTemplate: (environmentId: string) => + ["environments", environmentId, "custom-image-template"] as const, customImageSetupSession: (sessionId: string) => ["environment-custom-image-setup-sessions", sessionId] as const, }, diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 7305d6d21dc..5585ab2e2fb 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -121,7 +121,6 @@ function getOpenDialog(): HTMLElement | null { function createSession(overrides: Record = {}) { return { id: "session-1", - companyId: "company-1", environmentId: "env-1", templateId: null, promotedTemplateId: null, @@ -152,7 +151,6 @@ function createSession(overrides: Record = {}) { function createTemplate(overrides: Record = {}) { return { id: "template-1", - companyId: "company-1", environmentId: "env-1", provider: "daytona", templateKind: "snapshot", @@ -457,7 +455,7 @@ describe("CompanyEnvironments — test provider button", () => { await waitForAssertion(() => { expect(getOpenDialog()?.textContent).toContain("Configure image"); }); - expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1"); await act(async () => click(findButton(document.body, "Cancel"))); await waitForAssertion(() => expect(getOpenDialog()).toBeNull()); @@ -656,7 +654,6 @@ describe("CompanyEnvironments — test provider button", () => { expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith( "env-1", - "company-1", { templateId: "template-active" }, ); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 2f475fddb38..6fd86efef4f 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -245,12 +245,10 @@ function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"]) } function EnvironmentImageTemplatePanel({ - companyId, environment, providerCapability, providerDisplayName, }: { - companyId: string; environment: Environment; providerCapability: EnvironmentProviderCapability | null | undefined; providerDisplayName: string; @@ -258,11 +256,11 @@ function EnvironmentImageTemplatePanel({ const { pushToast } = useToast(); const queryClient = useQueryClient(); const state = capabilityState(providerCapability); - const overviewKey = queryKeys.environments.customImageTemplate(companyId, environment.id); + const overviewKey = queryKeys.environments.customImageTemplate(environment.id); const overviewQuery = useQuery({ queryKey: overviewKey, - queryFn: () => environmentsApi.customImageTemplate(environment.id, companyId), + queryFn: () => environmentsApi.customImageTemplate(environment.id), enabled: state.kind === "supported", retry: false, }); @@ -290,7 +288,7 @@ function EnvironmentImageTemplatePanel({ const startSetupMutation = useMutation({ mutationFn: (input: { templateId?: string | null } = {}) => - environmentsApi.startCustomImageSetupSession(environment.id, companyId, { + environmentsApi.startCustomImageSetupSession(environment.id, { templateId: input.templateId ?? null, }), onSuccess: (result) => { @@ -368,7 +366,7 @@ function EnvironmentImageTemplatePanel({ }); const rollbackTemplateMutation = useMutation({ - mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id, companyId), + mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id), onSuccess: (result) => { queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ activeTemplate: result.activeTemplate, @@ -392,7 +390,7 @@ function EnvironmentImageTemplatePanel({ }); const disableTemplateMutation = useMutation({ - mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId), + mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id), onSuccess: (template) => { queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ activeTemplate: null, @@ -1246,7 +1244,6 @@ export function CompanyEnvironments() { running machine as a reusable image for future runs.
Date: Thu, 2 Jul 2026 10:22:33 -0500 Subject: [PATCH 11/19] Show live descendant status in inbox rows (#8876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The inbox is where operators quickly scan which issues are active, blocked, or waiting for attention > - A blocked parent can still have active descendant work, but the inbox previously depended on only loaded rows to infer that state > - That made collapsed or partially loaded issue trees look more stuck than they really were > - This pull request carries live descendant summary data through the issue list API and inbox UI > - The benefit is a more accurate blocked-inbox signal, so operators can distinguish truly stalled work from blocked parents that still have live child activity ## Linked Issues or Issue Description No public GitHub issue was found for this exact inbox descendant-status polish. Feature request fields: **Subsystem affected** Cross-cutting: `server/`, `packages/shared`, plugin/MCP API surfaces, and `ui/` inbox rendering. **Problem or motivation** Inbox rows need to show when blocked or collapsed parents still have live descendant work, even when the live child row is not loaded in the current client tree. Without a server-provided descendant summary, a parent can look stalled even though active work continues below it. **Proposed solution** Expose an optional live descendant count on issue list results, request it from inbox views, and use it to render covered blocked status and live-below indicators. Keep the field opt-in so other issue list callers keep their existing payload shape and query cost. **Alternatives considered** Relying only on client-loaded subtree state was ruled out because it misses collapsed or unloaded descendants. Always returning the count was also avoided because most list callers do not need this extra summary. **Roadmap alignment** This is scoped operator-visibility polish for the existing inbox. It does not duplicate a named `ROADMAP.md` milestone. **Additional context** The recursive summary query is guarded against parent cycles, and the UI still falls back to loaded subtree live counts when server summary data is absent or stale. ## What Changed - Added optional `includeLiveDescendantSummary` support to issue list contracts, SDK surfaces, MCP tools, routes, services, and tests. - Added `liveDescendantCount` to issue list results when requested. - Updated inbox and blocked-inbox queries to request live descendant summaries. - Updated inbox row status rendering so blocked parents with live descendants show covered blocker treatment without duplicating the live-below chip. - Hardened live descendant summary traversal against parent cycles and preserved the loaded-subtree fallback path for blocked inbox rows. - Added focused tests for the API parameter, service behavior, helper logic, cycle handling, and inbox UI query/rendering behavior. ## Verification - `pnpm exec vitest run server/src/__tests__/issue-list-assignee-filter-routes.test.ts ui/src/lib/inbox-live-descendants.test.ts ui/src/components/IssueColumns.test.tsx ui/src/components/BlockedInboxView.test.tsx ui/src/pages/Inbox.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - Rebased cleanly onto current upstream `master` before pushing. - Confirmed the branch diff does not include `pnpm-lock.yaml` or `.github/workflows/*` changes. ## Risks Low to moderate risk. The new descendant count is opt-in on list requests, but it adds query work when the inbox asks for it. The recursive traversal now tracks visited ancestors to avoid cycle failures. The UI uses the server count as a supplement to existing loaded-tree state, so stale or absent counts fall back to the prior behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent, tool-enabled with local shell and git access. Reasoning mode and context window are managed by the Paperclip/Codex runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/mcp-server/src/tools.ts | 1 + packages/plugins/sdk/src/ui/components.ts | 1 + packages/shared/src/types/issue.ts | 1 + .../issue-list-assignee-filter-routes.test.ts | 228 +++++++++++++++++- server/src/routes/issues.ts | 6 + server/src/services/issues.ts | 95 +++++++- ui/src/api/issues.test.ts | 8 + ui/src/api/issues.ts | 2 + ui/src/components/BlockedInboxView.test.tsx | 40 ++- ui/src/components/BlockedInboxView.tsx | 25 +- ui/src/components/IssueColumns.test.tsx | 12 + ui/src/components/IssueColumns.tsx | 4 +- ui/src/lib/inbox-live-descendants.test.ts | 90 +++++++ ui/src/lib/inbox-live-descendants.ts | 55 +++++ ui/src/pages/Inbox.test.tsx | 52 +++- ui/src/pages/Inbox.tsx | 44 +++- .../stories/blocked-inbox.stories.tsx | 2 + 17 files changed, 649 insertions(+), 17 deletions(-) create mode 100644 ui/src/lib/inbox-live-descendants.test.ts create mode 100644 ui/src/lib/inbox-live-descendants.ts diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 28dbda8af57..f5772b33a48 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -74,6 +74,7 @@ const listIssuesSchema = z.object({ originKind: z.string().optional(), originId: z.string().optional(), includeRoutineExecutions: z.boolean().optional(), + includeLiveDescendantSummary: z.boolean().optional(), q: z.string().optional(), }); diff --git a/packages/plugins/sdk/src/ui/components.ts b/packages/plugins/sdk/src/ui/components.ts index b075506cd16..3f0594408a1 100644 --- a/packages/plugins/sdk/src/ui/components.ts +++ b/packages/plugins/sdk/src/ui/components.ts @@ -338,6 +338,7 @@ export interface IssuesListFilters { originId?: string; descendantOf?: string; includeRoutineExecutions?: boolean; + includeLiveDescendantSummary?: boolean; } export interface IssuesListProps { diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index c676cafb3b8..6dc6d44b670 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -585,6 +585,7 @@ export interface Issue { successfulRunHandoff?: SuccessfulRunHandoffState | null; watchdog?: IssueWatchdogSummary | null; scheduledRetry?: IssueScheduledRetry | null; + liveDescendantCount?: number; relatedWork?: IssueRelatedWorkSummary; referencedIssueIdentifiers?: string[]; planDocument?: IssueDocument | null; diff --git a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts index 977de540f73..e3b119c2c68 100644 --- a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { agents, companies, companyMemberships, createDb, issues, principalPermissionGrants } from "@paperclipai/db"; +import { agents, companies, companyMemberships, createDb, heartbeatRuns, issues, principalPermissionGrants } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -31,6 +31,7 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { afterEach(async () => { await db.delete(issues); + await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); @@ -220,4 +221,229 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { error: "assigneeAgentId must be a UUID or 'null'", }); }); + + it("returns opt-in live descendant counts for offscreen live descendants only", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const grandchildIssueId = randomUUID(); + const hiddenChildIssueId = randomUUID(); + const crossCompanyChildIssueId = randomUUID(); + const rootRunId = randomUUID(); + const grandchildRunId = randomUUID(); + const hiddenRunId = randomUUID(); + const crossCompanyRunId = randomUUID(); + + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "Other Company", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }, + ]); + await seedCloudTenantMember(companyId); + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId: otherCompanyId, + name: "Other", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(heartbeatRuns).values([ + { + id: rootRunId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId: rootIssueId }, + }, + { + id: grandchildRunId, + companyId, + agentId, + status: "queued", + contextSnapshot: { issueId: grandchildIssueId }, + }, + { + id: hiddenRunId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId: hiddenChildIssueId }, + }, + { + id: crossCompanyRunId, + companyId: otherCompanyId, + agentId: otherAgentId, + status: "running", + contextSnapshot: { issueId: crossCompanyChildIssueId }, + }, + ]); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Blocked parent", + status: "blocked", + priority: "critical", + executionRunId: rootRunId, + assigneeAgentId: agentId, + }, + { + id: childIssueId, + companyId, + title: "Offscreen child", + status: "todo", + priority: "medium", + parentId: rootIssueId, + assigneeAgentId: agentId, + }, + { + id: grandchildIssueId, + companyId, + title: "Offscreen live grandchild", + status: "todo", + priority: "medium", + parentId: childIssueId, + executionRunId: grandchildRunId, + assigneeAgentId: agentId, + }, + { + id: hiddenChildIssueId, + companyId, + title: "Hidden live child", + status: "todo", + priority: "medium", + parentId: rootIssueId, + executionRunId: hiddenRunId, + hiddenAt: new Date("2026-07-02T00:00:00.000Z"), + assigneeAgentId: agentId, + }, + { + id: crossCompanyChildIssueId, + companyId: otherCompanyId, + title: "Cross-company live child", + status: "todo", + priority: "medium", + parentId: rootIssueId, + executionRunId: crossCompanyRunId, + assigneeAgentId: otherAgentId, + }, + ]); + + const app = createApp(companyId); + const withoutSummary = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "blocked", limit: "20" }); + + expect(withoutSummary.status, JSON.stringify(withoutSummary.body)).toBe(200); + expect(withoutSummary.body).toHaveLength(1); + expect(withoutSummary.body[0].id).toBe(rootIssueId); + expect(withoutSummary.body[0].liveDescendantCount).toBeUndefined(); + + const withSummary = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "blocked", includeLiveDescendantSummary: "true", limit: "20" }); + + expect(withSummary.status, JSON.stringify(withSummary.body)).toBe(200); + expect(withSummary.body).toHaveLength(1); + expect(withSummary.body[0]).toMatchObject({ + id: rootIssueId, + liveDescendantCount: 1, + }); + }); + + it("does not recurse forever when live descendant summaries encounter a parent cycle", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const parentIssueId = randomUUID(); + const childIssueId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId: childIssueId }, + }); + await db.insert(issues).values([ + { + id: parentIssueId, + companyId, + title: "Cycle parent", + status: "blocked", + priority: "medium", + parentId: childIssueId, + assigneeAgentId: agentId, + }, + { + id: childIssueId, + companyId, + title: "Cycle live child", + status: "in_progress", + priority: "medium", + parentId: parentIssueId, + executionRunId: runId, + assigneeAgentId: agentId, + }, + ]); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "blocked", includeLiveDescendantSummary: "true", limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + id: parentIssueId, + liveDescendantCount: 1, + }); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8e0889c6190..4262972f146 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3067,6 +3067,7 @@ export function issueRoutes( const sortField = req.query.sortField as string | undefined; const sortDir = req.query.sortDir as string | undefined; const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); + const includeLiveDescendantSummary = parseOptionalBooleanQuery(req.query.includeLiveDescendantSummary); const assigneeAgentFilterRaw = req.query.assigneeAgentId; let assigneeAgentId: string | null | undefined; @@ -3110,6 +3111,10 @@ export function issueRoutes( res.status(400).json({ error: "hasPlanDocument must be true or false when provided" }); return; } + if (includeLiveDescendantSummary === null) { + res.status(400).json({ error: "includeLiveDescendantSummary must be true or false when provided" }); + return; + } if (assigneeAgentFilterRaw !== undefined) { if (typeof assigneeAgentFilterRaw !== "string") { res.status(422).json({ error: "assigneeAgentId must be a UUID or 'null'" }); @@ -3156,6 +3161,7 @@ export function issueRoutes( includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", includeBlockedInboxAttention: req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1", + includeLiveDescendantSummary: includeLiveDescendantSummary === true, hasPlanDocument, q: req.query.q as string | undefined, limit, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 686caa0bffa..0fa09135871 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -319,6 +319,7 @@ export interface IssueFilters { includePluginOperations?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + includeLiveDescendantSummary?: boolean; hasPlanDocument?: boolean; lowTrustBoundary?: LowTrustBoundary & { companyId: string }; q?: string; @@ -1481,6 +1482,85 @@ async function activeRunMapForIssues( return map; } +async function liveDescendantCountMapForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise> { + const uniqueIssueIds = [...new Set(issueIds)]; + const map = new Map(); + if (uniqueIssueIds.length === 0) return map; + + for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const targetRows = issueIdChunk.map((issueId) => sql`(${issueId}::uuid)`); + const rows = await dbOrTx.execute(sql<{ + issueId: string; + liveDescendantCount: number; + }>` + WITH RECURSIVE + target_issues(issue_id) AS ( + VALUES ${sql.join(targetRows, sql`, `)} + ), + live_issues(live_issue_id, parent_id) AS ( + SELECT DISTINCT live_issue.id, live_issue.parent_id + FROM issues live_issue + JOIN heartbeat_runs live_run ON live_run.id = live_issue.execution_run_id + WHERE live_issue.company_id = ${companyId} + AND live_issue.hidden_at IS NULL + AND live_run.company_id = ${companyId} + AND live_run.status IN ('queued', 'running') + UNION + SELECT DISTINCT live_issue.id, live_issue.parent_id + FROM heartbeat_runs live_run + JOIN issues live_issue ON live_issue.id::text = (live_run.context_snapshot ->> 'issueId') + WHERE live_issue.company_id = ${companyId} + AND live_issue.hidden_at IS NULL + AND live_run.company_id = ${companyId} + AND live_run.status IN ('queued', 'running') + ), + live_ancestors(live_issue_id, ancestor_id, next_parent_id, visited_issue_ids) AS ( + SELECT live_issues.live_issue_id, parent.id, parent.parent_id, ARRAY[live_issues.live_issue_id, parent.id] + FROM live_issues + JOIN issues parent ON parent.id = live_issues.parent_id + WHERE parent.company_id = ${companyId} + AND parent.hidden_at IS NULL + UNION ALL + SELECT + live_ancestors.live_issue_id, + parent.id, + parent.parent_id, + live_ancestors.visited_issue_ids || parent.id + FROM live_ancestors + JOIN issues parent ON parent.id = live_ancestors.next_parent_id + WHERE parent.company_id = ${companyId} + AND parent.hidden_at IS NULL + AND NOT parent.id = ANY(live_ancestors.visited_issue_ids) + ) + SELECT + live_ancestors.ancestor_id::text AS "issueId", + count(DISTINCT live_ancestors.live_issue_id)::int AS "liveDescendantCount" + FROM live_ancestors + JOIN target_issues ON target_issues.issue_id = live_ancestors.ancestor_id + WHERE live_ancestors.ancestor_id <> live_ancestors.live_issue_id + GROUP BY live_ancestors.ancestor_id + `); + + const resultRows = Array.isArray(rows) ? rows : Array.from(rows as Iterable); + for (const row of resultRows) { + if (typeof row !== "object" || row === null) continue; + const issueId = (row as { issueId?: unknown }).issueId; + const liveDescendantCount = (row as { liveDescendantCount?: unknown }).liveDescendantCount; + if (typeof issueId !== "string") continue; + const count = typeof liveDescendantCount === "number" + ? liveDescendantCount + : Number(liveDescendantCount); + if (Number.isFinite(count)) map.set(issueId, count); + } + } + + return map; +} + function createIssueBlockerAttention(input: Partial = {}): IssueBlockerAttention { return { state: input.state ?? "none", @@ -3170,6 +3250,7 @@ async function listBlockedInboxIssues( blockerAttention?: IssueBlockerAttention; blockedInboxAttention: IssueBlockedInboxAttention; productivityReview?: IssueProductivityReview | null; + liveDescendantCount?: number; lastActivityAt: Date; myLastTouchAt?: Date | null; lastExternalCommentAt?: Date | null; @@ -3191,6 +3272,7 @@ async function listBlockedInboxIssues( if (withRuns.length === 0) return []; const issueIds = withRuns.map((row) => row.id); + const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true; const [ statsRows, readRows, @@ -3199,6 +3281,7 @@ async function listBlockedInboxIssues( blockerAttentionByIssueId, productivityReviewByIssueId, blockedInboxAttentionByIssueId, + liveDescendantCountByIssueId, ] = await Promise.all([ contextUserId ? userCommentStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), contextUserId ? userReadStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), @@ -3207,6 +3290,9 @@ async function listBlockedInboxIssues( listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns), listIssueProductivityReviewMap(dbOrTx, companyId, issueIds), listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns), + includeLiveDescendantSummary + ? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds) + : Promise.resolve(new Map()), ]); const rawSearchInput = filters?.q?.trim() ?? ""; @@ -3256,6 +3342,7 @@ async function listBlockedInboxIssues( ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), + ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(contextUserId ? deriveIssueUserContext(row, contextUserId, { myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, @@ -4289,6 +4376,7 @@ export function issueService(db: Db) { const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; const includeBlockedBy = filters?.includeBlockedBy === true; const includeBlockedInboxAttention = filters?.includeBlockedInboxAttention === true; + const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; @@ -4436,7 +4524,7 @@ export function issueService(db: Db) { } const issueIds = withRuns.map((row) => row.id); - const [statsRows, readRows, lastActivityRows, blockedByMap] = await Promise.all([ + const [statsRows, readRows, lastActivityRows, blockedByMap, liveDescendantCountByIssueId] = await Promise.all([ contextUserId ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), @@ -4447,6 +4535,9 @@ export function issueService(db: Db) { includeBlockedBy ? blockedByMapForIssues(db, companyId, issueIds) : Promise.resolve(new Map()), + includeLiveDescendantSummary + ? liveDescendantCountMapForIssues(db, companyId, issueIds) + : Promise.resolve(new Map()), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); @@ -4476,6 +4567,7 @@ export function issueService(db: Db) { lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), + ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), @@ -4498,6 +4590,7 @@ export function issueService(db: Db) { lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), + ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 91f736f6688..24f4794bcfd 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -71,6 +71,14 @@ describe("issuesApi.list", () => { ); }); + it("passes live descendant summary opt-in through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { includeLiveDescendantSummary: true, limit: 25 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?includeLiveDescendantSummary=true&limit=25", + ); + }); + it("posts recovery action resolution to the source issue endpoint", async () => { await issuesApi.resolveRecoveryAction("issue-1", { actionId: "00000000-0000-0000-0000-0000000000aa", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 129b7b0ba61..1ecb12afa02 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -60,6 +60,7 @@ export const issuesApi = { includeRoutineExecutions?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + includeLiveDescendantSummary?: boolean; hasPlanDocument?: boolean; q?: string; limit?: number; @@ -89,6 +90,7 @@ export const issuesApi = { if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true"); + if (filters?.includeLiveDescendantSummary) params.set("includeLiveDescendantSummary", "true"); if (filters?.hasPlanDocument !== undefined) { params.set("hasPlanDocument", filters.hasPlanDocument ? "true" : "false"); } diff --git a/ui/src/components/BlockedInboxView.test.tsx b/ui/src/components/BlockedInboxView.test.tsx index 328ceb91fdb..98e44bf1fff 100644 --- a/ui/src/components/BlockedInboxView.test.tsx +++ b/ui/src/components/BlockedInboxView.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, IssueBlockedInboxAttention } from "@paperclipai/shared"; @@ -31,6 +31,14 @@ vi.mock("@/lib/router", () => ({ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + import { BlockedInboxView } from "./BlockedInboxView"; import { defaultIssueFilterState } from "../lib/issue-filters"; @@ -121,6 +129,7 @@ const blockedViewProps = { issueFilters: defaultIssueFilterState, currentUserId: "local-board", liveIssueIds: new Set(), + subtreeLiveCounts: new Map(), workspaceFilterContext: {}, showStatusColumn: true, showIdentifierColumn: true, @@ -306,6 +315,35 @@ describe("BlockedInboxView", () => { act(() => root.unmount()); }); + it("uses loaded live descendants when blocked inbox rows do not have a server summary", async () => { + mockIssuesApi.list.mockResolvedValue([ + { + ...makeIssue( + "blocked-parent", + "PAP-77", + "Blocked parent with active child", + attention({ reason: "blocked_chain_stalled" }), + ), + status: "blocked", + blockerAttention: null, + liveDescendantCount: undefined, + } as unknown as Issue, + ]); + + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => container.querySelector("a") !== null); + + expect(container.querySelector('[aria-label="Blocked · waiting on 1 active sub-task"]')).not.toBeNull(); + + act(() => root.unmount()); + }); + it("renders the visible error banner with retry when the query fails", async () => { mockIssuesApi.list.mockRejectedValue(new Error("network down")); diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx index a8220930e61..1a5565823f7 100644 --- a/ui/src/components/BlockedInboxView.tsx +++ b/ui/src/components/BlockedInboxView.tsx @@ -6,6 +6,7 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { applyIssueFilters, type IssueFilterState, type IssueFilterWorkspaceContext } from "../lib/issue-filters"; +import { resolveInboxIssueBlockerAttention } from "../lib/inbox-live-descendants"; import { blockedRowMatchesSearch, buildBlockedInboxRows, @@ -34,6 +35,7 @@ interface BlockedInboxViewProps { issueFilters: IssueFilterState; currentUserId: string | null; liveIssueIds: ReadonlySet; + subtreeLiveCounts: ReadonlyMap; workspaceFilterContext: IssueFilterWorkspaceContext; showStatusColumn: boolean; showIdentifierColumn: boolean; @@ -53,6 +55,7 @@ export function BlockedInboxView({ issueFilters, currentUserId, liveIssueIds, + subtreeLiveCounts, workspaceFilterContext, showStatusColumn, showIdentifierColumn, @@ -67,12 +70,13 @@ export function BlockedInboxView({ error, refetch, } = useQuery({ - queryKey: queryKeys.issues.listBlockedAttention(companyId), + queryKey: [...queryKeys.issues.listBlockedAttention(companyId), "live-descendant-summary"], queryFn: () => issuesApi.list(companyId, { attention: "blocked", includeBlockedInboxAttention: true, includeBlockedBy: true, + includeLiveDescendantSummary: true, limit: BLOCKED_LIST_LIMIT, }), }); @@ -209,6 +213,8 @@ export function BlockedInboxView({ issueLinkState={issueLinkState} agentNameById={agentNameById} userLabelById={userLabelById} + liveIssueIds={liveIssueIds} + subtreeLiveCounts={subtreeLiveCounts} showStatusColumn={showStatusColumn} showIdentifierColumn={showIdentifierColumn} showUpdatedColumn={showUpdatedColumn} @@ -236,6 +242,8 @@ export function BlockedInboxView({ issueLinkState={issueLinkState} agentNameById={agentNameById} userLabelById={userLabelById} + liveIssueIds={liveIssueIds} + subtreeLiveCounts={subtreeLiveCounts} showStatusColumn={showStatusColumn} showIdentifierColumn={showIdentifierColumn} showUpdatedColumn={showUpdatedColumn} @@ -257,6 +265,8 @@ interface BlockedInboxRowProps { issueLinkState: unknown; agentNameById: ReadonlyMap; userLabelById?: ReadonlyMap; + liveIssueIds: ReadonlySet; + subtreeLiveCounts: ReadonlyMap; showStatusColumn: boolean; showIdentifierColumn: boolean; showUpdatedColumn: boolean; @@ -283,12 +293,18 @@ function BlockedInboxRow({ issueLinkState, agentNameById, userLabelById, + liveIssueIds, + subtreeLiveCounts, showStatusColumn, showIdentifierColumn, showUpdatedColumn, }: BlockedInboxRowProps) { const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById); const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt); + const blockerAttention = resolveInboxIssueBlockerAttention(row.issue, { + isLive: liveIssueIds.has(row.issue.id), + loadedSubtreeLiveCount: subtreeLiveCounts.get(row.issue.id) ?? 0, + }); const desktopTrailing = ( @@ -345,13 +361,14 @@ function BlockedInboxRow({ desktopMetaLeading={ } mobileLeading={ - + } titleSuffix={ @@ -369,17 +386,19 @@ function BlockedInboxRow({ function BlockedRowDesktopMeta({ row, + blockerAttention, showStatusColumn, showIdentifierColumn, }: { row: BlockedInboxIssueRow; + blockerAttention: Issue["blockerAttention"] | null; showStatusColumn: boolean; showIdentifierColumn: boolean; }) { const identifier = row.issue.identifier ?? row.issue.id.slice(0, 8); return ( - {showStatusColumn ? : null} + {showStatusColumn ? : null} {showIdentifierColumn ? {identifier} : null} ); diff --git a/ui/src/components/IssueColumns.test.tsx b/ui/src/components/IssueColumns.test.tsx index 366a5719636..9b01df20666 100644 --- a/ui/src/components/IssueColumns.test.tsx +++ b/ui/src/components/IssueColumns.test.tsx @@ -67,6 +67,18 @@ describe("InboxIssueMetaLeading live state", () => { expect(text).not.toMatch(/(^|[^a-z])Live([^a-z]|$)/); }); + it("can suppress the subtree chip when the status glyph already carries descendant liveness", () => { + const text = renderLeading( + , + ); + expect(text).not.toContain("live below"); + }); + it("renders no live treatment when the issue and its subtree are idle", () => { const text = renderLeading( )} - {!isLive && subtreeLiveCount > 0 && ( + {showSubtreeLiveChip && !isLive && subtreeLiveCount > 0 && ( ): Issue { + return { + id: "issue-1", + status: "blocked", + blockerAttention: null, + liveDescendantCount: 0, + ...overrides, + } as unknown as Issue; +} + +function makeBlockerAttention( + overrides: Partial = {}, +): IssueBlockerAttention { + return { + state: "none", + reason: null, + unresolvedBlockerCount: 0, + coveredBlockerCount: 0, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: null, + sampleStalledBlockerIdentifier: null, + ...overrides, + }; +} + +describe("inbox live descendant status helpers", () => { + it("combines server and loaded live descendant counts without double-counting", () => { + expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: 3 }), 1)).toBe(3); + expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: 0 }), 2)).toBe(2); + expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: -1 }), 2.7)).toBe(2); + }); + + it("synthesizes covered blocker attention for a blocked row with live descendants", () => { + const attention = resolveInboxIssueBlockerAttention( + makeIssue({ liveDescendantCount: 2 }), + { isLive: false }, + ); + + expect(attention).toMatchObject({ + state: "covered", + reason: "active_child", + coveredBlockerCount: 2, + }); + }); + + it("uses loaded live descendants when the server count is absent", () => { + const attention = resolveInboxIssueBlockerAttention( + makeIssue({ liveDescendantCount: undefined }), + { isLive: false, loadedSubtreeLiveCount: 1 }, + ); + + expect(attention?.state).toBe("covered"); + expect(attention?.coveredBlockerCount).toBe(1); + }); + + it("keeps urgent blocked attention red even when descendants are live", () => { + for (const state of ["needs_attention", "stalled"] as const) { + const original = makeBlockerAttention({ state, reason: "attention_required" }); + const attention = resolveInboxIssueBlockerAttention( + makeIssue({ blockerAttention: original, liveDescendantCount: 4 }), + { isLive: false }, + ); + + expect(attention).toBe(original); + } + }); + + it("does not synthesize covered attention for the live row itself or non-blocked parents", () => { + expect( + resolveInboxIssueBlockerAttention( + makeIssue({ status: "blocked", liveDescendantCount: 2 }), + { isLive: true }, + ), + ).toBeNull(); + expect( + resolveInboxIssueBlockerAttention( + makeIssue({ status: "done", liveDescendantCount: 2 }), + { isLive: false }, + ), + ).toBeNull(); + }); +}); diff --git a/ui/src/lib/inbox-live-descendants.ts b/ui/src/lib/inbox-live-descendants.ts new file mode 100644 index 00000000000..36f53a6edee --- /dev/null +++ b/ui/src/lib/inbox-live-descendants.ts @@ -0,0 +1,55 @@ +import type { Issue, IssueBlockerAttention } from "@paperclipai/shared"; + +type InboxLiveDescendantIssue = Pick; + +interface InboxLiveDescendantOptions { + isLive: boolean; + loadedSubtreeLiveCount?: number; +} + +function normalizeLiveDescendantCount(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} + +function asBlockerAttention(value: unknown): IssueBlockerAttention | null { + if (!value || typeof value !== "object") return null; + const attention = value as Partial; + return typeof attention.state === "string" ? attention as IssueBlockerAttention : null; +} + +export function resolveIssueLiveDescendantCount( + issue: Pick, + loadedSubtreeLiveCount = 0, +): number { + return Math.max( + normalizeLiveDescendantCount(issue.liveDescendantCount), + normalizeLiveDescendantCount(loadedSubtreeLiveCount), + ); +} + +export function resolveInboxIssueBlockerAttention( + issue: InboxLiveDescendantIssue, + options: InboxLiveDescendantOptions, +): IssueBlockerAttention | null { + const blockerAttention = asBlockerAttention(issue.blockerAttention); + if (issue.status !== "blocked" || options.isLive) return blockerAttention; + if (blockerAttention?.state === "needs_attention" || blockerAttention?.state === "stalled") { + return blockerAttention; + } + if (blockerAttention?.state === "covered") return blockerAttention; + + const liveDescendantCount = resolveIssueLiveDescendantCount(issue, options.loadedSubtreeLiveCount); + if (liveDescendantCount <= 0) return blockerAttention; + + return { + state: "covered", + reason: "active_child", + unresolvedBlockerCount: blockerAttention?.unresolvedBlockerCount ?? 0, + coveredBlockerCount: liveDescendantCount, + stalledBlockerCount: blockerAttention?.stalledBlockerCount ?? 0, + attentionBlockerCount: blockerAttention?.attentionBlockerCount ?? 0, + sampleBlockerIdentifier: blockerAttention?.sampleBlockerIdentifier ?? null, + sampleStalledBlockerIdentifier: blockerAttention?.sampleStalledBlockerIdentifier ?? null, + }; +} diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index b59c8f9c256..fcf9a0b485b 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; import type { ComponentProps } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; @@ -138,6 +138,14 @@ vi.mock("@/lib/router", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + // jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect. if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; @@ -227,6 +235,7 @@ function createJoinRequest( } function resetInboxApiMocks() { + for (const mock of Object.values(apiMocks)) mock.mockReset(); routerMock.location.pathname = "/"; routerMock.location.search = ""; routerMock.location.hash = ""; @@ -327,6 +336,37 @@ describe("Inbox toolbar", () => { }); }); + it("requests live descendant summaries for issue rows", async () => { + routerMock.location.pathname = "/inbox/mine"; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + await vi.waitFor(() => { + expect(apiMocks.issuesList).toHaveBeenCalledTimes(3); + }); + + expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([ + true, + true, + true, + ]); + + act(() => { + root.unmount(); + }); + }); + it("syncs hover with j/k selection on inbox rows", async () => { routerMock.location.pathname = "/inbox/mine"; const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" }); @@ -360,18 +400,24 @@ describe("Inbox toolbar", () => { await act(async () => { rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false })); }); // After hovering row 1, that row is "selected" — same visual state as j/k selection. - expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent"); + await vi.waitFor(() => { + expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent"); + }); expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50"); await act(async () => { rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + rows[0]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false })); }); // Hovering a different row moves the selection to follow the mouse. - expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent"); + await vi.waitFor(() => { + expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent"); + }); expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50"); act(() => { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 63a77851127..4ad60fcbb97 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -49,6 +49,10 @@ import { shouldBlurPageSearchOnEnter, shouldBlurPageSearchOnEscape, } from "../lib/keyboardShortcuts"; +import { + resolveInboxIssueBlockerAttention, + resolveIssueLiveDescendantCount, +} from "../lib/inbox-live-descendants"; import { EmptyState } from "../components/EmptyState"; import { IssueGroupHeader } from "../components/IssueGroupHeader"; import { PageSkeleton } from "../components/PageSkeleton"; @@ -798,10 +802,11 @@ export function Inbox() { }); const { data: issues, isLoading: isIssuesLoading } = useQuery({ - queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions"], + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions", "live-descendant-summary"], queryFn: () => issuesApi.list(selectedCompanyId!, { includeRoutineExecutions: true, + includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, @@ -812,13 +817,14 @@ export function Inbox() { data: mineIssuesRaw = [], isLoading: isMineIssuesLoading, } = useQuery({ - queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"], + queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions", "live-descendant-summary"], queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", inboxArchivedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, + includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, @@ -829,12 +835,13 @@ export function Inbox() { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, } = useQuery({ - queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"], + queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions", "live-descendant-summary"], queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, + includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, @@ -881,12 +888,14 @@ export function Inbox() { queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), "inbox-supplement", + "live-descendant-summary", ], queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedSearchQuery, limit: 25, includeRoutineExecutions: true, + includeLiveDescendantSummary: true, }), enabled: shouldUseIssueSearchSupplement, placeholderData: (previousData) => previousData, @@ -2312,6 +2321,7 @@ export function Inbox() { issueFilters={issueFilters} currentUserId={currentUserId} liveIssueIds={liveIssueIds} + subtreeLiveCounts={subtreeLiveCounts} workspaceFilterContext={inboxWorkspaceGrouping} showStatusColumn={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")} showIdentifierColumn={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")} @@ -2372,6 +2382,22 @@ export function Inbox() { const assigneeUserProfile = issue.assigneeUserId ? companyUserProfileMap.get(issue.assigneeUserId) ?? null : null; + const isLive = liveIssueIds.has(issue.id); + const loadedSubtreeLiveCount = subtreeLiveCounts.get(issue.id) ?? 0; + const liveDescendantCount = resolveIssueLiveDescendantCount(issue, loadedSubtreeLiveCount); + const blockerAttention = resolveInboxIssueBlockerAttention(issue, { + isLive, + loadedSubtreeLiveCount, + }); + const showStatus = visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status"); + const showSubtreeLiveChip = !( + showStatus + && issue.status === "blocked" + && blockerAttention?.state === "covered" + ); + const rowStatusIcon = ( + + ); return ( 0 ? : null} } @@ -2433,7 +2461,9 @@ export function Inbox() { > - ) : undefined + ) : ( + + ) } unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} onMarkRead={() => markReadMutation.mutate(issue.id)} diff --git a/ui/storybook/stories/blocked-inbox.stories.tsx b/ui/storybook/stories/blocked-inbox.stories.tsx index 031d1fb3e8a..6aaf17ed9f3 100644 --- a/ui/storybook/stories/blocked-inbox.stories.tsx +++ b/ui/storybook/stories/blocked-inbox.stories.tsx @@ -187,6 +187,7 @@ function BlockedTabSurface({ search = "" }: { search?: string }) { searchQuery={search} agentNameById={new Map()} issueLinkState={null} + subtreeLiveCounts={new Map()} />
@@ -259,6 +260,7 @@ function BlockedTabEmptyState() { searchQuery="" agentNameById={new Map()} issueLinkState={null} + subtreeLiveCounts={new Map()} /> ); From dae6fc72f0257f8336a043846230b830115bde96 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 2 Jul 2026 08:26:23 -0700 Subject: [PATCH 12/19] docs: add fresh Greptile head gate to check-pr Add check-pr skill guidance that requires a fresh, clean Greptile review against the current PR or MR head before treating a review handoff as ready. This updates the GitHub and GitLab reference paths with concrete current-head checks and explicit blocking behavior for stale, missing, or failed Greptile results. Co-Authored-By: Paperclip --- .agents/skills/check-pr/SKILL.md | 379 ++++++++++++++++++ .../skills/check-pr/references/gitlab-api.md | 91 +++++ .../check-pr/references/graphql-queries.md | 89 ++++ 3 files changed, 559 insertions(+) create mode 100644 .agents/skills/check-pr/SKILL.md create mode 100644 .agents/skills/check-pr/references/gitlab-api.md create mode 100644 .agents/skills/check-pr/references/graphql-queries.md diff --git a/.agents/skills/check-pr/SKILL.md b/.agents/skills/check-pr/SKILL.md new file mode 100644 index 00000000000..88d52c10f0f --- /dev/null +++ b/.agents/skills/check-pr/SKILL.md @@ -0,0 +1,379 @@ +--- +name: check-pr +description: > + Checks a GitHub, GitLab, or Perforce (p4) pull request (or merge request, or shelved changelist) + for unresolved review comments, failing status checks, and incomplete PR descriptions. Waits for + pending checks to complete, categorizes issues as actionable or informational, and optionally fixes + and resolves them. Use when the user wants to check a PR/MR/CL, address review feedback, or prepare + a change for submission. +license: MIT +compatibility: Requires git and gh (GitHub CLI), glab (GitLab CLI), or p4 (Perforce CLI) installed and authenticated. +metadata: + author: greptileai + version: "1.3" +allowed-tools: Bash(gh:*) Bash(glab:*) Bash(git:*) Bash(p4:*) +--- + +# Check PR + +Analyze a pull request (GitHub), merge request (GitLab), or shelved changelist (Perforce) for review comments, status checks, and description completeness, then help address any issues found. + +## Inputs + +- **PR/MR/CL number** (optional): If not provided, detect the PR/MR for the current branch, or the default pending changelist for p4. + +## Instructions + +### 0. Detect platform + +First check if the user is working in a Perforce depot by looking for a `.p4config` file or `P4CLIENT`/`P4PORT` environment variables: + +```bash +# Check for Perforce environment +if p4 info >/dev/null 2>&1; then + VCS="perforce" +else + # Fall back to git remote detection + REMOTE_URL=$(git remote get-url origin) + if echo "$REMOTE_URL" | grep -qi "gitlab"; then + VCS="gitlab" + else + VCS="github" + fi +fi +``` + +For self-hosted GitLab instances whose hostname doesn't contain "gitlab", the user can override by passing `--vcs gitlab` as an input. For Perforce, the user can override by passing `--vcs perforce`. + +### 1. Identify the PR/MR/CL + +If a number was provided, use it. Otherwise, detect it: + +**GitHub:** +```bash +gh pr view --json number,headRefName,headRefOid -q '{number: .number, branch: .headRefName, head: .headRefOid}' +``` + +**GitLab:** +```bash +glab mr view --output json | jq '.iid' +``` + +**Perforce:** +```bash +# List pending changelists for the current user/client +p4 changes -s pending -u $P4USER -c $P4CLIENT +``` + +Key field differences between platforms: +- GitHub: `number`, `headRefName`, `headRefOid` +- GitLab: `iid`, `source_branch`, `sha` +- Perforce: changelist number (CL), `shelved` files for in-review CLs + +### 2. Fetch PR/MR/CL details + +**GitHub:** +```bash +gh pr view --json title,body,state,reviews,comments,headRefName,headRefOid,statusCheckRollup +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +gh api "repos/$OWNER_REPO/pulls//comments" +gh api --paginate "repos/$OWNER_REPO/issues//comments?per_page=100" +``` + +GitHub PRs are also issues, so general PR comments live on the issue comments endpoint. Greptile may edit a single general PR comment on each review cycle instead of creating a new review or comment. Always inspect the latest Greptile-authored general comment by `updated_at`, including any "Prompt to fix all with AI" section, before concluding that the PR is clear. + +**GitLab:** +```bash +glab mr view --output json +# Fetch discussions (inline diff comments are type "DiffNote"; general comments have null type) +glab api "projects/:fullpath/merge_requests//discussions" +``` + +For GitLab, paginate discussions if needed (add `?per_page=100&page=N`). + +**Perforce:** +```bash +# Get changelist description, files, and status +p4 describe -s + +# Get shelved files (for in-review CLs) +p4 describe -S + +# Get the diff of the shelved changelist +p4 diff2 //...@= //...@= + +# List review comments (if using p4 review workflow) +p4 review -c +``` + +Key Perforce CL fields: +- `Change`: changelist number +- `Status`: `pending`, `submitted`, `shelved` +- `Description`: the CL description / commit message +- `Files`: list of files in the CL + +### 3. Wait for pending checks + +Before analyzing, ensure all status checks have completed. If any checks are `PENDING` or `IN_PROGRESS` (GitHub) / `running` or `pending` (GitLab), poll every 30 seconds until all checks reach a terminal state. + +**GitHub:** poll `statusCheckRollup` from `gh pr view`. + +**GitLab:** +```bash +glab api "projects/:fullpath/merge_requests//pipelines" +``` +Pipeline statuses: `running`, `pending`, `success`, `failed`, `canceled`, `skipped`. Poll until no pipeline has `running` or `pending` status. + +**Perforce:** Perforce doesn't have built-in CI checks natively. If the team uses a review tool (Swarm, etc.) or an external CI triggered by shelve events, check the relevant system. Otherwise, proceed to analysis immediately. + +### 4. Require a fresh Greptile review for the current head + +For GitHub PRs, do not treat an existing Greptile review, comment, or summary as current unless it is tied to the PR's exact current `headRefOid`. This is especially important after pushing a new commit to an existing PR: a Greptile review on an older commit is stale, even if the PR still has a Greptile comment or prior review. + +Fetch the current head SHA immediately before the Greptile gate: + +```bash +HEAD_SHA=$(gh pr view --json headRefOid -q .headRefOid) +``` + +Then inspect check-runs for that commit and require a completed Greptile run: + +```bash +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +GREPTILE_CHECKS=$(gh api "repos/$OWNER_REPO/commits/$HEAD_SHA/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name | test("greptile"; "i"))]') + +# A run only counts as a valid fresh pass when it has completed AND concluded cleanly. +# GitHub check-run conclusions: success, neutral, skipped, failure, timed_out, +# cancelled, action_required, stale. Treat success/neutral as clean; +# everything else (especially failure and action_required) must block. +FRESH_GREPTILE_COMPLETED=$(echo "$GREPTILE_CHECKS" \ + | jq '[.[] | select(.status == "completed")] | length') +FRESH_GREPTILE_CLEAN=$(echo "$GREPTILE_CHECKS" \ + | jq '[.[] | select(.status == "completed" and (.conclusion | IN("success","neutral")))] | length') +FRESH_GREPTILE_BLOCKING=$(echo "$GREPTILE_CHECKS" \ + | jq '[.[] | select(.status == "completed" and ((.conclusion | IN("success","neutral")) | not))] | length') + +if [ "$FRESH_GREPTILE_COMPLETED" = "0" ]; then + echo "Blocked: no completed Greptile review/check is tied to current PR head $HEAD_SHA." + echo "Request a fresh Greptile review against this head before marking the PR check done." + echo "Suggested trigger: gh pr comment --body \"@greptile review\"" + exit 1 +fi + +if [ "$FRESH_GREPTILE_BLOCKING" != "0" ] || [ "$FRESH_GREPTILE_CLEAN" = "0" ]; then + echo "Blocked: Greptile completed on head $HEAD_SHA but did not conclude clean" + echo "(conclusion was failure/action_required/timed_out/cancelled/stale, or no clean run exists)." + echo "Address the findings, push, and re-run Greptile until it concludes success/clean on the new head." + exit 1 +fi +``` + +If a Greptile check exists for the current head but is still pending or in progress, wait for it with the same polling pattern used by `greploop` rather than proceeding from older review material. If no Greptile check appears for the current head after a reasonable wait, report the PR as blocked on a fresh Greptile review for `HEAD_SHA` and stop. Do not mark the check complete from PR comments, PR reviews, or Greptile summaries that cannot be associated with the current head SHA. + +For GitLab installations with Greptile integration, apply the same freshness rule against the MR's current head SHA: + +```bash +# 1. Get the MR's current head SHA +MR_SHA=$(glab mr view --output json | jq -r '.sha // .diff_refs.head_sha') + +# 2. Find the latest pipeline for that EXACT sha, then the Greptile job within it. +LATEST_PIPELINE_ID=$(glab api "projects/:fullpath/merge_requests//pipelines" \ + | jq -r --arg sha "$MR_SHA" '[.[] | select(.sha == $sha)] | sort_by(.id) | last | .id // empty') + +if [ -n "$LATEST_PIPELINE_ID" ]; then + GREPTILE_JOBS=$(glab api "projects/:fullpath/pipelines/$LATEST_PIPELINE_ID/jobs" \ + | jq --arg sha "$MR_SHA" '[.[] | select(.name | test("greptile"; "i")) + | {name, status, pipeline_sha: $sha}]') +else + GREPTILE_JOBS='[]' +fi + +GREPTILE_JOB_SUCCESS=$(echo "$GREPTILE_JOBS" \ + | jq '[.[] | select(.status == "success")] | length') +GREPTILE_JOB_BLOCKING=$(echo "$GREPTILE_JOBS" \ + | jq '[.[] | select(.status != "success")] | length') + +# 3. If Greptile integrates via MR notes instead of a CI job, require the newest +# Greptile note to reference the current head sha and report a clean review. +GREPTILE_NOTES=$(glab api "projects/:fullpath/merge_requests//discussions?per_page=100" \ + | jq --arg sha "$MR_SHA" '[.[].notes[] + | select(.author.username | test("greptile"; "i"))] + | sort_by(.updated_at // .created_at)') +GREPTILE_NOTE_CLEAN=$(echo "$GREPTILE_NOTES" \ + | jq --arg sha "$MR_SHA" 'if length == 0 then 0 + elif ((last.body // "") | contains($sha) and test("Confidence Score:[[:space:]]*5/5|Confidence:[[:space:]]*5/5|\\b5/5\\b"; "i") and (test("Prompt To Fix|blocking issue|failed|action required"; "i") | not)) then 1 + else 0 end') + +if [ "$GREPTILE_JOB_SUCCESS" = "0" ] && [ "$GREPTILE_NOTE_CLEAN" = "0" ]; then + echo "Blocked: no successful Greptile job or completed-clean current-head Greptile note is tied to MR head $MR_SHA." + exit 1 +fi + +if [ "$GREPTILE_JOB_BLOCKING" != "0" ]; then + echo "Blocked: at least one Greptile job for MR head $MR_SHA did not succeed." + exit 1 +fi +``` + +Block completion if, for `MR_SHA`, there is (a) no Greptile job or note at all (missing), (b) the newest Greptile job/note is tied to a different sha (stale), or (c) the Greptile job status is not `success`. A completed Greptile result for a different SHA is stale and must block completion. + +For Perforce installations with Greptile integration, apply the same rule using the CL's current shelved-revision identity and the Greptile webhook/review artifact tied to it; a Greptile result for an earlier shelf is stale and must block completion. + +### 5. Analyze the PR/MR + +Once all checks are complete, evaluate these areas: + +#### A. Status Checks + +- Are all CI checks passing? +- If any are failing, identify which ones and the failure reason. + +#### B. PR/MR Description + +- Is the description complete and follows team conventions? +- Are all required sections filled in? +- Are there TODOs or placeholders that need updating? + +#### C. Review Comments + +- Inline code review comments that need addressing +- Look for bot review comments (e.g. from `greptile-apps[bot]` on GitHub, or the Greptile bot user on GitLab, linters, etc.) +- Human reviewer comments +- **Perforce:** review comments from `p4 review` or external review tools + +#### D. General Comments + +- Discussion comments on the PR/MR +- For GitHub, check the issue comments endpoint and use `updated_at` to catch bot comments edited in place. Greptile's latest edited summary can contain actionable items even when there are no new inline comments. +- Bot comments (deploy previews, etc.) - usually informational +- **Perforce:** CL description should include a clear summary, affected files rationale, and testing notes + +### 6. Categorize issues + +For each issue found, categorize as: + +| Category | Meaning | +|---|---| +| **Actionable** | Code changes, test improvements, or fixes needed | +| **Informational** | Verification notes, questions, or FYIs that don't require changes | +| **Already addressed** | Issues that appear to be resolved by subsequent commits | + +### 7. Report findings + +Present a summary table: + +| Area | Issue | Status | Action Needed | +|------|-------|--------|---------------| +| Status Checks | CI build failing | Failing | Fix type error in `src/api.ts` | +| Review | "Add null check" - @reviewer | Actionable | Add guard clause | +| Description | TODO placeholder in test plan | Actionable | Fill in test plan | +| Review | "Looks good" - @teammate | Informational | None | + +### 8. Fix issues (if requested) + +If there are actionable items: + +1. Switch to the PR/MR's branch (git) or ensure files are open in the correct CL (Perforce) if not already. +2. Ask the user if they want to fix the issues. +3. If yes, make the fixes, then: + +**GitHub/GitLab:** commit and push: +```bash +git add +git commit -m "address review feedback" +git push +``` + +**Perforce:** open files for edit, make changes, and re-shelve: +```bash +p4 edit +# make changes +p4 shelve -f -c +``` + +### 9. Resolve review threads + +After addressing comments, resolve the corresponding review threads. + +**Perforce** - Perforce does not have a native "resolve thread" concept. Instead, mark comments as addressed by updating the CL description or by responding in the review tool being used (Swarm, etc.). If using `p4 review`: + +```bash +# Mark files as reviewed after addressing feedback +p4 review -c +``` + +**GitHub** - fetch unresolved thread IDs (paginate if needed - see [the GraphQL reference](references/graphql-queries.md)): + +```bash +gh api graphql -f query=' +query($cursor: String) { + repository(owner: "OWNER", name: "REPO") { + pullRequest(number: PR_NUMBER) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + comments(first: 1) { + nodes { body path } + } + } + } + } + } +}' +``` + +If `hasNextPage` is true, repeat with `-f cursor=ENDCURSOR` to get remaining threads. + +Then resolve threads that have been addressed or are informational: + +```bash +gh api graphql -f query=' +mutation { + resolveReviewThread(input: {threadId: "THREAD_ID"}) { + thread { isResolved } + } +}' +``` + +Batch multiple resolutions into a single mutation using aliases (`t1`, `t2`, etc.). + +**GitLab** - fetch unresolved discussions (see [the GitLab API reference](references/gitlab-api.md)): + +```bash +glab api "projects/:fullpath/merge_requests//discussions?per_page=100" +``` + +Filter for discussions where `"resolved": false`. Collect each discussion's `id`. + +Resolve each discussion individually (GitLab has no batch resolution): + +```bash +glab api --method PUT \ + "projects/:fullpath/merge_requests//discussions/" \ + --field resolved=true +``` + +Repeat for each unresolved discussion ID. + +### 10. Multiple PRs/MRs/CLs + +If checking a chain of PRs/MRs/CLs, process them sequentially. + +**Perforce** - to check multiple changelists at once: +```bash +p4 changes -s pending -u $P4USER -c $P4CLIENT -l +``` + +## Output format + +Summarize: +- PR/MR/CL title or description and current state +- Platform detected (GitHub / GitLab / Perforce) +- Status checks summary (passing/failing/pending) - or N/A for Perforce +- Total issues found +- Actionable items with descriptions +- Items that can be ignored with reasons +- Recommended next steps diff --git a/.agents/skills/check-pr/references/gitlab-api.md b/.agents/skills/check-pr/references/gitlab-api.md new file mode 100644 index 00000000000..134fe6bd0f9 --- /dev/null +++ b/.agents/skills/check-pr/references/gitlab-api.md @@ -0,0 +1,91 @@ +# GitLab API Reference + +Useful GitLab REST API calls for working with merge request discussions, using `glab api`. + +`glab api` automatically resolves `:fullpath` to the URL-encoded project path from the local git remote. + +## Fetch MR details + +```bash +glab mr view --output json +``` + +Key fields (compared to GitHub equivalents): +- `iid` - internal MR number (use this, not `id`) +- `source_branch` - equivalent to GitHub's `headRefName` +- `sha` - HEAD commit SHA, equivalent to GitHub's `headRefOid` +- `description` - equivalent to GitHub's `body` + +## Fetch all discussions (inline + general comments) + +```bash +glab api "projects/:fullpath/merge_requests//discussions?per_page=100" +``` + +Paginate with `&page=2`, `&page=3`, etc. until the response array length is less than `per_page`. + +Each discussion object: +- `id` - discussion ID (used for resolution) +- `resolved` - `true` or `false` +- `notes` - array of note objects + +Each note object: +- `type` - `"DiffNote"` for inline diff comments, `null` for general comments +- `author.username` - author's username +- `body` - comment text +- `position.new_path` - file path (for `DiffNote` type) + +## Filter for unresolved inline diff comments + +```bash +glab api "projects/:fullpath/merge_requests//discussions?per_page=100" | \ + jq '[.[] | select(.resolved == false and (.notes[0].type == "DiffNote"))]' +``` + +## Resolve a single discussion + +```bash +glab api --method PUT \ + "projects/:fullpath/merge_requests//discussions/" \ + --field resolved=true +``` + +There is no batch resolution in GitLab - issue one PUT per discussion. + +## Fetch pipeline status for an MR + +```bash +glab api "projects/:fullpath/merge_requests//pipelines" +``` + +Pipeline statuses: `running`, `pending`, `success`, `failed`, `canceled`, `skipped`. + +## Fetch jobs for a specific pipeline + +```bash +glab api "projects/:fullpath/pipelines//jobs" +``` + +Each job has `name`, `status`, `stage`, and `web_url`. + +## Fetch MR notes (general comments and bot reviews) + +```bash +glab api "projects/:fullpath/merge_requests//notes?per_page=100" +``` + +Filter by `author.username` to find Greptile bot comments. The exact bot username depends on the Greptile installation - check the first Greptile comment to identify it. + +## Post a comment on an MR + +```bash +glab mr note --message "your message here" +``` + +Or via API: + +```bash +glab api --method POST \ + "projects/:fullpath/merge_requests//notes" \ + --field body="your message here" +``` diff --git a/.agents/skills/check-pr/references/graphql-queries.md b/.agents/skills/check-pr/references/graphql-queries.md new file mode 100644 index 00000000000..3a3889e3006 --- /dev/null +++ b/.agents/skills/check-pr/references/graphql-queries.md @@ -0,0 +1,89 @@ +# GraphQL Queries Reference + +Useful GitHub GraphQL queries for working with PR review threads. + +## Fetch unresolved review threads (with pagination) + +```graphql +query($cursor: String) { + repository(owner: "OWNER", name: "REPO") { + pullRequest(number: PR_NUMBER) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + comments(first: 3) { + nodes { + body + path + author { login } + createdAt + } + } + } + } + } + } +} +``` + +Pass `-f cursor=ENDCURSOR` on subsequent requests if `hasNextPage` is `true`. + +## Resolve a single review thread + +```graphql +mutation { + resolveReviewThread(input: {threadId: "THREAD_ID"}) { + thread { isResolved } + } +} +``` + +## Batch-resolve multiple threads + +Use GraphQL aliases to resolve several threads in one request: + +```graphql +mutation { + t1: resolveReviewThread(input: {threadId: "THREAD_ID_1"}) { + thread { isResolved } + } + t2: resolveReviewThread(input: {threadId: "THREAD_ID_2"}) { + thread { isResolved } + } + t3: resolveReviewThread(input: {threadId: "THREAD_ID_3"}) { + thread { isResolved } + } +} +``` + +## Fetch PR details (REST) + +```bash +gh pr view --json title,body,state,reviews,comments,headRefName,statusCheckRollup +``` + +## Fetch inline review comments (REST) + +```bash +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +gh api "repos/$OWNER_REPO/pulls//comments" +``` + +## Fetch general PR comments edited in place (REST) + +General PR comments are issue comments. Greptile may update one summary comment repeatedly, so select by `updated_at` instead of `created_at`: + +```bash +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +gh api --paginate "repos/$OWNER_REPO/issues//comments?per_page=100" \ + | jq -s 'add + | map(select(.user.login | test("greptile"; "i"))) + | sort_by(.updated_at) + | last + | {author: .user.login, updated_at, body}' +``` From dea7c4e274e3212f4d39b5a3a8a5fe93195617a8 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:39:49 -0500 Subject: [PATCH 13/19] [codex] add company work timeline endpoint (#8875) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators need visibility into who initiated work, which agents ran, and how tasks were delegated across a company. > - The existing control plane stores the raw data across issues, heartbeat runs, comments, approvals, interactions, and activity logs. > - There was no single company-scoped API response that reconstructed those records into timeline actors, spans, events, and edges for a Gantt-style view. > - This pull request adds that aggregation endpoint behind the same company and issue read authorization model used elsewhere. > - The benefit is that UI work can consume one bounded endpoint instead of reimplementing timeline joins client-side. ## Linked Issues or Issue Description No public GitHub issue exists for this feature. ## Problem or motivation Paperclip stores enough execution and delegation data to show work over time, but consumers need a single endpoint that aggregates it consistently. ## Proposed solution Add `GET /api/companies/:companyId/timeline` with date and entity filters, bounded windows, pagination, actor normalization, run spans, human events, and delegation/assignment edges. ## Alternatives considered Querying each source separately from the UI would duplicate ACL and attribution logic and make client rendering depend on storage details. ## Roadmap alignment This supports operator visibility and auditability, and does not duplicate a listed roadmap item. ## What Changed - Added a `workTimelineService` that aggregates issue candidates from runs, activity, comments, approvals, interactions, and recently touched issues. - Added `GET /api/companies/:companyId/timeline` with `from`, `to`, `userId`, `goalId`, `projectId`, `issueId`, `limit`, and `offset` query parameters. - Enforced company-scope access plus per-issue `issue:read` filtering before emitting spans, events, or edges. - Added 31-day window capping, in-progress span handling for null `finishedAt`, retry/continuation metadata, user-lens subtree filtering, and activity-log run attribution fallback. - Added embedded-Postgres tests for aggregation joins, route behavior, ACL filtering, window capping, and user-lens closure. ## Verification - `pnpm vitest run server/src/__tests__/work-timeline-service.test.ts` - `pnpm exec tsc -p server/tsconfig.json --noEmit` Additional smoke attempted: - `pnpm dev:once` did not start the local app because the existing embedded instance has pending migration drift: Postgres rejected a foreign key on `pipeline_case_blockers.company_id` because that column does not exist. I did not manually alter the embedded database. ## Risks - Medium risk: this introduces a new aggregate endpoint over several tables, so query volume should be watched on very large companies. - The endpoint caps windows and paginates issue candidates to keep the first version bounded. - ACL behavior is fail-closed per issue: unreadable issues are filtered before response rows are emitted. - No migrations or schema changes are included. ## Model Used OpenAI GPT-5 via Codex coding agent, with tool use for repository inspection, editing, local Vitest execution, TypeScript checking, git, and GitHub CLI operations. ## 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 - [ ] 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 - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../__tests__/work-timeline-service.test.ts | 378 +++++++++ server/src/routes/companies.ts | 76 ++ server/src/routes/openapi.ts | 74 ++ server/src/services/index.ts | 9 + server/src/services/work-timeline.ts | 768 ++++++++++++++++++ 5 files changed, 1305 insertions(+) create mode 100644 server/src/__tests__/work-timeline-service.test.ts create mode 100644 server/src/services/work-timeline.ts diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts new file mode 100644 index 00000000000..160d063f5d8 --- /dev/null +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -0,0 +1,378 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + approvals, + authUsers, + companies, + createDb, + heartbeatRuns, + issueApprovals, + issueComments, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { companyRoutes } from "../routes/companies.js"; +import { normalizeTimelineWindow, workTimelineService } from "../services/work-timeline.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres work timeline tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("work timeline aggregation", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-work-timeline-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(issueComments); + await db.delete(issueApprovals); + await db.delete(approvals); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(authUsers); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: [], + memberships: [], + source: "local_implicit", + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api/companies", companyRoutes(db, {} as any)); + app.use(errorHandler); + return app; + } + + async function seedBase() { + const companyId = randomUUID(); + const userId = "user-1"; + const agentAId = randomUUID(); + const agentBId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Timeline Co", + issuePrefix: `T${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(authUsers).values({ + id: userId, + name: "User One", + email: "user@example.com", + emailVerified: true, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }); + await db.insert(agents).values([ + { + id: agentAId, + companyId, + name: "Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: agentBId, + companyId, + name: "QA", + role: "qa", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + return { companyId, userId, agentAId, agentBId }; + } + + it("normalizes timeline windows with a 31 day cap", () => { + const result = normalizeTimelineWindow({ + from: new Date("2026-01-01T00:00:00Z"), + to: new Date("2026-03-15T00:00:00Z"), + }, new Date("2026-03-15T00:00:00Z")); + + expect(result.capped).toBe(true); + expect(result.from.toISOString()).toBe("2026-02-12T00:00:00.000Z"); + }); + + it("aggregates runs, human events, approvals, and delegation edges", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const parentIssueId = randomUUID(); + const childIssueId = randomUUID(); + const contextRunId = randomUUID(); + const activityRunId = randomUUID(); + const approvalId = randomUUID(); + + await db.insert(issues).values([ + { + id: parentIssueId, + companyId, + title: "Parent", + identifier: "TL-1", + status: "in_progress", + priority: "medium", + createdByUserId: userId, + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-01T10:00:00Z"), + updatedAt: new Date("2026-03-01T10:00:00Z"), + }, + { + id: childIssueId, + companyId, + title: "Child", + identifier: "TL-2", + status: "in_progress", + priority: "medium", + parentId: parentIssueId, + createdByAgentId: agentAId, + assigneeAgentId: agentBId, + createdAt: new Date("2026-03-01T11:00:00Z"), + updatedAt: new Date("2026-03-01T11:00:00Z"), + }, + ]); + await db.insert(heartbeatRuns).values([ + { + id: contextRunId, + companyId, + agentId: agentBId, + status: "running", + invocationSource: "issue_assigned", + startedAt: new Date("2026-03-01T12:00:00Z"), + finishedAt: null, + contextSnapshot: { issueId: childIssueId }, + }, + { + id: activityRunId, + companyId, + agentId: agentAId, + status: "completed", + invocationSource: "manual", + startedAt: new Date("2026-03-01T12:30:00Z"), + finishedAt: new Date("2026-03-01T12:45:00Z"), + contextSnapshot: {}, + }, + ]); + await db.insert(activityLog).values([ + { + companyId, + actorType: "agent", + actorId: agentAId, + action: "issue.updated", + entityType: "issue", + entityId: parentIssueId, + agentId: agentAId, + runId: activityRunId, + createdAt: new Date("2026-03-01T12:35:00Z"), + }, + { + companyId, + actorType: "user", + actorId: userId, + action: "issue.assigned", + entityType: "issue", + entityId: childIssueId, + details: { assigneeAgentId: agentBId }, + createdAt: new Date("2026-03-01T13:00:00Z"), + }, + ]); + await db.insert(issueComments).values({ + companyId, + issueId: childIssueId, + authorUserId: userId, + body: "Looks good", + createdAt: new Date("2026-03-01T13:30:00Z"), + }); + await db.insert(approvals).values({ + id: approvalId, + companyId, + type: "request_board_approval", + status: "approved", + payload: {}, + decidedByUserId: userId, + decidedAt: new Date("2026-03-01T14:00:00Z"), + }); + await db.insert(issueApprovals).values({ companyId, issueId: childIssueId, approvalId }); + + const result = await workTimelineService(db).getTimeline({ + companyId, + from: new Date("2026-03-01T00:00:00Z"), + to: new Date("2026-03-02T00:00:00Z"), + }); + + expect(result.actors.map((actor) => actor.name)).toEqual(expect.arrayContaining(["Coder", "QA", "User One"])); + expect(result.spans).toEqual(expect.arrayContaining([ + expect.objectContaining({ runId: contextRunId, issueId: childIssueId, end: null, status: "running" }), + expect.objectContaining({ runId: activityRunId, issueId: parentIssueId, status: "completed" }), + ])); + expect(result.events.map((event) => event.kind)).toEqual(expect.arrayContaining([ + "created", + "commented", + "approved", + "delegated", + "assigned", + ])); + expect(result.edges).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "delegation", issueId: childIssueId }), + expect.objectContaining({ kind: "assignment", issueId: childIssueId }), + ])); + }); + + it("applies the user lens as a transitive issue subtree", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const unrelatedIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "User root", + status: "todo", + priority: "medium", + createdByUserId: userId, + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-03T10:00:00Z"), + updatedAt: new Date("2026-03-03T10:00:00Z"), + }, + { + id: childIssueId, + companyId, + title: "Delegated child", + status: "todo", + priority: "medium", + parentId: rootIssueId, + createdByAgentId: agentAId, + assigneeAgentId: agentBId, + createdAt: new Date("2026-03-03T11:00:00Z"), + updatedAt: new Date("2026-03-03T11:00:00Z"), + }, + { + id: unrelatedIssueId, + companyId, + title: "Unrelated", + status: "todo", + priority: "medium", + assigneeAgentId: agentBId, + createdAt: new Date("2026-03-03T12:00:00Z"), + updatedAt: new Date("2026-03-03T12:00:00Z"), + }, + ]); + + const result = await workTimelineService(db).getTimeline({ + companyId, + userId, + from: new Date("2026-03-03T00:00:00Z"), + to: new Date("2026-03-04T00:00:00Z"), + }); + + expect(result.events.map((event) => event.issueId)).toEqual(expect.arrayContaining([rootIssueId, childIssueId])); + expect(result.events.map((event) => event.issueId)).not.toContain(unrelatedIssueId); + }); + + it("filters unreadable issues before emitting timeline rows", async () => { + const { companyId, agentAId } = await seedBase(); + const visibleIssueId = randomUUID(); + const hiddenIssueId = randomUUID(); + await db.insert(issues).values([ + { + id: visibleIssueId, + companyId, + title: "Visible", + status: "todo", + priority: "medium", + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-04T10:00:00Z"), + updatedAt: new Date("2026-03-04T10:00:00Z"), + }, + { + id: hiddenIssueId, + companyId, + title: "Denied", + status: "todo", + priority: "medium", + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-04T11:00:00Z"), + updatedAt: new Date("2026-03-04T11:00:00Z"), + }, + ]); + + const result = await workTimelineService(db).getTimeline({ + companyId, + from: new Date("2026-03-04T00:00:00Z"), + to: new Date("2026-03-05T00:00:00Z"), + canReadIssue: async (issue) => issue.id !== hiddenIssueId, + }); + + expect(result.events.map((event) => event.issueId)).toContain(visibleIssueId); + expect(result.events.map((event) => event.issueId)).not.toContain(hiddenIssueId); + expect(result.pagination.totalIssues).toBe(1); + }); + + it("serves GET /api/companies/:companyId/timeline", async () => { + const { companyId, agentAId } = await seedBase(); + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Route issue", + identifier: "TL-9", + status: "todo", + priority: "medium", + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-05T10:00:00Z"), + updatedAt: new Date("2026-03-05T10:00:00Z"), + }); + + const res = await request(createApp()) + .get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.events).toEqual([ + expect.objectContaining({ kind: "created", issueId }), + ]); + expect(res.body).toEqual(expect.objectContaining({ + actors: expect.any(Array), + spans: expect.any(Array), + edges: expect.any(Array), + pagination: expect.objectContaining({ totalIssues: 1 }), + })); + }); +}); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 038ddaa8856..15cc0899d9f 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { Router, type Request } from "express"; import { and, count as countFn, eq } from "drizzle-orm"; +import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { agents as agentsTable } from "@paperclipai/db"; import { @@ -27,6 +28,7 @@ import { companyService, feedbackService, logActivity, + workTimelineService, } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; @@ -57,6 +59,26 @@ export function companyRoutes(db: Db, storage?: StorageService) { return parsed; } + function parseIntegerQuery(value: unknown, field: string) { + if (value === undefined || value === null || value === "") return undefined; + const parsed = typeof value === "string" ? Number(value) : NaN; + if (!Number.isFinite(parsed)) { + throw badRequest(`Invalid ${field} query value`); + } + return Math.floor(parsed); + } + + const timelineQuerySchema = z.object({ + from: z.string().optional(), + to: z.string().optional(), + userId: z.string().min(1).optional(), + goalId: z.string().uuid().optional(), + projectId: z.string().uuid().optional(), + issueId: z.string().uuid().optional(), + limit: z.string().optional(), + offset: z.string().optional(), + }).passthrough(); + function assertImportTargetAccess( req: Request, target: { mode: "new_company" } | { mode: "existing_company"; companyId: string }, @@ -123,6 +145,60 @@ export function companyRoutes(db: Db, storage?: StorageService) { res.json(await artifacts.list(companyId, query)); }); + router.get("/:companyId/timeline", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const companyScopeDecision = await access.decide({ + actor: req.actor, + action: "company_scope:read", + resource: { type: "company", companyId }, + }); + if (!companyScopeDecision.allowed) { + res.status(403).json({ error: "Timeline is outside this actor's authorization boundary" }); + return; + } + + const query = timelineQuerySchema.parse(req.query); + const timeline = workTimelineService(db); + const result = await timeline.getTimeline({ + companyId, + from: parseDateQuery(query.from, "from"), + to: parseDateQuery(query.to, "to"), + userId: query.userId, + goalId: query.goalId, + projectId: query.projectId, + issueId: query.issueId, + limit: parseIntegerQuery(query.limit, "limit"), + offset: parseIntegerQuery(query.offset, "offset"), + canReadIssue: async (issue) => { + const decision = await access.decide({ + actor: req.actor, + action: "issue:read", + resource: { + type: "issue", + companyId: issue.companyId, + issueId: issue.id, + projectId: issue.projectId, + parentIssueId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + status: issue.status, + }, + scope: { + issueId: issue.id, + projectId: issue.projectId, + parentIssueId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + }, + }); + return decision.allowed; + }, + }); + res.json(result); + }); + router.get("/:companyId", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 81b9858619e..384aa16f2f6 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -491,6 +491,63 @@ const environmentCustomImageTemplateRollbackResultSchema = z.object({ supersededTemplate: environmentCustomImageTemplateSchema, }).strict(); +const workTimelineQuerySchema = z.object({ + from: z.string().optional(), + to: z.string().optional(), + userId: z.string().optional(), + goalId: z.string().uuid().optional(), + projectId: z.string().uuid().optional(), + issueId: z.string().uuid().optional(), + limit: z.string().optional(), + offset: z.string().optional(), +}).strict(); + +const workTimelineResponseSchema = z.object({ + actors: z.array(z.object({ + id: z.string(), + type: z.enum(["agent", "user", "system", "plugin"]), + name: z.string(), + avatar: z.string().nullable().optional(), + }).strict()), + spans: z.array(z.object({ + actorId: z.string(), + laneHint: z.string().nullable(), + runId: z.string(), + issueId: z.string(), + issueIdentifier: z.string().nullable(), + start: z.string(), + end: z.string().nullable(), + status: z.string(), + retryOfRunId: z.string().nullable().optional(), + continuationAttempt: z.number().optional(), + invocationSource: z.string().nullable().optional(), + }).strict()), + events: z.array(z.object({ + actorId: z.string(), + kind: z.enum(["created", "commented", "approved", "delegated", "assigned"]), + issueId: z.string(), + at: z.string(), + }).strict()), + edges: z.array(z.object({ + fromActorId: z.string(), + toActorId: z.string(), + issueId: z.string(), + at: z.string(), + kind: z.enum(["delegation", "assignment", "mention"]), + }).strict()), + pagination: z.object({ + limit: z.number().int().positive(), + offset: z.number().int().nonnegative(), + totalIssues: z.number().int().nonnegative(), + hasMore: z.boolean(), + }).strict(), + window: z.object({ + from: z.string(), + to: z.string(), + capped: z.boolean(), + }).strict(), +}).strict(); + function paramsSchemaFromPath(routePath: string): z.ZodObject | undefined { const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]); if (names.length === 0) return undefined; @@ -904,6 +961,23 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/timeline", + tags: ["companies"], + summary: "Get company work timeline", + request: { + params: z.object({ companyId: z.string() }), + query: workTimelineQuerySchema, + }, + responses: { + 200: r.ok(workTimelineResponseSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + }, +}); + registry.registerPath({ method: "patch", path: "/api/companies/{companyId}", diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 20da1cef482..855d3a2652d 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -44,6 +44,15 @@ export { } from "./external-objects.js"; export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; +export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js"; +export type { + WorkTimelineActor, + WorkTimelineEdge, + WorkTimelineEvent, + WorkTimelineQuery, + WorkTimelineResult, + WorkTimelineSpan, +} from "./work-timeline.js"; export { approvalService } from "./approvals.js"; export { budgetService } from "./budgets.js"; export { secretService } from "./secrets.js"; diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts new file mode 100644 index 00000000000..4a60e105425 --- /dev/null +++ b/server/src/services/work-timeline.ts @@ -0,0 +1,768 @@ +import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + activityLog, + agents, + approvals, + authUsers, + heartbeatRuns, + issueApprovals, + issueComments, + issues, + issueThreadInteractions, +} from "@paperclipai/db"; + +export type TimelineActorType = "agent" | "user" | "system" | "plugin"; +export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned"; +export type TimelineEdgeKind = "delegation" | "assignment" | "mention"; + +export interface WorkTimelineActor { + id: string; + type: TimelineActorType; + name: string; + avatar?: string | null; +} + +export interface WorkTimelineSpan { + actorId: string; + laneHint: string | null; + runId: string; + issueId: string; + issueIdentifier: string | null; + start: string; + end: string | null; + status: string; + retryOfRunId?: string | null; + continuationAttempt?: number; + invocationSource?: string | null; +} + +export interface WorkTimelineEvent { + actorId: string; + kind: TimelineEventKind; + issueId: string; + at: string; +} + +export interface WorkTimelineEdge { + fromActorId: string; + toActorId: string; + issueId: string; + at: string; + kind: TimelineEdgeKind; +} + +export interface WorkTimelineQuery { + companyId: string; + from?: Date; + to?: Date; + userId?: string; + goalId?: string; + projectId?: string; + issueId?: string; + limit?: number; + offset?: number; + canReadIssue?: (issue: WorkTimelineIssueAccessInput) => Promise; +} + +export interface WorkTimelineResult { + actors: WorkTimelineActor[]; + spans: WorkTimelineSpan[]; + events: WorkTimelineEvent[]; + edges: WorkTimelineEdge[]; + pagination: { + limit: number; + offset: number; + totalIssues: number; + hasMore: boolean; + }; + window: { + from: string; + to: string; + capped: boolean; + }; +} + +export interface WorkTimelineIssueAccessInput { + id: string; + companyId: string; + projectId: string | null; + parentId: string | null; + assigneeAgentId: string | null; + assigneeUserId: string | null; + status: string; +} + +type IssueRow = { + id: string; + companyId: string; + projectId: string | null; + goalId: string | null; + parentId: string | null; + identifier: string | null; + createdByAgentId: string | null; + createdByUserId: string | null; + assigneeAgentId: string | null; + assigneeUserId: string | null; + status: string; + createdAt: Date; +}; + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 500; +const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000; +const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_SOURCE_ROWS = 5_000; + +function actorId(type: TimelineActorType, id: string) { + return `${type}:${id}`; +} + +function normalizeLimit(value: number | undefined) { + if (!Number.isFinite(value)) return DEFAULT_LIMIT; + return Math.max(1, Math.min(MAX_LIMIT, Math.floor(value ?? DEFAULT_LIMIT))); +} + +function normalizeOffset(value: number | undefined) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.floor(value ?? 0)); +} + +export function normalizeTimelineWindow(input: { from?: Date; to?: Date }, now = new Date()) { + const rawTo = input.to ?? now; + const to = rawTo.getTime() > now.getTime() ? now : rawTo; + const requestedFrom = input.from ?? new Date(to.getTime() - DEFAULT_WINDOW_MS); + let from = requestedFrom; + let capped = false; + if (to.getTime() - from.getTime() > MAX_WINDOW_MS) { + from = new Date(to.getTime() - MAX_WINDOW_MS); + capped = true; + } + if (from.getTime() > to.getTime()) { + from = new Date(to.getTime() - DEFAULT_WINDOW_MS); + capped = true; + } + return { from, to, capped }; +} + +function dateIso(value: Date | null | undefined) { + return value ? value.toISOString() : null; +} + +function readString(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function maybeUuidList(ids: Iterable) { + return Array.from(new Set(Array.from(ids).filter((id) => id.length > 0))); +} + +function runOverlapsWindow(from: Date, to: Date) { + const fromIso = from.toISOString(); + const toIso = to.toISOString(); + return and( + sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${toIso}::timestamptz`, + sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${fromIso}::timestamptz`, + ); +} + +export function workTimelineService(db: Db) { + async function collectIssueIds(input: WorkTimelineQuery, from: Date, to: Date) { + const ids = new Set(); + + if (input.issueId) { + ids.add(input.issueId); + } + + const filterConditions = [ + eq(issues.companyId, input.companyId), + isNull(issues.hiddenAt), + input.goalId ? eq(issues.goalId, input.goalId) : undefined, + input.projectId ? eq(issues.projectId, input.projectId) : undefined, + input.issueId ? eq(issues.id, input.issueId) : undefined, + ].filter(Boolean); + + const recentlyTouched = await db + .select({ id: issues.id }) + .from(issues) + .where( + and( + ...filterConditions, + or( + and(gte(issues.createdAt, from), lte(issues.createdAt, to)), + and(gte(issues.updatedAt, from), lte(issues.updatedAt, to)), + ), + ), + ) + .orderBy(desc(issues.updatedAt)) + .limit(MAX_SOURCE_ROWS); + for (const row of recentlyTouched) ids.add(row.id); + + const runContextRows = await db + .select({ issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'` }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, input.companyId), + runOverlapsWindow(from, to), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is not null`, + ), + ) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(MAX_SOURCE_ROWS); + for (const row of runContextRows) { + if (row.issueId) ids.add(row.issueId); + } + + const activityIssueRows = await db + .select({ issueId: activityLog.entityId }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, input.companyId), + eq(activityLog.entityType, "issue"), + gte(activityLog.createdAt, from), + lte(activityLog.createdAt, to), + ), + ) + .orderBy(desc(activityLog.createdAt)) + .limit(MAX_SOURCE_ROWS); + for (const row of activityIssueRows) ids.add(row.issueId); + + const commentIssueRows = await db + .select({ issueId: issueComments.issueId }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + isNull(issueComments.deletedAt), + gte(issueComments.createdAt, from), + lte(issueComments.createdAt, to), + ), + ) + .orderBy(desc(issueComments.createdAt)) + .limit(MAX_SOURCE_ROWS); + for (const row of commentIssueRows) ids.add(row.issueId); + + const interactionIssueRows = await db + .select({ issueId: issueThreadInteractions.issueId }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.companyId, input.companyId), + or( + and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)), + and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)), + ), + ), + ) + .orderBy(desc(issueThreadInteractions.createdAt)) + .limit(MAX_SOURCE_ROWS); + for (const row of interactionIssueRows) ids.add(row.issueId); + + const approvalIssueRows = await db + .select({ issueId: issueApprovals.issueId }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where( + and( + eq(issueApprovals.companyId, input.companyId), + or( + and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)), + and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)), + ), + ), + ) + .orderBy(desc(approvals.createdAt)) + .limit(MAX_SOURCE_ROWS); + for (const row of approvalIssueRows) ids.add(row.issueId); + + return maybeUuidList(ids); + } + + async function loadIssues(input: WorkTimelineQuery, issueIds: string[]) { + if (issueIds.length === 0) return []; + return db + .select({ + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + goalId: issues.goalId, + parentId: issues.parentId, + identifier: issues.identifier, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + status: issues.status, + createdAt: issues.createdAt, + }) + .from(issues) + .where( + and( + eq(issues.companyId, input.companyId), + isNull(issues.hiddenAt), + inArray(issues.id, issueIds), + input.goalId ? eq(issues.goalId, input.goalId) : undefined, + input.projectId ? eq(issues.projectId, input.projectId) : undefined, + input.issueId ? eq(issues.id, input.issueId) : undefined, + ), + ); + } + + async function applyUserLens(input: WorkTimelineQuery, rows: IssueRow[], from: Date, to: Date) { + if (!input.userId) return rows; + + const byId = new Map(rows.map((issue) => [issue.id, issue])); + const selected = new Set(); + for (const issue of rows) { + if (issue.createdByUserId === input.userId || issue.assigneeUserId === input.userId) selected.add(issue.id); + } + + const [commentRows, approvalRows, interactionRows, activityRows] = await Promise.all([ + db + .select({ issueId: issueComments.issueId }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + eq(issueComments.authorUserId, input.userId), + isNull(issueComments.deletedAt), + gte(issueComments.createdAt, from), + lte(issueComments.createdAt, to), + ), + ), + db + .select({ issueId: issueApprovals.issueId }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where( + and( + eq(issueApprovals.companyId, input.companyId), + eq(approvals.decidedByUserId, input.userId), + gte(approvals.decidedAt, from), + lte(approvals.decidedAt, to), + ), + ), + db + .select({ issueId: issueThreadInteractions.issueId }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.resolvedByUserId, input.userId), + gte(issueThreadInteractions.resolvedAt, from), + lte(issueThreadInteractions.resolvedAt, to), + ), + ), + db + .select({ issueId: activityLog.entityId }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, input.companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, input.userId), + eq(activityLog.entityType, "issue"), + gte(activityLog.createdAt, from), + lte(activityLog.createdAt, to), + ), + ), + ]); + + for (const row of [...commentRows, ...approvalRows, ...interactionRows, ...activityRows]) { + selected.add(row.issueId); + } + + let changed = true; + while (changed) { + changed = false; + for (const issue of rows) { + if (issue.parentId && selected.has(issue.parentId) && !selected.has(issue.id)) { + selected.add(issue.id); + changed = true; + } + } + } + + return rows.filter((issue) => selected.has(issue.id) || byId.get(issue.parentId ?? "") && selected.has(issue.parentId ?? "")); + } + + async function loadActorMaps(companyId: string, actorIds: Set) { + const agentIds = Array.from(actorIds) + .filter((id) => id.startsWith("agent:")) + .map((id) => id.slice("agent:".length)); + const userIds = Array.from(actorIds) + .filter((id) => id.startsWith("user:")) + .map((id) => id.slice("user:".length)); + + const [agentRows, userRows] = await Promise.all([ + agentIds.length > 0 + ? db + .select({ id: agents.id, name: agents.name, icon: agents.icon }) + .from(agents) + .where(and(eq(agents.companyId, companyId), inArray(agents.id, maybeUuidList(agentIds)))) + : [], + userIds.length > 0 + ? db + .select({ id: authUsers.id, name: authUsers.name, image: authUsers.image }) + .from(authUsers) + .where(inArray(authUsers.id, maybeUuidList(userIds))) + : [], + ]); + + return { + agents: new Map(agentRows.map((agent) => [agent.id, agent])), + users: new Map(userRows.map((user) => [user.id, user])), + }; + } + + function actorForIssueCreator(issue: IssueRow) { + if (issue.createdByAgentId) return actorId("agent", issue.createdByAgentId); + if (issue.createdByUserId) return actorId("user", issue.createdByUserId); + return actorId("system", "system"); + } + + function actorForIssueAssignee(issue: IssueRow) { + if (issue.assigneeAgentId) return actorId("agent", issue.assigneeAgentId); + if (issue.assigneeUserId) return actorId("user", issue.assigneeUserId); + return null; + } + + async function getTimeline(input: WorkTimelineQuery): Promise { + const { from, to, capped } = normalizeTimelineWindow(input); + const limit = normalizeLimit(input.limit); + const offset = normalizeOffset(input.offset); + + const candidateIssueIds = await collectIssueIds(input, from, to); + const loadedIssues = await loadIssues(input, candidateIssueIds); + const userScopedIssues = await applyUserLens(input, loadedIssues, from, to); + const accessibleIssues = input.canReadIssue + ? (await Promise.all(userScopedIssues.map(async (issue) => ({ + issue, + allowed: await input.canReadIssue?.({ + id: issue.id, + companyId: issue.companyId, + projectId: issue.projectId, + parentId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + status: issue.status, + }), + })))).filter((entry) => entry.allowed).map((entry) => entry.issue) + : userScopedIssues; + const sortedIssues = accessibleIssues.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); + const pagedIssues = sortedIssues.slice(offset, offset + limit); + const issueById = new Map(pagedIssues.map((issue) => [issue.id, issue])); + const readableIssueIds = Array.from(issueById.keys()); + + if (readableIssueIds.length === 0) { + return { + actors: [], + spans: [], + events: [], + edges: [], + pagination: { limit, offset, totalIssues: sortedIssues.length, hasMore: offset + limit < sortedIssues.length }, + window: { from: from.toISOString(), to: to.toISOString(), capped }, + }; + } + + const actorIds = new Set(); + const events: WorkTimelineEvent[] = []; + const edges: WorkTimelineEdge[] = []; + + for (const issue of pagedIssues) { + const creatorActorId = actorForIssueCreator(issue); + actorIds.add(creatorActorId); + events.push({ + actorId: creatorActorId, + kind: "created", + issueId: issue.id, + at: issue.createdAt.toISOString(), + }); + + const assigneeActorId = actorForIssueAssignee(issue); + if (assigneeActorId) { + actorIds.add(assigneeActorId); + edges.push({ + fromActorId: creatorActorId, + toActorId: assigneeActorId, + issueId: issue.id, + at: issue.createdAt.toISOString(), + kind: "assignment", + }); + } + + const parent = issue.parentId ? issueById.get(issue.parentId) : null; + const parentActorId = parent ? actorForIssueAssignee(parent) ?? actorForIssueCreator(parent) : null; + if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId) { + actorIds.add(parentActorId); + edges.push({ + fromActorId: parentActorId, + toActorId: assigneeActorId, + issueId: issue.id, + at: issue.createdAt.toISOString(), + kind: "delegation", + }); + events.push({ + actorId: parentActorId, + kind: "delegated", + issueId: issue.id, + at: issue.createdAt.toISOString(), + }); + } + } + + const [contextRunRows, activityRunRows, commentRows, approvalRows, interactionRows, logRows] = await Promise.all([ + db + .select({ + runId: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + status: heartbeatRuns.status, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + retryOfRunId: heartbeatRuns.retryOfRunId, + continuationAttempt: heartbeatRuns.continuationAttempt, + invocationSource: heartbeatRuns.invocationSource, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, input.companyId), + runOverlapsWindow(from, to), + inArray(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, readableIssueIds), + ), + ), + db + .select({ + runId: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + issueId: activityLog.entityId, + status: heartbeatRuns.status, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + retryOfRunId: heartbeatRuns.retryOfRunId, + continuationAttempt: heartbeatRuns.continuationAttempt, + invocationSource: heartbeatRuns.invocationSource, + }) + .from(activityLog) + .innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id)) + .where( + and( + eq(activityLog.companyId, input.companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, readableIssueIds), + runOverlapsWindow(from, to), + ), + ), + db + .select({ + issueId: issueComments.issueId, + authorAgentId: issueComments.authorAgentId, + authorUserId: issueComments.authorUserId, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + isNull(issueComments.deletedAt), + inArray(issueComments.issueId, readableIssueIds), + gte(issueComments.createdAt, from), + lte(issueComments.createdAt, to), + ), + ), + db + .select({ + issueId: issueApprovals.issueId, + decidedByUserId: approvals.decidedByUserId, + decidedAt: approvals.decidedAt, + requestedByAgentId: approvals.requestedByAgentId, + requestedByUserId: approvals.requestedByUserId, + createdAt: approvals.createdAt, + }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where( + and( + eq(issueApprovals.companyId, input.companyId), + inArray(issueApprovals.issueId, readableIssueIds), + or( + and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)), + and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)), + ), + ), + ), + db + .select({ + issueId: issueThreadInteractions.issueId, + resolvedByAgentId: issueThreadInteractions.resolvedByAgentId, + resolvedByUserId: issueThreadInteractions.resolvedByUserId, + resolvedAt: issueThreadInteractions.resolvedAt, + createdByAgentId: issueThreadInteractions.createdByAgentId, + createdByUserId: issueThreadInteractions.createdByUserId, + createdAt: issueThreadInteractions.createdAt, + }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.companyId, input.companyId), + inArray(issueThreadInteractions.issueId, readableIssueIds), + or( + and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)), + and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)), + ), + ), + ), + db + .select({ + issueId: activityLog.entityId, + actorType: activityLog.actorType, + actorId: activityLog.actorId, + action: activityLog.action, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, input.companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, readableIssueIds), + gte(activityLog.createdAt, from), + lte(activityLog.createdAt, to), + ), + ), + ]); + + const spanByRunId = new Map(); + for (const row of [...contextRunRows, ...activityRunRows]) { + if (!row.issueId || !issueById.has(row.issueId) || spanByRunId.has(row.runId)) continue; + const runActorId = actorId("agent", row.agentId); + actorIds.add(runActorId); + spanByRunId.set(row.runId, { + actorId: runActorId, + laneHint: row.invocationSource ?? null, + runId: row.runId, + issueId: row.issueId, + issueIdentifier: issueById.get(row.issueId)?.identifier ?? null, + start: (row.startedAt ?? row.createdAt).toISOString(), + end: dateIso(row.finishedAt), + status: row.status, + retryOfRunId: row.retryOfRunId ?? null, + continuationAttempt: row.continuationAttempt, + invocationSource: row.invocationSource ?? null, + }); + } + + for (const row of commentRows) { + const commentActorId = row.authorAgentId + ? actorId("agent", row.authorAgentId) + : row.authorUserId + ? actorId("user", row.authorUserId) + : actorId("system", "system"); + actorIds.add(commentActorId); + events.push({ actorId: commentActorId, kind: "commented", issueId: row.issueId, at: row.createdAt.toISOString() }); + } + + for (const row of approvalRows) { + const approvalActorId = row.decidedByUserId + ? actorId("user", row.decidedByUserId) + : row.requestedByAgentId + ? actorId("agent", row.requestedByAgentId) + : row.requestedByUserId + ? actorId("user", row.requestedByUserId) + : actorId("system", "system"); + actorIds.add(approvalActorId); + events.push({ + actorId: approvalActorId, + kind: "approved", + issueId: row.issueId, + at: (row.decidedAt ?? row.createdAt).toISOString(), + }); + } + + for (const row of interactionRows) { + const interactionActorId = row.resolvedByUserId + ? actorId("user", row.resolvedByUserId) + : row.resolvedByAgentId + ? actorId("agent", row.resolvedByAgentId) + : row.createdByAgentId + ? actorId("agent", row.createdByAgentId) + : row.createdByUserId + ? actorId("user", row.createdByUserId) + : actorId("system", "system"); + actorIds.add(interactionActorId); + events.push({ + actorId: interactionActorId, + kind: "approved", + issueId: row.issueId, + at: (row.resolvedAt ?? row.createdAt).toISOString(), + }); + } + + for (const row of logRows) { + const logActorType = row.actorType === "agent" || row.actorType === "user" || row.actorType === "plugin" + ? row.actorType + : "system"; + const fromActorId = actorId(logActorType, row.actorId); + actorIds.add(fromActorId); + if (row.action.includes("assign")) { + events.push({ actorId: fromActorId, kind: "assigned", issueId: row.issueId, at: row.createdAt.toISOString() }); + const details = row.details && typeof row.details === "object" && !Array.isArray(row.details) + ? row.details as Record + : {}; + const targetAgentId = readString(details.assigneeAgentId) ?? readString(details.toAgentId); + const targetUserId = readString(details.assigneeUserId) ?? readString(details.toUserId); + const toActorId = targetAgentId + ? actorId("agent", targetAgentId) + : targetUserId + ? actorId("user", targetUserId) + : null; + if (toActorId) { + actorIds.add(toActorId); + edges.push({ + fromActorId, + toActorId, + issueId: row.issueId, + at: row.createdAt.toISOString(), + kind: "assignment", + }); + } + } + } + + const actorMaps = await loadActorMaps(input.companyId, actorIds); + const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => { + const [type, rawId] = id.split(":", 2) as [TimelineActorType, string]; + if (type === "agent") { + const agent = actorMaps.agents.get(rawId); + return { id, type, name: agent?.name ?? "Unknown agent", avatar: agent?.icon ?? null }; + } + if (type === "user") { + const user = actorMaps.users.get(rawId); + return { id, type, name: user?.name ?? rawId, avatar: user?.image ?? null }; + } + return { id, type, name: type === "plugin" ? rawId : "System", avatar: null }; + }); + + return { + actors, + spans: Array.from(spanByRunId.values()).sort((left, right) => left.start.localeCompare(right.start)), + events: events.sort((left, right) => left.at.localeCompare(right.at)), + edges: edges.sort((left, right) => left.at.localeCompare(right.at)), + pagination: { + limit, + offset, + totalIssues: sortedIssues.length, + hasMore: offset + limit < sortedIssues.length, + }, + window: { from: from.toISOString(), to: to.toISOString(), capped }, + }; + } + + return { getTimeline }; +} From 60f7fb422394c94618b8eef27ae032702004f544 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:54:16 -0500 Subject: [PATCH 14/19] =?UTF-8?q?PAP-12424=20Work=20Timeline=20=E2=80=94?= =?UTF-8?q?=20Phase=20C:=20frontend=20Gantt=20page=20(Direction=20C)=20(#8?= =?UTF-8?q?880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators need to *see* how work actually flowed across their agents over time — who was invoked, what they worked on, and how work was delegated between them > - The dashboard shows point-in-time state but nothing reconstructs the temporal, cross-actor picture of heartbeat runs and delegations > - A read-only company work-timeline endpoint was landed first (server aggregation over runs/issues/activity); it had no frontend > - This pull request adds the Gantt-style **Work Timeline** page that renders that endpoint, plus the small additive server contract change it needs (shared DTOs + a task title on each span) > - The benefit is a single dense view — actor rows, concurrency lanes, delegation connectors, zoom and a mini-map — that makes agent activity legible without an N+1 fetch storm from the client ## Linked Issues or Issue Description No public GitHub issue. Problem, in-PR: - **Gap:** the company work-timeline aggregation endpoint has no UI. There is no way to visually inspect how heartbeat runs unfolded over time or how work was delegated between agents. - **Solution:** a dashboard-adjacent Gantt-style page at `/:companyPrefix/timeline`, linked from the sidebar's "Work" section, rendering runs as bars on per-actor rows with delegation connectors, kickoff chips, zoom, a lens filter, and a mini-map. - Built with React + custom inline SVG (no chart dependency; consistent with the existing Tailwind/Radix stack). ## What Changed - **Frontend Gantt page** (`ui/src/pages/Timeline.tsx`, `ui/src/components/timeline/WorkTimelineChart.tsx`): actor rows (agents/system only — humans never get a row), overlapping runs packed into concurrency sub-lanes, bars = heartbeat runs with a left colour tab for issue identity, truncated task title + timing/status on hover, click-through to the task. - **Human activity markers & human rows** for kickoff/delegation involving people, without giving humans their own run lane. - **Kickoff avatar chips** at each bar's leading edge; straight agent→agent delegation connectors (dashed for retries/changes-requested); in-progress runs extend to a dashed "now" line and fade out. - **Zoom** (hour/day/week, auto-fit), full-window **mini-map** with a draggable brush, **lens filter** (Everyone / per-user, server-side), and colour **by task / by status**. - **Pure layout/transform module** (`ui/src/lib/timeline/layout.ts`) — packing, kickoff derivation, connector resolution, scales — unit-tested in isolation. - **Server contract (additive):** moved the `WorkTimeline*` DTOs into `@paperclipai/shared` so the aggregation service and the UI consume one contract; added `issueTitle` to each span so the tooltip shows the task title with no N+1 client fetch. - Sidebar link, query keys, API client (`ui/src/api/workTimeline.ts`), and a Storybook story with fixtures. ## Verification - `pnpm --filter @paperclipai/shared build` ✅ - `pnpm --filter @paperclipai/server typecheck` ✅ · `pnpm --filter @paperclipai/ui typecheck` ✅ - `pnpm --filter @paperclipai/ui exec vitest run src/lib/timeline/layout.test.ts src/components/timeline/WorkTimelineChart.test.tsx` ✅ (15/15) - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/work-timeline-service.test.ts` ✅ (5/5) — the DTO move + `issueTitle` are additive; existing service tests use `objectContaining` and still pass. - Rendered `WorkTimelineChart` headless against a real slice of company activity via a Storybook story; manual browser QA of the live page passed on the feature branch. ## Risks - **Low risk.** The change is UI-only plus an additive server DTO refactor (types relocated to `@paperclipai/shared`, one new optional field). No schema/migration changes, no change to endpoint behaviour beyond the extra `issueTitle` field. The page is behind its own route and does not alter existing views. ## Model Used - Claude, Opus 4.8 (`claude-opus-4-8`), via Claude Code with extended thinking and tool use. ## 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 (only the merged endpoint PR #8875 is related; no duplicates) - [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 and contains no internal ticket id - [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 - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Paperclip Co-authored-by: Claude Opus 4.8 --- packages/shared/src/index.ts | 8 + packages/shared/src/types/index.ts | 10 + packages/shared/src/types/work-timeline.ts | 73 + server/src/services/work-timeline.ts | 79 +- ui/src/App.tsx | 2 + ui/src/api/workTimeline.ts | 31 + ui/src/components/Sidebar.tsx | 2 + .../timeline/WorkTimelineChart.test.tsx | 128 ++ .../components/timeline/WorkTimelineChart.tsx | 576 ++++++ ui/src/lib/queryKeys.ts | 1 + ui/src/lib/timeline/layout.test.ts | 197 ++ ui/src/lib/timeline/layout.ts | 342 ++++ ui/src/pages/Timeline.tsx | 242 +++ .../fixtures/workTimeline.human.sample.json | 1824 +++++++++++++++++ .../fixtures/workTimeline.sample.json | 1 + .../stories/work-timeline.stories.tsx | 164 ++ 16 files changed, 3624 insertions(+), 56 deletions(-) create mode 100644 packages/shared/src/types/work-timeline.ts create mode 100644 ui/src/api/workTimeline.ts create mode 100644 ui/src/components/timeline/WorkTimelineChart.test.tsx create mode 100644 ui/src/components/timeline/WorkTimelineChart.tsx create mode 100644 ui/src/lib/timeline/layout.test.ts create mode 100644 ui/src/lib/timeline/layout.ts create mode 100644 ui/src/pages/Timeline.tsx create mode 100644 ui/storybook/fixtures/workTimeline.human.sample.json create mode 100644 ui/storybook/fixtures/workTimeline.sample.json create mode 100644 ui/storybook/stories/work-timeline.stories.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8674779f43a..a943ed8719c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -752,6 +752,14 @@ export type { LiveEvent, DashboardRunActivityDay, DashboardSummary, + TimelineActorType, + TimelineEventKind, + TimelineEdgeKind, + WorkTimelineActor, + WorkTimelineSpan, + WorkTimelineEvent, + WorkTimelineEdge, + WorkTimelineResult, ActivityEvent, UserProfileActivitySummary, UserProfileAgentUsage, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 60a60195570..b31757fa759 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -472,6 +472,16 @@ export type { } from "./heartbeat.js"; export type { LiveEvent } from "./live.js"; export type { DashboardRunActivityDay, DashboardSummary } from "./dashboard.js"; +export type { + TimelineActorType, + TimelineEventKind, + TimelineEdgeKind, + WorkTimelineActor, + WorkTimelineSpan, + WorkTimelineEvent, + WorkTimelineEdge, + WorkTimelineResult, +} from "./work-timeline.js"; export type { ActivityEvent } from "./activity.js"; export type { UserProfileActivitySummary, diff --git a/packages/shared/src/types/work-timeline.ts b/packages/shared/src/types/work-timeline.ts new file mode 100644 index 00000000000..01b38382770 --- /dev/null +++ b/packages/shared/src/types/work-timeline.ts @@ -0,0 +1,73 @@ +/** + * Work Timeline (Gantt) types — shared between the aggregation service + * (`server/src/services/work-timeline.ts`) and the UI page + * (`ui/src/pages/Timeline.tsx`). Defined here so both sides consume one contract + * without redefining DTOs. Returned by `GET /api/companies/:companyId/timeline`. + */ + +export type TimelineActorType = "agent" | "user" | "system" | "plugin"; +export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned"; +export type TimelineEdgeKind = "delegation" | "assignment" | "mention"; + +export interface WorkTimelineActor { + /** Namespaced id, e.g. `agent:`, `user:`, `system:`. */ + id: string; + type: TimelineActorType; + name: string; + avatar?: string | null; +} + +export interface WorkTimelineSpan { + actorId: string; + laneHint: string | null; + runId: string; + issueId: string; + issueIdentifier: string | null; + /** Human-readable issue title, shown truncated in the hover tooltip (bars carry no ID). */ + issueTitle: string | null; + /** ISO timestamp of run start. */ + start: string; + /** ISO timestamp of run finish, or null when the run is still in progress. */ + end: string | null; + status: string; + retryOfRunId?: string | null; + continuationAttempt?: number; + invocationSource?: string | null; +} + +export interface WorkTimelineEvent { + actorId: string; + kind: TimelineEventKind; + issueId: string; + /** ISO timestamp. */ + at: string; +} + +export interface WorkTimelineEdge { + fromActorId: string; + toActorId: string; + issueId: string; + /** ISO timestamp. */ + at: string; + kind: TimelineEdgeKind; +} + +export interface WorkTimelineResult { + actors: WorkTimelineActor[]; + spans: WorkTimelineSpan[]; + events: WorkTimelineEvent[]; + edges: WorkTimelineEdge[]; + pagination: { + limit: number; + offset: number; + totalIssues: number; + hasMore: boolean; + }; + window: { + /** ISO timestamp of the window start. */ + from: string; + /** ISO timestamp of the window end. */ + to: string; + capped: boolean; + }; +} diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 4a60e105425..1c32baa70e7 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -12,45 +12,27 @@ import { issueThreadInteractions, } from "@paperclipai/db"; -export type TimelineActorType = "agent" | "user" | "system" | "plugin"; -export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned"; -export type TimelineEdgeKind = "delegation" | "assignment" | "mention"; +// DTO types are shared with the UI via @paperclipai/shared so both sides consume +// one contract. Re-exported here for back-compat with existing server imports. +import type { + TimelineActorType, + WorkTimelineActor, + WorkTimelineSpan, + WorkTimelineEvent, + WorkTimelineEdge, + WorkTimelineResult, +} from "@paperclipai/shared"; -export interface WorkTimelineActor { - id: string; - type: TimelineActorType; - name: string; - avatar?: string | null; -} - -export interface WorkTimelineSpan { - actorId: string; - laneHint: string | null; - runId: string; - issueId: string; - issueIdentifier: string | null; - start: string; - end: string | null; - status: string; - retryOfRunId?: string | null; - continuationAttempt?: number; - invocationSource?: string | null; -} - -export interface WorkTimelineEvent { - actorId: string; - kind: TimelineEventKind; - issueId: string; - at: string; -} - -export interface WorkTimelineEdge { - fromActorId: string; - toActorId: string; - issueId: string; - at: string; - kind: TimelineEdgeKind; -} +export type { + TimelineActorType, + TimelineEventKind, + TimelineEdgeKind, + WorkTimelineActor, + WorkTimelineSpan, + WorkTimelineEvent, + WorkTimelineEdge, + WorkTimelineResult, +} from "@paperclipai/shared"; export interface WorkTimelineQuery { companyId: string; @@ -65,24 +47,6 @@ export interface WorkTimelineQuery { canReadIssue?: (issue: WorkTimelineIssueAccessInput) => Promise; } -export interface WorkTimelineResult { - actors: WorkTimelineActor[]; - spans: WorkTimelineSpan[]; - events: WorkTimelineEvent[]; - edges: WorkTimelineEdge[]; - pagination: { - limit: number; - offset: number; - totalIssues: number; - hasMore: boolean; - }; - window: { - from: string; - to: string; - capped: boolean; - }; -} - export interface WorkTimelineIssueAccessInput { id: string; companyId: string; @@ -100,6 +64,7 @@ type IssueRow = { goalId: string | null; parentId: string | null; identifier: string | null; + title: string | null; createdByAgentId: string | null; createdByUserId: string | null; assigneeAgentId: string | null; @@ -290,6 +255,7 @@ export function workTimelineService(db: Db) { goalId: issues.goalId, parentId: issues.parentId, identifier: issues.identifier, + title: issues.title, createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId, assigneeAgentId: issues.assigneeAgentId, @@ -649,6 +615,7 @@ export function workTimelineService(db: Db) { runId: row.runId, issueId: row.issueId, issueIdentifier: issueById.get(row.issueId)?.identifier ?? null, + issueTitle: issueById.get(row.issueId)?.title ?? null, start: (row.startedAt ?? row.createdAt).toISOString(), end: dateIso(row.finishedAt), status: row.status, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a9e971f7c8..e5c7346f864 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -8,6 +8,7 @@ import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; import { DashboardLive } from "./pages/DashboardLive"; +import { Timeline } from "./pages/Timeline"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; @@ -79,6 +80,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/workTimeline.ts b/ui/src/api/workTimeline.ts new file mode 100644 index 00000000000..42a22b8ebf4 --- /dev/null +++ b/ui/src/api/workTimeline.ts @@ -0,0 +1,31 @@ +import type { WorkTimelineResult } from "@paperclipai/shared"; +import { api } from "./client"; + +export interface WorkTimelineParams { + from?: string; + to?: string; + /** lens: work kicked off / touched by this user. */ + userId?: string; + goalId?: string; + projectId?: string; + issueId?: string; + limit?: number; +} + +function query(params: WorkTimelineParams): string { + const search = new URLSearchParams(); + if (params.from) search.set("from", params.from); + if (params.to) search.set("to", params.to); + if (params.userId) search.set("userId", params.userId); + if (params.goalId) search.set("goalId", params.goalId); + if (params.projectId) search.set("projectId", params.projectId); + if (params.issueId) search.set("issueId", params.issueId); + if (params.limit) search.set("limit", String(params.limit)); + const qs = search.toString(); + return qs ? `?${qs}` : ""; +} + +export const workTimelineApi = { + get: (companyId: string, params: WorkTimelineParams = {}) => + api.get(`/companies/${companyId}/timeline${query(params)}`), +}; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 78cd9b882f8..ade1f437bca 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -18,6 +18,7 @@ import { PanelLeftOpen, Pin, MessagesSquare, + GanttChartSquare, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { NavLink } from "@/lib/router"; @@ -180,6 +181,7 @@ export function Sidebar() { ) : null} + {showWorkspacesLink ? ( diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx new file mode 100644 index 00000000000..1d42452ca2b --- /dev/null +++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import type { WorkTimelineResult } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkTimelineChart } from "./WorkTimelineChart"; + +vi.mock("@/lib/router", () => ({ + useNavigate: () => vi.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); +}); + +function renderChart(data: WorkTimelineResult) { + flushSync(() => { + root.render( + , + ); + }); +} + +function timelineSample(): WorkTimelineResult { + return { + actors: [ + { id: "agent:codex", type: "agent", name: "CodexCoder" }, + { id: "agent:qa", type: "agent", name: "QA" }, + ], + spans: [ + { + actorId: "agent:codex", + laneHint: null, + runId: "run-1", + issueId: "issue-1", + issueIdentifier: "PAP-12443", + issueTitle: "Work Timeline sticky gutter", + start: "2026-07-02T09:00:00.000Z", + end: "2026-07-02T10:00:00.000Z", + status: "completed", + retryOfRunId: null, + }, + { + actorId: "agent:qa", + laneHint: null, + runId: "run-2", + issueId: "issue-2", + issueIdentifier: "PAP-12426", + issueTitle: "QA validation", + start: "2026-07-02T11:00:00.000Z", + end: "2026-07-02T11:30:00.000Z", + status: "completed", + retryOfRunId: null, + }, + ], + events: [], + edges: [], + pagination: { limit: 200, offset: 0, totalIssues: 2, hasMore: false }, + window: { + from: "2026-07-02T00:00:00.000Z", + to: "2026-07-03T00:00:00.000Z", + capped: false, + }, + }; +} + +describe("WorkTimelineChart", () => { + it("renders actor labels in a sticky gutter outside the horizontally scrolling SVG", () => { + renderChart(timelineSample()); + + const scroller = container.querySelector("[data-testid='work-timeline-scroll']"); + const gutter = container.querySelector("[data-testid='work-timeline-actor-gutter']"); + const chartSvg = container.querySelector("svg.absolute"); + + expect(scroller).not.toBeNull(); + expect(gutter).not.toBeNull(); + expect(chartSvg).not.toBeNull(); + expect(gutter?.getAttribute("class")).toContain("sticky"); + expect(gutter?.getAttribute("class")).toContain("left-0"); + expect(gutter?.getAttribute("width")).toBe("176"); + expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width")); + expect(gutter?.textContent).toContain("CodexCoder"); + + flushSync(() => { + scroller!.scrollLeft = 10_000; + scroller!.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + + expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder"); + }); + + it("renders a human row with diamond event markers when the payload carries events", () => { + const data = timelineSample(); + data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" }); + data.events = [ + { actorId: "user:dotta", kind: "created", issueId: "issue-1", at: "2026-07-02T08:30:00.000Z" }, + { actorId: "user:dotta", kind: "commented", issueId: "issue-2", at: "2026-07-02T09:15:00.000Z" }, + { actorId: "user:dotta", kind: "approved", issueId: "issue-1", at: "2026-07-02T10:05:00.000Z" }, + ]; + renderChart(data); + + // Dotta gets a row in the gutter… + const gutter = container.querySelector("[data-testid='work-timeline-actor-gutter']"); + expect(gutter?.textContent).toContain("Dotta"); + // …and her three instant events render as clickable diamond marker paths. + const markers = container.querySelectorAll("svg.absolute path.cursor-pointer"); + expect(markers).toHaveLength(3); + }); +}); diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx new file mode 100644 index 00000000000..7b56b681070 --- /dev/null +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -0,0 +1,576 @@ +/** + * Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422). + * + * Renders actor rows with concurrency sub-lanes, run bars (no issue IDs on the + * bar — identity is the thin left colour tab; truncated title shows on hover), + * kickoff avatar chips at each bar's leading edge (incl. humans), straight + * agent→agent delegation connectors (dashed for retries), an in-progress fade to + * "now", a hover tooltip, and a full-window mini-map with a draggable brush. + */ +import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "@/lib/router"; +import type { + TimelineEventKind, + WorkTimelineActor, + WorkTimelineResult, +} from "@paperclipai/shared"; +import { + AXIS_H, + actorType, + chooseTickStepMs, + computeLayout, + formatDuration, + issueColor, + shortLabel, + type ColorMode, + type LayoutOptions, + type PositionedBar, + type PositionedMarker, +} from "@/lib/timeline/layout"; + +export type ZoomLevel = "hour" | "day" | "week"; + +const ZOOM_PX_PER_MIN: Record = { + hour: 8, + day: 1.6, + week: 0.32, +}; + +/** Pick an initial zoom whose plotted width comfortably fills a typical viewport. */ +export function defaultZoomForWindow(fromMs: number, toMs: number): ZoomLevel { + const hours = (toMs - fromMs) / 3_600_000; + if (hours <= 4) return "hour"; + if (hours <= 48) return "day"; + return "week"; +} + +const GEOM: Omit = { + gutter: 176, + rowH: 34, + barH: 15, + laneGap: 4, +}; +const AVATAR_R = 11; +const CHIP_R = 9; +const MARKER_R = 5.5; + +/** + * Per-kind styling for instant event markers (diamonds). Each kind gets a + * distinct fill + verb so created / commented / approved / delegated / assigned + * read apart at a glance; the hues sit mid-lightness so they hold on light+dark. + */ +const EVENT_STYLE: Record = { + created: { fill: "hsl(145 55% 42%)", verb: "created" }, + commented: { fill: "hsl(212 62% 54%)", verb: "commented on" }, + approved: { fill: "hsl(265 52% 60%)", verb: "approved" }, + delegated: { fill: "hsl(28 78% 52%)", verb: "delegated" }, + assigned: { fill: "hsl(190 58% 44%)", verb: "assigned" }, +}; + +interface TooltipState { + x: number; + y: number; + bar: PositionedBar; +} + +interface MarkerTooltipState { + x: number; + y: number; + marker: PositionedMarker; + /** resolved issue label (identifier/title) or the raw id as a fallback. */ + issueLabel: string; +} + +function fmtClock(ms: number): string { + const d = new Date(ms); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +function fmtTick(ms: number, stepMs: number): string { + const d = new Date(ms); + if (stepMs >= 24 * 60 * 60 * 1000) { + return `${d.getMonth() + 1}/${d.getDate()}`; + } + return fmtClock(ms); +} + +function truncate(text: string, n = 42): string { + return text.length > n ? `${text.slice(0, n - 1)}…` : text; +} + +/** An SVG avatar glyph: square for humans, dashed circle for system, circle for agents. */ +function AvatarGlyph({ + cx, + cy, + r, + label, + type, +}: { + cx: number; + cy: number; + r: number; + label: string; + type: string; +}) { + const stroke = "var(--color-foreground)"; + const fill = type === "system" ? "var(--color-muted)" : "var(--color-card)"; + return ( + + {type === "user" ? ( + + ) : ( + + )} + 10 ? 9 : 8} textAnchor="middle" fill={stroke}> + {label} + + + ); +} + +export interface WorkTimelineChartProps { + data: WorkTimelineResult; + zoom: ZoomLevel; + colorMode: ColorMode; + /** override "now" (tests / stories); defaults to Date.now(). */ + nowMs?: number; +} + +export function WorkTimelineChart({ data, zoom, colorMode, nowMs }: WorkTimelineChartProps) { + const navigate = useNavigate(); + const scrollRef = useRef(null); + const [tooltip, setTooltip] = useState(null); + const [markerTooltip, setMarkerTooltip] = useState(null); + const [scrollLeft, setScrollLeft] = useState(0); + const [viewportW, setViewportW] = useState(0); + + const now = nowMs ?? Date.now(); + const layout = useMemo( + () => computeLayout(data, { ...GEOM, pxPerMinute: ZOOM_PX_PER_MIN[zoom], nowMs: now }), + [data, zoom, now], + ); + + // Resolve an event's issue to its human label (identifier/title) via the legend + // hue map, falling back to the raw id for issues that have no run in-window. + const issueLabelById = useMemo( + () => new Map(layout.issues.map((i) => [i.key, i.label])), + [layout.issues], + ); + + const stepMs = chooseTickStepMs(layout.pxPerMinute); + const ticks: number[] = []; + const startTick = Math.ceil(layout.fromMs / stepMs) * stepMs; + for (let ms = startTick; ms <= layout.toMs; ms += stepMs) ticks.push(ms); + + const barFill = (bar: PositionedBar): string => { + if (colorMode === "status") { + if (bar.running) return "url(#tl-hatchV)"; + if (bar.span.status.includes("change") || bar.span.status.includes("fail") || bar.span.status === "blocked") + return "url(#tl-hatchD)"; + return "var(--color-card)"; + } + return "var(--color-card)"; + }; + + const openIssue = (issueId: string) => navigate(`/issues/${issueId}`); + + const showTooltip = (evt: React.MouseEvent, bar: PositionedBar) => { + setTooltip({ x: evt.clientX, y: evt.clientY, bar }); + }; + + const showMarkerTooltip = (evt: React.MouseEvent, marker: PositionedMarker) => { + const issueLabel = issueLabelById.get(marker.event.issueId) ?? marker.event.issueId; + setMarkerTooltip({ x: evt.clientX, y: evt.clientY, marker, issueLabel }); + }; + + return ( +
+
setScrollLeft(e.currentTarget.scrollLeft)} + > +
+ + + { + if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth); + }} + > + + + + + + + + + + + + + + + + {/* row backgrounds */} + {layout.rows.map((row, i) => ( + + ))} + + {/* gridlines + time labels */} + {ticks.map((ms) => { + const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute; + return ( + + + + {fmtTick(ms, stepMs)} + + + ); + })} + + {/* now line */} + {now >= layout.fromMs && now <= layout.toMs && ( + + )} + + {/* gutter divider + axis baseline */} + + + + {/* connectors (behind bars) */} + {layout.connectors.map((c, i) => { + const ang = (Math.atan2(c.y2 - c.y1, c.x2 - c.x1) * 180) / Math.PI; + return ( + + + + + + ); + })} + + {/* rows: gutter avatar/label, lane baselines, bars, chips */} + {layout.rows.map((row) => { + const cy = row.y + AXIS_H + row.h / 2; + return ( + + + + {truncate(row.actor.name, 18)} + + + {row.actor.type} + + + {Array.from({ length: row.laneCount }).map((_, ln) => { + const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2; + return ( + + ); + })} + + {row.bars.map((bar) => { + const yTop = bar.yTop + AXIS_H; + const w = bar.x2 - bar.x1; + const hue = issueColor(bar.span.issueId); + return ( + + showTooltip(e, bar)} + onMouseLeave={() => setTooltip(null)} + onClick={() => openIssue(bar.span.issueId)} + > + + {/* left colour tab = issue identity (no textual ID on the bar) */} + + {/* in-progress fade to "now" */} + {bar.running && w > 8 && ( + + )} + + {bar.kickoff && ( + + + + )} + + ); + })} + + {/* instant event markers — diamonds at x(event.at) on this row */} + {row.markers.map((marker) => { + const style = EVENT_STYLE[marker.event.kind]; + const mx = marker.x; + const my = marker.yc + AXIS_H; + return ( + showMarkerTooltip(e, marker)} + onMouseLeave={() => setMarkerTooltip(null)} + /> + ); + })} + + ); + })} + +
+
+ + + + {tooltip && } + {markerTooltip && } +
+ ); +} + +function MarkerTooltip({ tooltip }: { tooltip: MarkerTooltipState }) { + const { marker, issueLabel } = tooltip; + const style = EVENT_STYLE[marker.event.kind]; + const atMs = new Date(marker.event.at).getTime(); + const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300); + return ( +
+
+ + {style?.verb ?? marker.event.kind} + {truncate(issueLabel, 28)} +
+
{fmtClock(atMs)}
+
+ ); +} + +function ActorGutter({ rows, height }: { rows: ReturnType["rows"]; height: number }) { + return ( + + ); +} + +function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) { + const { bar } = tooltip; + const startMs = new Date(bar.span.start).getTime(); + const endMs = bar.span.end ? new Date(bar.span.end).getTime() : now; + const title = bar.span.issueTitle ?? bar.span.issueIdentifier ?? "run"; + const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300); + return ( +
+
{truncate(title)}
+
+ {fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "} + {bar.span.status} +
+ {bar.kickoff && ( +
+ kicked off by: {(bar.kickoff as WorkTimelineActor).name} + {bar.span.retryOfRunId ? " · retry" : ""} +
+ )} +
click → open task
+
+ ); +} + +function MiniMap({ + layout, + scrollRef, + viewportW, + scrollLeft, +}: { + layout: ReturnType; + scrollRef: React.RefObject; + viewportW: number; + scrollLeft: number; +}) { + const W = Math.max(320, viewportW || 900); + const H = 54; + const pad = 8; + const spanMs = layout.toMs - layout.fromMs || 1; + const mx = (ms: number) => pad + ((ms - layout.fromMs) / spanMs) * (W - 2 * pad); + + // one thin tick per run, stacked by row order + const rowIndex = new Map(layout.rows.map((r, i) => [r.actor.id, i])); + const laneH = (H - 2 * pad) / Math.max(1, layout.rows.length); + + const frac = layout.width > 0 ? (viewportW || W) / layout.width : 1; + const brushW = Math.max(24, Math.min(1, frac) * (W - 2 * pad)); + const brushX = pad + (layout.width > 0 ? scrollLeft / layout.width : 0) * (W - 2 * pad); + + const seek = (clientX: number, el: SVGSVGElement) => { + const rect = el.getBoundingClientRect(); + const f = Math.min(1, Math.max(0, (clientX - rect.left - pad) / (W - 2 * pad))); + if (scrollRef.current) { + scrollRef.current.scrollLeft = f * layout.width - scrollRef.current.clientWidth / 2; + } + }; + + return ( +
+ { + const el = e.currentTarget; + seek(e.clientX, el); + const move = (ev: MouseEvent) => seek(ev.clientX, el); + const up = () => { + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + }; + document.addEventListener("mousemove", move); + document.addEventListener("mouseup", up); + }} + > + + {layout.rows.flatMap((row) => + row.bars.map((bar) => { + const startMs = new Date(bar.span.start).getTime(); + const endMs = bar.span.end ? new Date(bar.span.end).getTime() : layout.toMs; + const yy = pad + (rowIndex.get(row.actor.id) ?? 0) * laneH; + return ( + + ); + }), + )} + + +
+ ); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 2fbc9a92245..011e3c5cd63 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -251,6 +251,7 @@ export const queryKeys = { ["company-search", companyId, q, scope, limit, offset] as const, }, dashboard: (companyId: string) => ["dashboard", 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, sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const, diff --git a/ui/src/lib/timeline/layout.test.ts b/ui/src/lib/timeline/layout.test.ts new file mode 100644 index 00000000000..4bb95d31690 --- /dev/null +++ b/ui/src/lib/timeline/layout.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import type { WorkTimelineResult } from "@paperclipai/shared"; +import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout"; + +const DAY = "2026-07-02"; +const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`; + +// A trimmed version of the board's locked-design sample (PAP-12422), expressed +// in the real endpoint contract: actor→actor edges rather than run→run. +function sample(): WorkTimelineResult { + const actors = [ + { id: "user:dotta", type: "user" as const, name: "dotta" }, + { id: "agent:ceo", type: "agent" as const, name: "CEO" }, + { id: "agent:cto", type: "agent" as const, name: "CTO Architect" }, + { id: "agent:ux", type: "agent" as const, name: "UXDesigner" }, + { id: "agent:senior", type: "agent" as const, name: "SeniorEngineer" }, + { id: "agent:codex", type: "agent" as const, name: "CodexCoder" }, + { id: "agent:qa", type: "agent" as const, name: "QA" }, + { id: "system:routine", type: "system" as const, name: "Circleback" }, + ]; + const span = ( + runId: string, + actorId: string, + issueId: string, + identifier: string, + start: string, + end: string | null, + status: string, + retryOfRunId: string | null = null, + ): WorkTimelineResult["spans"][number] => ({ + actorId, + laneHint: null, + runId, + issueId, + issueIdentifier: identifier, + issueTitle: `${identifier} title`, + start: t(start), + end: end ? t(end) : null, + status, + retryOfRunId, + }); + const spans = [ + span("r1", "agent:ceo", "i-405", "PAP-12405", "09:02", "09:10", "completed"), + span("r2", "agent:cto", "i-405", "PAP-12405", "09:14", "09:52", "completed"), + span("r3", "agent:senior", "i-075", "PAP-12075", "09:58", "10:20", "completed"), + span("r4", "agent:ux", "i-422", "PAP-12422", "10:10", "11:34", "running"), + span("r5", "agent:codex", "i-423", "PAP-12423", "10:10", "10:56", "completed"), + span("r6", "agent:codex", "i-075", "PAP-12075", "10:22", "11:08", "completed"), // overlaps r5 → sub-lane + span("r7", "agent:qa", "i-423", "PAP-12423", "11:40", "12:12", "completed"), + span("r8", "agent:codex", "i-423", "PAP-12423", "12:20", "12:38", "completed", "r5"), // retry + span("r9", "system:routine", "i-286", "PAP-12286", "20:00", "20:04", "completed"), + ]; + const edge = (from: string, to: string, issueId: string, at: string): WorkTimelineResult["edges"][number] => ({ + fromActorId: from, + toActorId: to, + issueId, + at: t(at), + kind: "delegation", + }); + const edges = [ + edge("user:dotta", "agent:ceo", "i-405", "09:01"), // human kickoff → chip only, no line + edge("agent:ceo", "agent:cto", "i-405", "09:13"), + edge("agent:cto", "agent:ux", "i-422", "10:09"), + edge("agent:cto", "agent:codex", "i-423", "10:09"), + edge("agent:senior", "agent:codex", "i-075", "10:21"), + edge("agent:codex", "agent:qa", "i-423", "11:39"), + edge("agent:qa", "agent:codex", "i-423", "12:19"), // → retry r8 (dashed) + ]; + return { + actors, + spans, + events: [], + edges, + pagination: { limit: 200, offset: 0, totalIssues: 6, hasMore: false }, + window: { from: t("09:00"), to: t("20:15"), capped: false }, + }; +} + +const OPTS: LayoutOptions = { + pxPerMinute: 1.7, + gutter: 172, + rowH: 34, + barH: 15, + laneGap: 4, + nowMs: new Date(t("13:00")).getTime(), +}; + +describe("computeLayout", () => { + it("excludes humans from rows but keeps agents and system actors", () => { + const layout = computeLayout(sample(), OPTS); + const rowActorTypes = layout.rows.map((r) => r.actor.type); + expect(rowActorTypes).not.toContain("user"); + expect(layout.rows.map((r) => r.actor.id)).toContain("system:routine"); + expect(layout.rows).toHaveLength(7); // 6 agents + 1 system + }); + + it("orders rows by first activity", () => { + const layout = computeLayout(sample(), OPTS); + expect(layout.rows[0].actor.id).toBe("agent:ceo"); + expect(layout.rows[layout.rows.length - 1].actor.id).toBe("system:routine"); + }); + + it("packs overlapping runs into concurrency sub-lanes", () => { + const layout = computeLayout(sample(), OPTS); + const codex = layout.rows.find((r) => r.actor.id === "agent:codex")!; + expect(codex.laneCount).toBe(2); // r5 and r6 overlap + const lanesTop = new Set(codex.bars.map((b) => b.yTop)); + expect(lanesTop.size).toBeGreaterThan(1); + }); + + it("derives the kickoff actor for a run (incl. human) from edges", () => { + const layout = computeLayout(sample(), OPTS); + const codexApi = layout.rows + .find((r) => r.actor.id === "agent:codex")! + .bars.find((b) => b.span.runId === "r5")!; + expect(codexApi.kickoff?.id).toBe("agent:cto"); + + const ceoRun = layout.rows.find((r) => r.actor.id === "agent:ceo")!.bars[0]; + expect(ceoRun.kickoff?.id).toBe("user:dotta"); // human kickoff shown as chip + }); + + it("draws agent→agent connectors only, dashing retries, never from a human", () => { + const layout = computeLayout(sample(), OPTS); + // 6 agent→agent edges resolve to bars; the dotta→ceo human edge draws no line. + expect(layout.connectors.length).toBe(6); + const dashed = layout.connectors.filter((c) => c.dashed); + expect(dashed).toHaveLength(1); // only the QA→codex retry hop + // every connector is left-to-right (source trailing edge → target leading edge) + for (const c of layout.connectors) expect(c.x2).toBeGreaterThanOrEqual(c.x1 - 1); + }); + + it("extends in-progress runs to now and clamps sub-minute bars", () => { + const layout = computeLayout(sample(), OPTS); + const running = layout.rows + .find((r) => r.actor.id === "agent:ux")! + .bars.find((b) => b.span.runId === "r4")!; + expect(running.running).toBe(true); + expect(running.x2).toBeGreaterThan(running.x1 + 3); + }); + + it("produces a deterministic issue hue + legend", () => { + const layout = computeLayout(sample(), OPTS); + expect(layout.issues.length).toBe(5); // i-405, i-075, i-422, i-423, i-286 + expect(issueColor("i-405")).toBe(issueColor("i-405")); + }); +}); + +describe("human activity markers", () => { + const withUserEvents = (): WorkTimelineResult => ({ + ...sample(), + events: [ + { actorId: "user:dotta", kind: "created", issueId: "i-405", at: t("09:00") }, + { actorId: "user:dotta", kind: "commented", issueId: "i-422", at: t("11:00") }, + { actorId: "user:dotta", kind: "approved", issueId: "i-423", at: t("12:15") }, + ], + }); + + it("gives a human actor a row driven purely by their in-window events", () => { + const layout = computeLayout(withUserEvents(), OPTS); + const dotta = layout.rows.find((r) => r.actor.id === "user:dotta"); + expect(dotta).toBeDefined(); + expect(dotta!.actor.type).toBe("user"); + expect(dotta!.bars).toHaveLength(0); // humans have no runs + }); + + it("positions instant markers at x(event.at) on the owning row, time-ordered", () => { + const layout = computeLayout(withUserEvents(), OPTS); + const dotta = layout.rows.find((r) => r.actor.id === "user:dotta")!; + expect(dotta.markers).toHaveLength(3); + expect(dotta.markers.map((m) => m.event.kind)).toEqual(["created", "commented", "approved"]); + const xs = dotta.markers.map((m) => m.x); + expect(xs).toEqual([...xs].sort((a, b) => a - b)); // monotonic in time + for (const m of dotta.markers) expect(m.yc).toBeCloseTo(dotta.y + dotta.h / 2); + }); + + it("keeps humans marker-only: no run bars, no connectors target them", () => { + const layout = computeLayout(withUserEvents(), OPTS); + // dotta→ceo human kickoff still draws no line; agent→agent count unchanged. + expect(layout.connectors.length).toBe(6); + }); + + it("still excludes a human with no in-window events", () => { + const layout = computeLayout(sample(), OPTS); // events: [] + expect(layout.rows.map((r) => r.actor.type)).not.toContain("user"); + }); +}); + +describe("helpers", () => { + it("shortLabel builds 2-char initials", () => { + expect(shortLabel("CodexCoder")).toBe("CO"); + expect(shortLabel("CTO Architect")).toBe("CA"); + }); + + it("chooseTickStepMs grows the step as the view zooms out", () => { + expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4)); + }); +}); diff --git a/ui/src/lib/timeline/layout.ts b/ui/src/lib/timeline/layout.ts new file mode 100644 index 00000000000..ca99dd1fecc --- /dev/null +++ b/ui/src/lib/timeline/layout.ts @@ -0,0 +1,342 @@ +/** + * Work Timeline layout — pure transform from the Phase B endpoint contract + * (`WorkTimelineResult`) into a renderable view model for the custom-SVG Gantt. + * + * Ports the board-locked "Direction C" logic (PAP-12422): agent/system rows only + * (humans never get a row), overlapping runs packed into concurrency sub-lanes, + * a kickoff actor derived per run (shown as an avatar chip — may be a human), + * and straight agent→agent delegation connectors from a source bar's trailing + * edge to a target bar's leading edge (dashed for retries / changes-requested). + * + * Everything here is deterministic given (result, options) so it can be unit + * tested without a DOM. + */ +import type { + WorkTimelineActor, + WorkTimelineEdge, + WorkTimelineEvent, + WorkTimelineResult, + WorkTimelineSpan, +} from "@paperclipai/shared"; + +export type ColorMode = "issue" | "status"; + +export interface LayoutOptions { + /** px per minute along the x axis (set by the zoom level). */ + pxPerMinute: number; + /** width of the left actor gutter in px. */ + gutter: number; + /** row height in px. */ + rowH: number; + /** bar height in px. */ + barH: number; + /** vertical gap between concurrency sub-lanes in px. */ + laneGap: number; + /** wall-clock "now" in ms, used to close in-progress runs. */ + nowMs: number; +} + +export interface PositionedBar { + span: WorkTimelineSpan; + /** leading (start) x in px. */ + x1: number; + /** trailing (end) x in px. */ + x2: number; + /** vertical center of the bar in px. */ + yc: number; + /** top of the bar in px. */ + yTop: number; + height: number; + running: boolean; + /** the actor who kicked this run off, if resolvable (may be a human/user). */ + kickoff: WorkTimelineActor | null; +} + +/** + * An instant human/actor action (issue created, comment, approval, delegation, + * assignment) — plotted as a diamond marker at its timestamp on the actor's row. + * Humans have no "runs", so these markers are their only presence on the chart. + */ +export interface PositionedMarker { + event: WorkTimelineEvent; + /** x position (px) of the marker centre = x(event.at). */ + x: number; + /** vertical centre of the marker in px (row-relative, excludes axis offset). */ + yc: number; +} + +export interface ActorRow { + actor: WorkTimelineActor; + /** top of the row (excluding axis offset) in px. */ + y: number; + /** row height in px. */ + h: number; + laneCount: number; + bars: PositionedBar[]; + /** instant event markers (issue created / comment / approval / …) on this row. */ + markers: PositionedMarker[]; +} + +export interface Connector { + x1: number; + y1: number; + x2: number; + y2: number; + /** dashed = a return / retry (changes-requested) hop. */ + dashed: boolean; +} + +export interface TimelineLayout { + rows: ActorRow[]; + connectors: Connector[]; + /** full inner width of the chart (gutter + plotted time + pad). */ + width: number; + /** full height of the chart including the axis strip. */ + height: number; + /** domain start (ms). */ + fromMs: number; + /** domain end (ms). */ + toMs: number; + gutter: number; + pxPerMinute: number; + /** ordered list of distinct issue keys present, for the legend + hue map. */ + issues: { key: string; label: string; color: string }[]; +} + +export const AXIS_H = 22; +const RUNNING_STATUSES = new Set(["running", "in_progress", "queued", "pending"]); + +export function isRunningStatus(status: string): boolean { + return RUNNING_STATUSES.has(status); +} + +export function actorType(actor: WorkTimelineActor | undefined): string { + return actor?.type ?? "system"; +} + +/** Deterministic, stable hue per issue that reads on both light and dark. */ +export function issueColor(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = (hash * 31 + key.charCodeAt(i)) & 0xffffffff; + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue} 62% 52%)`; +} + +/** 2-char initials for an SVG avatar chip. */ +export function shortLabel(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return (name.slice(0, 2) || "?").toUpperCase(); +} + +function spanStartMs(s: WorkTimelineSpan): number { + return new Date(s.start).getTime(); +} + +function spanEndMs(s: WorkTimelineSpan, nowMs: number): number { + const raw = s.end ? new Date(s.end).getTime() : nowMs; + return raw; +} + +/** + * Resolve the kickoff actor for a run: the source of the delegation/assignment + * edge that points at this run's actor on this run's issue, closest at-or-before + * the run start (falling back to the nearest such edge). Mirrors the design's + * "avatar chip at the leading edge = who kicked it off". + */ +function resolveKickoff( + span: WorkTimelineSpan, + edges: WorkTimelineEdge[], + actorById: Map, +): WorkTimelineActor | null { + const start = spanStartMs(span); + let best: { edge: WorkTimelineEdge; delta: number } | null = null; + for (const e of edges) { + if (e.toActorId !== span.actorId || e.issueId !== span.issueId) continue; + if (e.fromActorId === span.actorId) continue; // self-kickoff is not a delegation + const at = new Date(e.at).getTime(); + // Prefer edges at-or-before the run start; otherwise smallest absolute gap. + const delta = at <= start ? start - at : (start - at) + 1e12; + if (!best || delta < best.delta) best = { edge: e, delta }; + } + if (!best) return null; + return actorById.get(best.edge.fromActorId) ?? null; +} + +export function computeLayout(result: WorkTimelineResult, opts: LayoutOptions): TimelineLayout { + const { pxPerMinute, gutter, rowH, barH, laneGap, nowMs } = opts; + const fromMs = new Date(result.window.from).getTime(); + const toMs = new Date(result.window.to).getTime(); + const actorById = new Map(result.actors.map((a) => [a.id, a])); + + const x = (ms: number) => gutter + ((ms - fromMs) / 60000) * pxPerMinute; + + // Rows: any actor with in-window activity gets a row — agents/system via their + // runs (spans), and humans (type "user") via their instant events, since humans + // have no "runs" and would otherwise never appear. Order by first activity so the + // eye follows the delegation chain top-to-bottom. + const firstActivity = new Map(); + const noteActivity = (actorId: string, t: number) => { + const cur = firstActivity.get(actorId); + if (cur === undefined || t < cur) firstActivity.set(actorId, t); + }; + for (const s of result.spans) noteActivity(s.actorId, spanStartMs(s)); + for (const e of result.events) noteActivity(e.actorId, new Date(e.at).getTime()); + + // Instant events grouped by the actor who performed them. + const eventsByActor = new Map(); + for (const e of result.events) { + const arr = eventsByActor.get(e.actorId); + if (arr) arr.push(e); + else eventsByActor.set(e.actorId, [e]); + } + + const rowActors = result.actors + .filter((a) => firstActivity.has(a.id)) // drop idle actors with no run/event in-window + .sort((a, b) => (firstActivity.get(a.id)! - firstActivity.get(b.id)!)); + + // Issue hue map (ordered by first appearance) for the "by issue" color mode + legend. + const issueOrder: string[] = []; + const issueLabel = new Map(); + for (const s of result.spans) { + const key = s.issueId; + if (!issueLabel.has(key)) { + issueOrder.push(key); + issueLabel.set(key, s.issueIdentifier ?? s.issueTitle ?? "issue"); + } + } + const issues = issueOrder.map((key) => ({ key, label: issueLabel.get(key)!, color: issueColor(key) })); + + const barIndex = new Map(); // runId -> bar + const rows: ActorRow[] = []; + let y = 0; + for (const actor of rowActors) { + const runs = result.spans + .filter((s) => s.actorId === actor.id) + .sort((p, q) => spanStartMs(p) - spanStartMs(q)); + + // Greedy pack overlapping runs into sub-lanes. + const laneEnds: number[] = []; + const laneOf = new Map(); + for (const r of runs) { + const rs = spanStartMs(r); + const re = spanEndMs(r, nowMs); + let placed = -1; + for (let ln = 0; ln < laneEnds.length; ln++) { + if (laneEnds[ln] <= rs) { + placed = ln; + break; + } + } + if (placed === -1) { + placed = laneEnds.length; + laneEnds.push(re); + } else { + laneEnds[placed] = re; + } + laneOf.set(r.runId, placed); + } + const laneCount = Math.max(1, laneEnds.length); + const h = Math.max(rowH, laneCount * (barH + laneGap) + 8); + + const bars: PositionedBar[] = runs.map((r) => { + const lane = laneOf.get(r.runId) ?? 0; + const laneTop = y + 6 + lane * (barH + laneGap); + const x1 = x(spanStartMs(r)); + const x2raw = x(spanEndMs(r, nowMs)); + const x2 = Math.max(x1 + 3, x2raw); // clamp sub-minute runs to a visible min width + const bar: PositionedBar = { + span: r, + x1, + x2, + yTop: laneTop, + yc: laneTop + barH / 2, + height: barH, + running: isRunningStatus(r.status), + kickoff: resolveKickoff(r, result.edges, actorById), + }; + barIndex.set(r.runId, bar); + return bar; + }); + + // Instant markers for this actor, positioned at their timestamp on this row. + const markers: PositionedMarker[] = (eventsByActor.get(actor.id) ?? []) + .slice() + .sort((p, q) => new Date(p.at).getTime() - new Date(q.at).getTime()) + .map((event) => ({ + event, + x: x(new Date(event.at).getTime()), + yc: y + h / 2, + })); + + rows.push({ actor, y, h, laneCount, bars, markers }); + y += h; + } + + // Connectors: straight agent→agent lines, connected at both ends. For each bar + // with an agent/system kickoff, connect from the kickoff actor's nearest + // preceding bar (same issue preferred) to this bar's leading edge. + const connectors: Connector[] = []; + for (const row of rows) { + for (const bar of row.bars) { + const k = bar.kickoff; + if (!k || k.type === "user") continue; // agent→agent only; humans stay as chips + const source = nearestSourceBar(k.id, bar, barIndex, nowMs); + if (!source) continue; + connectors.push({ + x1: source.x2, + y1: source.yc, + x2: bar.x1, + y2: bar.yc, + dashed: Boolean(bar.span.retryOfRunId), + }); + } + } + + const width = gutter + ((toMs - fromMs) / 60000) * pxPerMinute + 40; + const height = y + AXIS_H; + return { rows, connectors, width, height, fromMs, toMs, gutter, pxPerMinute, issues }; +} + +/** The kickoff actor's bar that best precedes `target` (same issue preferred). */ +function nearestSourceBar( + kickoffActorId: string, + target: PositionedBar, + barIndex: Map, + nowMs: number, +): PositionedBar | null { + const targetStart = spanStartMs(target.span); + let sameIssue: PositionedBar | null = null; + let anyIssue: PositionedBar | null = null; + for (const bar of barIndex.values()) { + if (bar.span.actorId !== kickoffActorId) continue; + if (bar === target) continue; + const end = spanEndMs(bar.span, nowMs); + if (end > targetStart + 1) continue; // must precede (small tolerance) + if (bar.span.issueId === target.span.issueId) { + if (!sameIssue || spanEndMs(sameIssue.span, nowMs) < end) sameIssue = bar; + } + if (!anyIssue || spanEndMs(anyIssue.span, nowMs) < end) anyIssue = bar; + } + return sameIssue ?? anyIssue; +} + +/** Choose a "nice" gridline step (ms) targeting ~120px between labels. */ +export function chooseTickStepMs(pxPerMinute: number): number { + const targetPx = 120; + const minutesPerTarget = targetPx / pxPerMinute; + const steps = [15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080]; // minutes + for (const m of steps) { + if (m >= minutesPerTarget) return m * 60000; + } + return steps[steps.length - 1] * 60000; +} + +export function formatDuration(startMs: number, endMs: number): string { + const mins = Math.max(0, Math.round((endMs - startMs) / 60000)); + if (mins >= 1440) return `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`; + if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`; + return `${mins}m`; +} diff --git a/ui/src/pages/Timeline.tsx b/ui/src/pages/Timeline.tsx new file mode 100644 index 00000000000..a8efad08285 --- /dev/null +++ b/ui/src/pages/Timeline.tsx @@ -0,0 +1,242 @@ +/** + * Work Timeline page (PAP-12424 / Phase C of PAP-12405). + * + * A Gantt-style view of company actor activity built on the Phase B endpoint + * (`GET /companies/:companyId/timeline`). Rendering is the board-locked + * Direction C (PAP-12422): dense rows, mini-map brush, custom inline SVG. + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { GanttChartSquare } from "lucide-react"; +import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared"; +import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCompany } from "@/context/CompanyContext"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { EmptyState } from "@/components/EmptyState"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { WorkTimelineChart, defaultZoomForWindow, type ZoomLevel } from "@/components/timeline/WorkTimelineChart"; +import { issueColor, type ColorMode } from "@/lib/timeline/layout"; +import { cn } from "@/lib/utils"; + +const EVERYONE = "__everyone__"; + +function Segmented({ + value, + options, + onChange, +}: { + value: T; + options: { value: T; label: string }[]; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((opt, i) => ( + + ))} +
+ ); +} + +export function Timeline() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const [zoom, setZoom] = useState("day"); + const zoomTouched = useRef(false); + const setZoomManual = (z: ZoomLevel) => { + zoomTouched.current = true; + setZoom(z); + }; + const [colorMode, setColorMode] = useState("issue"); + const [lensUserId, setLensUserId] = useState(EVERYONE); + // Union of users discovered across fetches so the lens list stays stable. + const [knownUsers, setKnownUsers] = useState([]); + + useEffect(() => { + setBreadcrumbs([{ label: "Timeline" }]); + }, [setBreadcrumbs]); + + const params: WorkTimelineParams = useMemo( + () => (lensUserId === EVERYONE ? {} : { userId: lensUserId.replace(/^user:/, "") }), + [lensUserId], + ); + + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.workTimeline(selectedCompanyId ?? "", lensUserId), + queryFn: () => workTimelineApi.get(selectedCompanyId!, params), + enabled: !!selectedCompanyId, + }); + + useEffect(() => { + if (!data || zoomTouched.current) return; + setZoom(defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime())); + }, [data]); + + useEffect(() => { + if (!data) return; + setKnownUsers((prev) => { + const byId = new Map(prev.map((u) => [u.id, u])); + for (const a of data.actors) if (a.type === "user") byId.set(a.id, a); + return Array.from(byId.values()); + }); + }, [data]); + + if (!selectedCompanyId) { + return ; + } + + const header = ( +
+
+ +

Work Timeline

+
+

+ A Gantt view of who did what, when. Rows are actors; bars are heartbeat runs colored by task; + the avatar chip at a bar's leading edge is who kicked it off; straight lines are agent→agent + delegation. Hover a bar for its task & timing; click to open the task. +

+
+ ); + + const toolbar = ( +
+ + + +
+ ); + + return ( +
+ {header} + {toolbar} + + {isLoading && } + + {error && ( + + )} + + {data && !isLoading && ( + data.spans.length === 0 ? ( + + ) : ( +
+ +
+ +
+

+ {data.spans.length} run{data.spans.length === 1 ? "" : "s"} ·{" "} + {new Date(data.window.from).toLocaleString()} → {new Date(data.window.to).toLocaleString()} + {data.window.capped ? " · window capped" : ""} +

+
+ ) + )} +
+ ); +} + +function Legend({ data, colorMode }: { data: WorkTimelineResult; colorMode: ColorMode }) { + if (colorMode === "status") { + return ( +
+ + done + + + {" "} + in progress + + + {" "} + changes/blocked + +
+ ); + } + const issues = Array.from( + new Map(data.spans.map((s) => [s.issueId, s.issueIdentifier ?? s.issueTitle ?? "task"])).entries(), + ); + return ( +
+ {issues.slice(0, 12).map(([id, label]) => ( + + + {label} + + ))} + {issues.length > 12 && +{issues.length - 12} more} +
+ ); +} diff --git a/ui/storybook/fixtures/workTimeline.human.sample.json b/ui/storybook/fixtures/workTimeline.human.sample.json new file mode 100644 index 00000000000..abbedf42279 --- /dev/null +++ b/ui/storybook/fixtures/workTimeline.human.sample.json @@ -0,0 +1,1824 @@ +{ + "actors": [ + { + "id": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "type": "agent", + "name": "QA", + "avatar": "bug" + }, + { + "id": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "type": "agent", + "name": "CTO", + "avatar": "file-code" + }, + { + "id": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "type": "agent", + "name": "Senior Engineer Fable", + "avatar": "code" + }, + { + "id": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "type": "agent", + "name": "UXDesigner", + "avatar": "gem" + }, + { + "id": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "type": "user", + "name": "Dotta", + "avatar": "/api/assets/3b1e4f9b-6136-4315-8484-fd9f441aae54/content" + }, + { + "id": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "type": "agent", + "name": "CodexCoder", + "avatar": "code" + }, + { + "id": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "type": "agent", + "name": "CloudOpsEngineer", + "avatar": "cog" + }, + { + "id": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "type": "agent", + "name": "CEO", + "avatar": "crown" + }, + { + "id": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "type": "agent", + "name": "Strategic Ops", + "avatar": "target" + }, + { + "id": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "type": "agent", + "name": "Senior Planning Engineer Pro", + "avatar": "code" + }, + { + "id": "system:system", + "type": "system", + "name": "System", + "avatar": null + }, + { + "id": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "type": "agent", + "name": "DevRel", + "avatar": "globe" + }, + { + "id": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "type": "agent", + "name": "SecurityEngineer", + "avatar": "shield" + }, + { + "id": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "type": "agent", + "name": "ClaudeCoder", + "avatar": "code" + }, + { + "id": "agent:db1c09f1-422c-48c8-9711-4054d3657aac", + "type": "agent", + "name": "ConnectionCoder", + "avatar": "code" + } + ], + "spans": [ + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "571d1495-fe7e-4cf8-a79f-e4d8d779cecf", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "issueIdentifier": "PAP-12298", + "issueTitle": "blog page redesign", + "start": "2026-07-02T14:42:05.689Z", + "end": "2026-07-02T14:45:37.694Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "assignment", + "runId": "c7a242bb-adf1-46e2-a322-474ed791c7ff", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "issueIdentifier": "PAP-12420", + "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", + "start": "2026-07-02T14:44:59.649Z", + "end": "2026-07-02T14:48:23.151Z", + "status": "cancelled", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "automation", + "runId": "c214c9f0-b46b-47de-ba86-2520e1a8aa75", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "issueIdentifier": "PAP-12420", + "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", + "start": "2026-07-02T14:48:23.194Z", + "end": "2026-07-02T14:52:29.939Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "automation", + "runId": "6b0a55a9-7c1a-4d48-bdfb-159370e9c03d", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T14:50:35.277Z", + "end": "2026-07-02T14:55:43.520Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "laneHint": "assignment", + "runId": "e573c5dc-94b9-4b63-bdb3-c48327a85cea", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "issueIdentifier": "PAP-12421", + "issueTitle": "CTO/admin: attach corrected pages.paperclip.ing S3 policy to paperclip-page-uploader", + "start": "2026-07-02T14:52:04.426Z", + "end": "2026-07-02T14:56:08.638Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "laneHint": "assignment", + "runId": "8a716a87-61e2-4667-9cc4-3641fe42c6f0", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "issueIdentifier": "PAP-12422", + "issueTitle": "Work Timeline — Phase A: wireframe exploration (2–3 directions, board picks one)", + "start": "2026-07-02T14:52:21.788Z", + "end": "2026-07-02T15:07:44.596Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "4b085459-77b0-41e4-a62f-a76e1ba9b521", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "issueIdentifier": "PAP-12423", + "issueTitle": "Work Timeline — Phase B: timeline aggregation endpoint (GET /companies/:id/timeline)", + "start": "2026-07-02T14:52:21.929Z", + "end": "2026-07-02T15:03:54.376Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "972b6b5b-5fdd-419d-aea0-5641b4634448", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "issueIdentifier": "PAP-12427", + "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", + "start": "2026-07-02T14:54:41.918Z", + "end": "2026-07-02T14:57:27.722Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "d14a8901-4569-4f05-b8c2-5e49d4216a6d", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "issueIdentifier": "PAP-12428", + "issueTitle": "just change the terminology to “Responsible”", + "start": "2026-07-02T14:55:00.330Z", + "end": "2026-07-02T15:01:10.197Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "77ffd755-a755-41e6-b2cb-ee0817f11152", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T14:55:43.595Z", + "end": "2026-07-02T14:57:12.235Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "laneHint": "automation", + "runId": "960639c7-f6cc-45ad-b746-22ca507c237d", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T14:57:12.287Z", + "end": "2026-07-02T15:12:26.352Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "assignment", + "runId": "a61b657c-d297-43b7-948c-2be7bd161e3b", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "issueIdentifier": "PAP-12427", + "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", + "start": "2026-07-02T14:57:27.831Z", + "end": "2026-07-02T15:01:08.081Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "67f65027-b802-4e88-91a6-bebb5c94f16b", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "issueIdentifier": "PAP-12429", + "issueTitle": "give the AWS_ keys that ClaudeCoder has to CodexCoder and CloudOpsEngineer and UXDesigner", + "start": "2026-07-02T14:57:50.711Z", + "end": "2026-07-02T15:01:18.973Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "e9ff0a09-6607-4793-90f3-1b3ab1741ef0", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "issueIdentifier": "PAP-12430", + "issueTitle": "merged Create a single PR from this branch PAP-12404-inbox-blocked-but-descendent-live-icon", + "start": "2026-07-02T14:59:42.783Z", + "end": "2026-07-02T15:17:12.936Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "laneHint": "assignment", + "runId": "91f67e3b-18ae-4230-8fae-18b414ffcd14", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "issueIdentifier": "PAP-12431", + "issueTitle": "2026-07-02 create a standup message i can copy to slack about everything i've been working on for the last 36 hours", + "start": "2026-07-02T15:00:19.704Z", + "end": "2026-07-02T15:05:16.672Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "e08e32ae-9106-46e0-9c2b-97ab74520fc4", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "issueIdentifier": "PAP-12432", + "issueTitle": "CEO/admin: apply paperclip-page-uploader pages.paperclip.ing inline policy", + "start": "2026-07-02T15:00:46.697Z", + "end": "2026-07-02T15:06:05.586Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "laneHint": "automation", + "runId": "12223c0b-0bad-4b95-be56-ceb1ce955b0f", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "issueIdentifier": "PAP-12425", + "issueTitle": "Work Timeline — security review: timeline endpoint ACLs and data exposure", + "start": "2026-07-02T15:01:28.563Z", + "end": "2026-07-02T15:05:43.985Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "laneHint": "assignment", + "runId": "33181a90-d2af-4081-9deb-253d996d81f1", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "issueIdentifier": "PAP-12433", + "issueTitle": "project star’ing", + "start": "2026-07-02T15:03:27.392Z", + "end": "2026-07-02T15:52:11.373Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "laneHint": "assignment", + "runId": "0961f6e8-a1cd-4493-9979-99775285c748", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "issueIdentifier": "PAP-12434", + "issueTitle": "Strategic Ops: get AWS admin to attach pages.paperclip.ing uploader policy", + "start": "2026-07-02T15:05:38.870Z", + "end": "2026-07-02T15:08:15.576Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "assignment", + "runId": "0ecdb797-e6b4-4f12-a5f0-b7bce348138f", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "issueIdentifier": "PAP-12435", + "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", + "start": "2026-07-02T15:07:45.699Z", + "end": "2026-07-02T15:11:13.138Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "assignment", + "runId": "bcfaef62-ca9a-4a96-b279-bf2314108dbf", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "issueIdentifier": "PAP-12403", + "issueTitle": "github and environment variables", + "start": "2026-07-02T15:09:15.958Z", + "end": "2026-07-02T15:20:22.053Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "automation", + "runId": "e4e854bc-9cce-4c5f-bfb9-a2f3c0094075", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "issueIdentifier": "PAP-12420", + "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", + "start": "2026-07-02T15:10:25.014Z", + "end": "2026-07-02T15:15:34.966Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "laneHint": "assignment", + "runId": "b83d9b78-f63c-465c-808c-c1e1175a18c5", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "issueIdentifier": "PAP-12436", + "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", + "start": "2026-07-02T15:10:51.773Z", + "end": "2026-07-02T15:14:59.703Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "laneHint": "automation", + "runId": "90cd6835-be7c-4dd8-bdf7-017d40df9c38", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:12:26.440Z", + "end": "2026-07-02T15:14:56.976Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "f4d1d8a6-c74d-4d59-b831-b788eeaf93f2", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "issueIdentifier": "PAP-12437", + "issueTitle": "for parent task, cleanup the effecitvely merged ones", + "start": "2026-07-02T15:14:30.602Z", + "end": "2026-07-02T15:20:22.033Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "automation", + "runId": "cd8cf33f-5a1c-4b3a-905e-aabc0341edd1", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:14:57.094Z", + "end": "2026-07-02T15:16:53.870Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "a9ba6322-e0e3-43bb-bc0b-2af53bec9610", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "issueIdentifier": "PAP-12436", + "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", + "start": "2026-07-02T15:14:59.782Z", + "end": "2026-07-02T15:20:10.000Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "df0d6959-ef5f-4a75-b710-492cfd6de658", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:16:53.952Z", + "end": "2026-07-02T15:19:28.111Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "assignment", + "runId": "4cec085d-bf1f-48a2-aef1-62cce353a09f", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "issueIdentifier": "PAP-12438", + "issueTitle": "environment variables editor re-design", + "start": "2026-07-02T15:18:55.386Z", + "end": "2026-07-02T15:26:14.263Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "automation", + "runId": "17e168c3-c2f4-4cef-9492-c4e680bebcf5", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:19:28.163Z", + "end": "2026-07-02T15:21:38.254Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "8e58708e-88df-4681-84c5-f6a676d1dd6a", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "issueIdentifier": "PAP-10273", + "issueTitle": "Defect (Minor): preview emits noisy \"skill not present in package\" warnings for catalog-skill references", + "start": "2026-07-02T15:20:20.734Z", + "end": "2026-07-02T15:25:39.352Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "accd3b96-89a2-47f4-a3db-65f94ec6be3a", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "issueIdentifier": "PAP-10274", + "issueTitle": "Defect (Minor UX): \"Partial state is not rolled back\" message is incorrect — install transaction rolled back cleanly", + "start": "2026-07-02T15:20:20.784Z", + "end": "2026-07-02T15:28:39.720Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "ebc8e142-c416-4b85-86ff-be0fed98f41f", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "issueIdentifier": "PAP-10275", + "issueTitle": "Defect (Minor): CLI install lacks --adapter-override and --secret-value flags", + "start": "2026-07-02T15:20:20.854Z", + "end": "2026-07-02T15:22:56.590Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "85339e14-6ac3-459c-99a2-811e0e6adbba", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:21:38.307Z", + "end": "2026-07-02T15:22:48.667Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "automation", + "runId": "671705a1-718d-4be6-86b4-390295a17a74", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:22:48.720Z", + "end": "2026-07-02T15:24:16.925Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "automation", + "runId": "cbce714c-a056-477d-971b-3ceb02a4441b", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "issueIdentifier": "PAP-12435", + "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", + "start": "2026-07-02T15:23:13.966Z", + "end": "2026-07-02T15:23:32.715Z", + "status": "cancelled", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "88e7b7a9-4ea6-40bc-b402-ffd731a3dfe2", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "issueIdentifier": "PAP-12298", + "issueTitle": "blog page redesign", + "start": "2026-07-02T15:23:28.308Z", + "end": "2026-07-02T15:29:09.108Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "laneHint": "assignment", + "runId": "1b734867-943b-4ef3-a10e-3f2c1edcad93", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "issueIdentifier": "PAP-12439", + "issueTitle": "Wireframes: Environment Variables Editor redesign", + "start": "2026-07-02T15:25:07.982Z", + "end": "2026-07-02T15:32:24.457Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "laneHint": "automation", + "runId": "2bd1ec14-ecbb-42d8-8412-106a251780b8", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "issueIdentifier": "PAP-12438", + "issueTitle": "environment variables editor re-design", + "start": "2026-07-02T15:26:14.310Z", + "end": "2026-07-02T15:34:59.661Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "laneHint": "assignment", + "runId": "9eb8e7ba-53b5-4d22-8f8d-cbba6a5c5bb5", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "issueIdentifier": "PAP-12440", + "issueTitle": "Defect (Minor): teams-catalog install is not atomic — mid-import failure can orphan skills/agents", + "start": "2026-07-02T15:27:39.339Z", + "end": "2026-07-02T15:37:04.236Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "laneHint": "automation", + "runId": "339694a1-6a7d-4de0-8466-43574a7c6b4d", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "issueIdentifier": "PAP-12422", + "issueTitle": "Work Timeline — Phase A: wireframe exploration (2–3 directions, board picks one)", + "start": "2026-07-02T15:28:13.886Z", + "end": "2026-07-02T15:42:27.258Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "automation", + "runId": "040ce2ef-acd4-47f2-bcf1-5a1194f5a450", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "issueIdentifier": "PAP-12438", + "issueTitle": "environment variables editor re-design", + "start": "2026-07-02T15:34:59.718Z", + "end": "2026-07-02T15:38:04.415Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "20f1e461-b4b6-4d5f-b897-6f60656429fb", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "issueIdentifier": "PAP-12298", + "issueTitle": "blog page redesign", + "start": "2026-07-02T15:35:56.578Z", + "end": "2026-07-02T15:38:40.543Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "063a9727-b191-49e2-9802-b6b63ec65b97", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "issueIdentifier": "PAP-12424", + "issueTitle": "Work Timeline — Phase C: frontend Gantt component + Timeline page", + "start": "2026-07-02T15:41:30.586Z", + "end": "2026-07-02T16:06:58.629Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + } + ], + "events": [ + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-06-03T19:39:46.628Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-06-03T19:40:14.945Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-06-03T19:40:14.974Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "5e955520-f5ea-4ad4-aef6-860c95b1797e", + "at": "2026-06-03T20:23:11.278Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "bc7a42bb-91bb-40b3-b3a8-6f9ebf98bc94", + "at": "2026-06-30T22:22:13.060Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "6ff91230-ab9d-4b95-9590-1471ae37297e", + "at": "2026-07-01T16:31:55.423Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-01T18:23:43.255Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "48264b4d-0004-4015-9ca0-08c867eed064", + "at": "2026-07-02T00:13:22.977Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "2e375459-115c-4f0c-a1c8-59e359b5c3f6", + "at": "2026-07-02T02:21:56.069Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "df84e69c-35e1-4323-a95f-e10d27b3227c", + "at": "2026-07-02T02:39:19.891Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "ad7f2c54-6e50-421f-a805-4c743948421a", + "at": "2026-07-02T03:29:27.493Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "delegated", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T04:52:34.737Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "db6e4af1-0669-42d0-a6ef-e12dcfd7112d", + "at": "2026-07-02T04:56:04.034Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T05:07:46.810Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "created", + "issueId": "abe00e5c-bbcd-4782-ba9e-13532a09184f", + "at": "2026-07-02T05:08:53.619Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "approved", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T14:42:05.663Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "created", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "delegated", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T14:45:19.329Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:48:06.921Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:48:23.164Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "approved", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T14:50:35.254Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "created", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "delegated", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:52:21.751Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "created", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "delegated", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T14:54:44.297Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:54:55.966Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "delegated", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:55:40.913Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T14:57:12.257Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:57:14.910Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "at": "2026-07-02T14:57:50.676Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "at": "2026-07-02T14:59:42.755Z" + }, + { + "actorId": "system:system", + "kind": "created", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "at": "2026-07-02T15:00:19.659Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "created", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "delegated", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T15:01:00.247Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T15:01:05.759Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "at": "2026-07-02T15:01:14.833Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T15:01:28.524Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:02:00.701Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:03:27.354Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "commented", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:04:40.445Z" + }, + { + "actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "kind": "commented", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "at": "2026-07-02T15:05:03.153Z" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "kind": "commented", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T15:05:38.298Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "created", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "delegated", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:05:52.785Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "approved", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:06:51.894Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:07:21.753Z" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "kind": "created", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "kind": "delegated", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "kind": "commented", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:07:56.186Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T15:09:15.910Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T15:10:24.986Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "created", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "delegated", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:11:04.865Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:11:14.738Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:12:26.380Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:14:43.831Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:14:44.435Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T15:15:02.505Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:15:56.982Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:16:35.238Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "at": "2026-07-02T15:17:07.032Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:18:55.353Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "approved", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T15:19:05.154Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:19:11.188Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T15:19:25.678Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "approved", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:19:39.277Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:19:55.477Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:20:10.329Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:20:17.139Z" + }, + { + "actorId": "system:system", + "kind": "commented", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-07-02T15:20:20.709Z" + }, + { + "actorId": "system:system", + "kind": "commented", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-07-02T15:20:20.751Z" + }, + { + "actorId": "system:system", + "kind": "commented", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-07-02T15:20:20.803Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:21:26.500Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:22:40.012Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-07-02T15:22:40.511Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:24:07.505Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-07-02T15:24:36.094Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:25:26.349Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-07-02T15:25:28.318Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:25:56.773Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:27:39.300Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:28:01.540Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-07-02T15:28:02.346Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:28:13.852Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:28:52.824Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:30:55.654Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:34:59.686Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "approved", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:35:56.554Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:36:48.753Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "approved", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:37:11.074Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:37:24.105Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "approved", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:38:21.704Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:38:31.695Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:41:22.766Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "approved", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:43:04.428Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "commented", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:47:28.798Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T15:48:25.833Z" + } + ], + "edges": [ + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-06-03T19:39:46.628Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-06-03T19:40:14.945Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-06-03T19:40:14.974Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "5e955520-f5ea-4ad4-aef6-860c95b1797e", + "at": "2026-06-03T20:23:11.278Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "issueId": "bc7a42bb-91bb-40b3-b3a8-6f9ebf98bc94", + "at": "2026-06-30T22:22:13.060Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:db1c09f1-422c-48c8-9711-4054d3657aac", + "issueId": "6ff91230-ab9d-4b95-9590-1471ae37297e", + "at": "2026-07-01T16:31:55.423Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-01T18:23:43.255Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "48264b4d-0004-4015-9ca0-08c867eed064", + "at": "2026-07-02T00:13:22.977Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "2e375459-115c-4f0c-a1c8-59e359b5c3f6", + "at": "2026-07-02T02:21:56.069Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "df84e69c-35e1-4323-a95f-e10d27b3227c", + "at": "2026-07-02T02:39:19.891Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "ad7f2c54-6e50-421f-a805-4c743948421a", + "at": "2026-07-02T03:29:27.493Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T04:52:34.737Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "db6e4af1-0669-42d0-a6ef-e12dcfd7112d", + "at": "2026-07-02T04:56:04.034Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T05:07:46.810Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "abe00e5c-bbcd-4782-ba9e-13532a09184f", + "at": "2026-07-02T05:08:53.619Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "at": "2026-07-02T14:57:50.676Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "at": "2026-07-02T14:59:42.755Z", + "kind": "assignment" + }, + { + "fromActorId": "system:system", + "toActorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "at": "2026-07-02T15:00:19.659Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:03:27.354Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "toActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "toActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:18:55.353Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:27:39.300Z", + "kind": "assignment" + } + ], + "pagination": { + "limit": 500, + "offset": 0, + "totalIssues": 37, + "hasMore": false + }, + "window": { + "from": "2026-07-02T14:00:00.000Z", + "to": "2026-07-02T16:00:00.000Z", + "capped": false + } +} \ No newline at end of file diff --git a/ui/storybook/fixtures/workTimeline.sample.json b/ui/storybook/fixtures/workTimeline.sample.json new file mode 100644 index 00000000000..29a8ada09d6 --- /dev/null +++ b/ui/storybook/fixtures/workTimeline.sample.json @@ -0,0 +1 @@ +{"actors": [{"id": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "type": "agent", "name": "CEO", "avatar": "crown"}, {"id": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "type": "agent", "name": "ClaudeCoder", "avatar": "code"}, {"id": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "type": "agent", "name": "CloudOpsEngineer", "avatar": "cog"}, {"id": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "type": "agent", "name": "UXDesigner", "avatar": "gem"}, {"id": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "type": "agent", "name": "CTO", "avatar": "file-code"}, {"id": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "type": "agent", "name": "Senior Engineer Fable", "avatar": "code"}, {"id": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", "type": "agent", "name": "Senior Planning Engineer Pro", "avatar": "code"}, {"id": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", "type": "agent", "name": "DevRel", "avatar": "globe"}, {"id": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "type": "agent", "name": "CodexCoder", "avatar": "code"}, {"id": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "type": "agent", "name": "QA", "avatar": "bug"}, {"id": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "type": "agent", "name": "SecurityEngineer", "avatar": "shield"}, {"id": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", "type": "agent", "name": "Strategic Ops", "avatar": "target"}], "spans": [{"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "063a9727-b191-49e2-9802-b6b63ec65b97", "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", "issueIdentifier": "PAP-12424", "issueTitle": "Work Timeline \u2014 Phase C: frontend Gantt component + Timeline page", "start": "2026-07-02T15:41:30.586Z", "end": null, "status": "running", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "20f1e461-b4b6-4d5f-b897-6f60656429fb", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:35:56.578Z", "end": "2026-07-02T15:38:40.543Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "040ce2ef-acd4-47f2-bcf1-5a1194f5a450", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:34:59.718Z", "end": "2026-07-02T15:38:04.415Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "339694a1-6a7d-4de0-8466-43574a7c6b4d", "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", "issueIdentifier": "PAP-12422", "issueTitle": "Work Timeline \u2014 Phase A: wireframe exploration (2\u20133 directions, board picks one)", "start": "2026-07-02T15:28:13.886Z", "end": "2026-07-02T15:42:27.258Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "9eb8e7ba-53b5-4d22-8f8d-cbba6a5c5bb5", "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", "issueIdentifier": "PAP-12440", "issueTitle": "Defect (Minor): teams-catalog install is not atomic \u2014 mid-import failure can orphan skills/agents", "start": "2026-07-02T15:27:39.339Z", "end": "2026-07-02T15:37:04.236Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "2bd1ec14-ecbb-42d8-8412-106a251780b8", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:26:14.310Z", "end": "2026-07-02T15:34:59.661Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "assignment", "runId": "1b734867-943b-4ef3-a10e-3f2c1edcad93", "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", "issueIdentifier": "PAP-12439", "issueTitle": "Wireframes: Environment Variables Editor redesign", "start": "2026-07-02T15:25:07.982Z", "end": "2026-07-02T15:32:24.457Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "88e7b7a9-4ea6-40bc-b402-ffd731a3dfe2", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:23:28.308Z", "end": "2026-07-02T15:29:09.108Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "cbce714c-a056-477d-971b-3ceb02a4441b", "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", "issueIdentifier": "PAP-12435", "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", "start": "2026-07-02T15:23:13.966Z", "end": "2026-07-02T15:23:32.715Z", "status": "cancelled", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "671705a1-718d-4be6-86b4-390295a17a74", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:22:48.720Z", "end": "2026-07-02T15:24:16.925Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "85339e14-6ac3-459c-99a2-811e0e6adbba", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:21:38.307Z", "end": "2026-07-02T15:22:48.667Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "ebc8e142-c416-4b85-86ff-be0fed98f41f", "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", "issueIdentifier": "PAP-10275", "issueTitle": "Defect (Minor): CLI install lacks --adapter-override and --secret-value flags", "start": "2026-07-02T15:20:20.854Z", "end": "2026-07-02T15:22:56.590Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "accd3b96-89a2-47f4-a3db-65f94ec6be3a", "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", "issueIdentifier": "PAP-10274", "issueTitle": "Defect (Minor UX): \"Partial state is not rolled back\" message is incorrect \u2014 install transaction rolled back cleanly", "start": "2026-07-02T15:20:20.784Z", "end": "2026-07-02T15:28:39.720Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "8e58708e-88df-4681-84c5-f6a676d1dd6a", "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", "issueIdentifier": "PAP-10273", "issueTitle": "Defect (Minor): preview emits noisy \"skill not present in package\" warnings for catalog-skill references", "start": "2026-07-02T15:20:20.734Z", "end": "2026-07-02T15:25:39.352Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "17e168c3-c2f4-4cef-9492-c4e680bebcf5", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:19:28.163Z", "end": "2026-07-02T15:21:38.254Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "assignment", "runId": "4cec085d-bf1f-48a2-aef1-62cce353a09f", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:18:55.386Z", "end": "2026-07-02T15:26:14.263Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "df0d6959-ef5f-4a75-b710-492cfd6de658", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:16:53.952Z", "end": "2026-07-02T15:19:28.111Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "a9ba6322-e0e3-43bb-bc0b-2af53bec9610", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "issueIdentifier": "PAP-12436", "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", "start": "2026-07-02T15:14:59.782Z", "end": "2026-07-02T15:20:10.000Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "automation", "runId": "cd8cf33f-5a1c-4b3a-905e-aabc0341edd1", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:14:57.094Z", "end": "2026-07-02T15:16:53.870Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "f4d1d8a6-c74d-4d59-b831-b788eeaf93f2", "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", "issueIdentifier": "PAP-12437", "issueTitle": "for parent task, cleanup the effecitvely merged ones", "start": "2026-07-02T15:14:30.602Z", "end": "2026-07-02T15:20:22.033Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "laneHint": "automation", "runId": "90cd6835-be7c-4dd8-bdf7-017d40df9c38", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:12:26.440Z", "end": "2026-07-02T15:14:56.976Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "b83d9b78-f63c-465c-808c-c1e1175a18c5", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "issueIdentifier": "PAP-12436", "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", "start": "2026-07-02T15:10:51.773Z", "end": "2026-07-02T15:14:59.703Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "e4e854bc-9cce-4c5f-bfb9-a2f3c0094075", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T15:10:25.014Z", "end": "2026-07-02T15:15:34.966Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "assignment", "runId": "bcfaef62-ca9a-4a96-b279-bf2314108dbf", "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", "issueIdentifier": "PAP-12403", "issueTitle": "github and environment variables", "start": "2026-07-02T15:09:15.958Z", "end": "2026-07-02T15:20:22.053Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "0ecdb797-e6b4-4f12-a5f0-b7bce348138f", "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", "issueIdentifier": "PAP-12435", "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", "start": "2026-07-02T15:07:45.699Z", "end": "2026-07-02T15:11:13.138Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", "laneHint": "assignment", "runId": "0961f6e8-a1cd-4493-9979-99775285c748", "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", "issueIdentifier": "PAP-12434", "issueTitle": "Strategic Ops: get AWS admin to attach pages.paperclip.ing uploader policy", "start": "2026-07-02T15:05:38.870Z", "end": "2026-07-02T15:08:15.576Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", "laneHint": "assignment", "runId": "33181a90-d2af-4081-9deb-253d996d81f1", "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", "issueIdentifier": "PAP-12433", "issueTitle": "project star\u2019ing", "start": "2026-07-02T15:03:27.392Z", "end": "2026-07-02T15:52:11.373Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "laneHint": "automation", "runId": "12223c0b-0bad-4b95-be56-ceb1ce955b0f", "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", "issueIdentifier": "PAP-12425", "issueTitle": "Work Timeline \u2014 security review: timeline endpoint ACLs and data exposure", "start": "2026-07-02T15:01:28.563Z", "end": "2026-07-02T15:05:43.985Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "e08e32ae-9106-46e0-9c2b-97ab74520fc4", "issueId": "0facd02e-425f-4822-875c-2f97988de98a", "issueIdentifier": "PAP-12432", "issueTitle": "CEO/admin: apply paperclip-page-uploader pages.paperclip.ing inline policy", "start": "2026-07-02T15:00:46.697Z", "end": "2026-07-02T15:06:05.586Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", "laneHint": "assignment", "runId": "91f67e3b-18ae-4230-8fae-18b414ffcd14", "issueId": "1533f78d-d677-449f-afd9-4cf653168438", "issueIdentifier": "PAP-12431", "issueTitle": "2026-07-02 create a standup message i can copy to slack about everything i've been working on for the last 36 hours", "start": "2026-07-02T15:00:19.704Z", "end": "2026-07-02T15:05:16.672Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "e9ff0a09-6607-4793-90f3-1b3ab1741ef0", "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", "issueIdentifier": "PAP-12430", "issueTitle": "merged Create a single PR from this branch PAP-12404-inbox-blocked-but-descendent-live-icon", "start": "2026-07-02T14:59:42.783Z", "end": "2026-07-02T15:17:12.936Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "67f65027-b802-4e88-91a6-bebb5c94f16b", "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", "issueIdentifier": "PAP-12429", "issueTitle": "give the AWS_ keys that ClaudeCoder has to CodexCoder and CloudOpsEngineer and UXDesigner", "start": "2026-07-02T14:57:50.711Z", "end": "2026-07-02T15:01:18.973Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "a61b657c-d297-43b7-948c-2be7bd161e3b", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "issueIdentifier": "PAP-12427", "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", "start": "2026-07-02T14:57:27.831Z", "end": "2026-07-02T15:01:08.081Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "960639c7-f6cc-45ad-b746-22ca507c237d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:57:12.287Z", "end": "2026-07-02T15:12:26.352Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "77ffd755-a755-41e6-b2cb-ee0817f11152", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:55:43.595Z", "end": "2026-07-02T14:57:12.235Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "d14a8901-4569-4f05-b8c2-5e49d4216a6d", "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", "issueIdentifier": "PAP-12428", "issueTitle": "just change the terminology to \u201cResponsible\u201d", "start": "2026-07-02T14:55:00.330Z", "end": "2026-07-02T15:01:10.197Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "972b6b5b-5fdd-419d-aea0-5641b4634448", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "issueIdentifier": "PAP-12427", "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", "start": "2026-07-02T14:54:41.918Z", "end": "2026-07-02T14:57:27.722Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "4b085459-77b0-41e4-a62f-a76e1ba9b521", "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", "issueIdentifier": "PAP-12423", "issueTitle": "Work Timeline \u2014 Phase B: timeline aggregation endpoint (GET /companies/:id/timeline)", "start": "2026-07-02T14:52:21.929Z", "end": "2026-07-02T15:03:54.376Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "assignment", "runId": "8a716a87-61e2-4667-9cc4-3641fe42c6f0", "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", "issueIdentifier": "PAP-12422", "issueTitle": "Work Timeline \u2014 Phase A: wireframe exploration (2\u20133 directions, board picks one)", "start": "2026-07-02T14:52:21.788Z", "end": "2026-07-02T15:07:44.596Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "e573c5dc-94b9-4b63-bdb3-c48327a85cea", "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", "issueIdentifier": "PAP-12421", "issueTitle": "CTO/admin: attach corrected pages.paperclip.ing S3 policy to paperclip-page-uploader", "start": "2026-07-02T14:52:04.426Z", "end": "2026-07-02T14:56:08.638Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "6b0a55a9-7c1a-4d48-bdfb-159370e9c03d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:50:35.277Z", "end": "2026-07-02T14:55:43.520Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "c214c9f0-b46b-47de-ba86-2520e1a8aa75", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T14:48:23.194Z", "end": "2026-07-02T14:52:29.939Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "c7a242bb-adf1-46e2-a322-474ed791c7ff", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T14:44:59.649Z", "end": "2026-07-02T14:48:23.151Z", "status": "cancelled", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "571d1495-fe7e-4cf8-a79f-e4d8d779cecf", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T14:42:05.689Z", "end": "2026-07-02T14:45:37.694Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}], "events": [], "edges": [{"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "at": "2026-07-02T15:26:14.310Z", "kind": "delegation"}, {"fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "at": "2026-07-02T15:34:59.718Z", "kind": "delegation"}, {"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T14:55:43.595Z", "kind": "delegation"}, {"fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T14:57:12.287Z", "kind": "delegation"}, {"fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:12:26.440Z", "kind": "delegation"}, {"fromActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:14:57.094Z", "kind": "delegation"}, {"fromActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:16:53.952Z", "kind": "delegation"}, {"fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:19:28.163Z", "kind": "delegation"}, {"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:21:38.307Z", "kind": "delegation"}, {"fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:22:48.720Z", "kind": "delegation"}, {"fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "at": "2026-07-02T15:14:59.782Z", "kind": "delegation"}, {"fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "at": "2026-07-02T14:57:27.831Z", "kind": "delegation"}], "pagination": {"limit": 200, "offset": 0, "totalIssues": 26, "hasMore": false}, "window": {"from": "2026-07-02T14:00:00.000Z", "to": "2026-07-02T15:50:00.000Z", "capped": false}} \ No newline at end of file diff --git a/ui/storybook/stories/work-timeline.stories.tsx b/ui/storybook/stories/work-timeline.stories.tsx new file mode 100644 index 00000000000..169541f8f14 --- /dev/null +++ b/ui/storybook/stories/work-timeline.stories.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { WorkTimelineResult } from "@paperclipai/shared"; +import { WorkTimelineChart, type ZoomLevel } from "@/components/timeline/WorkTimelineChart"; +import { issueColor, type ColorMode } from "@/lib/timeline/layout"; +import { cn } from "@/lib/utils"; +import sampleJson from "../fixtures/workTimeline.sample.json"; +import humanSampleJson from "../fixtures/workTimeline.human.sample.json"; + +const sample = sampleJson as unknown as WorkTimelineResult; +// A second real slice (2026-07-02 14:00–16:00Z) captured straight from the live +// `/timeline` endpoint that DOES carry human events — Dotta's created / commented / +// approved / delegated actions render as instant diamond markers on her own row. +const humanSample = humanSampleJson as unknown as WorkTimelineResult; +// The fixture is a real slice of PAP company activity (2026-07-02 14:00–15:50Z); +// pin "now" to the window end so in-progress runs fade correctly. +const NOW = new Date("2026-07-02T15:45:00.000Z").getTime(); + +function Segmented({ + value, + options, + onChange, +}: { + value: T; + options: { value: T; label: string }[]; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((opt, i) => ( + + ))} +
+ ); +} + +function TimelineHarness({ + initialZoom = "day" as ZoomLevel, + initialColor = "issue" as ColorMode, + data = sample, + now = NOW, +}: { + initialZoom?: ZoomLevel; + initialColor?: ColorMode; + data?: WorkTimelineResult; + now?: number; +}) { + const [zoom, setZoom] = useState(initialZoom); + const [colorMode, setColorMode] = useState(initialColor); + const issues = Array.from( + new Map(data.spans.map((s) => [s.issueId, s.issueIdentifier ?? s.issueTitle ?? "task"])).entries(), + ); + return ( +
+
+
+

Work Timeline

+

+ A Gantt view of who did what, when — real PAP activity (2026-07-02, 14:00–15:50Z). Rows are actors; bars are + heartbeat runs colored by task; the avatar chip at a bar's leading edge is who kicked it off; straight lines + are agent→agent delegation. Hover a bar for its task & timing; click to open the task. +

+
+ +
+ + +
+ +
+
+ {colorMode === "issue" + ? issues.slice(0, 10).map(([id, label]) => ( + + + {label} + + )) + : ( + <> + + done + + + {" "} + in progress + + + )} +
+
+ +
+

+ {data.spans.length} runs · {data.actors.length} actors · {data.events.length} human/instant events · real + company data +

+
+
+
+ ); +} + +const meta: Meta = { + title: "Pages/Work Timeline", + component: TimelineHarness, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +export const HourByTask: Story = { args: { initialZoom: "hour", initialColor: "issue" } }; +export const DayZoom: Story = { args: { initialZoom: "day", initialColor: "issue" } }; +export const ByStatus: Story = { args: { initialZoom: "hour", initialColor: "status" } }; +// Live slice that carries human events — Dotta gets a row with diamond markers +// for her created / commented / approved / delegated actions (PAP-12444). +export const WithHumanMarkers: Story = { + args: { + initialZoom: "hour", + initialColor: "issue", + data: humanSample, + now: new Date("2026-07-02T16:00:00.000Z").getTime(), + }, +}; From 69c55d465d511bf057e2033a173223072bee55d4 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 2 Jul 2026 12:38:03 -0700 Subject: [PATCH 15/19] Add telemetry data contract docs (#8886) Adds the public telemetry data contract README, links it from contributor docs, and adds a focused README contract test for generated helper names. Verification: - git diff --check origin/master..HEAD - pnpm exec vitest run packages/shared/src/telemetry/readme-contract.test.ts - PR CI green - Greptile 5/5 Co-Authored-By: Paperclip --- CONTRIBUTING.md | 4 + README.md | 2 + packages/shared/src/telemetry/README.md | 116 ++++++++++++++++++ .../src/telemetry/readme-contract.test.ts | 19 +++ 4 files changed, 141 insertions(+) create mode 100644 packages/shared/src/telemetry/README.md create mode 100644 packages/shared/src/telemetry/readme-contract.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10849c754c6..ee22a1b7bdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,6 +102,10 @@ Every PR must include a **Model Used** section specifying which AI model produce All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing. +### Telemetry Changes + +If your change adds, removes, or modifies emitted telemetry events, update the [Telemetry Data Contract](packages/shared/src/telemetry/README.md) in the same PR. Keep clients emitting raw dimension values and avoid documenting or relying on private delivery details. + ### Paperclip Gates Must Pass All Paperclip CI gates (lint, typecheck, tests, build, and any other required checks) must be satisfied before a PR can be merged. Don't ask for a merge while gates are red — fix them first. diff --git a/README.md b/README.md index d794b6ce4bb..0c865adba24 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,8 @@ Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (t Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent. +Contributors changing emitted telemetry events should follow the [Telemetry Data Contract](packages/shared/src/telemetry/README.md). + Telemetry is **enabled by default** and can be disabled with any of the following: | Method | How | diff --git a/packages/shared/src/telemetry/README.md b/packages/shared/src/telemetry/README.md new file mode 100644 index 00000000000..3a741ecdfca --- /dev/null +++ b/packages/shared/src/telemetry/README.md @@ -0,0 +1,116 @@ +# Telemetry Data Contract + +This document explains how contributors should use Paperclip's public telemetry +contract. It intentionally does not list individual events or dimensions. + +The canonical source for first-party event names, dimensions, optionality, +allowed primitive value types, and enum descriptions is +`packages/shared/src/telemetry/generated/paperclip-telemetry.ts`. + +Shared enum constants live in `packages/shared/src/constants.ts`. Use those +constants when code needs a reusable domain, but treat the generated telemetry +types as the final authority for emitted first-party telemetry shapes. + +## Public Sources + +Use these files when reviewing or changing telemetry code: + +| Contract item | Public source | +| --- | --- | +| First-party event names | `PaperclipEventName` in `generated/paperclip-telemetry.ts` | +| Per-event dimensions and optionality | `EventDimensionsMap` in `generated/paperclip-telemetry.ts` | +| Enum descriptions for telemetry dimensions | `PAPERCLIP_ENUM_DESCRIPTIONS` in `generated/paperclip-telemetry.ts` | +| Schema version and event envelope helpers | `SCHEMA_VERSION`, `makeEvent()`, and `makeBatch()` in `generated/paperclip-telemetry.ts` | +| Runtime-safe event names and dimensions | `TelemetryEventName` and `TelemetryEventDimensions` in `types.ts` | +| Allowed primitive dimension values | `TelemetryDimensionValue` in `types.ts` | +| Shared reusable enum domains | Named exports in `constants.ts` | +| First-party typed emit helpers | `events.ts` | +| Generic client behavior | `client.ts` | + +Do not copy generated event lists or dimension tables into this README. They +will drift as the generated contract changes. + +## Emission Boundary + +Paperclip telemetry uses named events with explicit dimension fields. Treat +open-ended string dimensions as public contract values, not as a place for user +content or private operational data. Do not send PII, secrets, credentials, +private paths, prompts, model output, or other sensitive values through +telemetry dimensions. + +Telemetry emitters send raw dimension values. They must not pre-normalize +enum-like values into a reporting form just to match today's known domain. + +The receiving layer owns canonicalization. Keeping canonicalization in one place +means emitters can stay simple and accurate: emit what the product observed, use +the generated contract for required and optional fields, and let the receiving +layer decide how legacy spellings, aliases, unknown names, and future values map +to a stable reporting shape. + +Do not add client-side lowercasing, alias mapping, or fallback mapping unless +the generated telemetry contract specifically requires that emitted value. + +If a dimension is privacy-protected before emission, emit only the protected +value and its matching public marker as defined by the typed helper or generated +contract. Do not emit private source material in telemetry dimensions. + +## Dimension Values + +Telemetry dimension values must be primitives. Use only the value types allowed +by `TelemetryDimensionValue`: + +- `string` +- `number` +- `boolean` + +Do not emit `null`, `undefined`, arrays, or objects as dimension values. Optional +dimensions should be omitted when absent. + +When a dimension is enum-like, use the shared constant from `constants.ts` when +one exists. If no shared constant exists, use the generated telemetry type as the +domain. In all cases, the generated telemetry type remains the source of truth +for the emitted value. + +## Required, Optional, And Sentinel Values + +Required and optional dimensions are defined by `EventDimensionsMap`. + +Required dimensions must be present for every event of that name. Optional +dimensions should be emitted only when the value is known and useful. + +Sentinel values are only for required fields that have no observed raw value at +the emitting layer. Do not use a sentinel to hide a concrete value that is new, +custom, or not yet represented by a shared constant. Emit the concrete raw value +and let the receiving layer canonicalize it. + +## Adding Or Changing Telemetry + +Client code is responsible for emitting approved telemetry events at the right +place in the product. It is not responsible for deciding which new events should +exist. Do not introduce ad hoc event names, dimensions, or enum domains in client +code; they must exist in the generated telemetry contract before emitters use +them. + +1. Start from `generated/paperclip-telemetry.ts`. The generated types are what + reviewers use to verify event names, dimensions, optionality, value types, + enum descriptions, and schema version. +2. Choose stable event and dimension names. Do not include user content, local + machine details, secrets, credentials, private paths, or values that are not + part of the public event contract. +3. Use only `string`, `number`, or `boolean` dimension values. +4. Reuse a shared constant from `constants.ts` for enum-like dimensions when one + exists. If the generated telemetry domain has values beyond a shared constant, + keep the emitter aligned with the generated telemetry type. +5. Keep emitters raw. Do not normalize, alias-map, or lowercase enum-like values + in the client unless the generated contract explicitly calls for that emitted + value. +6. Add or update a typed helper in `events.ts` when the event is first-party and + should have a stable helper API. +7. Update tests for helper behavior, including raw pass-through for enum-like + values when that is the intended boundary. +8. Update this README only when the contributor workflow, source-of-truth + pointers, or durable invariants change. Do not add an event catalog here. + +Before opening a pull request, verify that the emitted code, typed helpers, and +generated telemetry contract agree. If they disagree, fix the contract or code +rather than documenting around the mismatch in this README. diff --git a/packages/shared/src/telemetry/readme-contract.test.ts b/packages/shared/src/telemetry/readme-contract.test.ts new file mode 100644 index 00000000000..eec9d4511dd --- /dev/null +++ b/packages/shared/src/telemetry/readme-contract.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import * as generatedTelemetry from "./generated/paperclip-telemetry.js"; + +const readmePath = fileURLToPath(new URL("./README.md", import.meta.url)); + +describe("telemetry README contract", () => { + it("documents generated event helper exports that exist", () => { + const readme = readFileSync(readmePath, "utf8"); + + expect(generatedTelemetry.SCHEMA_VERSION).toBeDefined(); + expect(generatedTelemetry.makeEvent).toBeTypeOf("function"); + expect(generatedTelemetry.makeBatch).toBeTypeOf("function"); + expect(readme).toContain("`SCHEMA_VERSION`, `makeEvent()`, and `makeBatch()`"); + expect(readme).not.toContain("createPaperclipTelemetryEvent"); + expect(readme).not.toContain("createPaperclipTelemetryEnvelope"); + }); +}); From ec92728536a281a464bf35c5c1fea470a2fd02f0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:52:09 -0500 Subject: [PATCH 16/19] [codex] remove Work Timeline page from navigation (#8882) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The app shell exposes user-visible product surfaces through routes and the left navigation > - The Work Timeline frontend landed on `master` in related PR #8880 before the product surface was ready for general users > - The backend aggregation endpoint and shared DTOs can remain available for continued iteration without exposing the page in the main app > - This pull request removes the visible `/timeline` route, sidebar entry, page implementation, chart component, and Storybook fixtures > - The benefit is that users no longer see an unfinished Work Timeline page on `master`, while future work can continue from a preserved branch/workspace ## Linked Issues or Issue Description No public GitHub issue exists for this rollback request. Bug-style description: - **What happened:** A Work Timeline page was exposed in the app navigation before the surface was ready for users. - **Expected behavior:** Unready product work should not be visible from the default app shell on `master`. - **Steps to reproduce:** Open the app on current `master`; the sidebar includes a `Timeline` item that routes to `/timeline`. - **Paperclip version/commit:** `60f7fb422394c94618b8eef27ae032702004f544`. - **Deployment mode:** Local app / standard Paperclip app shell. - **Related public PR:** #8880. ## What Changed - Removed the `/timeline` route and `Timeline` page import from the app route table. - Removed the `Timeline` sidebar item and unused `GanttChartSquare` icon import. - Deleted the frontend Work Timeline API wrapper, chart component, layout helper, page, Storybook story, and sample fixtures. - Removed the now-unused `queryKeys.workTimeline` entry. - Left the backend timeline endpoint and shared DTOs intact so the data contract can continue to be developed off the preserved work. ## Verification - `pnpm --filter @paperclipai/ui typecheck` - Searched the frontend for stale `workTimeline`, `WorkTimeline`, `/timeline` route/sidebar, and `GanttChartSquare` references after deletion. ## Risks - Low runtime risk: this removes an app route and navigation entry for an unfinished surface. - Deep links to `/timeline` will now fall through to the app's existing not-found behavior. - The backend endpoint remains available; if the intent was to remove the API too, that should be handled in a separate, explicit PR. ## Model Used OpenAI Codex coding agent, GPT-5-based model, with shell/tool use for repository inspection, code editing, git, and GitHub CLI operations. ## 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 --- ui/src/App.tsx | 2 - ui/src/api/workTimeline.ts | 31 - ui/src/components/Sidebar.tsx | 2 - .../timeline/WorkTimelineChart.test.tsx | 128 -- .../components/timeline/WorkTimelineChart.tsx | 576 ------ ui/src/lib/queryKeys.ts | 1 - ui/src/lib/timeline/layout.test.ts | 197 -- ui/src/lib/timeline/layout.ts | 342 ---- ui/src/pages/Timeline.tsx | 242 --- .../fixtures/workTimeline.human.sample.json | 1824 ----------------- .../fixtures/workTimeline.sample.json | 1 - .../stories/work-timeline.stories.tsx | 164 -- 12 files changed, 3510 deletions(-) delete mode 100644 ui/src/api/workTimeline.ts delete mode 100644 ui/src/components/timeline/WorkTimelineChart.test.tsx delete mode 100644 ui/src/components/timeline/WorkTimelineChart.tsx delete mode 100644 ui/src/lib/timeline/layout.test.ts delete mode 100644 ui/src/lib/timeline/layout.ts delete mode 100644 ui/src/pages/Timeline.tsx delete mode 100644 ui/storybook/fixtures/workTimeline.human.sample.json delete mode 100644 ui/storybook/fixtures/workTimeline.sample.json delete mode 100644 ui/storybook/stories/work-timeline.stories.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e5c7346f864..4a9e971f7c8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -8,7 +8,6 @@ import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; import { DashboardLive } from "./pages/DashboardLive"; -import { Timeline } from "./pages/Timeline"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; @@ -80,7 +79,6 @@ function boardRoutes() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/ui/src/api/workTimeline.ts b/ui/src/api/workTimeline.ts deleted file mode 100644 index 42a22b8ebf4..00000000000 --- a/ui/src/api/workTimeline.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { WorkTimelineResult } from "@paperclipai/shared"; -import { api } from "./client"; - -export interface WorkTimelineParams { - from?: string; - to?: string; - /** lens: work kicked off / touched by this user. */ - userId?: string; - goalId?: string; - projectId?: string; - issueId?: string; - limit?: number; -} - -function query(params: WorkTimelineParams): string { - const search = new URLSearchParams(); - if (params.from) search.set("from", params.from); - if (params.to) search.set("to", params.to); - if (params.userId) search.set("userId", params.userId); - if (params.goalId) search.set("goalId", params.goalId); - if (params.projectId) search.set("projectId", params.projectId); - if (params.issueId) search.set("issueId", params.issueId); - if (params.limit) search.set("limit", String(params.limit)); - const qs = search.toString(); - return qs ? `?${qs}` : ""; -} - -export const workTimelineApi = { - get: (companyId: string, params: WorkTimelineParams = {}) => - api.get(`/companies/${companyId}/timeline${query(params)}`), -}; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index ade1f437bca..78cd9b882f8 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -18,7 +18,6 @@ import { PanelLeftOpen, Pin, MessagesSquare, - GanttChartSquare, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { NavLink } from "@/lib/router"; @@ -181,7 +180,6 @@ export function Sidebar() { ) : null} - {showWorkspacesLink ? ( diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx deleted file mode 100644 index 1d42452ca2b..00000000000 --- a/ui/src/components/timeline/WorkTimelineChart.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// @vitest-environment jsdom - -import { flushSync } from "react-dom"; -import { createRoot, type Root } from "react-dom/client"; -import type { WorkTimelineResult } from "@paperclipai/shared"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkTimelineChart } from "./WorkTimelineChart"; - -vi.mock("@/lib/router", () => ({ - useNavigate: () => vi.fn(), -})); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -let container: HTMLDivElement; -let root: Root; - -beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - flushSync(() => root.unmount()); - container.remove(); -}); - -function renderChart(data: WorkTimelineResult) { - flushSync(() => { - root.render( - , - ); - }); -} - -function timelineSample(): WorkTimelineResult { - return { - actors: [ - { id: "agent:codex", type: "agent", name: "CodexCoder" }, - { id: "agent:qa", type: "agent", name: "QA" }, - ], - spans: [ - { - actorId: "agent:codex", - laneHint: null, - runId: "run-1", - issueId: "issue-1", - issueIdentifier: "PAP-12443", - issueTitle: "Work Timeline sticky gutter", - start: "2026-07-02T09:00:00.000Z", - end: "2026-07-02T10:00:00.000Z", - status: "completed", - retryOfRunId: null, - }, - { - actorId: "agent:qa", - laneHint: null, - runId: "run-2", - issueId: "issue-2", - issueIdentifier: "PAP-12426", - issueTitle: "QA validation", - start: "2026-07-02T11:00:00.000Z", - end: "2026-07-02T11:30:00.000Z", - status: "completed", - retryOfRunId: null, - }, - ], - events: [], - edges: [], - pagination: { limit: 200, offset: 0, totalIssues: 2, hasMore: false }, - window: { - from: "2026-07-02T00:00:00.000Z", - to: "2026-07-03T00:00:00.000Z", - capped: false, - }, - }; -} - -describe("WorkTimelineChart", () => { - it("renders actor labels in a sticky gutter outside the horizontally scrolling SVG", () => { - renderChart(timelineSample()); - - const scroller = container.querySelector("[data-testid='work-timeline-scroll']"); - const gutter = container.querySelector("[data-testid='work-timeline-actor-gutter']"); - const chartSvg = container.querySelector("svg.absolute"); - - expect(scroller).not.toBeNull(); - expect(gutter).not.toBeNull(); - expect(chartSvg).not.toBeNull(); - expect(gutter?.getAttribute("class")).toContain("sticky"); - expect(gutter?.getAttribute("class")).toContain("left-0"); - expect(gutter?.getAttribute("width")).toBe("176"); - expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width")); - expect(gutter?.textContent).toContain("CodexCoder"); - - flushSync(() => { - scroller!.scrollLeft = 10_000; - scroller!.dispatchEvent(new Event("scroll", { bubbles: true })); - }); - - expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder"); - }); - - it("renders a human row with diamond event markers when the payload carries events", () => { - const data = timelineSample(); - data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" }); - data.events = [ - { actorId: "user:dotta", kind: "created", issueId: "issue-1", at: "2026-07-02T08:30:00.000Z" }, - { actorId: "user:dotta", kind: "commented", issueId: "issue-2", at: "2026-07-02T09:15:00.000Z" }, - { actorId: "user:dotta", kind: "approved", issueId: "issue-1", at: "2026-07-02T10:05:00.000Z" }, - ]; - renderChart(data); - - // Dotta gets a row in the gutter… - const gutter = container.querySelector("[data-testid='work-timeline-actor-gutter']"); - expect(gutter?.textContent).toContain("Dotta"); - // …and her three instant events render as clickable diamond marker paths. - const markers = container.querySelectorAll("svg.absolute path.cursor-pointer"); - expect(markers).toHaveLength(3); - }); -}); diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx deleted file mode 100644 index 7b56b681070..00000000000 --- a/ui/src/components/timeline/WorkTimelineChart.tsx +++ /dev/null @@ -1,576 +0,0 @@ -/** - * Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422). - * - * Renders actor rows with concurrency sub-lanes, run bars (no issue IDs on the - * bar — identity is the thin left colour tab; truncated title shows on hover), - * kickoff avatar chips at each bar's leading edge (incl. humans), straight - * agent→agent delegation connectors (dashed for retries), an in-progress fade to - * "now", a hover tooltip, and a full-window mini-map with a draggable brush. - */ -import { useMemo, useRef, useState } from "react"; -import { useNavigate } from "@/lib/router"; -import type { - TimelineEventKind, - WorkTimelineActor, - WorkTimelineResult, -} from "@paperclipai/shared"; -import { - AXIS_H, - actorType, - chooseTickStepMs, - computeLayout, - formatDuration, - issueColor, - shortLabel, - type ColorMode, - type LayoutOptions, - type PositionedBar, - type PositionedMarker, -} from "@/lib/timeline/layout"; - -export type ZoomLevel = "hour" | "day" | "week"; - -const ZOOM_PX_PER_MIN: Record = { - hour: 8, - day: 1.6, - week: 0.32, -}; - -/** Pick an initial zoom whose plotted width comfortably fills a typical viewport. */ -export function defaultZoomForWindow(fromMs: number, toMs: number): ZoomLevel { - const hours = (toMs - fromMs) / 3_600_000; - if (hours <= 4) return "hour"; - if (hours <= 48) return "day"; - return "week"; -} - -const GEOM: Omit = { - gutter: 176, - rowH: 34, - barH: 15, - laneGap: 4, -}; -const AVATAR_R = 11; -const CHIP_R = 9; -const MARKER_R = 5.5; - -/** - * Per-kind styling for instant event markers (diamonds). Each kind gets a - * distinct fill + verb so created / commented / approved / delegated / assigned - * read apart at a glance; the hues sit mid-lightness so they hold on light+dark. - */ -const EVENT_STYLE: Record = { - created: { fill: "hsl(145 55% 42%)", verb: "created" }, - commented: { fill: "hsl(212 62% 54%)", verb: "commented on" }, - approved: { fill: "hsl(265 52% 60%)", verb: "approved" }, - delegated: { fill: "hsl(28 78% 52%)", verb: "delegated" }, - assigned: { fill: "hsl(190 58% 44%)", verb: "assigned" }, -}; - -interface TooltipState { - x: number; - y: number; - bar: PositionedBar; -} - -interface MarkerTooltipState { - x: number; - y: number; - marker: PositionedMarker; - /** resolved issue label (identifier/title) or the raw id as a fallback. */ - issueLabel: string; -} - -function fmtClock(ms: number): string { - const d = new Date(ms); - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; -} - -function fmtTick(ms: number, stepMs: number): string { - const d = new Date(ms); - if (stepMs >= 24 * 60 * 60 * 1000) { - return `${d.getMonth() + 1}/${d.getDate()}`; - } - return fmtClock(ms); -} - -function truncate(text: string, n = 42): string { - return text.length > n ? `${text.slice(0, n - 1)}…` : text; -} - -/** An SVG avatar glyph: square for humans, dashed circle for system, circle for agents. */ -function AvatarGlyph({ - cx, - cy, - r, - label, - type, -}: { - cx: number; - cy: number; - r: number; - label: string; - type: string; -}) { - const stroke = "var(--color-foreground)"; - const fill = type === "system" ? "var(--color-muted)" : "var(--color-card)"; - return ( - - {type === "user" ? ( - - ) : ( - - )} - 10 ? 9 : 8} textAnchor="middle" fill={stroke}> - {label} - - - ); -} - -export interface WorkTimelineChartProps { - data: WorkTimelineResult; - zoom: ZoomLevel; - colorMode: ColorMode; - /** override "now" (tests / stories); defaults to Date.now(). */ - nowMs?: number; -} - -export function WorkTimelineChart({ data, zoom, colorMode, nowMs }: WorkTimelineChartProps) { - const navigate = useNavigate(); - const scrollRef = useRef(null); - const [tooltip, setTooltip] = useState(null); - const [markerTooltip, setMarkerTooltip] = useState(null); - const [scrollLeft, setScrollLeft] = useState(0); - const [viewportW, setViewportW] = useState(0); - - const now = nowMs ?? Date.now(); - const layout = useMemo( - () => computeLayout(data, { ...GEOM, pxPerMinute: ZOOM_PX_PER_MIN[zoom], nowMs: now }), - [data, zoom, now], - ); - - // Resolve an event's issue to its human label (identifier/title) via the legend - // hue map, falling back to the raw id for issues that have no run in-window. - const issueLabelById = useMemo( - () => new Map(layout.issues.map((i) => [i.key, i.label])), - [layout.issues], - ); - - const stepMs = chooseTickStepMs(layout.pxPerMinute); - const ticks: number[] = []; - const startTick = Math.ceil(layout.fromMs / stepMs) * stepMs; - for (let ms = startTick; ms <= layout.toMs; ms += stepMs) ticks.push(ms); - - const barFill = (bar: PositionedBar): string => { - if (colorMode === "status") { - if (bar.running) return "url(#tl-hatchV)"; - if (bar.span.status.includes("change") || bar.span.status.includes("fail") || bar.span.status === "blocked") - return "url(#tl-hatchD)"; - return "var(--color-card)"; - } - return "var(--color-card)"; - }; - - const openIssue = (issueId: string) => navigate(`/issues/${issueId}`); - - const showTooltip = (evt: React.MouseEvent, bar: PositionedBar) => { - setTooltip({ x: evt.clientX, y: evt.clientY, bar }); - }; - - const showMarkerTooltip = (evt: React.MouseEvent, marker: PositionedMarker) => { - const issueLabel = issueLabelById.get(marker.event.issueId) ?? marker.event.issueId; - setMarkerTooltip({ x: evt.clientX, y: evt.clientY, marker, issueLabel }); - }; - - return ( -
-
setScrollLeft(e.currentTarget.scrollLeft)} - > -
- - - { - if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth); - }} - > - - - - - - - - - - - - - - - - {/* row backgrounds */} - {layout.rows.map((row, i) => ( - - ))} - - {/* gridlines + time labels */} - {ticks.map((ms) => { - const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute; - return ( - - - - {fmtTick(ms, stepMs)} - - - ); - })} - - {/* now line */} - {now >= layout.fromMs && now <= layout.toMs && ( - - )} - - {/* gutter divider + axis baseline */} - - - - {/* connectors (behind bars) */} - {layout.connectors.map((c, i) => { - const ang = (Math.atan2(c.y2 - c.y1, c.x2 - c.x1) * 180) / Math.PI; - return ( - - - - - - ); - })} - - {/* rows: gutter avatar/label, lane baselines, bars, chips */} - {layout.rows.map((row) => { - const cy = row.y + AXIS_H + row.h / 2; - return ( - - - - {truncate(row.actor.name, 18)} - - - {row.actor.type} - - - {Array.from({ length: row.laneCount }).map((_, ln) => { - const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2; - return ( - - ); - })} - - {row.bars.map((bar) => { - const yTop = bar.yTop + AXIS_H; - const w = bar.x2 - bar.x1; - const hue = issueColor(bar.span.issueId); - return ( - - showTooltip(e, bar)} - onMouseLeave={() => setTooltip(null)} - onClick={() => openIssue(bar.span.issueId)} - > - - {/* left colour tab = issue identity (no textual ID on the bar) */} - - {/* in-progress fade to "now" */} - {bar.running && w > 8 && ( - - )} - - {bar.kickoff && ( - - - - )} - - ); - })} - - {/* instant event markers — diamonds at x(event.at) on this row */} - {row.markers.map((marker) => { - const style = EVENT_STYLE[marker.event.kind]; - const mx = marker.x; - const my = marker.yc + AXIS_H; - return ( - showMarkerTooltip(e, marker)} - onMouseLeave={() => setMarkerTooltip(null)} - /> - ); - })} - - ); - })} - -
-
- - - - {tooltip && } - {markerTooltip && } -
- ); -} - -function MarkerTooltip({ tooltip }: { tooltip: MarkerTooltipState }) { - const { marker, issueLabel } = tooltip; - const style = EVENT_STYLE[marker.event.kind]; - const atMs = new Date(marker.event.at).getTime(); - const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300); - return ( -
-
- - {style?.verb ?? marker.event.kind} - {truncate(issueLabel, 28)} -
-
{fmtClock(atMs)}
-
- ); -} - -function ActorGutter({ rows, height }: { rows: ReturnType["rows"]; height: number }) { - return ( - - ); -} - -function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) { - const { bar } = tooltip; - const startMs = new Date(bar.span.start).getTime(); - const endMs = bar.span.end ? new Date(bar.span.end).getTime() : now; - const title = bar.span.issueTitle ?? bar.span.issueIdentifier ?? "run"; - const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300); - return ( -
-
{truncate(title)}
-
- {fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "} - {bar.span.status} -
- {bar.kickoff && ( -
- kicked off by: {(bar.kickoff as WorkTimelineActor).name} - {bar.span.retryOfRunId ? " · retry" : ""} -
- )} -
click → open task
-
- ); -} - -function MiniMap({ - layout, - scrollRef, - viewportW, - scrollLeft, -}: { - layout: ReturnType; - scrollRef: React.RefObject; - viewportW: number; - scrollLeft: number; -}) { - const W = Math.max(320, viewportW || 900); - const H = 54; - const pad = 8; - const spanMs = layout.toMs - layout.fromMs || 1; - const mx = (ms: number) => pad + ((ms - layout.fromMs) / spanMs) * (W - 2 * pad); - - // one thin tick per run, stacked by row order - const rowIndex = new Map(layout.rows.map((r, i) => [r.actor.id, i])); - const laneH = (H - 2 * pad) / Math.max(1, layout.rows.length); - - const frac = layout.width > 0 ? (viewportW || W) / layout.width : 1; - const brushW = Math.max(24, Math.min(1, frac) * (W - 2 * pad)); - const brushX = pad + (layout.width > 0 ? scrollLeft / layout.width : 0) * (W - 2 * pad); - - const seek = (clientX: number, el: SVGSVGElement) => { - const rect = el.getBoundingClientRect(); - const f = Math.min(1, Math.max(0, (clientX - rect.left - pad) / (W - 2 * pad))); - if (scrollRef.current) { - scrollRef.current.scrollLeft = f * layout.width - scrollRef.current.clientWidth / 2; - } - }; - - return ( -
- { - const el = e.currentTarget; - seek(e.clientX, el); - const move = (ev: MouseEvent) => seek(ev.clientX, el); - const up = () => { - document.removeEventListener("mousemove", move); - document.removeEventListener("mouseup", up); - }; - document.addEventListener("mousemove", move); - document.addEventListener("mouseup", up); - }} - > - - {layout.rows.flatMap((row) => - row.bars.map((bar) => { - const startMs = new Date(bar.span.start).getTime(); - const endMs = bar.span.end ? new Date(bar.span.end).getTime() : layout.toMs; - const yy = pad + (rowIndex.get(row.actor.id) ?? 0) * laneH; - return ( - - ); - }), - )} - - -
- ); -} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 011e3c5cd63..2fbc9a92245 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -251,7 +251,6 @@ export const queryKeys = { ["company-search", companyId, q, scope, limit, offset] as const, }, dashboard: (companyId: string) => ["dashboard", 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, sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const, diff --git a/ui/src/lib/timeline/layout.test.ts b/ui/src/lib/timeline/layout.test.ts deleted file mode 100644 index 4bb95d31690..00000000000 --- a/ui/src/lib/timeline/layout.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { WorkTimelineResult } from "@paperclipai/shared"; -import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout"; - -const DAY = "2026-07-02"; -const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`; - -// A trimmed version of the board's locked-design sample (PAP-12422), expressed -// in the real endpoint contract: actor→actor edges rather than run→run. -function sample(): WorkTimelineResult { - const actors = [ - { id: "user:dotta", type: "user" as const, name: "dotta" }, - { id: "agent:ceo", type: "agent" as const, name: "CEO" }, - { id: "agent:cto", type: "agent" as const, name: "CTO Architect" }, - { id: "agent:ux", type: "agent" as const, name: "UXDesigner" }, - { id: "agent:senior", type: "agent" as const, name: "SeniorEngineer" }, - { id: "agent:codex", type: "agent" as const, name: "CodexCoder" }, - { id: "agent:qa", type: "agent" as const, name: "QA" }, - { id: "system:routine", type: "system" as const, name: "Circleback" }, - ]; - const span = ( - runId: string, - actorId: string, - issueId: string, - identifier: string, - start: string, - end: string | null, - status: string, - retryOfRunId: string | null = null, - ): WorkTimelineResult["spans"][number] => ({ - actorId, - laneHint: null, - runId, - issueId, - issueIdentifier: identifier, - issueTitle: `${identifier} title`, - start: t(start), - end: end ? t(end) : null, - status, - retryOfRunId, - }); - const spans = [ - span("r1", "agent:ceo", "i-405", "PAP-12405", "09:02", "09:10", "completed"), - span("r2", "agent:cto", "i-405", "PAP-12405", "09:14", "09:52", "completed"), - span("r3", "agent:senior", "i-075", "PAP-12075", "09:58", "10:20", "completed"), - span("r4", "agent:ux", "i-422", "PAP-12422", "10:10", "11:34", "running"), - span("r5", "agent:codex", "i-423", "PAP-12423", "10:10", "10:56", "completed"), - span("r6", "agent:codex", "i-075", "PAP-12075", "10:22", "11:08", "completed"), // overlaps r5 → sub-lane - span("r7", "agent:qa", "i-423", "PAP-12423", "11:40", "12:12", "completed"), - span("r8", "agent:codex", "i-423", "PAP-12423", "12:20", "12:38", "completed", "r5"), // retry - span("r9", "system:routine", "i-286", "PAP-12286", "20:00", "20:04", "completed"), - ]; - const edge = (from: string, to: string, issueId: string, at: string): WorkTimelineResult["edges"][number] => ({ - fromActorId: from, - toActorId: to, - issueId, - at: t(at), - kind: "delegation", - }); - const edges = [ - edge("user:dotta", "agent:ceo", "i-405", "09:01"), // human kickoff → chip only, no line - edge("agent:ceo", "agent:cto", "i-405", "09:13"), - edge("agent:cto", "agent:ux", "i-422", "10:09"), - edge("agent:cto", "agent:codex", "i-423", "10:09"), - edge("agent:senior", "agent:codex", "i-075", "10:21"), - edge("agent:codex", "agent:qa", "i-423", "11:39"), - edge("agent:qa", "agent:codex", "i-423", "12:19"), // → retry r8 (dashed) - ]; - return { - actors, - spans, - events: [], - edges, - pagination: { limit: 200, offset: 0, totalIssues: 6, hasMore: false }, - window: { from: t("09:00"), to: t("20:15"), capped: false }, - }; -} - -const OPTS: LayoutOptions = { - pxPerMinute: 1.7, - gutter: 172, - rowH: 34, - barH: 15, - laneGap: 4, - nowMs: new Date(t("13:00")).getTime(), -}; - -describe("computeLayout", () => { - it("excludes humans from rows but keeps agents and system actors", () => { - const layout = computeLayout(sample(), OPTS); - const rowActorTypes = layout.rows.map((r) => r.actor.type); - expect(rowActorTypes).not.toContain("user"); - expect(layout.rows.map((r) => r.actor.id)).toContain("system:routine"); - expect(layout.rows).toHaveLength(7); // 6 agents + 1 system - }); - - it("orders rows by first activity", () => { - const layout = computeLayout(sample(), OPTS); - expect(layout.rows[0].actor.id).toBe("agent:ceo"); - expect(layout.rows[layout.rows.length - 1].actor.id).toBe("system:routine"); - }); - - it("packs overlapping runs into concurrency sub-lanes", () => { - const layout = computeLayout(sample(), OPTS); - const codex = layout.rows.find((r) => r.actor.id === "agent:codex")!; - expect(codex.laneCount).toBe(2); // r5 and r6 overlap - const lanesTop = new Set(codex.bars.map((b) => b.yTop)); - expect(lanesTop.size).toBeGreaterThan(1); - }); - - it("derives the kickoff actor for a run (incl. human) from edges", () => { - const layout = computeLayout(sample(), OPTS); - const codexApi = layout.rows - .find((r) => r.actor.id === "agent:codex")! - .bars.find((b) => b.span.runId === "r5")!; - expect(codexApi.kickoff?.id).toBe("agent:cto"); - - const ceoRun = layout.rows.find((r) => r.actor.id === "agent:ceo")!.bars[0]; - expect(ceoRun.kickoff?.id).toBe("user:dotta"); // human kickoff shown as chip - }); - - it("draws agent→agent connectors only, dashing retries, never from a human", () => { - const layout = computeLayout(sample(), OPTS); - // 6 agent→agent edges resolve to bars; the dotta→ceo human edge draws no line. - expect(layout.connectors.length).toBe(6); - const dashed = layout.connectors.filter((c) => c.dashed); - expect(dashed).toHaveLength(1); // only the QA→codex retry hop - // every connector is left-to-right (source trailing edge → target leading edge) - for (const c of layout.connectors) expect(c.x2).toBeGreaterThanOrEqual(c.x1 - 1); - }); - - it("extends in-progress runs to now and clamps sub-minute bars", () => { - const layout = computeLayout(sample(), OPTS); - const running = layout.rows - .find((r) => r.actor.id === "agent:ux")! - .bars.find((b) => b.span.runId === "r4")!; - expect(running.running).toBe(true); - expect(running.x2).toBeGreaterThan(running.x1 + 3); - }); - - it("produces a deterministic issue hue + legend", () => { - const layout = computeLayout(sample(), OPTS); - expect(layout.issues.length).toBe(5); // i-405, i-075, i-422, i-423, i-286 - expect(issueColor("i-405")).toBe(issueColor("i-405")); - }); -}); - -describe("human activity markers", () => { - const withUserEvents = (): WorkTimelineResult => ({ - ...sample(), - events: [ - { actorId: "user:dotta", kind: "created", issueId: "i-405", at: t("09:00") }, - { actorId: "user:dotta", kind: "commented", issueId: "i-422", at: t("11:00") }, - { actorId: "user:dotta", kind: "approved", issueId: "i-423", at: t("12:15") }, - ], - }); - - it("gives a human actor a row driven purely by their in-window events", () => { - const layout = computeLayout(withUserEvents(), OPTS); - const dotta = layout.rows.find((r) => r.actor.id === "user:dotta"); - expect(dotta).toBeDefined(); - expect(dotta!.actor.type).toBe("user"); - expect(dotta!.bars).toHaveLength(0); // humans have no runs - }); - - it("positions instant markers at x(event.at) on the owning row, time-ordered", () => { - const layout = computeLayout(withUserEvents(), OPTS); - const dotta = layout.rows.find((r) => r.actor.id === "user:dotta")!; - expect(dotta.markers).toHaveLength(3); - expect(dotta.markers.map((m) => m.event.kind)).toEqual(["created", "commented", "approved"]); - const xs = dotta.markers.map((m) => m.x); - expect(xs).toEqual([...xs].sort((a, b) => a - b)); // monotonic in time - for (const m of dotta.markers) expect(m.yc).toBeCloseTo(dotta.y + dotta.h / 2); - }); - - it("keeps humans marker-only: no run bars, no connectors target them", () => { - const layout = computeLayout(withUserEvents(), OPTS); - // dotta→ceo human kickoff still draws no line; agent→agent count unchanged. - expect(layout.connectors.length).toBe(6); - }); - - it("still excludes a human with no in-window events", () => { - const layout = computeLayout(sample(), OPTS); // events: [] - expect(layout.rows.map((r) => r.actor.type)).not.toContain("user"); - }); -}); - -describe("helpers", () => { - it("shortLabel builds 2-char initials", () => { - expect(shortLabel("CodexCoder")).toBe("CO"); - expect(shortLabel("CTO Architect")).toBe("CA"); - }); - - it("chooseTickStepMs grows the step as the view zooms out", () => { - expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4)); - }); -}); diff --git a/ui/src/lib/timeline/layout.ts b/ui/src/lib/timeline/layout.ts deleted file mode 100644 index ca99dd1fecc..00000000000 --- a/ui/src/lib/timeline/layout.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Work Timeline layout — pure transform from the Phase B endpoint contract - * (`WorkTimelineResult`) into a renderable view model for the custom-SVG Gantt. - * - * Ports the board-locked "Direction C" logic (PAP-12422): agent/system rows only - * (humans never get a row), overlapping runs packed into concurrency sub-lanes, - * a kickoff actor derived per run (shown as an avatar chip — may be a human), - * and straight agent→agent delegation connectors from a source bar's trailing - * edge to a target bar's leading edge (dashed for retries / changes-requested). - * - * Everything here is deterministic given (result, options) so it can be unit - * tested without a DOM. - */ -import type { - WorkTimelineActor, - WorkTimelineEdge, - WorkTimelineEvent, - WorkTimelineResult, - WorkTimelineSpan, -} from "@paperclipai/shared"; - -export type ColorMode = "issue" | "status"; - -export interface LayoutOptions { - /** px per minute along the x axis (set by the zoom level). */ - pxPerMinute: number; - /** width of the left actor gutter in px. */ - gutter: number; - /** row height in px. */ - rowH: number; - /** bar height in px. */ - barH: number; - /** vertical gap between concurrency sub-lanes in px. */ - laneGap: number; - /** wall-clock "now" in ms, used to close in-progress runs. */ - nowMs: number; -} - -export interface PositionedBar { - span: WorkTimelineSpan; - /** leading (start) x in px. */ - x1: number; - /** trailing (end) x in px. */ - x2: number; - /** vertical center of the bar in px. */ - yc: number; - /** top of the bar in px. */ - yTop: number; - height: number; - running: boolean; - /** the actor who kicked this run off, if resolvable (may be a human/user). */ - kickoff: WorkTimelineActor | null; -} - -/** - * An instant human/actor action (issue created, comment, approval, delegation, - * assignment) — plotted as a diamond marker at its timestamp on the actor's row. - * Humans have no "runs", so these markers are their only presence on the chart. - */ -export interface PositionedMarker { - event: WorkTimelineEvent; - /** x position (px) of the marker centre = x(event.at). */ - x: number; - /** vertical centre of the marker in px (row-relative, excludes axis offset). */ - yc: number; -} - -export interface ActorRow { - actor: WorkTimelineActor; - /** top of the row (excluding axis offset) in px. */ - y: number; - /** row height in px. */ - h: number; - laneCount: number; - bars: PositionedBar[]; - /** instant event markers (issue created / comment / approval / …) on this row. */ - markers: PositionedMarker[]; -} - -export interface Connector { - x1: number; - y1: number; - x2: number; - y2: number; - /** dashed = a return / retry (changes-requested) hop. */ - dashed: boolean; -} - -export interface TimelineLayout { - rows: ActorRow[]; - connectors: Connector[]; - /** full inner width of the chart (gutter + plotted time + pad). */ - width: number; - /** full height of the chart including the axis strip. */ - height: number; - /** domain start (ms). */ - fromMs: number; - /** domain end (ms). */ - toMs: number; - gutter: number; - pxPerMinute: number; - /** ordered list of distinct issue keys present, for the legend + hue map. */ - issues: { key: string; label: string; color: string }[]; -} - -export const AXIS_H = 22; -const RUNNING_STATUSES = new Set(["running", "in_progress", "queued", "pending"]); - -export function isRunningStatus(status: string): boolean { - return RUNNING_STATUSES.has(status); -} - -export function actorType(actor: WorkTimelineActor | undefined): string { - return actor?.type ?? "system"; -} - -/** Deterministic, stable hue per issue that reads on both light and dark. */ -export function issueColor(key: string): string { - let hash = 0; - for (let i = 0; i < key.length; i++) { - hash = (hash * 31 + key.charCodeAt(i)) & 0xffffffff; - } - const hue = Math.abs(hash) % 360; - return `hsl(${hue} 62% 52%)`; -} - -/** 2-char initials for an SVG avatar chip. */ -export function shortLabel(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); - return (name.slice(0, 2) || "?").toUpperCase(); -} - -function spanStartMs(s: WorkTimelineSpan): number { - return new Date(s.start).getTime(); -} - -function spanEndMs(s: WorkTimelineSpan, nowMs: number): number { - const raw = s.end ? new Date(s.end).getTime() : nowMs; - return raw; -} - -/** - * Resolve the kickoff actor for a run: the source of the delegation/assignment - * edge that points at this run's actor on this run's issue, closest at-or-before - * the run start (falling back to the nearest such edge). Mirrors the design's - * "avatar chip at the leading edge = who kicked it off". - */ -function resolveKickoff( - span: WorkTimelineSpan, - edges: WorkTimelineEdge[], - actorById: Map, -): WorkTimelineActor | null { - const start = spanStartMs(span); - let best: { edge: WorkTimelineEdge; delta: number } | null = null; - for (const e of edges) { - if (e.toActorId !== span.actorId || e.issueId !== span.issueId) continue; - if (e.fromActorId === span.actorId) continue; // self-kickoff is not a delegation - const at = new Date(e.at).getTime(); - // Prefer edges at-or-before the run start; otherwise smallest absolute gap. - const delta = at <= start ? start - at : (start - at) + 1e12; - if (!best || delta < best.delta) best = { edge: e, delta }; - } - if (!best) return null; - return actorById.get(best.edge.fromActorId) ?? null; -} - -export function computeLayout(result: WorkTimelineResult, opts: LayoutOptions): TimelineLayout { - const { pxPerMinute, gutter, rowH, barH, laneGap, nowMs } = opts; - const fromMs = new Date(result.window.from).getTime(); - const toMs = new Date(result.window.to).getTime(); - const actorById = new Map(result.actors.map((a) => [a.id, a])); - - const x = (ms: number) => gutter + ((ms - fromMs) / 60000) * pxPerMinute; - - // Rows: any actor with in-window activity gets a row — agents/system via their - // runs (spans), and humans (type "user") via their instant events, since humans - // have no "runs" and would otherwise never appear. Order by first activity so the - // eye follows the delegation chain top-to-bottom. - const firstActivity = new Map(); - const noteActivity = (actorId: string, t: number) => { - const cur = firstActivity.get(actorId); - if (cur === undefined || t < cur) firstActivity.set(actorId, t); - }; - for (const s of result.spans) noteActivity(s.actorId, spanStartMs(s)); - for (const e of result.events) noteActivity(e.actorId, new Date(e.at).getTime()); - - // Instant events grouped by the actor who performed them. - const eventsByActor = new Map(); - for (const e of result.events) { - const arr = eventsByActor.get(e.actorId); - if (arr) arr.push(e); - else eventsByActor.set(e.actorId, [e]); - } - - const rowActors = result.actors - .filter((a) => firstActivity.has(a.id)) // drop idle actors with no run/event in-window - .sort((a, b) => (firstActivity.get(a.id)! - firstActivity.get(b.id)!)); - - // Issue hue map (ordered by first appearance) for the "by issue" color mode + legend. - const issueOrder: string[] = []; - const issueLabel = new Map(); - for (const s of result.spans) { - const key = s.issueId; - if (!issueLabel.has(key)) { - issueOrder.push(key); - issueLabel.set(key, s.issueIdentifier ?? s.issueTitle ?? "issue"); - } - } - const issues = issueOrder.map((key) => ({ key, label: issueLabel.get(key)!, color: issueColor(key) })); - - const barIndex = new Map(); // runId -> bar - const rows: ActorRow[] = []; - let y = 0; - for (const actor of rowActors) { - const runs = result.spans - .filter((s) => s.actorId === actor.id) - .sort((p, q) => spanStartMs(p) - spanStartMs(q)); - - // Greedy pack overlapping runs into sub-lanes. - const laneEnds: number[] = []; - const laneOf = new Map(); - for (const r of runs) { - const rs = spanStartMs(r); - const re = spanEndMs(r, nowMs); - let placed = -1; - for (let ln = 0; ln < laneEnds.length; ln++) { - if (laneEnds[ln] <= rs) { - placed = ln; - break; - } - } - if (placed === -1) { - placed = laneEnds.length; - laneEnds.push(re); - } else { - laneEnds[placed] = re; - } - laneOf.set(r.runId, placed); - } - const laneCount = Math.max(1, laneEnds.length); - const h = Math.max(rowH, laneCount * (barH + laneGap) + 8); - - const bars: PositionedBar[] = runs.map((r) => { - const lane = laneOf.get(r.runId) ?? 0; - const laneTop = y + 6 + lane * (barH + laneGap); - const x1 = x(spanStartMs(r)); - const x2raw = x(spanEndMs(r, nowMs)); - const x2 = Math.max(x1 + 3, x2raw); // clamp sub-minute runs to a visible min width - const bar: PositionedBar = { - span: r, - x1, - x2, - yTop: laneTop, - yc: laneTop + barH / 2, - height: barH, - running: isRunningStatus(r.status), - kickoff: resolveKickoff(r, result.edges, actorById), - }; - barIndex.set(r.runId, bar); - return bar; - }); - - // Instant markers for this actor, positioned at their timestamp on this row. - const markers: PositionedMarker[] = (eventsByActor.get(actor.id) ?? []) - .slice() - .sort((p, q) => new Date(p.at).getTime() - new Date(q.at).getTime()) - .map((event) => ({ - event, - x: x(new Date(event.at).getTime()), - yc: y + h / 2, - })); - - rows.push({ actor, y, h, laneCount, bars, markers }); - y += h; - } - - // Connectors: straight agent→agent lines, connected at both ends. For each bar - // with an agent/system kickoff, connect from the kickoff actor's nearest - // preceding bar (same issue preferred) to this bar's leading edge. - const connectors: Connector[] = []; - for (const row of rows) { - for (const bar of row.bars) { - const k = bar.kickoff; - if (!k || k.type === "user") continue; // agent→agent only; humans stay as chips - const source = nearestSourceBar(k.id, bar, barIndex, nowMs); - if (!source) continue; - connectors.push({ - x1: source.x2, - y1: source.yc, - x2: bar.x1, - y2: bar.yc, - dashed: Boolean(bar.span.retryOfRunId), - }); - } - } - - const width = gutter + ((toMs - fromMs) / 60000) * pxPerMinute + 40; - const height = y + AXIS_H; - return { rows, connectors, width, height, fromMs, toMs, gutter, pxPerMinute, issues }; -} - -/** The kickoff actor's bar that best precedes `target` (same issue preferred). */ -function nearestSourceBar( - kickoffActorId: string, - target: PositionedBar, - barIndex: Map, - nowMs: number, -): PositionedBar | null { - const targetStart = spanStartMs(target.span); - let sameIssue: PositionedBar | null = null; - let anyIssue: PositionedBar | null = null; - for (const bar of barIndex.values()) { - if (bar.span.actorId !== kickoffActorId) continue; - if (bar === target) continue; - const end = spanEndMs(bar.span, nowMs); - if (end > targetStart + 1) continue; // must precede (small tolerance) - if (bar.span.issueId === target.span.issueId) { - if (!sameIssue || spanEndMs(sameIssue.span, nowMs) < end) sameIssue = bar; - } - if (!anyIssue || spanEndMs(anyIssue.span, nowMs) < end) anyIssue = bar; - } - return sameIssue ?? anyIssue; -} - -/** Choose a "nice" gridline step (ms) targeting ~120px between labels. */ -export function chooseTickStepMs(pxPerMinute: number): number { - const targetPx = 120; - const minutesPerTarget = targetPx / pxPerMinute; - const steps = [15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080]; // minutes - for (const m of steps) { - if (m >= minutesPerTarget) return m * 60000; - } - return steps[steps.length - 1] * 60000; -} - -export function formatDuration(startMs: number, endMs: number): string { - const mins = Math.max(0, Math.round((endMs - startMs) / 60000)); - if (mins >= 1440) return `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`; - if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`; - return `${mins}m`; -} diff --git a/ui/src/pages/Timeline.tsx b/ui/src/pages/Timeline.tsx deleted file mode 100644 index a8efad08285..00000000000 --- a/ui/src/pages/Timeline.tsx +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Work Timeline page (PAP-12424 / Phase C of PAP-12405). - * - * A Gantt-style view of company actor activity built on the Phase B endpoint - * (`GET /companies/:companyId/timeline`). Rendering is the board-locked - * Direction C (PAP-12422): dense rows, mini-map brush, custom inline SVG. - */ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { GanttChartSquare } from "lucide-react"; -import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared"; -import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline"; -import { queryKeys } from "@/lib/queryKeys"; -import { useCompany } from "@/context/CompanyContext"; -import { useBreadcrumbs } from "@/context/BreadcrumbContext"; -import { EmptyState } from "@/components/EmptyState"; -import { PageSkeleton } from "@/components/PageSkeleton"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { WorkTimelineChart, defaultZoomForWindow, type ZoomLevel } from "@/components/timeline/WorkTimelineChart"; -import { issueColor, type ColorMode } from "@/lib/timeline/layout"; -import { cn } from "@/lib/utils"; - -const EVERYONE = "__everyone__"; - -function Segmented({ - value, - options, - onChange, -}: { - value: T; - options: { value: T; label: string }[]; - onChange: (v: T) => void; -}) { - return ( -
- {options.map((opt, i) => ( - - ))} -
- ); -} - -export function Timeline() { - const { selectedCompanyId } = useCompany(); - const { setBreadcrumbs } = useBreadcrumbs(); - const [zoom, setZoom] = useState("day"); - const zoomTouched = useRef(false); - const setZoomManual = (z: ZoomLevel) => { - zoomTouched.current = true; - setZoom(z); - }; - const [colorMode, setColorMode] = useState("issue"); - const [lensUserId, setLensUserId] = useState(EVERYONE); - // Union of users discovered across fetches so the lens list stays stable. - const [knownUsers, setKnownUsers] = useState([]); - - useEffect(() => { - setBreadcrumbs([{ label: "Timeline" }]); - }, [setBreadcrumbs]); - - const params: WorkTimelineParams = useMemo( - () => (lensUserId === EVERYONE ? {} : { userId: lensUserId.replace(/^user:/, "") }), - [lensUserId], - ); - - const { data, isLoading, error } = useQuery({ - queryKey: queryKeys.workTimeline(selectedCompanyId ?? "", lensUserId), - queryFn: () => workTimelineApi.get(selectedCompanyId!, params), - enabled: !!selectedCompanyId, - }); - - useEffect(() => { - if (!data || zoomTouched.current) return; - setZoom(defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime())); - }, [data]); - - useEffect(() => { - if (!data) return; - setKnownUsers((prev) => { - const byId = new Map(prev.map((u) => [u.id, u])); - for (const a of data.actors) if (a.type === "user") byId.set(a.id, a); - return Array.from(byId.values()); - }); - }, [data]); - - if (!selectedCompanyId) { - return ; - } - - const header = ( -
-
- -

Work Timeline

-
-

- A Gantt view of who did what, when. Rows are actors; bars are heartbeat runs colored by task; - the avatar chip at a bar's leading edge is who kicked it off; straight lines are agent→agent - delegation. Hover a bar for its task & timing; click to open the task. -

-
- ); - - const toolbar = ( -
- - - -
- ); - - return ( -
- {header} - {toolbar} - - {isLoading && } - - {error && ( - - )} - - {data && !isLoading && ( - data.spans.length === 0 ? ( - - ) : ( -
- -
- -
-

- {data.spans.length} run{data.spans.length === 1 ? "" : "s"} ·{" "} - {new Date(data.window.from).toLocaleString()} → {new Date(data.window.to).toLocaleString()} - {data.window.capped ? " · window capped" : ""} -

-
- ) - )} -
- ); -} - -function Legend({ data, colorMode }: { data: WorkTimelineResult; colorMode: ColorMode }) { - if (colorMode === "status") { - return ( -
- - done - - - {" "} - in progress - - - {" "} - changes/blocked - -
- ); - } - const issues = Array.from( - new Map(data.spans.map((s) => [s.issueId, s.issueIdentifier ?? s.issueTitle ?? "task"])).entries(), - ); - return ( -
- {issues.slice(0, 12).map(([id, label]) => ( - - - {label} - - ))} - {issues.length > 12 && +{issues.length - 12} more} -
- ); -} diff --git a/ui/storybook/fixtures/workTimeline.human.sample.json b/ui/storybook/fixtures/workTimeline.human.sample.json deleted file mode 100644 index abbedf42279..00000000000 --- a/ui/storybook/fixtures/workTimeline.human.sample.json +++ /dev/null @@ -1,1824 +0,0 @@ -{ - "actors": [ - { - "id": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "type": "agent", - "name": "QA", - "avatar": "bug" - }, - { - "id": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "type": "agent", - "name": "CTO", - "avatar": "file-code" - }, - { - "id": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "type": "agent", - "name": "Senior Engineer Fable", - "avatar": "code" - }, - { - "id": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "type": "agent", - "name": "UXDesigner", - "avatar": "gem" - }, - { - "id": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "type": "user", - "name": "Dotta", - "avatar": "/api/assets/3b1e4f9b-6136-4315-8484-fd9f441aae54/content" - }, - { - "id": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "type": "agent", - "name": "CodexCoder", - "avatar": "code" - }, - { - "id": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "type": "agent", - "name": "CloudOpsEngineer", - "avatar": "cog" - }, - { - "id": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "type": "agent", - "name": "CEO", - "avatar": "crown" - }, - { - "id": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "type": "agent", - "name": "Strategic Ops", - "avatar": "target" - }, - { - "id": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "type": "agent", - "name": "Senior Planning Engineer Pro", - "avatar": "code" - }, - { - "id": "system:system", - "type": "system", - "name": "System", - "avatar": null - }, - { - "id": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", - "type": "agent", - "name": "DevRel", - "avatar": "globe" - }, - { - "id": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", - "type": "agent", - "name": "SecurityEngineer", - "avatar": "shield" - }, - { - "id": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "type": "agent", - "name": "ClaudeCoder", - "avatar": "code" - }, - { - "id": "agent:db1c09f1-422c-48c8-9711-4054d3657aac", - "type": "agent", - "name": "ConnectionCoder", - "avatar": "code" - } - ], - "spans": [ - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "laneHint": "automation", - "runId": "571d1495-fe7e-4cf8-a79f-e4d8d779cecf", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "issueIdentifier": "PAP-12298", - "issueTitle": "blog page redesign", - "start": "2026-07-02T14:42:05.689Z", - "end": "2026-07-02T14:45:37.694Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "laneHint": "assignment", - "runId": "c7a242bb-adf1-46e2-a322-474ed791c7ff", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "issueIdentifier": "PAP-12420", - "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", - "start": "2026-07-02T14:44:59.649Z", - "end": "2026-07-02T14:48:23.151Z", - "status": "cancelled", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "laneHint": "automation", - "runId": "c214c9f0-b46b-47de-ba86-2520e1a8aa75", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "issueIdentifier": "PAP-12420", - "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", - "start": "2026-07-02T14:48:23.194Z", - "end": "2026-07-02T14:52:29.939Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "laneHint": "automation", - "runId": "6b0a55a9-7c1a-4d48-bdfb-159370e9c03d", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T14:50:35.277Z", - "end": "2026-07-02T14:55:43.520Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "laneHint": "assignment", - "runId": "e573c5dc-94b9-4b63-bdb3-c48327a85cea", - "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", - "issueIdentifier": "PAP-12421", - "issueTitle": "CTO/admin: attach corrected pages.paperclip.ing S3 policy to paperclip-page-uploader", - "start": "2026-07-02T14:52:04.426Z", - "end": "2026-07-02T14:56:08.638Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "laneHint": "assignment", - "runId": "8a716a87-61e2-4667-9cc4-3641fe42c6f0", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "issueIdentifier": "PAP-12422", - "issueTitle": "Work Timeline — Phase A: wireframe exploration (2–3 directions, board picks one)", - "start": "2026-07-02T14:52:21.788Z", - "end": "2026-07-02T15:07:44.596Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "laneHint": "assignment", - "runId": "4b085459-77b0-41e4-a62f-a76e1ba9b521", - "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", - "issueIdentifier": "PAP-12423", - "issueTitle": "Work Timeline — Phase B: timeline aggregation endpoint (GET /companies/:id/timeline)", - "start": "2026-07-02T14:52:21.929Z", - "end": "2026-07-02T15:03:54.376Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "laneHint": "assignment", - "runId": "972b6b5b-5fdd-419d-aea0-5641b4634448", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "issueIdentifier": "PAP-12427", - "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", - "start": "2026-07-02T14:54:41.918Z", - "end": "2026-07-02T14:57:27.722Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "laneHint": "assignment", - "runId": "d14a8901-4569-4f05-b8c2-5e49d4216a6d", - "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", - "issueIdentifier": "PAP-12428", - "issueTitle": "just change the terminology to “Responsible”", - "start": "2026-07-02T14:55:00.330Z", - "end": "2026-07-02T15:01:10.197Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "laneHint": "automation", - "runId": "77ffd755-a755-41e6-b2cb-ee0817f11152", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T14:55:43.595Z", - "end": "2026-07-02T14:57:12.235Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "laneHint": "automation", - "runId": "960639c7-f6cc-45ad-b746-22ca507c237d", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T14:57:12.287Z", - "end": "2026-07-02T15:12:26.352Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "laneHint": "assignment", - "runId": "a61b657c-d297-43b7-948c-2be7bd161e3b", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "issueIdentifier": "PAP-12427", - "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", - "start": "2026-07-02T14:57:27.831Z", - "end": "2026-07-02T15:01:08.081Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "laneHint": "assignment", - "runId": "67f65027-b802-4e88-91a6-bebb5c94f16b", - "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", - "issueIdentifier": "PAP-12429", - "issueTitle": "give the AWS_ keys that ClaudeCoder has to CodexCoder and CloudOpsEngineer and UXDesigner", - "start": "2026-07-02T14:57:50.711Z", - "end": "2026-07-02T15:01:18.973Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "laneHint": "assignment", - "runId": "e9ff0a09-6607-4793-90f3-1b3ab1741ef0", - "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", - "issueIdentifier": "PAP-12430", - "issueTitle": "merged Create a single PR from this branch PAP-12404-inbox-blocked-but-descendent-live-icon", - "start": "2026-07-02T14:59:42.783Z", - "end": "2026-07-02T15:17:12.936Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", - "laneHint": "assignment", - "runId": "91f67e3b-18ae-4230-8fae-18b414ffcd14", - "issueId": "1533f78d-d677-449f-afd9-4cf653168438", - "issueIdentifier": "PAP-12431", - "issueTitle": "2026-07-02 create a standup message i can copy to slack about everything i've been working on for the last 36 hours", - "start": "2026-07-02T15:00:19.704Z", - "end": "2026-07-02T15:05:16.672Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "laneHint": "assignment", - "runId": "e08e32ae-9106-46e0-9c2b-97ab74520fc4", - "issueId": "0facd02e-425f-4822-875c-2f97988de98a", - "issueIdentifier": "PAP-12432", - "issueTitle": "CEO/admin: apply paperclip-page-uploader pages.paperclip.ing inline policy", - "start": "2026-07-02T15:00:46.697Z", - "end": "2026-07-02T15:06:05.586Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", - "laneHint": "automation", - "runId": "12223c0b-0bad-4b95-be56-ceb1ce955b0f", - "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", - "issueIdentifier": "PAP-12425", - "issueTitle": "Work Timeline — security review: timeline endpoint ACLs and data exposure", - "start": "2026-07-02T15:01:28.563Z", - "end": "2026-07-02T15:05:43.985Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "laneHint": "assignment", - "runId": "33181a90-d2af-4081-9deb-253d996d81f1", - "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", - "issueIdentifier": "PAP-12433", - "issueTitle": "project star’ing", - "start": "2026-07-02T15:03:27.392Z", - "end": "2026-07-02T15:52:11.373Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "laneHint": "assignment", - "runId": "0961f6e8-a1cd-4493-9979-99775285c748", - "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", - "issueIdentifier": "PAP-12434", - "issueTitle": "Strategic Ops: get AWS admin to attach pages.paperclip.ing uploader policy", - "start": "2026-07-02T15:05:38.870Z", - "end": "2026-07-02T15:08:15.576Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "laneHint": "assignment", - "runId": "0ecdb797-e6b4-4f12-a5f0-b7bce348138f", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "issueIdentifier": "PAP-12435", - "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", - "start": "2026-07-02T15:07:45.699Z", - "end": "2026-07-02T15:11:13.138Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "laneHint": "assignment", - "runId": "bcfaef62-ca9a-4a96-b279-bf2314108dbf", - "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", - "issueIdentifier": "PAP-12403", - "issueTitle": "github and environment variables", - "start": "2026-07-02T15:09:15.958Z", - "end": "2026-07-02T15:20:22.053Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "laneHint": "automation", - "runId": "e4e854bc-9cce-4c5f-bfb9-a2f3c0094075", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "issueIdentifier": "PAP-12420", - "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", - "start": "2026-07-02T15:10:25.014Z", - "end": "2026-07-02T15:15:34.966Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "laneHint": "assignment", - "runId": "b83d9b78-f63c-465c-808c-c1e1175a18c5", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "issueIdentifier": "PAP-12436", - "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", - "start": "2026-07-02T15:10:51.773Z", - "end": "2026-07-02T15:14:59.703Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", - "laneHint": "automation", - "runId": "90cd6835-be7c-4dd8-bdf7-017d40df9c38", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T15:12:26.440Z", - "end": "2026-07-02T15:14:56.976Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "laneHint": "assignment", - "runId": "f4d1d8a6-c74d-4d59-b831-b788eeaf93f2", - "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", - "issueIdentifier": "PAP-12437", - "issueTitle": "for parent task, cleanup the effecitvely merged ones", - "start": "2026-07-02T15:14:30.602Z", - "end": "2026-07-02T15:20:22.033Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "laneHint": "automation", - "runId": "cd8cf33f-5a1c-4b3a-905e-aabc0341edd1", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T15:14:57.094Z", - "end": "2026-07-02T15:16:53.870Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "laneHint": "assignment", - "runId": "a9ba6322-e0e3-43bb-bc0b-2af53bec9610", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "issueIdentifier": "PAP-12436", - "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", - "start": "2026-07-02T15:14:59.782Z", - "end": "2026-07-02T15:20:10.000Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "laneHint": "automation", - "runId": "df0d6959-ef5f-4a75-b710-492cfd6de658", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T15:16:53.952Z", - "end": "2026-07-02T15:19:28.111Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "laneHint": "assignment", - "runId": "4cec085d-bf1f-48a2-aef1-62cce353a09f", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "issueIdentifier": "PAP-12438", - "issueTitle": "environment variables editor re-design", - "start": "2026-07-02T15:18:55.386Z", - "end": "2026-07-02T15:26:14.263Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "laneHint": "automation", - "runId": "17e168c3-c2f4-4cef-9492-c4e680bebcf5", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T15:19:28.163Z", - "end": "2026-07-02T15:21:38.254Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "laneHint": "automation", - "runId": "8e58708e-88df-4681-84c5-f6a676d1dd6a", - "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", - "issueIdentifier": "PAP-10273", - "issueTitle": "Defect (Minor): preview emits noisy \"skill not present in package\" warnings for catalog-skill references", - "start": "2026-07-02T15:20:20.734Z", - "end": "2026-07-02T15:25:39.352Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "laneHint": "automation", - "runId": "accd3b96-89a2-47f4-a3db-65f94ec6be3a", - "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", - "issueIdentifier": "PAP-10274", - "issueTitle": "Defect (Minor UX): \"Partial state is not rolled back\" message is incorrect — install transaction rolled back cleanly", - "start": "2026-07-02T15:20:20.784Z", - "end": "2026-07-02T15:28:39.720Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "laneHint": "automation", - "runId": "ebc8e142-c416-4b85-86ff-be0fed98f41f", - "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", - "issueIdentifier": "PAP-10275", - "issueTitle": "Defect (Minor): CLI install lacks --adapter-override and --secret-value flags", - "start": "2026-07-02T15:20:20.854Z", - "end": "2026-07-02T15:22:56.590Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "laneHint": "automation", - "runId": "85339e14-6ac3-459c-99a2-811e0e6adbba", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T15:21:38.307Z", - "end": "2026-07-02T15:22:48.667Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "laneHint": "automation", - "runId": "671705a1-718d-4be6-86b4-390295a17a74", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "issueIdentifier": "PAP-12405", - "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", - "start": "2026-07-02T15:22:48.720Z", - "end": "2026-07-02T15:24:16.925Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "laneHint": "automation", - "runId": "cbce714c-a056-477d-971b-3ceb02a4441b", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "issueIdentifier": "PAP-12435", - "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", - "start": "2026-07-02T15:23:13.966Z", - "end": "2026-07-02T15:23:32.715Z", - "status": "cancelled", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "laneHint": "automation", - "runId": "88e7b7a9-4ea6-40bc-b402-ffd731a3dfe2", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "issueIdentifier": "PAP-12298", - "issueTitle": "blog page redesign", - "start": "2026-07-02T15:23:28.308Z", - "end": "2026-07-02T15:29:09.108Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "laneHint": "assignment", - "runId": "1b734867-943b-4ef3-a10e-3f2c1edcad93", - "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", - "issueIdentifier": "PAP-12439", - "issueTitle": "Wireframes: Environment Variables Editor redesign", - "start": "2026-07-02T15:25:07.982Z", - "end": "2026-07-02T15:32:24.457Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "laneHint": "automation", - "runId": "2bd1ec14-ecbb-42d8-8412-106a251780b8", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "issueIdentifier": "PAP-12438", - "issueTitle": "environment variables editor re-design", - "start": "2026-07-02T15:26:14.310Z", - "end": "2026-07-02T15:34:59.661Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "laneHint": "assignment", - "runId": "9eb8e7ba-53b5-4d22-8f8d-cbba6a5c5bb5", - "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", - "issueIdentifier": "PAP-12440", - "issueTitle": "Defect (Minor): teams-catalog install is not atomic — mid-import failure can orphan skills/agents", - "start": "2026-07-02T15:27:39.339Z", - "end": "2026-07-02T15:37:04.236Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "assignment" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "laneHint": "automation", - "runId": "339694a1-6a7d-4de0-8466-43574a7c6b4d", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "issueIdentifier": "PAP-12422", - "issueTitle": "Work Timeline — Phase A: wireframe exploration (2–3 directions, board picks one)", - "start": "2026-07-02T15:28:13.886Z", - "end": "2026-07-02T15:42:27.258Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "laneHint": "automation", - "runId": "040ce2ef-acd4-47f2-bcf1-5a1194f5a450", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "issueIdentifier": "PAP-12438", - "issueTitle": "environment variables editor re-design", - "start": "2026-07-02T15:34:59.718Z", - "end": "2026-07-02T15:38:04.415Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "laneHint": "automation", - "runId": "20f1e461-b4b6-4d5f-b897-6f60656429fb", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "issueIdentifier": "PAP-12298", - "issueTitle": "blog page redesign", - "start": "2026-07-02T15:35:56.578Z", - "end": "2026-07-02T15:38:40.543Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "laneHint": "automation", - "runId": "063a9727-b191-49e2-9802-b6b63ec65b97", - "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", - "issueIdentifier": "PAP-12424", - "issueTitle": "Work Timeline — Phase C: frontend Gantt component + Timeline page", - "start": "2026-07-02T15:41:30.586Z", - "end": "2026-07-02T16:06:58.629Z", - "status": "succeeded", - "retryOfRunId": null, - "continuationAttempt": 0, - "invocationSource": "automation" - } - ], - "events": [ - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "created", - "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", - "at": "2026-06-03T19:39:46.628Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "created", - "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", - "at": "2026-06-03T19:40:14.945Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "created", - "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", - "at": "2026-06-03T19:40:14.974Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "created", - "issueId": "5e955520-f5ea-4ad4-aef6-860c95b1797e", - "at": "2026-06-03T20:23:11.278Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "bc7a42bb-91bb-40b3-b3a8-6f9ebf98bc94", - "at": "2026-06-30T22:22:13.060Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "6ff91230-ab9d-4b95-9590-1471ae37297e", - "at": "2026-07-01T16:31:55.423Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-01T18:23:43.255Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "48264b4d-0004-4015-9ca0-08c867eed064", - "at": "2026-07-02T00:13:22.977Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "2e375459-115c-4f0c-a1c8-59e359b5c3f6", - "at": "2026-07-02T02:21:56.069Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "created", - "issueId": "df84e69c-35e1-4323-a95f-e10d27b3227c", - "at": "2026-07-02T02:39:19.891Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "ad7f2c54-6e50-421f-a805-4c743948421a", - "at": "2026-07-02T03:29:27.493Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", - "at": "2026-07-02T04:45:14.251Z" - }, - { - "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "kind": "delegated", - "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", - "at": "2026-07-02T04:45:14.251Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", - "at": "2026-07-02T04:52:34.737Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "db6e4af1-0669-42d0-a6ef-e12dcfd7112d", - "at": "2026-07-02T04:56:04.034Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T05:07:46.810Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "created", - "issueId": "abe00e5c-bbcd-4782-ba9e-13532a09184f", - "at": "2026-07-02T05:08:53.619Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "approved", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-02T14:42:05.663Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "created", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T14:44:59.609Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "delegated", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T14:44:59.609Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "commented", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-02T14:45:19.329Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "commented", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T14:48:06.921Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "commented", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T14:48:23.164Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "approved", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T14:50:35.254Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "created", - "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", - "at": "2026-07-02T14:52:04.388Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "delegated", - "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", - "at": "2026-07-02T14:52:04.388Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "created", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T14:52:21.683Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "delegated", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T14:52:21.683Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "commented", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T14:52:21.751Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "created", - "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", - "at": "2026-07-02T14:52:21.778Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "delegated", - "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", - "at": "2026-07-02T14:52:21.778Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "created", - "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", - "at": "2026-07-02T14:52:53.705Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "delegated", - "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", - "at": "2026-07-02T14:52:53.705Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "created", - "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", - "at": "2026-07-02T14:53:41.388Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "delegated", - "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", - "at": "2026-07-02T14:53:41.388Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "created", - "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", - "at": "2026-07-02T14:53:55.809Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "delegated", - "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", - "at": "2026-07-02T14:53:55.809Z" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "kind": "created", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "at": "2026-07-02T14:54:41.875Z" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "kind": "delegated", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "at": "2026-07-02T14:54:41.875Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T14:54:44.297Z" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "kind": "commented", - "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", - "at": "2026-07-02T14:54:55.966Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", - "at": "2026-07-02T14:55:00.287Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "delegated", - "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", - "at": "2026-07-02T14:55:00.287Z" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "kind": "commented", - "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", - "at": "2026-07-02T14:55:40.913Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T14:57:12.257Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "commented", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "at": "2026-07-02T14:57:14.910Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", - "at": "2026-07-02T14:57:50.676Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", - "at": "2026-07-02T14:59:42.755Z" - }, - { - "actorId": "system:system", - "kind": "created", - "issueId": "1533f78d-d677-449f-afd9-4cf653168438", - "at": "2026-07-02T15:00:19.659Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "created", - "issueId": "0facd02e-425f-4822-875c-2f97988de98a", - "at": "2026-07-02T15:00:46.643Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "delegated", - "issueId": "0facd02e-425f-4822-875c-2f97988de98a", - "at": "2026-07-02T15:00:46.643Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "commented", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "at": "2026-07-02T15:01:00.247Z" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "kind": "commented", - "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", - "at": "2026-07-02T15:01:05.759Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "commented", - "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", - "at": "2026-07-02T15:01:14.833Z" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "kind": "commented", - "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", - "at": "2026-07-02T15:01:28.524Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "commented", - "issueId": "0facd02e-425f-4822-875c-2f97988de98a", - "at": "2026-07-02T15:02:00.701Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", - "at": "2026-07-02T15:03:27.354Z" - }, - { - "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "kind": "commented", - "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", - "at": "2026-07-02T15:04:40.445Z" - }, - { - "actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", - "kind": "commented", - "issueId": "1533f78d-d677-449f-afd9-4cf653168438", - "at": "2026-07-02T15:05:03.153Z" - }, - { - "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", - "kind": "commented", - "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", - "at": "2026-07-02T15:05:38.298Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "created", - "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", - "at": "2026-07-02T15:05:38.834Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "delegated", - "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", - "at": "2026-07-02T15:05:38.834Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "commented", - "issueId": "0facd02e-425f-4822-875c-2f97988de98a", - "at": "2026-07-02T15:05:52.785Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "approved", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T15:06:51.894Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "commented", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T15:07:21.753Z" - }, - { - "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "kind": "created", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "at": "2026-07-02T15:07:45.644Z" - }, - { - "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "kind": "delegated", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "at": "2026-07-02T15:07:45.644Z" - }, - { - "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "kind": "commented", - "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", - "at": "2026-07-02T15:07:56.186Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "commented", - "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", - "at": "2026-07-02T15:09:15.910Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "commented", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T15:10:24.986Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "created", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:10:51.721Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "delegated", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:10:51.721Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "commented", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "at": "2026-07-02T15:11:04.865Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "commented", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T15:11:14.738Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T15:12:26.380Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", - "at": "2026-07-02T15:14:30.565Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "delegated", - "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", - "at": "2026-07-02T15:14:30.565Z" - }, - { - "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T15:14:43.831Z" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "kind": "commented", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:14:44.435Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "commented", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T15:15:02.505Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "commented", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:15:56.982Z" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T15:16:35.238Z" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "kind": "commented", - "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", - "at": "2026-07-02T15:17:07.032Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "created", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "at": "2026-07-02T15:18:55.353Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "approved", - "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", - "at": "2026-07-02T15:19:05.154Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T15:19:11.188Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "commented", - "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", - "at": "2026-07-02T15:19:25.678Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "approved", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:19:39.277Z" - }, - { - "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "kind": "commented", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:19:55.477Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "commented", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "at": "2026-07-02T15:20:10.329Z" - }, - { - "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "kind": "commented", - "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", - "at": "2026-07-02T15:20:17.139Z" - }, - { - "actorId": "system:system", - "kind": "commented", - "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", - "at": "2026-07-02T15:20:20.709Z" - }, - { - "actorId": "system:system", - "kind": "commented", - "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", - "at": "2026-07-02T15:20:20.751Z" - }, - { - "actorId": "system:system", - "kind": "commented", - "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", - "at": "2026-07-02T15:20:20.803Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T15:21:26.500Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T15:22:40.012Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "commented", - "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", - "at": "2026-07-02T15:22:40.511Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "commented", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T15:24:07.505Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "commented", - "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", - "at": "2026-07-02T15:24:36.094Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "created", - "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", - "at": "2026-07-02T15:25:07.938Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "delegated", - "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", - "at": "2026-07-02T15:25:07.938Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "commented", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "at": "2026-07-02T15:25:26.349Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "commented", - "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", - "at": "2026-07-02T15:25:28.318Z" - }, - { - "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "kind": "commented", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "at": "2026-07-02T15:25:56.773Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "created", - "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", - "at": "2026-07-02T15:27:39.300Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "commented", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-02T15:28:01.540Z" - }, - { - "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "kind": "commented", - "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", - "at": "2026-07-02T15:28:02.346Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "commented", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T15:28:13.852Z" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "kind": "commented", - "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", - "at": "2026-07-02T15:28:52.824Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "commented", - "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", - "at": "2026-07-02T15:30:55.654Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "commented", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "at": "2026-07-02T15:34:59.686Z" - }, - { - "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "kind": "approved", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-02T15:35:56.554Z" - }, - { - "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "kind": "commented", - "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", - "at": "2026-07-02T15:36:48.753Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "approved", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "at": "2026-07-02T15:37:11.074Z" - }, - { - "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "kind": "commented", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "at": "2026-07-02T15:37:24.105Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "approved", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-02T15:38:21.704Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "commented", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-02T15:38:31.695Z" - }, - { - "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "kind": "commented", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T15:41:22.766Z" - }, - { - "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "kind": "approved", - "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", - "at": "2026-07-02T15:43:04.428Z" - }, - { - "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "kind": "commented", - "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", - "at": "2026-07-02T15:47:28.798Z" - }, - { - "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "kind": "commented", - "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", - "at": "2026-07-02T15:48:25.833Z" - } - ], - "edges": [ - { - "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", - "at": "2026-06-03T19:39:46.628Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", - "at": "2026-06-03T19:40:14.945Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", - "at": "2026-06-03T19:40:14.974Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "issueId": "5e955520-f5ea-4ad4-aef6-860c95b1797e", - "at": "2026-06-03T20:23:11.278Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "issueId": "bc7a42bb-91bb-40b3-b3a8-6f9ebf98bc94", - "at": "2026-06-30T22:22:13.060Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:db1c09f1-422c-48c8-9711-4054d3657aac", - "issueId": "6ff91230-ab9d-4b95-9590-1471ae37297e", - "at": "2026-07-01T16:31:55.423Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", - "at": "2026-07-01T18:23:43.255Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "issueId": "48264b4d-0004-4015-9ca0-08c867eed064", - "at": "2026-07-02T00:13:22.977Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "2e375459-115c-4f0c-a1c8-59e359b5c3f6", - "at": "2026-07-02T02:21:56.069Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "issueId": "df84e69c-35e1-4323-a95f-e10d27b3227c", - "at": "2026-07-02T02:39:19.891Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "issueId": "ad7f2c54-6e50-421f-a805-4c743948421a", - "at": "2026-07-02T03:29:27.493Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", - "at": "2026-07-02T04:45:14.251Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", - "at": "2026-07-02T04:45:14.251Z", - "kind": "delegation" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", - "at": "2026-07-02T04:52:34.737Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "db6e4af1-0669-42d0-a6ef-e12dcfd7112d", - "at": "2026-07-02T04:56:04.034Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "issueId": "828497e6-087d-4336-b627-331e51380ad4", - "at": "2026-07-02T05:07:46.810Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "issueId": "abe00e5c-bbcd-4782-ba9e-13532a09184f", - "at": "2026-07-02T05:08:53.619Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T14:44:59.609Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", - "at": "2026-07-02T14:44:59.609Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", - "at": "2026-07-02T14:52:04.388Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", - "at": "2026-07-02T14:52:04.388Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T14:52:21.683Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", - "at": "2026-07-02T14:52:21.683Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", - "at": "2026-07-02T14:52:21.778Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", - "at": "2026-07-02T14:52:21.778Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", - "at": "2026-07-02T14:52:53.705Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", - "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", - "at": "2026-07-02T14:52:53.705Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", - "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", - "at": "2026-07-02T14:53:41.388Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", - "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", - "at": "2026-07-02T14:53:41.388Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", - "at": "2026-07-02T14:53:55.809Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", - "at": "2026-07-02T14:53:55.809Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "at": "2026-07-02T14:54:41.875Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", - "at": "2026-07-02T14:54:41.875Z", - "kind": "delegation" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", - "at": "2026-07-02T14:55:00.287Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", - "at": "2026-07-02T14:55:00.287Z", - "kind": "delegation" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", - "at": "2026-07-02T14:57:50.676Z", - "kind": "assignment" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", - "at": "2026-07-02T14:59:42.755Z", - "kind": "assignment" - }, - { - "fromActorId": "system:system", - "toActorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", - "issueId": "1533f78d-d677-449f-afd9-4cf653168438", - "at": "2026-07-02T15:00:19.659Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "issueId": "0facd02e-425f-4822-875c-2f97988de98a", - "at": "2026-07-02T15:00:46.643Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "issueId": "0facd02e-425f-4822-875c-2f97988de98a", - "at": "2026-07-02T15:00:46.643Z", - "kind": "delegation" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", - "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", - "at": "2026-07-02T15:03:27.354Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "toActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", - "at": "2026-07-02T15:05:38.834Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "toActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", - "at": "2026-07-02T15:05:38.834Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "at": "2026-07-02T15:07:45.644Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", - "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", - "at": "2026-07-02T15:07:45.644Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:10:51.721Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", - "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", - "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", - "at": "2026-07-02T15:10:51.721Z", - "kind": "delegation" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", - "at": "2026-07-02T15:14:30.565Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", - "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", - "at": "2026-07-02T15:14:30.565Z", - "kind": "delegation" - }, - { - "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", - "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "issueId": "e0189c44-1245-4526-9423-1128862d7c95", - "at": "2026-07-02T15:18:55.353Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", - "at": "2026-07-02T15:25:07.938Z", - "kind": "assignment" - }, - { - "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", - "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", - "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", - "at": "2026-07-02T15:25:07.938Z", - "kind": "delegation" - }, - { - "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", - "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", - "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", - "at": "2026-07-02T15:27:39.300Z", - "kind": "assignment" - } - ], - "pagination": { - "limit": 500, - "offset": 0, - "totalIssues": 37, - "hasMore": false - }, - "window": { - "from": "2026-07-02T14:00:00.000Z", - "to": "2026-07-02T16:00:00.000Z", - "capped": false - } -} \ No newline at end of file diff --git a/ui/storybook/fixtures/workTimeline.sample.json b/ui/storybook/fixtures/workTimeline.sample.json deleted file mode 100644 index 29a8ada09d6..00000000000 --- a/ui/storybook/fixtures/workTimeline.sample.json +++ /dev/null @@ -1 +0,0 @@ -{"actors": [{"id": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "type": "agent", "name": "CEO", "avatar": "crown"}, {"id": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "type": "agent", "name": "ClaudeCoder", "avatar": "code"}, {"id": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "type": "agent", "name": "CloudOpsEngineer", "avatar": "cog"}, {"id": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "type": "agent", "name": "UXDesigner", "avatar": "gem"}, {"id": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "type": "agent", "name": "CTO", "avatar": "file-code"}, {"id": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "type": "agent", "name": "Senior Engineer Fable", "avatar": "code"}, {"id": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", "type": "agent", "name": "Senior Planning Engineer Pro", "avatar": "code"}, {"id": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", "type": "agent", "name": "DevRel", "avatar": "globe"}, {"id": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "type": "agent", "name": "CodexCoder", "avatar": "code"}, {"id": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "type": "agent", "name": "QA", "avatar": "bug"}, {"id": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "type": "agent", "name": "SecurityEngineer", "avatar": "shield"}, {"id": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", "type": "agent", "name": "Strategic Ops", "avatar": "target"}], "spans": [{"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "063a9727-b191-49e2-9802-b6b63ec65b97", "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", "issueIdentifier": "PAP-12424", "issueTitle": "Work Timeline \u2014 Phase C: frontend Gantt component + Timeline page", "start": "2026-07-02T15:41:30.586Z", "end": null, "status": "running", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "20f1e461-b4b6-4d5f-b897-6f60656429fb", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:35:56.578Z", "end": "2026-07-02T15:38:40.543Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "040ce2ef-acd4-47f2-bcf1-5a1194f5a450", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:34:59.718Z", "end": "2026-07-02T15:38:04.415Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "339694a1-6a7d-4de0-8466-43574a7c6b4d", "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", "issueIdentifier": "PAP-12422", "issueTitle": "Work Timeline \u2014 Phase A: wireframe exploration (2\u20133 directions, board picks one)", "start": "2026-07-02T15:28:13.886Z", "end": "2026-07-02T15:42:27.258Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "9eb8e7ba-53b5-4d22-8f8d-cbba6a5c5bb5", "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", "issueIdentifier": "PAP-12440", "issueTitle": "Defect (Minor): teams-catalog install is not atomic \u2014 mid-import failure can orphan skills/agents", "start": "2026-07-02T15:27:39.339Z", "end": "2026-07-02T15:37:04.236Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "2bd1ec14-ecbb-42d8-8412-106a251780b8", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:26:14.310Z", "end": "2026-07-02T15:34:59.661Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "assignment", "runId": "1b734867-943b-4ef3-a10e-3f2c1edcad93", "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", "issueIdentifier": "PAP-12439", "issueTitle": "Wireframes: Environment Variables Editor redesign", "start": "2026-07-02T15:25:07.982Z", "end": "2026-07-02T15:32:24.457Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "88e7b7a9-4ea6-40bc-b402-ffd731a3dfe2", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:23:28.308Z", "end": "2026-07-02T15:29:09.108Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "cbce714c-a056-477d-971b-3ceb02a4441b", "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", "issueIdentifier": "PAP-12435", "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", "start": "2026-07-02T15:23:13.966Z", "end": "2026-07-02T15:23:32.715Z", "status": "cancelled", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "671705a1-718d-4be6-86b4-390295a17a74", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:22:48.720Z", "end": "2026-07-02T15:24:16.925Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "85339e14-6ac3-459c-99a2-811e0e6adbba", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:21:38.307Z", "end": "2026-07-02T15:22:48.667Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "ebc8e142-c416-4b85-86ff-be0fed98f41f", "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", "issueIdentifier": "PAP-10275", "issueTitle": "Defect (Minor): CLI install lacks --adapter-override and --secret-value flags", "start": "2026-07-02T15:20:20.854Z", "end": "2026-07-02T15:22:56.590Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "accd3b96-89a2-47f4-a3db-65f94ec6be3a", "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", "issueIdentifier": "PAP-10274", "issueTitle": "Defect (Minor UX): \"Partial state is not rolled back\" message is incorrect \u2014 install transaction rolled back cleanly", "start": "2026-07-02T15:20:20.784Z", "end": "2026-07-02T15:28:39.720Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "8e58708e-88df-4681-84c5-f6a676d1dd6a", "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", "issueIdentifier": "PAP-10273", "issueTitle": "Defect (Minor): preview emits noisy \"skill not present in package\" warnings for catalog-skill references", "start": "2026-07-02T15:20:20.734Z", "end": "2026-07-02T15:25:39.352Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "17e168c3-c2f4-4cef-9492-c4e680bebcf5", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:19:28.163Z", "end": "2026-07-02T15:21:38.254Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "assignment", "runId": "4cec085d-bf1f-48a2-aef1-62cce353a09f", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:18:55.386Z", "end": "2026-07-02T15:26:14.263Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "df0d6959-ef5f-4a75-b710-492cfd6de658", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:16:53.952Z", "end": "2026-07-02T15:19:28.111Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "a9ba6322-e0e3-43bb-bc0b-2af53bec9610", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "issueIdentifier": "PAP-12436", "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", "start": "2026-07-02T15:14:59.782Z", "end": "2026-07-02T15:20:10.000Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "automation", "runId": "cd8cf33f-5a1c-4b3a-905e-aabc0341edd1", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:14:57.094Z", "end": "2026-07-02T15:16:53.870Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "f4d1d8a6-c74d-4d59-b831-b788eeaf93f2", "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", "issueIdentifier": "PAP-12437", "issueTitle": "for parent task, cleanup the effecitvely merged ones", "start": "2026-07-02T15:14:30.602Z", "end": "2026-07-02T15:20:22.033Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "laneHint": "automation", "runId": "90cd6835-be7c-4dd8-bdf7-017d40df9c38", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:12:26.440Z", "end": "2026-07-02T15:14:56.976Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "b83d9b78-f63c-465c-808c-c1e1175a18c5", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "issueIdentifier": "PAP-12436", "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", "start": "2026-07-02T15:10:51.773Z", "end": "2026-07-02T15:14:59.703Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "e4e854bc-9cce-4c5f-bfb9-a2f3c0094075", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T15:10:25.014Z", "end": "2026-07-02T15:15:34.966Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "assignment", "runId": "bcfaef62-ca9a-4a96-b279-bf2314108dbf", "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", "issueIdentifier": "PAP-12403", "issueTitle": "github and environment variables", "start": "2026-07-02T15:09:15.958Z", "end": "2026-07-02T15:20:22.053Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "0ecdb797-e6b4-4f12-a5f0-b7bce348138f", "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", "issueIdentifier": "PAP-12435", "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", "start": "2026-07-02T15:07:45.699Z", "end": "2026-07-02T15:11:13.138Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", "laneHint": "assignment", "runId": "0961f6e8-a1cd-4493-9979-99775285c748", "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", "issueIdentifier": "PAP-12434", "issueTitle": "Strategic Ops: get AWS admin to attach pages.paperclip.ing uploader policy", "start": "2026-07-02T15:05:38.870Z", "end": "2026-07-02T15:08:15.576Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", "laneHint": "assignment", "runId": "33181a90-d2af-4081-9deb-253d996d81f1", "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", "issueIdentifier": "PAP-12433", "issueTitle": "project star\u2019ing", "start": "2026-07-02T15:03:27.392Z", "end": "2026-07-02T15:52:11.373Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "laneHint": "automation", "runId": "12223c0b-0bad-4b95-be56-ceb1ce955b0f", "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", "issueIdentifier": "PAP-12425", "issueTitle": "Work Timeline \u2014 security review: timeline endpoint ACLs and data exposure", "start": "2026-07-02T15:01:28.563Z", "end": "2026-07-02T15:05:43.985Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "e08e32ae-9106-46e0-9c2b-97ab74520fc4", "issueId": "0facd02e-425f-4822-875c-2f97988de98a", "issueIdentifier": "PAP-12432", "issueTitle": "CEO/admin: apply paperclip-page-uploader pages.paperclip.ing inline policy", "start": "2026-07-02T15:00:46.697Z", "end": "2026-07-02T15:06:05.586Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", "laneHint": "assignment", "runId": "91f67e3b-18ae-4230-8fae-18b414ffcd14", "issueId": "1533f78d-d677-449f-afd9-4cf653168438", "issueIdentifier": "PAP-12431", "issueTitle": "2026-07-02 create a standup message i can copy to slack about everything i've been working on for the last 36 hours", "start": "2026-07-02T15:00:19.704Z", "end": "2026-07-02T15:05:16.672Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "e9ff0a09-6607-4793-90f3-1b3ab1741ef0", "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", "issueIdentifier": "PAP-12430", "issueTitle": "merged Create a single PR from this branch PAP-12404-inbox-blocked-but-descendent-live-icon", "start": "2026-07-02T14:59:42.783Z", "end": "2026-07-02T15:17:12.936Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "67f65027-b802-4e88-91a6-bebb5c94f16b", "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", "issueIdentifier": "PAP-12429", "issueTitle": "give the AWS_ keys that ClaudeCoder has to CodexCoder and CloudOpsEngineer and UXDesigner", "start": "2026-07-02T14:57:50.711Z", "end": "2026-07-02T15:01:18.973Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "a61b657c-d297-43b7-948c-2be7bd161e3b", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "issueIdentifier": "PAP-12427", "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", "start": "2026-07-02T14:57:27.831Z", "end": "2026-07-02T15:01:08.081Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "960639c7-f6cc-45ad-b746-22ca507c237d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:57:12.287Z", "end": "2026-07-02T15:12:26.352Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "77ffd755-a755-41e6-b2cb-ee0817f11152", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:55:43.595Z", "end": "2026-07-02T14:57:12.235Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "d14a8901-4569-4f05-b8c2-5e49d4216a6d", "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", "issueIdentifier": "PAP-12428", "issueTitle": "just change the terminology to \u201cResponsible\u201d", "start": "2026-07-02T14:55:00.330Z", "end": "2026-07-02T15:01:10.197Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "972b6b5b-5fdd-419d-aea0-5641b4634448", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "issueIdentifier": "PAP-12427", "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", "start": "2026-07-02T14:54:41.918Z", "end": "2026-07-02T14:57:27.722Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "4b085459-77b0-41e4-a62f-a76e1ba9b521", "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", "issueIdentifier": "PAP-12423", "issueTitle": "Work Timeline \u2014 Phase B: timeline aggregation endpoint (GET /companies/:id/timeline)", "start": "2026-07-02T14:52:21.929Z", "end": "2026-07-02T15:03:54.376Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "assignment", "runId": "8a716a87-61e2-4667-9cc4-3641fe42c6f0", "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", "issueIdentifier": "PAP-12422", "issueTitle": "Work Timeline \u2014 Phase A: wireframe exploration (2\u20133 directions, board picks one)", "start": "2026-07-02T14:52:21.788Z", "end": "2026-07-02T15:07:44.596Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "e573c5dc-94b9-4b63-bdb3-c48327a85cea", "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", "issueIdentifier": "PAP-12421", "issueTitle": "CTO/admin: attach corrected pages.paperclip.ing S3 policy to paperclip-page-uploader", "start": "2026-07-02T14:52:04.426Z", "end": "2026-07-02T14:56:08.638Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "6b0a55a9-7c1a-4d48-bdfb-159370e9c03d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:50:35.277Z", "end": "2026-07-02T14:55:43.520Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "c214c9f0-b46b-47de-ba86-2520e1a8aa75", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T14:48:23.194Z", "end": "2026-07-02T14:52:29.939Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "c7a242bb-adf1-46e2-a322-474ed791c7ff", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T14:44:59.649Z", "end": "2026-07-02T14:48:23.151Z", "status": "cancelled", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "571d1495-fe7e-4cf8-a79f-e4d8d779cecf", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T14:42:05.689Z", "end": "2026-07-02T14:45:37.694Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}], "events": [], "edges": [{"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "at": "2026-07-02T15:26:14.310Z", "kind": "delegation"}, {"fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "at": "2026-07-02T15:34:59.718Z", "kind": "delegation"}, {"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T14:55:43.595Z", "kind": "delegation"}, {"fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T14:57:12.287Z", "kind": "delegation"}, {"fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:12:26.440Z", "kind": "delegation"}, {"fromActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:14:57.094Z", "kind": "delegation"}, {"fromActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:16:53.952Z", "kind": "delegation"}, {"fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:19:28.163Z", "kind": "delegation"}, {"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:21:38.307Z", "kind": "delegation"}, {"fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:22:48.720Z", "kind": "delegation"}, {"fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "at": "2026-07-02T15:14:59.782Z", "kind": "delegation"}, {"fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "at": "2026-07-02T14:57:27.831Z", "kind": "delegation"}], "pagination": {"limit": 200, "offset": 0, "totalIssues": 26, "hasMore": false}, "window": {"from": "2026-07-02T14:00:00.000Z", "to": "2026-07-02T15:50:00.000Z", "capped": false}} \ No newline at end of file diff --git a/ui/storybook/stories/work-timeline.stories.tsx b/ui/storybook/stories/work-timeline.stories.tsx deleted file mode 100644 index 169541f8f14..00000000000 --- a/ui/storybook/stories/work-timeline.stories.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState } from "react"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { WorkTimelineResult } from "@paperclipai/shared"; -import { WorkTimelineChart, type ZoomLevel } from "@/components/timeline/WorkTimelineChart"; -import { issueColor, type ColorMode } from "@/lib/timeline/layout"; -import { cn } from "@/lib/utils"; -import sampleJson from "../fixtures/workTimeline.sample.json"; -import humanSampleJson from "../fixtures/workTimeline.human.sample.json"; - -const sample = sampleJson as unknown as WorkTimelineResult; -// A second real slice (2026-07-02 14:00–16:00Z) captured straight from the live -// `/timeline` endpoint that DOES carry human events — Dotta's created / commented / -// approved / delegated actions render as instant diamond markers on her own row. -const humanSample = humanSampleJson as unknown as WorkTimelineResult; -// The fixture is a real slice of PAP company activity (2026-07-02 14:00–15:50Z); -// pin "now" to the window end so in-progress runs fade correctly. -const NOW = new Date("2026-07-02T15:45:00.000Z").getTime(); - -function Segmented({ - value, - options, - onChange, -}: { - value: T; - options: { value: T; label: string }[]; - onChange: (v: T) => void; -}) { - return ( -
- {options.map((opt, i) => ( - - ))} -
- ); -} - -function TimelineHarness({ - initialZoom = "day" as ZoomLevel, - initialColor = "issue" as ColorMode, - data = sample, - now = NOW, -}: { - initialZoom?: ZoomLevel; - initialColor?: ColorMode; - data?: WorkTimelineResult; - now?: number; -}) { - const [zoom, setZoom] = useState(initialZoom); - const [colorMode, setColorMode] = useState(initialColor); - const issues = Array.from( - new Map(data.spans.map((s) => [s.issueId, s.issueIdentifier ?? s.issueTitle ?? "task"])).entries(), - ); - return ( -
-
-
-

Work Timeline

-

- A Gantt view of who did what, when — real PAP activity (2026-07-02, 14:00–15:50Z). Rows are actors; bars are - heartbeat runs colored by task; the avatar chip at a bar's leading edge is who kicked it off; straight lines - are agent→agent delegation. Hover a bar for its task & timing; click to open the task. -

-
- -
- - -
- -
-
- {colorMode === "issue" - ? issues.slice(0, 10).map(([id, label]) => ( - - - {label} - - )) - : ( - <> - - done - - - {" "} - in progress - - - )} -
-
- -
-

- {data.spans.length} runs · {data.actors.length} actors · {data.events.length} human/instant events · real - company data -

-
-
-
- ); -} - -const meta: Meta = { - title: "Pages/Work Timeline", - component: TimelineHarness, - parameters: { layout: "fullscreen" }, -}; -export default meta; - -type Story = StoryObj; - -export const HourByTask: Story = { args: { initialZoom: "hour", initialColor: "issue" } }; -export const DayZoom: Story = { args: { initialZoom: "day", initialColor: "issue" } }; -export const ByStatus: Story = { args: { initialZoom: "hour", initialColor: "status" } }; -// Live slice that carries human events — Dotta gets a row with diamond markers -// for her created / commented / approved / delegated actions (PAP-12444). -export const WithHumanMarkers: Story = { - args: { - initialZoom: "hour", - initialColor: "issue", - data: humanSample, - now: new Date("2026-07-02T16:00:00.000Z").getTime(), - }, -}; From 2c4c110e900cfe052378542fc238de6fb77675c5 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 2 Jul 2026 15:49:18 -0700 Subject: [PATCH 17/19] Fix issue create response relation summaries (#8901) Return blockedBy and blocks relation summaries from issue create paths after blocker relations are synced. Refresh child relation summaries after blockParentUntilDone adds a parent blocker relation. Co-Authored-By: Paperclip --- server/src/__tests__/issues-service.test.ts | 108 ++++++++++++++++++++ server/src/services/issues.ts | 23 ++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 0dbc66b68f5..b56785ff38d 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3200,6 +3200,114 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]); }); + it("returns blocked-by summaries on newly created issues", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const blockerId = randomUUID(); + await db.insert(issues).values({ + id: blockerId, + companyId, + title: "Blocker", + status: "todo", + priority: "high", + }); + + const created = await svc.create(companyId, { + title: "Blocked issue", + status: "blocked", + priority: "medium", + blockedByIssueIds: [blockerId], + }); + + expect(created.blockedBy.map((relation) => relation.id)).toEqual([blockerId]); + expect(created.blockedBy[0]).toEqual(expect.objectContaining({ + title: "Blocker", + status: "todo", + priority: "high", + })); + expect(created.blocks).toEqual([]); + }); + + it("returns blocked-by summaries on newly created child issues", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const parentId = randomUUID(); + const blockerId = randomUUID(); + await db.insert(issues).values([ + { + id: parentId, + companyId, + title: "Parent", + status: "todo", + priority: "medium", + }, + { + id: blockerId, + companyId, + title: "Blocker", + status: "todo", + priority: "high", + }, + ]); + + const { issue: child } = await svc.createChild(parentId, { + title: "Blocked child issue", + status: "blocked", + priority: "medium", + blockedByIssueIds: [blockerId], + }); + + expect(child.parentId).toBe(parentId); + expect(child.blockedBy.map((relation) => relation.id)).toEqual([blockerId]); + expect(child.blocks).toEqual([]); + }); + + it("returns blocks summaries when child creation blocks the parent", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const parentId = randomUUID(); + await db.insert(issues).values({ + id: parentId, + companyId, + title: "Parent", + status: "todo", + priority: "medium", + }); + + const { issue: child } = await svc.createChild(parentId, { + title: "Parent-blocking child", + status: "todo", + priority: "medium", + blockParentUntilDone: true, + }); + + expect(child.blocks.map((relation) => relation.id)).toEqual([parentId]); + expect(child.blocks[0]).toEqual(expect.objectContaining({ + title: "Parent", + status: "todo", + priority: "medium", + })); + expect(child.blockedBy).toEqual([]); + }); + it("adds terminal blockers to immediate blocked-by summaries", async () => { const companyId = randomUUID(); await db.insert(companies).values({ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 0fa09135871..ca2f48959e1 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -3991,6 +3991,23 @@ export function issueService(db: Db) { return empty; } + async function withIssueRelationSummaries( + companyId: string, + rows: T[], + dbOrTx: DbReader = db, + ): Promise> { + if (rows.length === 0) return []; + const relationMap = await getIssueRelationSummaryMap( + companyId, + rows.map((row) => row.id), + dbOrTx, + ); + return rows.map((row) => ({ + ...row, + ...(relationMap.get(row.id) ?? { blockedBy: [], blocks: [] }), + })); + } + async function assertNoBlockingCycles( companyId: string, issueId: string, @@ -4960,7 +4977,7 @@ export function issueService(db: Db) { actorUserId, ...issueData } = data; - const child = await issueService(db).create(parent.companyId, { + let child = await issueService(db).create(parent.companyId, { ...issueData, parentId: parent.id, projectId: issueData.projectId ?? parent.projectId, @@ -4983,6 +5000,7 @@ export function issueService(db: Db) { [...new Set([...existingBlockers.map((row) => row.blockerIssueId), child.id])], { agentId: actorAgentId ?? null, userId: actorUserId ?? null }, ); + [child] = await withIssueRelationSummaries(parent.companyId, [child], db); } return { @@ -5484,7 +5502,8 @@ export function issueService(db: Db) { ); } const [enriched] = await withIssueLabels(tx, [issue]); - return enriched; + const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx); + return withRelations; }); }, From 246e1b38bf6d716f058d899689b0f47252041267 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:39:16 -0500 Subject: [PATCH 18/19] [codex] Include checkbox selections in continuation wakes (#8893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Issue-thread interactions are the subsystem that lets board users answer structured prompts and resume agent work > - Checkbox confirmations capture a selected subset of known options, then wake the assignee through continuation context > - The wake context previously carried generic interaction metadata, but not the accepted checkbox option ids or option labels > - That meant the resumed agent could be woken after a checkbox confirmation without seeing the board's selected options in the turn context > - This pull request carries accepted checkbox selections through the interaction continuation wake snapshot and renders them into the adapter wake prompt > - The benefit is that agents can act on checkbox-confirmation selections without refetching or guessing the user's choices ## Linked Issues or Issue Description No public GitHub issue exists for this bug. Searched public issues and PRs for checkbox confirmation / continuation selection duplicates and found no matching issue or PR. ### Pre-submission checklist - [x] I have searched existing open and closed issues and this is not a duplicate. - [x] I am on the latest released version of Paperclip or can reproduce on `master`. - [x] I have confirmed the error originates in Paperclip itself, not in my agent adapter, API provider, or local configuration. ### What happened? When a board user accepted a `request_checkbox_confirmation` interaction, the assignee continuation wake included generic interaction metadata but did not include the accepted checkbox selections. The resumed agent turn therefore had no in-prompt access to the selected option ids or option labels/descriptions. ### Expected behavior When a `request_checkbox_confirmation` interaction is accepted, the resumed agent wake should include the checkbox prompt, selected option ids, and selected option labels/descriptions so the agent can act on the selected subset directly. ### Steps to reproduce 1. Create an issue-thread `request_checkbox_confirmation` interaction with multiple options and `continuationPolicy: "wake_assignee"`. 2. Accept the interaction with one or more selected options. 3. Inspect the continuation wake payload/prompt received by the assignee. 4. Observe that the selected checkbox options are missing from the wake context before this fix. ### Paperclip version or commit Reproduced against the pre-fix code path on `master`; this PR head is `9d17e70bce373e4850117f30c015c973c4b61789`. ### Deployment mode Local dev (pnpm dev) / built from source. ### Installation method Built from source (pnpm dev / pnpm build). ### Agent adapter(s) involved Not adapter-specific (core bug). The Codex/local adapter path exposed the missing wake context, but the missing field was in core interaction continuation payload construction. ### Database mode Embedded PGlite or external Postgres; the bug is not database-mode specific. ### Access context Both. Board users resolve the checkbox interaction, and agent bearer-key wakes consume the continuation context. ### Node.js version `v22.22.2` ### Operating system Linux workspace. ### Relevant logs or output No runtime exception is required to reproduce this. The failure mode is missing `checkboxSelection` data in the resolved interaction continuation wake payload. ### Relevant config Not config-related. ### Additional context Root cause: accepted checkbox interaction results were not extracted into the continuation wake context, and adapter wake payload normalization/rendering had no typed `checkboxSelection` field. ### Privacy checklist - [x] I have reviewed all pasted output for PII (usernames, file paths, API keys, tokens, company names) and redacted where necessary. ## What Changed - Added checkbox selection extraction for accepted `request_checkbox_confirmation` interactions and stored it in interaction continuation wake context. - Included checkbox selection context in heartbeat wake payload construction. - Added adapter-utils normalization and wake prompt rendering for checkbox prompt, selected ids, and selected option details. - Added regression coverage for route continuation context, heartbeat payload summaries, and adapter wake prompt rendering. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/heartbeat-context-summary.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts` - `git diff --check origin/master...HEAD` - `rg -n "checkbox|confirmation|interaction|wake|continuation" ROADMAP.md` - `gh pr list --state all --search "checkbox continuation selection repo:paperclipai/paperclip" --json number,title,state,url,headRefName --limit 20` - `gh issue list --state all --search "checkbox confirmation options repo:paperclipai/paperclip" --json number,title,state,url --limit 20` ## Risks Low risk. The new payload field is additive, only populated for accepted checkbox confirmations, and existing continuation fields are preserved. The main compatibility risk is downstream code assuming an exact wake payload shape; adapter normalization treats the new field as optional. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent based on GPT-5, with shell/tool execution in this workspace. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../adapter-utils/src/server-utils.test.ts | 76 +++++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 66 +++++++++++++++- .../heartbeat-context-summary.test.ts | 18 ++++- .../issue-thread-interaction-routes.test.ts | 74 +++++++++++++++++- server/src/routes/issues.ts | 38 ++++++++++ server/src/services/heartbeat.ts | 3 + 6 files changed, 272 insertions(+), 3 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 45b335f7959..03c81933fdc 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -643,6 +643,82 @@ describe("renderPaperclipWakePrompt", () => { expect(prompt).toContain("named unblock owner/action"); }); + it("renders resolved checkbox selections in scoped wake prompts", () => { + const payload = { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-1581", + title: "Delete selected files", + status: "in_progress", + }, + interactionKind: "request_checkbox_confirmation", + interactionStatus: "accepted", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("- checkbox prompt: Delete selected files?"); + expect(prompt).toContain("- checkbox selection ids: file-b"); + expect(prompt).toContain("- checkbox selection options: file-b (b.txt) - Generated build output"); + expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, + }); + }); + + it("renders accepted empty checkbox selections explicitly", () => { + const payload = { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-1581", + title: "Delete selected files", + status: "in_progress", + }, + interactionKind: "request_checkbox_confirmation", + interactionStatus: "accepted", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("- checkbox prompt: Delete selected files?"); + expect(prompt).toContain("- checkbox selection ids: (none)"); + expect(prompt).toContain("- checkbox selection options: (none)"); + expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + }); + }); + it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => { const title = "验证中文任务"; const commentBody = [ diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 8261abdf369..b6ee56659c7 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -581,6 +581,16 @@ type PaperclipWakeTreeHoldSummary = { reason: string | null; }; +type PaperclipWakeCheckboxSelection = { + prompt: string | null; + selectedOptionIds: string[]; + selectedOptions: Array<{ + id: string; + label: string; + description: string | null; + }>; +}; + type PaperclipWakePayload = { reason: string | null; issue: PaperclipWakeIssue | null; @@ -597,6 +607,7 @@ type PaperclipWakePayload = { taskWatchdog: PaperclipWakeTaskWatchdogContext | null; interactionKind: string | null; interactionStatus: string | null; + checkboxSelection: PaperclipWakeCheckboxSelection | null; annotationDeltas: PaperclipWakeAnnotationDelta[]; childIssueSummaries: PaperclipWakeChildIssueSummary[]; childIssueSummaryTruncated: boolean; @@ -929,6 +940,42 @@ function normalizePaperclipWakeTreeHoldSummary(value: unknown): PaperclipWakeTre return { holdId, rootIssueId, mode, reason }; } +function normalizePaperclipWakeCheckboxSelection(value: unknown): PaperclipWakeCheckboxSelection | null { + const selection = parseObject(value); + const hasExplicitSelection = + Object.prototype.hasOwnProperty.call(selection, "prompt") || + Object.prototype.hasOwnProperty.call(selection, "selectedOptionIds") || + Object.prototype.hasOwnProperty.call(selection, "selectedOptions"); + const prompt = asString(selection.prompt, "").trim() || null; + const selectedOptionIds = Array.isArray(selection.selectedOptionIds) + ? selection.selectedOptionIds + .map((entry) => asString(entry, "").trim()) + .filter(Boolean) + : []; + const selectedOptions = Array.isArray(selection.selectedOptions) + ? selection.selectedOptions + .map((entry) => { + const option = parseObject(entry); + const id = asString(option.id, "").trim(); + if (!id) return null; + return { + id, + label: asString(option.label, id).trim() || id, + description: asString(option.description, "").trim() || null, + }; + }) + .filter((entry): entry is { id: string; label: string; description: string | null } => Boolean(entry)) + : []; + + if (!hasExplicitSelection && selectedOptionIds.length === 0 && selectedOptions.length === 0 && !prompt) return null; + const optionById = new Map(selectedOptions.map((option) => [option.id, option])); + return { + prompt, + selectedOptionIds, + selectedOptions: selectedOptionIds.map((id) => optionById.get(id) ?? { id, label: id, description: null }), + }; +} + function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null { const principal = parseObject(value); const typeRaw = asString(principal.type, "").trim().toLowerCase(); @@ -1113,7 +1160,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl : []; const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold); - if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !normalizePaperclipWakeIssue(payload.issue)) { + const checkboxSelection = normalizePaperclipWakeCheckboxSelection(payload.checkboxSelection); + if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !checkboxSelection && !normalizePaperclipWakeIssue(payload.issue)) { return null; } @@ -1134,6 +1182,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl taskWatchdog, interactionKind: asString(payload.interactionKind, "").trim() || null, interactionStatus: asString(payload.interactionStatus, "").trim() || null, + checkboxSelection, childIssueSummaries, childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false), commentIds, @@ -1239,6 +1288,21 @@ export function renderPaperclipWakePrompt( if (normalized.issue?.priority) { lines.push(`- issue priority: ${normalized.issue.priority}`); } + if (normalized.checkboxSelection) { + if (normalized.checkboxSelection.prompt) { + lines.push(`- checkbox prompt: ${normalized.checkboxSelection.prompt}`); + } + const selectedOptionIds = normalized.checkboxSelection.selectedOptionIds.join(", ") || "(none)"; + const selectedOptions = normalized.checkboxSelection.selectedOptions + .map((option) => { + const label = option.label && option.label !== option.id ? ` (${option.label})` : ""; + const description = option.description ? ` - ${option.description}` : ""; + return `${option.id}${label}${description}`; + }) + .join(", ") || "(none)"; + lines.push(`- checkbox selection ids: ${selectedOptionIds}`); + lines.push(`- checkbox selection options: ${selectedOptions}`); + } if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) { const hasWakeComments = normalized.comments.length > 0; const acceptedPlanContinuation = diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 567317eabbc..4463640d97f 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -123,6 +123,11 @@ describe("mergeCoalescedContextSnapshot", () => { interactionKind: "request_confirmation", interactionStatus: "accepted", continuationPolicy: "wake_assignee_on_accept", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, wakeReason: "issue_commented", }, { @@ -137,11 +142,12 @@ describe("mergeCoalescedContextSnapshot", () => { expect(merged.interactionKind).toBeUndefined(); expect(merged.interactionStatus).toBeUndefined(); expect(merged.continuationPolicy).toBeUndefined(); + expect(merged.checkboxSelection).toBeUndefined(); expect(merged.commentId).toBe("comment-1"); expect(merged.wakeCommentId).toBe("comment-1"); }); - it("preserves accepted-plan interaction state for the interaction wake itself", () => { + it("preserves resolved interaction state for the interaction wake itself", () => { const merged = mergeCoalescedContextSnapshot( { issueId: "issue-1", @@ -152,6 +158,11 @@ describe("mergeCoalescedContextSnapshot", () => { interactionKind: "request_confirmation", interactionStatus: "accepted", continuationPolicy: "wake_assignee_on_accept", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, wakeReason: "issue_commented", }, ); @@ -160,6 +171,11 @@ describe("mergeCoalescedContextSnapshot", () => { expect(merged.interactionKind).toBe("request_confirmation"); expect(merged.interactionStatus).toBe("accepted"); expect(merged.continuationPolicy).toBe("wake_assignee_on_accept"); + expect(merged.checkboxSelection).toEqual({ + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }); }); }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index d3574f79038..0c08bcb0d25 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -566,7 +566,7 @@ describe.sequential("issue thread interaction routes", () => { prompt: "Delete selected files?", options: [ { id: "file-a", label: "a.txt" }, - { id: "file-b", label: "b.txt" }, + { id: "file-b", label: "b.txt", description: "Generated build output" }, ], }, result: { @@ -602,6 +602,18 @@ describe.sequential("issue thread interaction routes", () => { interactionId: "interaction-checkbox", interactionKind: "request_checkbox_confirmation", interactionStatus: "accepted", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, + }), + contextSnapshot: expect.objectContaining({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, }), }), ); @@ -617,6 +629,66 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("preserves accepted empty checkbox selections in assignee wake context", async () => { + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-checkbox-empty", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_checkbox_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-checkbox", + payload: { + version: 1, + prompt: "Delete selected files?", + options: [ + { id: "file-a", label: "a.txt", description: "Temporary export" }, + { id: "file-b", label: "b.txt", description: "Generated build output" }, + ], + }, + result: { + version: 1, + outcome: "accepted", + selectedOptionIds: [], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-checkbox-empty/accept") + .send({ selectedOptionIds: [] }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + payload: expect.objectContaining({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + }), + contextSnapshot: expect.objectContaining({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + }), + }), + ); + }); + it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => { mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" })); mockInteractionService.acceptInteraction.mockResolvedValueOnce({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4262972f146..cf9f37ae790 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -967,6 +967,7 @@ function queueResolvedInteractionContinuationWakeup(input: { const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason); const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id); const interactionResult = readConfirmationResultForWake(input.interaction.result); + const checkboxSelection = readCheckboxSelectionForWake(input.interaction); const planReviewInteraction = planTarget && input.interaction.kind === "request_confirmation" ? { @@ -990,6 +991,7 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceCommentId: input.interaction.sourceCommentId ?? null, sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), + ...(checkboxSelection ? { checkboxSelection } : {}), mutation: "interaction", }, requestedByActorType: input.actor.actorType, @@ -1003,6 +1005,7 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceCommentId: input.interaction.sourceCommentId ?? null, sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), + ...(checkboxSelection ? { checkboxSelection } : {}), wakeReason: "issue_commented", source: input.source, ...(forceFreshSession ? { forceFreshSession: true } : {}), @@ -1016,6 +1019,41 @@ function queueResolvedInteractionContinuationWakeup(input: { }, "failed to wake assignee on issue interaction resolution")); } +function readCheckboxSelectionForWake(input: { + kind: string; + payload?: unknown; + result?: unknown; +}) { + if (input.kind !== "request_checkbox_confirmation") return null; + const result = readObject(input.result); + if (result.outcome !== "accepted") return null; + const selectedOptionIds = Array.isArray(result.selectedOptionIds) + ? result.selectedOptionIds.filter((value): value is string => typeof value === "string" && value.length > 0) + : []; + const payload = readObject(input.payload); + const options = Array.isArray(payload.options) + ? payload.options + .map((value) => { + const option = readObject(value); + const id = readNonEmptyString(option.id); + if (!id) return null; + return { + id, + label: readNonEmptyString(option.label) ?? id, + description: readNonEmptyString(option.description), + }; + }) + .filter((value): value is { id: string; label: string; description: string | null } => Boolean(value)) + : []; + const optionById = new Map(options.map((option) => [option.id, option])); + + return { + prompt: readNonEmptyString(payload.prompt), + selectedOptionIds, + selectedOptions: selectedOptionIds.map((id) => optionById.get(id) ?? { id, label: id, description: null }), + }; +} + function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 70e54654948..67eb46f7bda 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3545,6 +3545,7 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ "interactionKind", "interactionStatus", "continuationPolicy", + "checkboxSelection", ] as const; function isInteractionResolutionWakePayload(payload: Record | null | undefined) { @@ -3844,6 +3845,7 @@ export async function buildPaperclipWakePayload(input: { const interactionId = readNonEmptyString(input.contextSnapshot.interactionId); const interactionKind = readNonEmptyString(input.contextSnapshot.interactionKind); const interactionStatus = readNonEmptyString(input.contextSnapshot.interactionStatus); + const checkboxSelection = parseObject(input.contextSnapshot.checkboxSelection); const planReviewContext = issueId ? await buildPlanReviewContext({ db: input.db, @@ -3888,6 +3890,7 @@ export async function buildPaperclipWakePayload(input: { : null, interactionKind, interactionStatus, + checkboxSelection: Object.keys(checkboxSelection).length > 0 ? checkboxSelection : null, checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, From bac7307ec4b879e339f1cd8e99c131883c1d89b4 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 2 Jul 2026 17:06:34 -0700 Subject: [PATCH 19/19] fix(adapter-utils): improve sandbox restore failure diagnostics (#8903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters share managed-runtime helpers from `@paperclipai/adapter-utils` so local and sandboxed runs can prepare, execute, and restore workspaces consistently. > - The sandbox managed runtime syncs workspaces in both directions by creating tar archives on the local host and inside the remote sandbox. > - A prior fix avoided archiving `.` during upload because tar self-entries can force chmod/utime on a directory the command user does not own. > - The restore/download path still archived the remote workspace as `.`, and failed managed commands only surfaced stderr. > - This pull request applies entry-based tar creation to the sandbox restore path and includes stdout in managed command failure diagnostics. > - The benefit is that restore failures become visible to operators and sandbox workspace restore avoids the same directory-metadata failure class already fixed for upload. ## Linked Issues or Issue Description No existing public issue found; describing in-PR (bug). - **What happens:** sandbox workspace restore can fail while creating `workspace-download.tar` from the remote workspace when the tar command includes a `.` self-entry and the command user cannot update metadata on the workspace directory. If the failing command writes its diagnostic to stdout, the managed runtime error can collapse to a generic failed shell command without the useful tar message. - **Expected behavior:** restore should archive the workspace entries without a `.` self-entry, and failed managed runtime commands should include useful stdout/stderr diagnostics. - **Where:** `packages/adapter-utils/src/sandbox-managed-runtime.ts` restore/download path and `packages/adapter-utils/src/command-managed-runtime.ts` command error formatting. - **Related public context:** #7836 fixed the upload side of the same tar self-entry failure class. ## What Changed - Added stdout-aware failed-command formatting in the command managed runtime, keeping diagnostics bounded to the tail of stdout/stderr. - Added remote workspace tarball creation that names top-level entries explicitly instead of archiving `.` during sandbox restore. - Preserved empty-workspace restore support by creating a valid empty tarball when the remote workspace has no entries. - Added regression coverage for stdout diagnostics, restore tar members, and empty workspace restore tarballs. ## Verification - `pnpm vitest run packages/adapter-utils/src/command-managed-runtime.test.ts packages/adapter-utils/src/sandbox-managed-runtime.test.ts` — 2 files, 17 tests passed. - `pnpm --filter @paperclipai/adapter-utils typecheck` — passed. - `git diff --check origin/master..HEAD` — passed. - Local public-hygiene/PII scan of the committed diff checked for internal ticket refs, local/private URLs, common token/key patterns, private-key blocks, and email-like values — passed. - GitHub duplicate/related search found no public issue or PR for `workspace-download.tar Permission denied` or `sandbox restore tar permission denied`; #7836 is linked as related prior work. - PR CI on commit `3ad4c8d` — all GitHub Actions lanes, security scans, and aggregate `verify` passed. - Greptile Review on commit `3ad4c8d` — Confidence Score 5/5; the prior P2 thread is resolved with no open P2s, recommendations, or follow-ups. ## Risks Low. This is limited to shared adapter runtime error formatting and sandbox restore archive construction. Archive contents should remain equivalent apart from the removed `.` self-entry, and the new diagnostics are bounded to avoid dumping unbounded command output. ## Model Used OpenAI Codex, GPT-5, tool-enabled coding agent with shell and GitHub CLI access. Context window size was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (n/a; internal adapter-runtime behavior only) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/command-managed-runtime.test.ts | 20 ++++++ .../src/command-managed-runtime.ts | 19 +++++- .../src/sandbox-managed-runtime.test.ts | 64 +++++++++++++------ .../src/sandbox-managed-runtime.ts | 29 +++++++-- 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 1d97a20a60f..d9eb811780f 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -395,4 +395,24 @@ describe("command managed runtime", () => { expect(progress.every((entry) => entry.total === payload.length)).toBe(true); expect(progress.at(-1)?.done).toBe(payload.length); }); + + it("includes stdout diagnostics when a managed runtime command fails", async () => { + const startedAt = new Date().toISOString(); + const runner: CommandManagedRuntimeRunner = { + execute: async () => ({ + exitCode: 2, + signal: null, + timedOut: false, + stdout: "tar: workspace-download.tar: Cannot open: Permission denied\n", + stderr: "", + pid: null, + startedAt, + }), + }; + const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 }); + + await expect(client.run("tar -cf workspace-download.tar .", { timeoutMs: 30_000 })).rejects.toThrow( + /stdout: tar: workspace-download\.tar: Cannot open: Permission denied/, + ); + }); }); diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index beb0d4dbdfe..77acad78cbe 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -69,10 +69,25 @@ function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer { return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); } +const FAILED_COMMAND_OUTPUT_TAIL_CHARS = 4_000; + +function formatFailedCommandOutput(result: RunProcessResult): string { + const tail = (text: string): string => { + const trimmed = text.trim(); + if (trimmed.length <= FAILED_COMMAND_OUTPUT_TAIL_CHARS) return trimmed; + return `...[truncated]\n${trimmed.slice(-FAILED_COMMAND_OUTPUT_TAIL_CHARS)}`; + }; + const stderr = tail(result.stderr); + const stdout = tail(result.stdout); + const parts: string[] = []; + if (stderr.length > 0) parts.push(`stderr: ${stderr}`); + if (stdout.length > 0) parts.push(`stdout: ${stdout}`); + return parts.length > 0 ? `:\n${parts.join("\n")}` : ""; +} + function requireSuccessfulResult(result: RunProcessResult, action: string): void { if (result.exitCode === 0 && !result.timedOut) return; - const stderr = result.stderr.trim(); - const detail = stderr.length > 0 ? `: ${stderr}` : ""; + const detail = formatFailedCommandOutput(result); throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`); } diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 6a002521ce1..5338a8e7680 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -507,8 +507,9 @@ describe("sandbox managed runtime", () => { await writeFile(path.join(localWorkspaceDir, "src", "main.ts"), "x\n", "utf8"); await writeFile(path.join(localAssetsDir, "asset.txt"), "a\n", "utf8"); - // Capture every tar uploaded to the sandbox so we can inspect its members. + // Capture every tar uploaded/downloaded through the sandbox so we can inspect its members. const uploadedTars: { remotePath: string; bytes: Buffer }[] = []; + const downloadedTars: { remotePath: string; bytes: Buffer }[] = []; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); @@ -519,7 +520,11 @@ describe("sandbox managed runtime", () => { if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer }); await writeFile(remotePath, buffer); }, - readFile: async (remotePath) => await readFile(remotePath), + readFile: async (remotePath) => { + const buffer = await readFile(remotePath); + if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer }); + return buffer; + }, listFiles: async () => [], remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); @@ -529,7 +534,7 @@ describe("sandbox managed runtime", () => { }, }; - await prepareSandboxManagedRuntime({ + const prepared = await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", @@ -559,6 +564,12 @@ describe("sandbox managed runtime", () => { // And the workspace still extracts correctly into an existing target dir. await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("ws\n"); await expect(readFile(path.join(remoteWorkspaceDir, "src", "main.ts"), "utf8")).resolves.toBe("x\n"); + + await prepared.restoreWorkspace(); + expect(downloadedTars).toHaveLength(1); + const downloadMembers = await listTarMembers(rootDir, "workspace-download-list.tar", downloadedTars[0]!.bytes); + expect(downloadMembers).not.toContain("."); + expect(downloadMembers).not.toContain("./"); }); it("excludes transient symlinked home dirs from the asset tar while keeping required content", async () => { @@ -735,13 +746,15 @@ describe("sandbox managed runtime", () => { expect(restoreLines.some((line) => line.includes("100%"))).toBe(true); }); - it("creates a valid empty workspace tarball when the local workspace is empty", async () => { + it("creates valid empty workspace tarballs when the workspace is empty", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-empty-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); await mkdir(localWorkspaceDir, { recursive: true }); + const downloadedTars: { remotePath: string; bytes: Buffer }[] = []; + const runCommands: string[] = []; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); @@ -750,30 +763,41 @@ describe("sandbox managed runtime", () => { await mkdir(path.dirname(remotePath), { recursive: true }); await writeFile(remotePath, Buffer.from(bytes)); }, - readFile: async (remotePath) => await readFile(remotePath), + readFile: async (remotePath) => { + const buffer = await readFile(remotePath); + if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer }); + return buffer; + }, listFiles: async () => [], remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); }, run: async (command) => { + runCommands.push(command); await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; - await expect( - prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }), - ).resolves.toBeDefined(); + const prepared = await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + }); + + await prepared.restoreWorkspace(); + expect(downloadedTars).toHaveLength(1); + const members = await listTarMembers(rootDir, "empty-workspace-download.tar", downloadedTars[0]!.bytes); + expect(members).toEqual([]); + const emptyArchiveCommand = runCommands.find((command) => command.includes("dd if=/dev/zero")); + expect(emptyArchiveCommand).toBeDefined(); + expect(emptyArchiveCommand).not.toContain("/dev/null"); }); }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index e6fb7355d95..cbaed7298e3 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -323,6 +323,25 @@ function tarExcludeFlags(exclude: string[] | undefined): string { return ["._*", ...(exclude ?? [])].map((entry) => `--exclude ${shellQuote(entry)}`).join(" "); } +function createRemoteTarballFromDirectoryCommand(input: { + remoteDir: string; + archivePath: string; + exclude?: string[]; +}): string { + // Match the local archive path: name top-level entries explicitly so tar + // does not include a "." self-entry that it later tries to chmod/utime. + return [ + `mkdir -p ${shellQuote(path.posix.dirname(input.archivePath))}`, + `cd ${shellQuote(input.remoteDir)}`, + "set -- *", + `if [ "$#" -eq 1 ] && [ "$1" = "*" ] && [ ! -e "$1" ] && [ ! -L "$1" ]; then set --; fi`, + `for entry in .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue; set -- "$@" "$entry"; done`, + `if [ "$#" -eq 0 ]; then ` + + `dd if=/dev/zero of=${shellQuote(input.archivePath)} bs=1024 count=1; ` + + `else tar -cf ${shellQuote(input.archivePath)} ${tarExcludeFlags(input.exclude)} -- "$@"; fi`, + ].join(" && "); +} + async function emitRuntimeStatus( sink: RuntimeStatusSink | undefined, phase: RuntimeStatusPhase, @@ -586,11 +605,11 @@ export async function prepareSandboxManagedRuntime(input: { const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar"); await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox"); await input.client.run( - `sh -c ${shellQuote( - `mkdir -p ${shellQuote(runtimeRootDir)} && ` + - `tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` + - `${tarExcludeFlags(restoreExclude)} .`, - )}`, + `sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({ + remoteDir: workspaceRemoteDir, + archivePath: remoteWorkspaceTar, + exclude: restoreExclude, + }))}`, { timeoutMs: input.spec.timeoutMs }, ); const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace");