From be8ae528c4919b151aa0e425a2a81149c18b7141 Mon Sep 17 00:00:00 2001 From: Evyatar Bluzer Date: Thu, 2 Jul 2026 03:14:57 +0700 Subject: [PATCH 001/107] 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 002/107] 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 003/107] 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 004/107] 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 005/107] 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 006/107] 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 007/107] 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 008/107] 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 009/107] 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 010/107] [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 011/107] =?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 012/107] 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 013/107] [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 014/107] 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 015/107] [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 016/107] 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"); From 936687ca55827121b6adb3a86584ae91ca59d089 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 2 Jul 2026 20:58:26 -0700 Subject: [PATCH 017/107] fix(workspace): restore clean branch drift on finalize (#8914) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs can execute inside reusable, runtime-created git worktree execution workspaces. > - Those managed worktrees record the expected branch so later dispatches do not accidentally run an agent in the wrong checkout. > - Successful run finalization already checked branch coherence, but it treated every unrecorded branch switch as fatal. > - A common publishing flow can briefly switch a clean worktree to a PR/publish branch that points at the same commit as the recorded issue branch, leaving no divergent work to protect. > - This pull request keeps the strict finalization guard for unsafe drift, but lets finalization restore the recorded branch when same-commit repair is provably safe. > - The benefit is fewer false failed runs after harmless branch switches while preserving hard failures for divergent or dirty worktrees. ## Linked Issues or Issue Description No public issue exists for this exact finalization failure. Related public worktree-recovery context: #3087 and #3056, but those address different worktree realization/reuse recovery paths rather than successful-run finalization branch repair. Bug report details: **What happened?** When an adapter run succeeded after switching a managed git worktree from its recorded issue branch to a publish/PR branch, finalization failed with a managed worktree branch mismatch even when the publish branch and recorded branch pointed at the same commit and the worktree was clean. **Expected behavior** Finalization should restore the recorded branch only when it can prove the worktree is clean, registered, and the recorded branch points at the current `HEAD`. If the actual branch has different commits or unsafe state, finalization should continue to fail with bounded validation evidence. **Steps to reproduce** 1. Create a runtime-managed `git_worktree` execution workspace for an issue run. 2. During the adapter run, create and check out a new publish branch without committing new changes. 3. Return adapter success and let heartbeat finalization run. 4. Before this change, finalization records a failed branch check and fails the run even though the branches point at the same commit. 5. With this change, finalization records the repair operation, restores the recorded branch, and records a successful finalize row. 6. Repeat with a commit on the publish branch; finalization still fails because the branch heads differ. **Paperclip version or commit** Reproduced against `master` at `bac7307ec`; fixed by this PR at `64ec605cf`. **Deployment mode** Local dev / built from source. **Agent adapter(s) involved** Not adapter-specific. This is core heartbeat/workspace finalization behavior. **Database mode** Embedded test Postgres in the focused server test. **Access context** Agent run finalization. **Node.js version** `v25.6.1` **Operating system** `Darwin 24.6.0 arm64` **Relevant logs or output** The new focused test intentionally exercises both outcomes: ```text Test Files 1 passed (1) Tests 3 passed (3) ``` **Relevant config** Runtime-created `git_worktree` execution workspace. **Additional context** The unsafe divergent branch case still fails with `workspace_validation_failed` and `git_worktree_branch_incoherence` evidence. **Privacy checklist** Reviewed; this description avoids internal task links, local workspace paths, credentials, and instance-specific URLs. ## What Changed - Reused the existing guarded branch-coherence repair helper during heartbeat finalization when the final branch inspection finds clean same-commit branch drift. - Recorded repair metadata in the `workspace_finalize` operation so reviewers/operators can audit whether finalization repaired branch drift. - Preserved failure behavior for divergent branch heads and surfaced the bounded workspace validation evidence from the repair helper. - Added focused server coverage for safe finalization repair and unsafe divergent branch failure. - Updated execution semantics docs to describe the narrower finalization rule. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check` ## Risks Low to medium risk. The change affects successful-run finalization for runtime-created git worktree execution workspaces. The repair path is constrained to clean, registered, same-commit branch drift, and the focused test confirms divergent branch heads still fail instead of being restored silently. ## Model Used OpenAI Codex, GPT-5-based coding agent. Exact hosted model ID was not exposed in the runtime; tool use and local shell execution were 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 - [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 --- doc/execution-semantics.md | 2 +- ...eartbeat-workspace-finalize-branch.test.ts | 133 ++++++++++++++++-- server/src/services/heartbeat.ts | 84 ++++++++++- server/src/services/workspace-runtime.ts | 2 +- 4 files changed, 200 insertions(+), 21 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 929f5a80235..1fc4fcac366 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -315,7 +315,7 @@ 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. +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 may auto-restore the recorded branch only when the worktree is clean, still registered, and the recorded branch points at the current `HEAD`; the repair is recorded as a workspace operation before the successful finalize row. If that safe repair cannot be proven, finalization records a failed workspace finalize and the run fails with bounded evidence for the expected and actual branch. A branch change is sanctioned when a control-plane path updates the execution workspace record before finalization, when publishing work happens in a separate worktree and the managed issue worktree remains on its recorded branch, or when the finalizer performs this clean same-commit restoration. ### Explicit recovery actions diff --git a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts index 031d70f9803..13fe1a77f6e 100644 --- a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts +++ b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts @@ -238,6 +238,14 @@ async function listFinalizeOperations(db: Db, runId: string) { .orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.createdAt)); } +async function listRunWorkspaceOperations(db: Db, runId: string) { + return db + .select() + .from(workspaceOperations) + .where(eq(workspaceOperations.heartbeatRunId, runId)) + .orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.createdAt)); +} + describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => { let db!: Db; let tempDb: Awaited> | null = null; @@ -291,18 +299,20 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => await tempDb?.cleanup(); }); - it("fails a successful adapter run when the managed worktree branch drift was not recorded", async () => { + it("repairs clean unrecorded branch drift before recording workspace finalization", 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; + let workspaceCwd: string | null = null; adapterExecute.mockImplementationOnce(async (input) => { const workspace = readAdapterWorkspace(input); recordedBranch = workspace.branchName; executionWorkspaceId = workspace.executionWorkspaceId; + workspaceCwd = workspace.cwd; await runGit(workspace.cwd, ["checkout", "-b", publishBranch]); await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId)); return { @@ -319,23 +329,117 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => 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); + await expect(execFileAsync("git", ["branch", "--show-current"], { cwd: workspaceCwd! })) + .resolves.toMatchObject({ stdout: `${recordedBranch}\n` }); + + const operations = await listRunWorkspaceOperations(db, run!.id); + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "worktree_prepare", + command: `git checkout ${recordedBranch}`, + status: "succeeded", + executionWorkspaceId, + metadata: expect.objectContaining({ + branchIncoherenceRepair: true, + expectedBranchName: recordedBranch, + actualBranchName: publishBranch, + executionWorkspaceId, + sourceIssueId: issueId, + }), + }), + ]), + ); + + 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: recordedBranch, + actualBranchName: recordedBranch, + }, + managedGitWorktreeBranchRepair: { + attempted: true, + succeeded: true, + initial: expect.objectContaining({ + valid: false, + reasonCode: "branch_mismatch", + expectedBranchName: recordedBranch, + actualBranchName: publishBranch, + }), + }, + }); + }, 20_000); + + it("fails unrecorded branch drift when the checked-out branch has different commits", 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 writeFile(path.join(workspace.cwd, "publish.txt"), "publish branch work\n", "utf8"); + await runGit(workspace.cwd, ["add", "publish.txt"]); + await runGit(workspace.cwd, ["commit", "-m", "Add publish branch work"]); + 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 with commits.", + 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, + reason: "git_worktree_branch_incoherence", + sourceIssueId: issueId, + executionWorkspaceId, + expectedBranch: recordedBranch, + actualBranch: publishBranch, + 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", }), }); await waitForRuntimeStateLastRun(db, agentId, run!.id); @@ -349,13 +453,16 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => stderrExcerpt: expect.stringContaining("Managed git worktree branch check failed"), }); expect(finalizeOps[0]?.metadata).toMatchObject({ - managedGitWorktreeBranch: { + managedGitWorktreeBranch: expect.objectContaining({ executionWorkspaceId, valid: false, reasonCode: "branch_mismatch", expectedBranchName: recordedBranch, actualBranchName: publishBranch, - }, + }), + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + }), }); }, 20_000); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 67eb46f7bda..bd963ada977 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -96,6 +96,7 @@ import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./ import { buildWorkspaceReadyComment, cleanupExecutionWorkspaceArtifacts, + ensureGitWorktreeBranchCoherent, ensurePersistedExecutionWorkspaceAvailable, ensureRuntimeServicesForRun, formatManagedGitWorktreeBranchInspection, @@ -1200,7 +1201,7 @@ function fingerprintFinalizeWorkspaceBranchValidation(input: { const digest = createHash("sha256") .update(stableStringifyForFingerprint({ version: 1, - reason: "git_worktree_branch_mismatch_after_run", + reason: "git_worktree_branch_incoherence", issueId: input.issueId, executionWorkspaceId: input.executionWorkspaceId, worktreePath: input.inspection.worktreePath ? path.resolve(input.inspection.worktreePath) : null, @@ -10796,15 +10797,84 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ) => { if (adapterFinalizeOutcome) return; let finalizeBranchMetadata: Record | null = null; + let finalizeBranchRepairMetadata: Record | null = null; if (status === "succeeded") { const branchInspection = await inspectFinalizeWorkspaceBranch(); if (branchInspection) { - const managedGitWorktreeBranch = formatManagedGitWorktreeBranchInspection(branchInspection.inspection); + let inspection = branchInspection.inspection; + const initialManagedGitWorktreeBranch = formatManagedGitWorktreeBranchInspection(inspection); + if (!inspection.valid && inspection.reasonCode === "branch_mismatch" && inspection.repoRoot) { + try { + await ensureGitWorktreeBranchCoherent({ + repoRoot: inspection.repoRoot, + worktreePath: inspection.worktreePath, + expectedBranchName: inspection.expectedBranchName, + actualBranchName: inspection.actualBranchName, + sourceIssue: issueRef + ? { + id: issueRef.id, + identifier: issueRef.identifier, + title: issueRef.title, + workMode: issueRef.workMode, + } + : null, + executionWorkspaceId: branchInspection.workspaceRecord.id, + recorder: workspaceOperationRecorder, + }); + } catch (repairErr) { + const workspaceValidationFailure = isWorkspaceValidationFailure(repairErr) ? repairErr : null; + finalizeBranchMetadata = { + executionWorkspaceId: branchInspection.workspaceRecord.id, + ...initialManagedGitWorktreeBranch, + }; + finalizeBranchRepairMetadata = { + attempted: true, + succeeded: false, + initial: initialManagedGitWorktreeBranch, + reason: repairErr instanceof Error ? repairErr.message : String(repairErr), + }; + await workspaceOperationRecorder.recordOperation({ + phase: "workspace_finalize", + cwd: executionWorkspace.cwd, + metadata: { + adapterType: agent.adapterType, + executionTargetKind: executionTarget?.kind ?? "local", + ...metadata, + managedGitWorktreeBranch: finalizeBranchMetadata, + managedGitWorktreeBranchRepair: finalizeBranchRepairMetadata, + ...(workspaceValidationFailure?.resultJson + ? { workspaceValidation: workspaceValidationFailure.resultJson.workspaceValidation ?? workspaceValidationFailure.resultJson } + : {}), + }, + run: async () => ({ + status: "failed", + stderr: `Managed git worktree branch check failed: ${repairErr instanceof Error ? repairErr.message : String(repairErr)}\n`, + }), + }); + adapterFinalizeOutcome = "failed"; + throw repairErr; + } + + const repairedInspection = await inspectManagedGitWorktreeBranch({ + worktreePath: inspection.worktreePath, + expectedBranchName: inspection.expectedBranchName, + repoRoot: inspection.repoRoot, + }); + finalizeBranchRepairMetadata = { + attempted: true, + succeeded: repairedInspection.valid, + initial: initialManagedGitWorktreeBranch, + repaired: formatManagedGitWorktreeBranchInspection(repairedInspection), + }; + inspection = repairedInspection; + } + + const managedGitWorktreeBranch = formatManagedGitWorktreeBranchInspection(inspection); finalizeBranchMetadata = { executionWorkspaceId: branchInspection.workspaceRecord.id, ...managedGitWorktreeBranch, }; - if (!branchInspection.inspection.valid) { + if (!inspection.valid) { const workspaceValidationFingerprint = fingerprintFinalizeWorkspaceBranchValidation({ issueId: issueRef?.id ?? null, executionWorkspaceId: branchInspection.workspaceRecord.id, @@ -10818,18 +10888,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionTargetKind: executionTarget?.kind ?? "local", ...metadata, managedGitWorktreeBranch: finalizeBranchMetadata, + ...(finalizeBranchRepairMetadata ? { managedGitWorktreeBranchRepair: finalizeBranchRepairMetadata } : {}), }, run: async () => ({ status: "failed", - stderr: `Managed git worktree branch check failed: ${branchInspection.inspection.reason ?? "unknown branch mismatch"}\n`, + stderr: `Managed git worktree branch check failed: ${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.`, + `Execution workspace ${branchInspection.workspaceRecord.id} expected git worktree branch "${inspection.expectedBranchName}" at "${inspection.worktreePath}", but ${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", + reason: "git_worktree_branch_incoherence", fingerprint: workspaceValidationFingerprint, adapterType: agent.adapterType, issueId: issueRef?.id ?? null, @@ -10851,6 +10922,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionTargetKind: executionTarget?.kind ?? "local", ...metadata, ...(finalizeBranchMetadata ? { managedGitWorktreeBranch: finalizeBranchMetadata } : {}), + ...(finalizeBranchRepairMetadata ? { managedGitWorktreeBranchRepair: finalizeBranchRepairMetadata } : {}), }, run: async () => ({ status }), }); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index d7e87effde9..866e1898317 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -816,7 +816,7 @@ function branchIncoherenceValidationFailure(evidence: GitWorktreeBranchIncoheren ); } -async function ensureGitWorktreeBranchCoherent(input: { +export async function ensureGitWorktreeBranchCoherent(input: { repoRoot: string; worktreePath: string; expectedBranchName: string | null; From bf982c8c835b0435c0af465287c670e2dcf203c4 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 2 Jul 2026 21:32:50 -0700 Subject: [PATCH 018/107] Normalize adapter display labels (#8913) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Adapter names are part of the board-facing agent setup and management experience. > - The product now treats adapters as harnesses, while execution environments are modeled separately. > - Several built-in adapter labels still carried legacy local wording from the older harness-by-environment model. > - That wording makes the UI noisier and implies a distinction users no longer need to reason about. > - This pull request normalizes adapter display labels while keeping persisted adapter type identifiers unchanged. > - The benefit is clearer adapter selection and management copy without a database migration. ## Linked Issues or Issue Description No public GitHub issue was found for this exact cleanup. Related public PRs: - Supersedes #8910, an earlier branch for the same cleanup that did not include the later docs/gateway/Cursor alignment. - Refs #8819, which is related display-registry work for external multi-segment adapter labels, but not a duplicate of this built-in label cleanup. Feature request details: - Subsystem affected: Cross-cutting (`ui/`, `packages/adapters`, and docs). - Problem or motivation: user-facing adapter names include legacy local qualifiers even though adapters map to harnesses and environments are first-class elsewhere. - Proposed solution: remove the legacy local wording from built-in display labels, keep machine-readable adapter type ids unchanged, and keep gateway disambiguation where it is useful. - Alternatives considered: changing persisted adapter type ids was ruled out because it would create migration and compatibility risk; one-off UI replacements were ruled out because the display registry is already the correct central label boundary. - Roadmap alignment: this is small adapter UX polish, not a new roadmap-level core feature. ## What Changed - Updated the adapter display registry so known adapter labels are final and no built-in local adapter renders a legacy local suffix. - Preserved clean derived labels for unknown plugin local types while keeping gateway disambiguation for unknown gateway types. - Updated `AdapterManager` to prefer registry labels when the server reports raw adapter type ids for built-ins. - Removed legacy local wording from built-in adapter metadata labels in UI and adapter packages. - Aligned Cursor adapter metadata with the central display registry label. - Updated adapter docs and Storybook fixtures to match the new display names. - Added focused registry coverage for built-in labels and unknown plugin suffix behavior. ## Verification - `pnpm check:tokens` - `git diff --check origin/master...fix/adapter-display-labels` - Patch-addition scan for added secrets, private paths, and internal links: no matches. - GitHub duplicate search for open adapter-label/local-suffix issues and PRs; #8910 was identified as the older superseded public PR. - `pnpm exec vitest run ui/src/adapters/adapter-display-registry.test.ts` - `pnpm --filter @paperclipai/ui typecheck` - Stale-label scan found no remaining user-facing display-label suffixes; remaining local wording is operational/test terminology such as adapter ids, docs about running locally, and test descriptions. ## Risks Low risk. The change is display-label and documentation focused, and adapter type ids remain unchanged. The main risk is ambiguous gateway naming, mitigated by keeping explicit gateway labels where variants need disambiguation. ## Model Used OpenAI GPT-5 via Codex, tool-enabled coding agent in a local repository workspace. Context window size is not exposed by this environment. ## 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 --- docs/adapters/claude-local.md | 2 +- docs/adapters/codex-local.md | 2 +- docs/adapters/creating-an-adapter.md | 2 +- docs/adapters/external-adapters.md | 2 +- docs/adapters/gemini-local.md | 2 +- docs/adapters/overview.md | 14 +++--- docs/deploy/environment-variables.md | 4 +- .../board-operator/creating-a-company.md | 4 +- docs/start/architecture.md | 2 +- packages/adapters/acpx-local/src/index.ts | 2 +- packages/adapters/claude-local/src/index.ts | 2 +- packages/adapters/codex-local/src/index.ts | 2 +- packages/adapters/cursor-local/src/index.ts | 2 +- packages/adapters/gemini-local/src/index.ts | 2 +- packages/adapters/grok-local/src/index.ts | 2 +- packages/adapters/opencode-local/src/index.ts | 2 +- packages/adapters/pi-local/src/index.ts | 2 +- ui/src/adapters/acpx-local/index.ts | 2 +- .../adapters/adapter-display-registry.test.ts | 47 ++++++++++++++++++ ui/src/adapters/adapter-display-registry.ts | 48 +++++++++++-------- ui/src/adapters/claude-local/index.ts | 2 +- ui/src/adapters/codex-local/index.ts | 2 +- ui/src/adapters/cursor/index.ts | 2 +- ui/src/adapters/gemini-local/index.ts | 2 +- ui/src/adapters/grok-local/index.ts | 2 +- ui/src/adapters/opencode-local/index.ts | 2 +- ui/src/adapters/pi-local/index.ts | 2 +- ui/src/pages/AdapterManager.tsx | 5 +- ui/storybook/.storybook/preview.tsx | 4 +- .../stories/agent-management.stories.tsx | 4 +- .../stories/dialogs-modals.stories.tsx | 8 ++-- .../stories/forms-editors.stories.tsx | 4 +- 32 files changed, 123 insertions(+), 63 deletions(-) create mode 100644 ui/src/adapters/adapter-display-registry.test.ts diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 1cc39e219c1..c61b8ca6046 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -1,5 +1,5 @@ --- -title: Claude Local +title: Claude Code summary: Claude Code local adapter setup and configuration --- diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index fa4877b05a7..e1bd06b17cb 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -1,5 +1,5 @@ --- -title: Codex Local +title: Codex summary: OpenAI Codex local adapter setup and configuration --- diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index 2f4711aa150..c0f43ff8a2b 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -54,7 +54,7 @@ my-adapter/ # external plugin ```ts export const type = "my_agent"; // snake_case, globally unique -export const label = "My Agent (local)"; +export const label = "My Agent"; export const models = [ { id: "model-a", label: "Model A" }, ]; diff --git a/docs/adapters/external-adapters.md b/docs/adapters/external-adapters.md index 522679f667c..3a6b4ee6c7e 100644 --- a/docs/adapters/external-adapters.md +++ b/docs/adapters/external-adapters.md @@ -116,7 +116,7 @@ The plugin loader calls `createServerAdapter()` from your package root. This fun ```ts export const type = "my_adapter"; // snake_case, globally unique -export const label = "My Agent (local)"; +export const label = "My Agent"; export const models = [ { id: "model-a", label: "Model A" }, diff --git a/docs/adapters/gemini-local.md b/docs/adapters/gemini-local.md index 51380b058d2..a0458ec51dc 100644 --- a/docs/adapters/gemini-local.md +++ b/docs/adapters/gemini-local.md @@ -1,5 +1,5 @@ --- -title: Gemini Local +title: Gemini CLI summary: Gemini CLI local adapter setup and configuration --- diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index a2f62b54bba..43a39631550 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -18,13 +18,13 @@ When a heartbeat fires, Paperclip: | Adapter | Type Key | Description | |---------|----------|-------------| -| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | -| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | -| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | -| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | +| [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | +| [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | +| OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | -| Pi Local | `pi_local` | Runs an embedded Pi agent locally | -| Hermes Local | `hermes_local` | Runs the local Hermes CLI through `@paperclipai/hermes-paperclip-adapter` | +| Pi | `pi_local` | Runs an embedded Pi agent locally | +| Hermes | `hermes_local` | Runs the local Hermes CLI through `@paperclipai/hermes-paperclip-adapter` | | Hermes Gateway | `hermes_gateway` | Calls an already-running Hermes API server through `@paperclipai/hermes-paperclip-adapter/gateway` | | OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | @@ -49,7 +49,7 @@ These adapters ship as standalone npm packages and are installed via the plugin | Adapter | Package | Type Key | Description | |---------|---------|----------|-------------| -| Droid Local | `@henkey/droid-paperclip-adapter` | `droid_local` | Runs Factory Droid locally | +| Droid | `@henkey/droid-paperclip-adapter` | `droid_local` | Runs Factory Droid locally | ## External Adapters diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index b5b6486dfb7..b46dd64b1be 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -50,5 +50,5 @@ These are set automatically by the server when invoking agents: | Variable | Description | |----------|-------------| -| `ANTHROPIC_API_KEY` | Anthropic API key (for Claude Local adapter) | -| `OPENAI_API_KEY` | OpenAI API key (for Codex Local adapter) | +| `ANTHROPIC_API_KEY` | Anthropic API key (for Claude Code adapter) | +| `OPENAI_API_KEY` | OpenAI API key (for Codex adapter) | diff --git a/docs/guides/board-operator/creating-a-company.md b/docs/guides/board-operator/creating-a-company.md index fa437be8bb6..cf71111f512 100644 --- a/docs/guides/board-operator/creating-a-company.md +++ b/docs/guides/board-operator/creating-a-company.md @@ -23,11 +23,11 @@ Go to the Goals section and create your top-level company goal. ## Step 3: Create the CEO Agent -The CEO is the first agent you create. Choose an adapter type (Claude Local is a good default) and configure: +The CEO is the first agent you create. Choose an adapter type (Claude Code is a good default) and configure: - **Name** — e.g. "CEO" - **Role** — `ceo` -- **Adapter** — how the agent runs (Claude Local, Codex Local, etc.) +- **Adapter** — how the agent runs (Claude Code, Codex, etc.) - **Prompt template** — instructions for what the CEO does on each heartbeat - **Budget** — monthly spend limit in cents diff --git a/docs/start/architecture.md b/docs/start/architecture.md index 27d72575e1b..dd8839a51bc 100644 --- a/docs/start/architecture.md +++ b/docs/start/architecture.md @@ -19,7 +19,7 @@ Paperclip is a monorepo with four main layers. │ Schema, migrations, embedded mode │ ├─────────────────────────────────────┤ │ Adapters │ -│ Claude Local, Codex Local, │ +│ Claude Code, Codex, │ │ Process, HTTP │ └─────────────────────────────────────┘ ``` diff --git a/packages/adapters/acpx-local/src/index.ts b/packages/adapters/acpx-local/src/index.ts index dcbba95354c..ead70e617c7 100644 --- a/packages/adapters/acpx-local/src/index.ts +++ b/packages/adapters/acpx-local/src/index.ts @@ -1,7 +1,7 @@ import type { AdapterModel } from "@paperclipai/adapter-utils"; export const type = "acpx_local"; -export const label = "ACPX (local)"; +export const label = "ACPX"; export const DEFAULT_ACPX_LOCAL_AGENT = "claude"; export const DEFAULT_ACPX_LOCAL_MODE = "persistent"; diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 1370bd1113e..3310bc8b3f5 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -1,7 +1,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "claude_local"; -export const label = "Claude Code (local)"; +export const label = "Claude Code"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index dac11bbc764..3a38914972e 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -1,7 +1,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "codex_local"; -export const label = "Codex (local)"; +export const label = "Codex"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex"; diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index f93928d85ea..a576f503ca3 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -1,7 +1,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "cursor"; -export const label = "Cursor CLI (local)"; +export const label = "Cursor"; // Cursor CLI is not distributed as an npm package — the official install // path is the upstream installer script at cursor.com/install. Other adapters diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 72597a24f13..827fac3ef1c 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -4,7 +4,7 @@ import { } from "@paperclipai/adapter-utils"; export const type = "gemini_local"; -export const label = "Gemini CLI (local)"; +export const label = "Gemini CLI"; export const SANDBOX_INSTALL_COMMAND = buildSandboxNpmInstallCommand("@google/gemini-cli"); diff --git a/packages/adapters/grok-local/src/index.ts b/packages/adapters/grok-local/src/index.ts index 7e12bd1d99f..9e50feed8ff 100644 --- a/packages/adapters/grok-local/src/index.ts +++ b/packages/adapters/grok-local/src/index.ts @@ -1,5 +1,5 @@ export const type = "grok_local"; -export const label = "Grok Build (local)"; +export const label = "Grok Build"; export const DEFAULT_GROK_LOCAL_MODEL = "grok-build"; diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 77fe859c310..18c2df67403 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -1,7 +1,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "opencode_local"; -export const label = "OpenCode (local)"; +export const label = "OpenCode"; // Use OpenCode's official installer instead of `npm install -g opencode-ai`. // The npm package reifies four large Linux x64 prebuilt-binary subpackages diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index fef8bc2bed3..57fb7daea61 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -1,7 +1,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "pi_local"; -export const label = "Pi (local)"; +export const label = "Pi"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent@0.74.0"; diff --git a/ui/src/adapters/acpx-local/index.ts b/ui/src/adapters/acpx-local/index.ts index d8ae9675e09..4308d635228 100644 --- a/ui/src/adapters/acpx-local/index.ts +++ b/ui/src/adapters/acpx-local/index.ts @@ -4,7 +4,7 @@ import { SchemaConfigFields } from "../schema-config-fields"; export const acpxLocalUIAdapter: UIAdapterModule = { type: "acpx_local", - label: "ACPX (local)", + label: "ACPX", parseStdoutLine: parseAcpxStdoutLine, ConfigFields: SchemaConfigFields, buildAdapterConfig: buildAcpxLocalConfig, diff --git a/ui/src/adapters/adapter-display-registry.test.ts b/ui/src/adapters/adapter-display-registry.test.ts new file mode 100644 index 00000000000..8c32f162b7c --- /dev/null +++ b/ui/src/adapters/adapter-display-registry.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { getAdapterDisplay, getAdapterLabel, getAdapterLabels } from "./adapter-display-registry"; + +describe("adapter display registry", () => { + it("uses user-facing labels without the legacy local qualifier for built-in adapters", () => { + expect(getAdapterLabel("codex_local")).toBe("Codex"); + expect(getAdapterLabel("claude_local")).toBe("Claude Code"); + expect(getAdapterLabel("acpx_local")).toBe("ACPX"); + expect(getAdapterLabel("cursor")).toBe("Cursor"); + expect(getAdapterLabel("gemini_local")).toBe("Gemini CLI"); + expect(getAdapterLabel("grok_local")).toBe("Grok Build"); + expect(getAdapterLabel("hermes_local")).toBe("Hermes"); + expect(getAdapterLabel("hermes_gateway")).toBe("Hermes Gateway"); + expect(getAdapterLabel("opencode_local")).toBe("OpenCode"); + expect(getAdapterLabel("pi_local")).toBe("Pi"); + + expect(getAdapterLabels()).toMatchObject({ + codex_local: "Codex", + claude_local: "Claude Code", + acpx_local: "ACPX", + cursor: "Cursor", + gemini_local: "Gemini CLI", + grok_local: "Grok Build", + hermes_local: "Hermes", + hermes_gateway: "Hermes Gateway", + opencode_local: "OpenCode", + pi_local: "Pi", + }); + }); + + it("drops local suffixes for unknown plugin adapter labels", () => { + expect(getAdapterLabel("droid_local")).toBe("Droid"); + expect(getAdapterDisplay("droid_local")).toMatchObject({ + label: "Droid", + description: "External adapter", + }); + }); + + it("keeps a gateway suffix for unknown plugin adapter labels", () => { + expect(getAdapterLabel("droid_gateway")).toBe("Droid (gateway)"); + expect(getAdapterDisplay("droid_gateway")).toMatchObject({ + label: "Droid (gateway)", + description: "External gateway adapter", + }); + }); +}); diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index 17a20dca8f6..032332d43eb 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -21,13 +21,19 @@ import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; // Type suffix parsing // --------------------------------------------------------------------------- -const TYPE_SUFFIXES: Record = { - _local: "local", +// Suffixes stripped from type ids when deriving a human-readable label for +// unknown (plugin) adapter types. "_local" is a legacy qualifier from before +// first-class Environments and is never displayed; "_gateway" is re-appended +// as " (gateway)" to disambiguate gateway variants. Known adapters in +// `adapterDisplayMap` have final labels and never get a derived suffix. +const STRIPPED_TYPE_SUFFIXES = ["_local", "_gateway"] as const; + +const DISPLAY_SUFFIXES: Record = { _gateway: "gateway", }; function getTypeSuffix(type: string): string | null { - for (const [suffix, mode] of Object.entries(TYPE_SUFFIXES)) { + for (const [suffix, mode] of Object.entries(DISPLAY_SUFFIXES)) { if (type.endsWith(suffix)) return mode; } return null; @@ -55,57 +61,57 @@ export interface AdapterDisplayInfo { const adapterDisplayMap: Record = { acpx_local: { label: "ACPX", - description: "Experimental local ACPX multi-agent adapter", + description: "Experimental ACPX multi-agent harness", icon: Bot, experimental: true, hideFromVisualSelection: true, }, claude_local: { label: "Claude Code", - description: "Local Claude agent", + description: "Claude Code CLI harness", icon: Sparkles, recommended: true, }, codex_local: { label: "Codex", - description: "Local Codex agent", + description: "Codex CLI harness", icon: Code, recommended: true, }, gemini_local: { label: "Gemini CLI", - description: "Local Gemini agent", + description: "Gemini CLI harness", icon: Gem, }, grok_local: { label: "Grok Build", - description: "Local Grok Build agent", + description: "Grok Build harness", icon: Bot, }, hermes_gateway: { - label: "Hermes", + label: "Hermes Gateway", description: "Remote Hermes API server", icon: Bot, hideFromVisualSelection: true, }, hermes_local: { label: "Hermes", - description: "Local Hermes agent", + description: "Hermes harness", icon: Bot, }, opencode_local: { label: "OpenCode", - description: "Local multi-provider agent", + description: "OpenCode multi-provider harness", icon: OpenCodeLogoIcon, }, pi_local: { label: "Pi", - description: "Local Pi agent", + description: "Pi harness", icon: Terminal, }, cursor: { label: "Cursor", - description: "Local Cursor agent", + description: "Cursor CLI harness", icon: MousePointer2, }, cursor_cloud: { @@ -142,7 +148,7 @@ const adapterDisplayMap: Record = { function humanizeType(type: string): string { // Strip known type suffixes so "droid_local" → "Droid", not "Droid Local" let base = type; - for (const suffix of Object.keys(TYPE_SUFFIXES)) { + for (const suffix of STRIPPED_TYPE_SUFFIXES) { if (base.endsWith(suffix)) { base = base.slice(0, -suffix.length); break; @@ -152,16 +158,20 @@ function humanizeType(type: string): string { } export function getAdapterLabel(type: string): string { - const base = adapterDisplayMap[type]?.label ?? humanizeType(type); - return withSuffix(base, getTypeSuffix(type)); + // Known labels are final — only unknown (plugin) types get a derived + // suffix, so labels like "OpenClaw Gateway" don't become + // "OpenClaw Gateway (gateway)". + const known = adapterDisplayMap[type]; + if (known) return known.label; + return withSuffix(humanizeType(type), getTypeSuffix(type)); } export function getAdapterLabels(): Record { - const suffixed: Record = {}; + const labels: Record = {}; for (const [type, info] of Object.entries(adapterDisplayMap)) { - suffixed[type] = withSuffix(info.label, getTypeSuffix(type)); + labels[type] = info.label; } - return suffixed; + return labels; } export function getAdapterDisplay(type: string): AdapterDisplayInfo { diff --git a/ui/src/adapters/claude-local/index.ts b/ui/src/adapters/claude-local/index.ts index fc2904d0141..5bb32eaa8cd 100644 --- a/ui/src/adapters/claude-local/index.ts +++ b/ui/src/adapters/claude-local/index.ts @@ -5,7 +5,7 @@ import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui"; export const claudeLocalUIAdapter: UIAdapterModule = { type: "claude_local", - label: "Claude Code (local)", + label: "Claude Code", parseStdoutLine: parseClaudeStdoutLine, ConfigFields: ClaudeLocalConfigFields, buildAdapterConfig: buildClaudeLocalConfig, diff --git a/ui/src/adapters/codex-local/index.ts b/ui/src/adapters/codex-local/index.ts index 31a11bf3d5a..0d9dd1a3dd4 100644 --- a/ui/src/adapters/codex-local/index.ts +++ b/ui/src/adapters/codex-local/index.ts @@ -5,7 +5,7 @@ import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui"; export const codexLocalUIAdapter: UIAdapterModule = { type: "codex_local", - label: "Codex (local)", + label: "Codex", parseStdoutLine: parseCodexStdoutLine, ConfigFields: CodexLocalConfigFields, buildAdapterConfig: buildCodexLocalConfig, diff --git a/ui/src/adapters/cursor/index.ts b/ui/src/adapters/cursor/index.ts index b80cff6e9b1..9aa5ba0280e 100644 --- a/ui/src/adapters/cursor/index.ts +++ b/ui/src/adapters/cursor/index.ts @@ -5,7 +5,7 @@ import { buildCursorLocalConfig } from "@paperclipai/adapter-cursor-local/ui"; export const cursorLocalUIAdapter: UIAdapterModule = { type: "cursor", - label: "Cursor CLI (local)", + label: "Cursor", parseStdoutLine: parseCursorStdoutLine, ConfigFields: CursorLocalConfigFields, buildAdapterConfig: buildCursorLocalConfig, diff --git a/ui/src/adapters/gemini-local/index.ts b/ui/src/adapters/gemini-local/index.ts index d4cb89e5154..97a585d3812 100644 --- a/ui/src/adapters/gemini-local/index.ts +++ b/ui/src/adapters/gemini-local/index.ts @@ -5,7 +5,7 @@ import { buildGeminiLocalConfig } from "@paperclipai/adapter-gemini-local/ui"; export const geminiLocalUIAdapter: UIAdapterModule = { type: "gemini_local", - label: "Gemini CLI (local)", + label: "Gemini CLI", parseStdoutLine: parseGeminiStdoutLine, ConfigFields: GeminiLocalConfigFields, buildAdapterConfig: buildGeminiLocalConfig, diff --git a/ui/src/adapters/grok-local/index.ts b/ui/src/adapters/grok-local/index.ts index 31b7ffe27e1..ae08b64cacc 100644 --- a/ui/src/adapters/grok-local/index.ts +++ b/ui/src/adapters/grok-local/index.ts @@ -5,7 +5,7 @@ import { GrokLocalConfigFields } from "./config-fields"; export const grokLocalUIAdapter: UIAdapterModule = { type: "grok_local", - label: "Grok Build (local)", + label: "Grok Build", parseStdoutLine: parseGrokStdoutLine, createStdoutParser: createGrokStdoutParser, ConfigFields: GrokLocalConfigFields, diff --git a/ui/src/adapters/opencode-local/index.ts b/ui/src/adapters/opencode-local/index.ts index b68f3ace00d..97e5029b243 100644 --- a/ui/src/adapters/opencode-local/index.ts +++ b/ui/src/adapters/opencode-local/index.ts @@ -5,7 +5,7 @@ import { buildOpenCodeLocalConfig } from "@paperclipai/adapter-opencode-local/ui export const openCodeLocalUIAdapter: UIAdapterModule = { type: "opencode_local", - label: "OpenCode (local)", + label: "OpenCode", parseStdoutLine: parseOpenCodeStdoutLine, ConfigFields: OpenCodeLocalConfigFields, buildAdapterConfig: buildOpenCodeLocalConfig, diff --git a/ui/src/adapters/pi-local/index.ts b/ui/src/adapters/pi-local/index.ts index cfebf6698dd..907bb148d33 100644 --- a/ui/src/adapters/pi-local/index.ts +++ b/ui/src/adapters/pi-local/index.ts @@ -5,7 +5,7 @@ import { buildPiLocalConfig } from "@paperclipai/adapter-pi-local/ui"; export const piLocalUIAdapter: UIAdapterModule = { type: "pi_local", - label: "Pi (local)", + label: "Pi", parseStdoutLine: parsePiStdoutLine, ConfigFields: PiLocalConfigFields, buildAdapterConfig: buildPiLocalConfig, diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index c55b66f17ae..a90cc517f00 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -72,7 +72,10 @@ function AdapterRow({
- {adapter.label || getAdapterLabel(adapter.type)} + {/* The server reports label = raw type id, so prefer the display + registry's human label; keep a real self-reported label if an + external adapter ever provides one. */} + {adapter.label && adapter.label !== adapter.type ? adapter.label : getAdapterLabel(adapter.type)} {adapter.source === "external" ? "External" : "Built-in"} {adapter.source === "external" && ( diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 011ec14b5b0..552da835c2e 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -104,7 +104,7 @@ function installStorybookApiFixtures() { return Response.json([ { type: "claude_local", - label: "Claude Local", + label: "Claude Code", source: "builtin", modelsCount: 2, loaded: true, @@ -119,7 +119,7 @@ function installStorybookApiFixtures() { }, { type: "codex_local", - label: "Codex Local", + label: "Codex", source: "builtin", modelsCount: 3, loaded: true, diff --git a/ui/storybook/stories/agent-management.stories.tsx b/ui/storybook/stories/agent-management.stories.tsx index c5a727fe373..2fdf2ef24b3 100644 --- a/ui/storybook/stories/agent-management.stories.tsx +++ b/ui/storybook/stories/agent-management.stories.tsx @@ -298,7 +298,7 @@ const storybookSecrets: CompanySecret[] = [ const adapterFixtures: AdapterInfo[] = [ { type: "codex_local", - label: "Codex Local", + label: "Codex", source: "builtin", modelsCount: 3, loaded: true, @@ -313,7 +313,7 @@ const adapterFixtures: AdapterInfo[] = [ }, { type: "claude_local", - label: "Claude Local", + label: "Claude Code", source: "builtin", modelsCount: 2, loaded: true, diff --git a/ui/storybook/stories/dialogs-modals.stories.tsx b/ui/storybook/stories/dialogs-modals.stories.tsx index c0a7b7e2ef7..24e25c74d29 100644 --- a/ui/storybook/stories/dialogs-modals.stories.tsx +++ b/ui/storybook/stories/dialogs-modals.stories.tsx @@ -373,7 +373,7 @@ function hydrateDialogQueries(queryClient: ReturnType) { queryClient.setQueryData(queryKeys.adapters.all, [ { type: "codex_local", - label: "Codex local", + label: "Codex", source: "builtin", modelsCount: 5, loaded: true, @@ -388,7 +388,7 @@ function hydrateDialogQueries(queryClient: ReturnType) { }, { type: "claude_local", - label: "Claude local", + label: "Claude Code", source: "builtin", modelsCount: 4, loaded: true, @@ -721,7 +721,7 @@ function useCheapLaneAdapterOverrides(variant: CheapLaneVariant) { queryClient.setQueryData(queryKeys.adapters.all, [ { type: "codex_local", - label: "Codex local", + label: "Codex", source: "builtin", modelsCount: 5, loaded: true, @@ -736,7 +736,7 @@ function useCheapLaneAdapterOverrides(variant: CheapLaneVariant) { }, { type: "opencode_local", - label: "OpenCode local", + label: "OpenCode", source: "builtin", modelsCount: 2, loaded: true, diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx index 8c267fea905..8fa17c20716 100644 --- a/ui/storybook/stories/forms-editors.stories.tsx +++ b/ui/storybook/stories/forms-editors.stories.tsx @@ -121,7 +121,7 @@ const adapterSchema: JsonSchemaNode = { title: "Adapter name", description: "Human-readable name shown in the adapter manager.", minLength: 3, - default: "Codex local", + default: "Codex", }, mode: { type: "string", @@ -175,7 +175,7 @@ const adapterSchema: JsonSchemaNode = { const validAdapterValues = { ...getDefaultValues(adapterSchema), - adapterName: "Codex local", + adapterName: "Codex", mode: "implementation", apiKey: "secret:openai-api-key", concurrency: 2, From c48feee190f0f79c4988a082cd0344640ac8ab79 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 2 Jul 2026 22:21:56 -0700 Subject: [PATCH 019/107] Improve live agent feedback during sandboxed runs (#8915) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A core part of that experience is watching active agent runs without dropping into raw logs first > - Local and sandbox-backed adapters already record useful run output, progress, and tool activity > - But active issue threads could sit visually stale while the agent was syncing workspaces, tailing sandbox output, or emitting incremental tool-call updates > - Operators need timely, human-readable progress while preserving the raw transcript underneath > - This pull request streams sandbox run-log progress into runtime status, keeps visible issue threads refreshed, and folds repeated ACPX tool updates into stable transcript cards > - The benefit is that long-running agent work becomes easier to supervise without changing the task/comment control-plane model ## Linked Issues or Issue Description No public GitHub issue exists for this exact change. Problem/motivation: - During long-running sandboxed agent work, the issue UI can appear idle even though the agent is actively syncing, running tools, or producing incremental output. - Operators need realtime feedback at the issue-thread layer, not only after opening raw logs or waiting for the final heartbeat result. - Related public context: #1808 previously added live-run status dots to Projects; #4362 touches heartbeat wakeup behavior but is not a duplicate of this runtime/UI feedback change. ## What Changed - Added sandbox run-log streaming support and defaulted sandbox-capable local adapters into the richer live-feedback path. - Surfaced environment/sandbox sync progress through heartbeat runtime status with bounded, redacted snippets. - Added live issue-thread cache patching so visible active runs update as progress events arrive. - Folded repeated ACPX `tool_call` updates into one transcript card instead of stacking duplicate cards. - Updated adapter docs and added focused regression coverage for sandbox log streaming, runtime status, ACPX parsing, live updates, transcript rendering, and issue chat messages. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/context/LiveUpdatesProvider.test.ts` - `pnpm exec vitest run server/src/services/heartbeat-run-runtime-status.test.ts server/src/__tests__/heartbeat-runtime-state.test.ts ui/src/context/LiveUpdatesProvider.test.ts` - `pnpm exec vitest run packages/adapter-utils/src/execution-target-sandbox.test.ts packages/adapter-utils/src/sandbox-managed-runtime.test.ts server/src/services/heartbeat-run-runtime-status.test.ts server/src/__tests__/agent-live-run-routes.test.ts server/src/__tests__/heartbeat-runtime-state.test.ts packages/adapters/acpx-local/src/ui/parse-stdout.test.ts ui/src/context/LiveUpdatesProvider.test.ts ui/src/components/transcript/RunTranscriptView.test.tsx ui/src/lib/issue-chat-messages.test.ts ui/src/components/IssueChatThread.test.tsx` - GitHub PR workflow on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`: `verify`, build, typecheck/release-registry, e2e, general shards, serialized server shards, and canary dry run passed. - Greptile Review on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`: Confidence Score 5/5, no unresolved review threads. ## Risks - Live issue-thread cache patching could miss an edge case for a route shape not covered by tests. - Surfacing active-run snippets needs continued care around redaction; this PR keeps snippets bounded and adds redaction-focused coverage. - More frequent active-run UI refreshes could expose performance issues on very large issue threads, though updates are scoped to visible run/query caches. ## Model Used OpenAI GPT-5 via Codex, operating as a tool-enabled coding agent with shell, git, and repository-editing capabilities. Context window size is not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- docs/adapters/overview.md | 16 +- .../src/execution-target-sandbox.test.ts | 267 +++++++++++++++++ .../adapter-utils/src/execution-target.ts | 82 +++++- .../adapter-utils/src/runtime-progress.ts | 3 + .../src/sandbox-managed-runtime.test.ts | 40 ++- .../src/sandbox-managed-runtime.ts | 84 ++++-- .../src/sandbox-run-log-stream.ts | 278 ++++++++++++++++++ packages/adapters/acpx-local/src/index.ts | 8 +- .../adapters/acpx-local/src/server/execute.ts | 3 + .../acpx-local/src/ui/parse-stdout.test.ts | 54 ++++ .../acpx-local/src/ui/parse-stdout.ts | 24 +- .../claude-local/src/server/execute.ts | 1 + .../codex-local/src/server/execute.ts | 1 + .../cursor-local/src/server/execute.ts | 1 + .../gemini-local/src/server/execute.ts | 1 + .../opencode-local/src/server/execute.ts | 1 + .../adapters/pi-local/src/server/execute.ts | 1 + packages/shared/src/types/environment.ts | 4 + packages/shared/src/types/heartbeat.ts | 6 +- .../__tests__/agent-live-run-routes.test.ts | 6 + .../__tests__/heartbeat-runtime-state.test.ts | 12 + server/src/services/environment-config.ts | 2 + .../services/environment-execution-target.ts | 4 + .../heartbeat-run-runtime-status.test.ts | 105 +++++++ .../services/heartbeat-run-runtime-status.ts | 85 +++++- server/src/services/heartbeat.ts | 211 ++++++++++++- ui/src/api/heartbeats.ts | 6 + ui/src/components/IssueChatThread.test.tsx | 7 +- ui/src/components/IssueChatThread.tsx | 78 +++-- .../transcript/RunTranscriptView.test.tsx | 96 ++++++ .../transcript/RunTranscriptView.tsx | 28 +- ui/src/context/LiveUpdatesProvider.test.ts | 127 ++++++++ ui/src/context/LiveUpdatesProvider.tsx | 164 +++++++++++ ui/src/lib/issue-chat-messages.test.ts | 6 + ui/src/lib/issue-chat-messages.ts | 8 + ui/src/pages/CompanyEnvironments.tsx | 10 + 36 files changed, 1742 insertions(+), 88 deletions(-) create mode 100644 packages/adapter-utils/src/sandbox-run-log-stream.ts diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 43a39631550..ac153d29374 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| ACPX Local | `acpx_local` | Runs Claude, Codex, or a custom ACP agent through ACPX with live structured event streaming | | [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | | OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | @@ -92,12 +93,25 @@ my-adapter/ ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin +- **Need a coding agent?** Use `claude_local`, `codex_local`, `acpx_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin +- **Need the richest live run feedback (especially for sandbox workers)?** Use `acpx_local` — see [Feedback granularity](#feedback-granularity) - **Need Hermes on another host or already running as a service?** Use `hermes_gateway` - **Need to run a script or command?** Use `process` - **Need to call a custom external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters) +## Feedback Granularity + +Adapter choice determines how much structured, live detail a run's transcript can show while the agent is still working. Every adapter's stdout is streamed to the run log and rendered live in the UI — including runs on sandbox execution targets, whose logs are tailed and delivered incrementally — but the *granularity* of what you see depends on the event stream the adapter emits. + +Rough tiers, richest first: + +1. **`acpx_local` — full structured event stream.** ACPX emits a JSONL event per meaningful runtime moment: `acpx.session` (agent, mode, session identity), `acpx.status` (progress text plus context-window usage), `acpx.text_delta` (assistant/thinking token deltas), `acpx.tool_call` (tool title, call id, and status updates as the call progresses), `acpx.result` (stop reason summary), and `acpx.error` (code, message, retryability). The transcript renders these as live-updating message, thinking, tool, and status blocks, and repeated `acpx.tool_call` status updates fold into a single tool card instead of stacking duplicates. +2. **CLI wrappers (`claude_local`, `codex_local`, `cursor`, `opencode_local`, …).** These parse each CLI's own streaming JSON output. You get assistant text, tool calls/results, and a final usage/cost summary, but granularity is limited to what the CLI prints — some emit tool progress, others only call/finish pairs. +3. **Generic adapters (`process`, `http`).** Plain stdout/stderr lines with no structured transcript — you see raw output only. + +**Recommendation:** for sandbox workers, prefer `acpx_local`. Sandbox run logs are streamed live, so the richer the event stream, the more useful the live transcript and status line are while a remote run is in flight. ACPX's status events (including context usage) and incremental tool-call updates give the closest thing to watching the agent work locally. + ## UI Parser Contract External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details. diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 0e7fac90fbb..f977922171b 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -16,6 +16,7 @@ import { startAdapterExecutionTargetPaperclipBridge, type AdapterSandboxExecutionTarget, } from "./execution-target.js"; +import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js"; import { runChildProcess } from "./server-utils.js"; import { shellQuote } from "./ssh.js"; @@ -75,6 +76,33 @@ describe("sandbox adapter execution targets", () => { return contents; } + function encodeTailTick(stdout: Buffer, stderr: Buffer): string { + return [ + "__PAPERCLIP_RUN_LOG_STDOUT__", + stdout.toString("base64"), + "__PAPERCLIP_RUN_LOG_STDERR__", + stderr.toString("base64"), + "__PAPERCLIP_RUN_LOG_END__", + "", + ].join("\n"); + } + + async function waitForCondition(predicate: () => boolean, message: string): Promise { + const deadline = Date.now() + 1000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(message); + } + + function combinedStream( + events: Array<{ stream: "stdout" | "stderr"; chunk: string }>, + stream: "stdout" | "stderr", + ): string { + return events.filter((event) => event.stream === stream).map((event) => event.chunk).join(""); + } + it("executes through the provider-neutral runner without a remote spec", async () => { const runner = { execute: vi.fn(async () => ({ @@ -464,6 +492,245 @@ describe("sandbox adapter execution targets", () => { } }); + it("creates a sandbox run log tail factory when bridge streaming is enabled", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); + await mkdir(runtimeRootDir, { recursive: true }); + + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd, + runner: createLocalSandboxRunner(), + streamRunLogs: true, + timeoutMs: 30_000, + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-stream", + target, + runtimeRootDir, + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: "http://127.0.0.1:9", + onLog: async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + }); + try { + expect(bridge?.runLogTail).toBeTruthy(); + expect(combinedStream(logs, "stdout")).toContain("Sandbox run log streaming enabled"); + + const wrapped = bridge!.runLogTail!.create().wrapCommand("agent-cli", ["--message", "hello world"]); + expect(wrapped.command).toBe("sh"); + expect(wrapped.args.join("\n")).toContain("tee -a"); + expect(wrapped.args.join("\n")).toContain("agent-cli"); + } finally { + await bridge?.stop(); + } + }); + + it("defaults sandbox run log streaming on and honors the explicit opt-out", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-default-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); + await mkdir(runtimeRootDir, { recursive: true }); + + const baseTarget: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd, + runner: createLocalSandboxRunner(), + timeoutMs: 30_000, + }; + + const defaultBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-stream-default", + target: baseTarget, + runtimeRootDir, + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: "http://127.0.0.1:9", + }); + try { + expect(defaultBridge?.runLogTail).toBeTruthy(); + } finally { + await defaultBridge?.stop(); + } + + const optOutBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-stream-opt-out", + target: { ...baseTarget, streamRunLogs: false }, + runtimeRootDir, + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: "http://127.0.0.1:9", + }); + try { + expect(optOutBridge?.runLogTail ?? null).toBeNull(); + } finally { + await optOutBridge?.stop(); + } + }); + + it("tails sandbox run log chunks with byte offsets and dedupes the final batch", async () => { + const stdoutText = "stdout-abc\n"; + const stderrText = "stderr-xyz\n"; + const stdoutBytes = Buffer.from(stdoutText, "utf8"); + const stderrBytes = Buffer.from(stderrText, "utf8"); + const stdoutOffsets: number[] = []; + const stderrOffsets: number[] = []; + const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + + const runner = { + execute: vi.fn(async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + timeoutMs?: number; + }) => { + const script = input.args?.[1] ?? ""; + const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1])); + const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1); + const stderrStart = Math.max(0, (offsets[1] ?? 1) - 1); + stdoutOffsets.push(stdoutStart + 1); + stderrOffsets.push(stderrStart + 1); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: encodeTailTick( + stdoutBytes.subarray(stdoutStart, stdoutStart + 4), + stderrBytes.subarray(stderrStart, stderrStart + 4), + ), + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + }), + }; + + const tail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: "/workspace", + logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", + pollIntervalMs: 1, + maxChunkBytesPerTick: 4, + tickTimeoutMs: 50, + }).create(); + + tail.start(async (stream, chunk) => { + events.push({ stream, chunk }); + }); + + await waitForCondition( + () => combinedStream(events, "stdout") === stdoutText && combinedStream(events, "stderr") === stderrText, + "run log tail did not stream expected stdout/stderr chunks", + ); + + await tail.finish({ stdout: stdoutText, stderr: stderrText }); + + expect(combinedStream(events, "stdout")).toBe(stdoutText); + expect(combinedStream(events, "stderr")).toBe(stderrText); + expect(stdoutOffsets.slice(0, 3)).toEqual([1, 5, 9]); + expect(stderrOffsets.slice(0, 3)).toEqual([1, 5, 9]); + expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "sh", + cwd: "/workspace", + env: { PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge" }, + timeoutMs: 50, + })); + }); + + it("emits only the unstreamed final suffix when the tail loop stops early", async () => { + const finalStdout = "prefix suffix\n"; + const finalBytes = Buffer.from(finalStdout, "utf8"); + const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + + const runner = { + execute: vi.fn(async (input: { args?: string[] }) => { + const script = input.args?.[1] ?? ""; + const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1])); + const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: encodeTailTick(finalBytes.subarray(stdoutStart, stdoutStart + 7), Buffer.alloc(0)), + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + }), + }; + + const tail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: "/workspace", + logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", + pollIntervalMs: 1, + maxChunkBytesPerTick: 7, + tickTimeoutMs: 50, + }).create(); + + tail.start(async (stream, chunk) => { + events.push({ stream, chunk }); + }); + await waitForCondition(() => combinedStream(events, "stdout").length >= 7, "run log tail did not emit prefix"); + await tail.finish({ stdout: finalStdout, stderr: "" }); + + expect(combinedStream(events, "stdout")).toBe(finalStdout); + expect(events.filter((event) => event.stream === "stdout").map((event) => event.chunk).join("|")) + .toBe("prefix |suffix\n"); + }); + + it("delivers the final batch and a warning when run log polling degrades", async () => { + const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const runner = { + execute: vi.fn(async () => ({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "tail failed", + pid: null, + startedAt: new Date().toISOString(), + })), + }; + + const tail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: "/workspace", + logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", + pollIntervalMs: 1, + tickTimeoutMs: 50, + maxConsecutiveFailures: 1, + }).create(); + + tail.start(async (stream, chunk) => { + events.push({ stream, chunk }); + }); + await waitForCondition(() => runner.execute.mock.calls.length >= 1, "run log tail did not poll before finish"); + await new Promise((resolve) => setTimeout(resolve, 5)); + await tail.finish({ stdout: "final out\n", stderr: "final err\n" }); + + expect(combinedStream(events, "stdout")).toBe("final out\n"); + expect(combinedStream(events, "stderr")).toBe( + "final err\n[paperclip] Run log streaming degraded during the run; remaining output was delivered at completion.\n", + ); + }); + 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); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 6978e8e9034..466c00a96c0 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -15,9 +15,14 @@ import { createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, + sandboxCallbackBridgeDirectories, startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js"; +import { + createSandboxRunLogTailFactory, + type SandboxRunLogTailFactory, +} from "./sandbox-run-log-stream.js"; import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; import { ensureCommandResolvable, @@ -57,6 +62,13 @@ export interface AdapterSandboxExecutionTarget { remoteCwd: string; timeoutMs?: number | null; runner?: CommandManagedRuntimeRunner; + /** + * Sandbox-backed adapter runs stream the agent CLI's stdout/stderr + * incrementally via a log-tail loop beside the callback bridge instead of + * waiting for the batched provider result. Streaming is ON by default; + * set to `false` to explicitly opt out back to batch-at-end delivery. + */ + streamRunLogs?: boolean | null; } export type AdapterExecutionTarget = @@ -86,6 +98,12 @@ export interface AdapterExecutionTargetProcessOptions { onRuntimeProgress?: RuntimeStatusSink; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; terminalResultCleanup?: TerminalResultCleanupOptions; + /** + * Sandbox-only: factory from the Paperclip bridge handle that streams the + * CLI's stdout/stderr during the run. When provided, the batched provider + * onLog is suppressed and incremental chunks flow through `onLog` instead. + */ + runLogTail?: SandboxRunLogTailFactory | null; } export interface AdapterExecutionTargetShellOptions { @@ -98,6 +116,12 @@ export interface AdapterExecutionTargetShellOptions { export interface AdapterExecutionTargetPaperclipBridgeHandle { env: Record; + /** + * Present when the sandbox target opted into run-log streaming + * (`streamRunLogs`). Create one handle per CLI attempt and pass it to + * `runAdapterExecutionTargetProcess` via `options.runLogTail`. + */ + runLogTail?: SandboxRunLogTailFactory | null; stop(): Promise; } @@ -412,18 +436,38 @@ export async function runAdapterExecutionTargetProcess( phase: "adapter_startup", message: "Starting adapter in sandbox", }); - return await runner.execute({ - command, - args, - cwd: target.remoteCwd, - env, - stdin: options.stdin, - timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined, - onLog: options.onLog, - onSpawn: options.onSpawn - ? async (meta) => options.onSpawn?.({ ...meta, processGroupId: null }) - : undefined, - }); + const runLogTail = options.runLogTail?.create() ?? null; + let execCommand = command; + let execArgs = args; + if (runLogTail) { + ({ command: execCommand, args: execArgs } = runLogTail.wrapCommand(command, args)); + runLogTail.start(options.onLog); + } + try { + const result = await runner.execute({ + command: execCommand, + args: execArgs, + cwd: target.remoteCwd, + env, + stdin: options.stdin, + timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined, + // The tail loop already streams incremental chunks; suppress the + // runner's end-of-run batched onLog to avoid duplicate log bytes. + onLog: runLogTail ? undefined : options.onLog, + onSpawn: options.onSpawn + ? async (meta) => options.onSpawn?.({ ...meta, processGroupId: null }) + : undefined, + }); + if (runLogTail) { + await runLogTail.finish({ stdout: result.stdout, stderr: result.stderr }); + } + return result; + } catch (error) { + if (runLogTail) { + await runLogTail.abort(); + } + throw error; + } } const env = @@ -886,6 +930,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar leaseId: readStringMeta(parsed, "leaseId"), remoteCwd, timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null, + streamRunLogs: typeof parsed.streamRunLogs === "boolean" ? parsed.streamRunLogs : null, }; } @@ -1194,12 +1239,25 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { throw error; } + let runLogTail: SandboxRunLogTailFactory | null = null; + if (target.transport === "sandbox" && target.streamRunLogs !== false) { + runLogTail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: target.remoteCwd, + logsDir: sandboxCallbackBridgeDirectories(queueDir).logsDir, + shellCommand, + }); + await onLog("stdout", "[paperclip] Sandbox run log streaming enabled for this run.\n"); + } + return { env: { PAPERCLIP_API_URL: server.baseUrl, PAPERCLIP_API_KEY: bridgeToken, PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + PAPERCLIP_BRIDGE_QUEUE_DIR: queueDir, }, + runLogTail, stop: async () => { await Promise.allSettled([ server?.stop(), diff --git a/packages/adapter-utils/src/runtime-progress.ts b/packages/adapter-utils/src/runtime-progress.ts index 11beabda637..e217927675b 100644 --- a/packages/adapter-utils/src/runtime-progress.ts +++ b/packages/adapter-utils/src/runtime-progress.ts @@ -32,6 +32,9 @@ export type RuntimeStatusPhase = export interface RuntimeStatusUpdate { phase: RuntimeStatusPhase; message: string; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: Date | string | null; } export type RuntimeStatusSink = (update: RuntimeStatusUpdate) => void | Promise; diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 5338a8e7680..a101fc817cf 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -148,12 +148,18 @@ describe("sandbox managed runtime", () => { await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).resolves.toBe("remove\n"); await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n"); await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n"); - expect(runtimeStatuses).toEqual([ + expect(runtimeStatuses).toEqual(expect.arrayContaining([ "config_sync:Syncing workspace to sandbox", "config_sync:Syncing runtime assets to sandbox", "restore:Restoring workspace from sandbox", "finalize:Finalizing sandbox workspace", - ]); + ])); + expect(runtimeStatuses).toEqual(expect.arrayContaining([ + expect.stringMatching(/^config_sync:Syncing workspace to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/), + expect.stringMatching(/^config_sync:Syncing skills to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/), + expect.stringMatching(/^restore:Restoring workspace from sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/), + ])); + expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing sandbox workspace"); }); it("syncs git-backed workspaces through a shallow standalone clone and keeps .git out of archives", async () => { @@ -185,19 +191,29 @@ describe("sandbox managed runtime", () => { const uploadedTars: { remotePath: string; bytes: Buffer }[] = []; const downloadedTars: { remotePath: string; bytes: Buffer }[] = []; + const driveProgress = async ( + total: number, + onProgress: ((done: number, total: number | null) => void | Promise) | undefined, + ) => { + if (!onProgress) return; + await onProgress(Math.max(1, Math.floor(total / 2)), total); + await onProgress(total, total); + }; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, - writeFile: async (remotePath, bytes) => { + writeFile: async (remotePath, bytes, options) => { await mkdir(path.dirname(remotePath), { recursive: true }); const buffer = Buffer.from(bytes); if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer }); await writeFile(remotePath, buffer); + await driveProgress(buffer.byteLength, options?.onProgress); }, - readFile: async (remotePath) => { + readFile: async (remotePath, options) => { const buffer = await readFile(remotePath); if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer }); + await driveProgress(buffer.byteLength, options?.onProgress); return buffer; }, listFiles: async () => [], @@ -208,7 +224,7 @@ describe("sandbox managed runtime", () => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; - const runtimeStatusPhases: string[] = []; + const runtimeStatuses: Array<{ phase: string; message: string }> = []; const prepared = await prepareSandboxManagedRuntime({ spec: { @@ -223,7 +239,7 @@ describe("sandbox managed runtime", () => { client, workspaceLocalDir: localWorkspaceDir, onRuntimeProgress: async (status) => { - runtimeStatusPhases.push(status.phase); + runtimeStatuses.push({ phase: status.phase, message: status.message }); }, }); @@ -269,13 +285,21 @@ describe("sandbox managed runtime", () => { const downloadMembers = await listTarMembers(rootDir, "workspace-download-list.tar", downloadedTars[0]!.bytes); expect(downloadMembers.some((entry) => entry === ".git" || entry.startsWith(".git/"))).toBe(false); expect(downloadMembers.some((entry) => entry === "node_modules" || entry.startsWith("node_modules/"))).toBe(false); - expect(runtimeStatusPhases).toEqual([ + expect(runtimeStatuses.map((status) => status.phase)).toEqual(expect.arrayContaining([ "git_sync", "config_sync", "export", "restore", "finalize", - ]); + ])); + expect(runtimeStatuses.some((status) => ( + status.phase === "git_sync" && + /^Syncing git history to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) + ))).toBe(true); + expect(runtimeStatuses.some((status) => ( + status.phase === "export" && + /^Exporting git history from sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) + ))).toBe(true); }); it("repairs stale host index deletions when the sandbox restores a clean git worktree", async () => { diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index cbaed7298e3..9edb59703c5 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -382,10 +382,28 @@ function makeTransferProgress( phase: RuntimeProgressPhase, direction: RuntimeProgressDirection, label?: string, -): { options: SandboxTransferProgressOptions | undefined; finish: () => Promise } { - if (!sink) return { options: undefined, finish: async () => {} }; + runtimeStatus?: { + sink: RuntimeStatusSink | undefined; + phase: RuntimeStatusPhase; + }, +): { + options: SandboxTransferProgressOptions | undefined; + finish: (doneBytes?: number, totalBytes?: number | null) => Promise; +} { + if (!sink && !runtimeStatus?.sink) { + return { options: undefined, finish: async () => {} }; + } const reporter = createRuntimeProgressReporter({ - sink, + sink: async (line) => { + await sink?.(line); + if (runtimeStatus?.sink) { + await emitRuntimeStatus( + runtimeStatus.sink, + runtimeStatus.phase, + line.replace(/^\[paperclip\]\s*/, "").trim(), + ); + } + }, phase, direction, target: "sandbox", @@ -397,8 +415,8 @@ function makeTransferProgress( await reporter.report(transferredBytes, totalBytes); }, }, - finish: async () => { - await reporter.complete(); + finish: async (doneBytes, totalBytes) => { + await reporter.complete(doneBytes, totalBytes); }, }; } @@ -460,9 +478,15 @@ export async function prepareSandboxManagedRuntime(input: { const gitTarBytes = await fs.readFile(gitTarPath); const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar"); await input.client.makeDir(runtimeRootDir); - const gitUpload = makeTransferProgress(input.onProgress, "Syncing", "to", "git history"); + const gitUpload = makeTransferProgress( + input.onProgress, + "Syncing", + "to", + "git history", + { sink: input.onRuntimeProgress, phase: "git_sync" }, + ); await input.client.writeFile(remoteGitTar, toArrayBuffer(gitTarBytes), gitUpload.options); - await gitUpload.finish(); + await gitUpload.finish(gitTarBytes.byteLength, gitTarBytes.byteLength); await input.client.run( `sh -c ${shellQuote( `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + @@ -494,13 +518,19 @@ export async function prepareSandboxManagedRuntime(input: { const workspaceTarBytes = await fs.readFile(workspaceTarPath); const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar"); await input.client.makeDir(runtimeRootDir); - const workspaceUpload = makeTransferProgress(input.onProgress, "Syncing", "to", "workspace"); + const workspaceUpload = makeTransferProgress( + input.onProgress, + "Syncing", + "to", + "workspace", + { sink: input.onRuntimeProgress, phase: "config_sync" }, + ); await input.client.writeFile( remoteWorkspaceTar, toArrayBuffer(workspaceTarBytes), workspaceUpload.options, ); - await workspaceUpload.finish(); + await workspaceUpload.finish(workspaceTarBytes.byteLength, workspaceTarBytes.byteLength); const extractWorkspaceTarCommand = gitSnapshot ? `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + `tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` + @@ -534,9 +564,15 @@ export async function prepareSandboxManagedRuntime(input: { const assetTarBytes = await fs.readFile(assetTarPath); const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key); const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`); - const assetUpload = makeTransferProgress(input.onProgress, "Syncing", "to", asset.key); + const assetUpload = makeTransferProgress( + input.onProgress, + "Syncing", + "to", + asset.key, + { sink: input.onRuntimeProgress, phase: "config_sync" }, + ); await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes), assetUpload.options); - await assetUpload.finish(); + await assetUpload.finish(assetTarBytes.byteLength, assetTarBytes.byteLength); await input.client.run( `sh -c ${shellQuote( `rm -rf ${shellQuote(remoteAssetDir)} && ` + @@ -582,9 +618,16 @@ export async function prepareSandboxManagedRuntime(input: { }))}`, { timeoutMs: input.spec.timeoutMs }, ); - const gitExport = makeTransferProgress(restoreSink, "Exporting git history", "from"); + const gitExport = makeTransferProgress( + restoreSink, + "Exporting git history", + "from", + undefined, + { sink: input.onRuntimeProgress, phase: "export" }, + ); const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options); - await gitExport.finish(); + const bundleBuffer = toBuffer(bundleBytes); + await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength); await input.client.remove(remoteGitBundle).catch(() => undefined); remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath) .then((bytes) => toBuffer(bytes).toString("utf8").trim()) @@ -592,7 +635,7 @@ export async function prepareSandboxManagedRuntime(input: { remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty"; await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined); const bundlePath = path.join(tempDir, "git-delta.bundle"); - await fs.writeFile(bundlePath, toBuffer(bundleBytes)); + await fs.writeFile(bundlePath, bundleBuffer); importedHead = await fetchGitBundleIntoLocalRef({ localDir: input.workspaceLocalDir, bundlePath, @@ -612,13 +655,20 @@ export async function prepareSandboxManagedRuntime(input: { }))}`, { timeoutMs: input.spec.timeoutMs }, ); - const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace"); + const workspaceRestore = makeTransferProgress( + restoreSink, + "Restoring", + "from", + "workspace", + { sink: input.onRuntimeProgress, phase: "restore" }, + ); const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options); - await workspaceRestore.finish(); + const archiveBuffer = toBuffer(archiveBytes); + await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength); await input.client.remove(remoteWorkspaceTar).catch(() => undefined); const localArchivePath = path.join(tempDir, "workspace.tar"); const extractedDir = path.join(tempDir, "workspace"); - await fs.writeFile(localArchivePath, toBuffer(archiveBytes)); + await fs.writeFile(localArchivePath, archiveBuffer); await extractTarballToDirectory({ archivePath: localArchivePath, localDir: extractedDir, diff --git a/packages/adapter-utils/src/sandbox-run-log-stream.ts b/packages/adapter-utils/src/sandbox-run-log-stream.ts new file mode 100644 index 00000000000..013ab43048c --- /dev/null +++ b/packages/adapter-utils/src/sandbox-run-log-stream.ts @@ -0,0 +1,278 @@ +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; +import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js"; +import { shellQuote } from "./ssh.js"; + +// Sandbox providers execute commands through batch RPCs, so agent CLI output +// normally only reaches the host when the process exits. This module streams +// that output during the run instead: `wrapCommand` tees the CLI's +// stdout/stderr into log files under the bridge runtime directory inside the +// sandbox, and a host-side poll loop tails those files (byte offsets + +// base64 transport, mirroring the callback-bridge queue client) and emits +// incremental `onLog` chunks through the existing run-log pipeline. + +const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL"; +const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge"; + +const DEFAULT_TAIL_POLL_INTERVAL_MS = 250; +const DEFAULT_TAIL_MAX_CHUNK_BYTES = 64 * 1024; +const DEFAULT_TAIL_TICK_TIMEOUT_MS = 15_000; +const DEFAULT_TAIL_MAX_CONSECUTIVE_FAILURES = 3; + +const TAIL_MARKER_STDOUT = "__PAPERCLIP_RUN_LOG_STDOUT__"; +const TAIL_MARKER_STDERR = "__PAPERCLIP_RUN_LOG_STDERR__"; +const TAIL_MARKER_END = "__PAPERCLIP_RUN_LOG_END__"; + +export type SandboxRunLogSink = (stream: "stdout" | "stderr", chunk: string) => Promise; + +export interface SandboxRunLogTailHandle { + /** + * Wrap the agent CLI invocation in a shell script that tees stdout/stderr + * into tailable log files while preserving the original streams (the + * provider result must keep the full stdout for adapter parsing) and the + * original exit code. + */ + wrapCommand(command: string, args: string[]): { command: string; args: string[] }; + /** Start the host-side poll loop that tails the log files via the runner. */ + start(onLog: SandboxRunLogSink): void; + /** + * Stop the poll loop and emit any bytes of the final batched output that + * were not already streamed. Emitting the suffix past the streamed byte + * offset both dedupes the final batch and guarantees full coverage when + * the tail loop degraded mid-run. + */ + finish(finalBatch: { stdout: string; stderr: string }): Promise; + /** Stop the poll loop without emitting anything further (error path). */ + abort(): Promise; +} + +export interface SandboxRunLogTailFactory { + create(): SandboxRunLogTailHandle; +} + +export interface SandboxRunLogTailFactoryOptions { + runner: CommandManagedRuntimeRunner; + remoteCwd: string; + /** Remote directory the log files live in (bridge queue `logs/` dir). */ + logsDir: string; + shellCommand?: "bash" | "sh" | null; + pollIntervalMs?: number | null; + maxChunkBytesPerTick?: number | null; + tickTimeoutMs?: number | null; + maxConsecutiveFailures?: number | null; +} + +function normalizePositiveInt(value: number | null | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.trunc(value) + : fallback; +} + +interface TailStreamState { + stream: "stdout" | "stderr"; + logFile: string; + offset: number; + decoder: StringDecoder; +} + +function decodeBase64Section(lines: string[]): Buffer { + const joined = lines.join("").replace(/\s+/g, ""); + if (joined.length === 0) return Buffer.alloc(0); + return Buffer.from(joined, "base64"); +} + +export function createSandboxRunLogTailFactory( + options: SandboxRunLogTailFactoryOptions, +): SandboxRunLogTailFactory { + const shellCommand = preferredShellForSandbox(options.shellCommand); + const pollIntervalMs = normalizePositiveInt(options.pollIntervalMs, DEFAULT_TAIL_POLL_INTERVAL_MS); + const maxChunkBytes = normalizePositiveInt(options.maxChunkBytesPerTick, DEFAULT_TAIL_MAX_CHUNK_BYTES); + const tickTimeoutMs = normalizePositiveInt(options.tickTimeoutMs, DEFAULT_TAIL_TICK_TIMEOUT_MS); + const maxConsecutiveFailures = normalizePositiveInt( + options.maxConsecutiveFailures, + DEFAULT_TAIL_MAX_CONSECUTIVE_FAILURES, + ); + + let sequence = 0; + + function createHandle(): SandboxRunLogTailHandle { + sequence += 1; + const baseName = `run-${sequence}`; + const stdoutLog = path.posix.join(options.logsDir, `${baseName}-stdout.log`); + const stderrLog = path.posix.join(options.logsDir, `${baseName}-stderr.log`); + const statusFile = path.posix.join(options.logsDir, `${baseName}-status`); + + const streams: [TailStreamState, TailStreamState] = [ + { stream: "stdout", logFile: stdoutLog, offset: 0, decoder: new StringDecoder("utf8") }, + { stream: "stderr", logFile: stderrLog, offset: 0, decoder: new StringDecoder("utf8") }, + ]; + + let sink: SandboxRunLogSink | null = null; + let stopped = false; + let degraded = false; + let loopPromise: Promise | null = null; + let wakeSleep: (() => void) | null = null; + + function sleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + wakeSleep = null; + resolve(); + }, ms); + wakeSleep = () => { + clearTimeout(timer); + wakeSleep = null; + resolve(); + }; + }); + } + + function buildTickScript(): string { + const lines: string[] = [`printf '%s\\n' ${shellQuote(TAIL_MARKER_STDOUT)}`]; + for (const state of streams) { + if (state.stream === "stderr") { + lines.push(`printf '%s\\n' ${shellQuote(TAIL_MARKER_STDERR)}`); + } + lines.push( + `if [ -f ${shellQuote(state.logFile)} ]; then tail -c +${state.offset + 1} ${shellQuote(state.logFile)} | head -c ${maxChunkBytes} | base64; fi`, + ); + } + lines.push(`printf '%s\\n' ${shellQuote(TAIL_MARKER_END)}`); + return lines.join("\n"); + } + + function parseTickOutput(stdout: string): { stdout: Buffer; stderr: Buffer } | null { + const lines = stdout.split(/\r?\n/); + const stdoutIndex = lines.indexOf(TAIL_MARKER_STDOUT); + const stderrIndex = lines.indexOf(TAIL_MARKER_STDERR); + const endIndex = lines.indexOf(TAIL_MARKER_END); + if (stdoutIndex < 0 || stderrIndex < stdoutIndex || endIndex < stderrIndex) { + return null; + } + return { + stdout: decodeBase64Section(lines.slice(stdoutIndex + 1, stderrIndex)), + stderr: decodeBase64Section(lines.slice(stderrIndex + 1, endIndex)), + }; + } + + async function emitBytes(state: TailStreamState, bytes: Buffer): Promise { + if (bytes.length === 0) return; + state.offset += bytes.length; + const text = state.decoder.write(bytes); + if (text.length > 0 && sink) { + await sink(state.stream, text); + } + } + + async function tick(): Promise { + const result = await options.runner.execute({ + command: shellCommand, + args: shellCommandArgs(buildTickScript()), + cwd: options.remoteCwd, + env: { [SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE }, + timeoutMs: tickTimeoutMs, + }); + if (result.timedOut || (result.exitCode ?? 1) !== 0) { + throw new Error( + `Run log tail tick failed (exit ${result.exitCode ?? "null"}${result.timedOut ? ", timed out" : ""}).`, + ); + } + const sections = parseTickOutput(result.stdout); + if (!sections) { + throw new Error("Run log tail tick returned unparseable output."); + } + await emitBytes(streams[0], sections.stdout); + await emitBytes(streams[1], sections.stderr); + } + + async function loop(): Promise { + let consecutiveFailures = 0; + while (!stopped) { + await sleep(pollIntervalMs); + if (stopped) break; + try { + await tick(); + consecutiveFailures = 0; + } catch { + consecutiveFailures += 1; + if (consecutiveFailures >= maxConsecutiveFailures) { + degraded = true; + break; + } + } + } + } + + async function stopLoop(): Promise { + stopped = true; + wakeSleep?.(); + if (loopPromise) { + await loopPromise.catch(() => undefined); + loopPromise = null; + } + } + + return { + wrapCommand(command, args) { + const quotedInvocation = [command, ...args].map(shellQuote).join(" "); + // Tee stdout/stderr into tailable log files while keeping both + // streams flowing to the provider result. fd 3 carries the real + // stdout out of the inner group so stderr can ride the inner pipe + // into its own tee. The exit status survives the pipeline through + // the status file. + const script = [ + `out_log=${shellQuote(stdoutLog)}`, + `err_log=${shellQuote(stderrLog)}`, + `status_file=${shellQuote(statusFile)}`, + `mkdir -p ${shellQuote(options.logsDir)}`, + `: > "$out_log"`, + `: > "$err_log"`, + `rm -f "$status_file"`, + `{`, + ` { ${quotedInvocation} 3>&-; printf '%s' "$?" > "$status_file"; } 2>&1 1>&3 | tee -a "$err_log" >&2`, + `} 3>&1 | tee -a "$out_log"`, + `if [ -s "$status_file" ]; then exit "$(cat "$status_file")"; fi`, + `exit 1`, + ].join("\n"); + return { command: shellCommand, args: shellCommandArgs(script) }; + }, + start(onLog) { + if (loopPromise || stopped) return; + sink = onLog; + loopPromise = loop(); + }, + async finish(finalBatch) { + await stopLoop(); + if (!sink) return; + for (const state of streams) { + const finalBytes = Buffer.from( + state.stream === "stdout" ? finalBatch.stdout : finalBatch.stderr, + "utf8", + ); + if (finalBytes.length > state.offset) { + const text = state.decoder.write(finalBytes.subarray(state.offset)); + if (text.length > 0) { + await sink(state.stream, text); + } + } + const rest = state.decoder.end(); + if (rest.length > 0) { + await sink(state.stream, rest); + } + } + if (degraded) { + await sink( + "stderr", + "[paperclip] Run log streaming degraded during the run; remaining output was delivered at completion.\n", + ); + } + }, + async abort() { + await stopLoop(); + }, + }; + } + + return { create: createHandle }; +} diff --git a/packages/adapters/acpx-local/src/index.ts b/packages/adapters/acpx-local/src/index.ts index ead70e617c7..82980691661 100644 --- a/packages/adapters/acpx-local/src/index.ts +++ b/packages/adapters/acpx-local/src/index.ts @@ -25,10 +25,14 @@ Adapter: acpx_local Use when: - The agent should run through Agent Client Protocol via ACPX on the Paperclip host or a managed execution environment. - You want one built-in adapter that can target Claude, Codex, or a custom ACP server command. -- You need Paperclip-managed session identity and live streamed ACP events in later ACPX runtime phases. +- You want the richest live run feedback. acpx_local streams structured JSONL events (acpx.session, acpx.status, acpx.text_delta, acpx.tool_call, acpx.result, acpx.error) that the UI renders as live message, thinking, tool, and status blocks. +- The agent runs on a sandbox execution target. Sandbox run logs stream live, so + acpx_local's granular events make remote runs as observable as local ones. + Prefer acpx_local for sandbox workers, especially when watching progress in + real time. Don't use when: -- You need today's stable Claude Code or Codex CLI wrapper behavior. Use claude_local or codex_local until acpx_local runtime execution is enabled. +- You depend on CLI-wrapper-specific behavior of claude_local or codex_local (their session files, CLI flags, or CLI-version-specific output). - The host cannot satisfy ACPX's Node >=22.12.0 prerequisite. - The agent runtime is not an ACP server and cannot be launched through ACPX. diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index d465b02c2e1..dc56b35d03a 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -1096,6 +1096,8 @@ async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeE return; } if (event.type === "tool_call") { + const eventRecord = event as Record; + const toolInput = eventRecord.input; await emitAcpxLog(ctx, { type: "acpx.tool_call", name: event.title ?? "acp_tool", @@ -1103,6 +1105,7 @@ async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeE status: event.status, text: event.text, tag: event.tag, + ...(toolInput !== undefined ? { input: toolInput } : {}), }); return; } diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts b/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts index 80fb2671600..932ffbe74ef 100644 --- a/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts +++ b/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts @@ -96,6 +96,60 @@ describe("parseAcpxStdoutLine", () => { ]); }); + it("merges explicit tool_call input payload with status text", () => { + const entries = parseAcpxStdoutLine( + emit({ + type: "acpx.tool_call", + name: "read", + toolCallId: "tool-3", + status: "in_progress", + text: "reading README.md", + input: { file: "README.md" }, + }), + TS, + ); + expect(entries).toEqual([ + { + kind: "tool_call", + ts: TS, + name: "read", + toolUseId: "tool-3", + input: { file: "README.md", status: "in_progress", text: "reading README.md" }, + }, + ]); + }); + + it("keeps terminal tool_call status while preserving existing input", () => { + const entries = parseAcpxStdoutLine( + emit({ + type: "acpx.tool_call", + name: "read", + toolCallId: "tool-4", + status: "completed", + text: "ok", + input: { file: "README.md", status: "running" }, + }), + TS, + ); + expect(entries).toEqual([ + { + kind: "tool_call", + ts: TS, + name: "read", + toolUseId: "tool-4", + input: { file: "README.md", status: "running", text: "ok" }, + }, + { + kind: "tool_result", + ts: TS, + toolUseId: "tool-4", + toolName: "read", + content: "ok", + isError: false, + }, + ]); + }); + it("emits a paired tool_result entry when a tool_call reports terminal status", () => { const completed = parseAcpxStdoutLine( emit({ diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.ts b/packages/adapters/acpx-local/src/ui/parse-stdout.ts index 019e8f33229..1818a51a4c1 100644 --- a/packages/adapters/acpx-local/src/ui/parse-stdout.ts +++ b/packages/adapters/acpx-local/src/ui/parse-stdout.ts @@ -36,6 +36,11 @@ function pickToolUseId(parsed: Record): string { ); } +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + function statusText(parsed: Record): string { const text = asString(parsed.text).trim(); const tag = asString(parsed.tag).trim(); @@ -86,12 +91,19 @@ export function parseAcpxStdoutLine(line: string, ts: string): TranscriptEntry[] const text = asString(parsed.text); const name = asString(parsed.name, "acp_tool"); const toolUseId = pickToolUseId(parsed); - const input = - parsed.input !== undefined - ? parsed.input - : text || status - ? { ...(text ? { text } : {}), ...(status ? { status } : {}) } - : {}; + const parsedInput = parsed.input; + const input = (() => { + if (parsedInput === undefined) { + return text || status ? { ...(text ? { text } : {}), ...(status ? { status } : {}) } : {}; + } + const inputRecord = asRecord(parsedInput); + if (!inputRecord) return parsedInput; + return { + ...inputRecord, + ...(status && inputRecord.status === undefined ? { status } : {}), + ...(text && inputRecord.text === undefined ? { text } : {}), + }; + })(); const entries: TranscriptEntry[] = [ { kind: "tool_call", diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 2fd71a6c1b3..3bfec50c621 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -793,6 +793,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise parseClaudeStreamJson(stdout).resultJson !== null, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 193febc6de3..ae5e7591257 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -893,6 +893,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { ...run, currentStatusMessage: "Syncing workspace to sandbox", currentStatusUpdatedAt: new Date("2026-04-10T09:30:05.000Z"), + currentToolName: "bash", + lastAssistantSnippet: "Inspecting files", + lastEventAt: new Date("2026-04-10T09:30:06.000Z"), })); const res = await requestApp( @@ -331,6 +334,9 @@ describe("agent live run routes", () => { expect(res.body).toMatchObject({ currentStatusMessage: "Syncing workspace to sandbox", currentStatusUpdatedAt: "2026-04-10T09:30:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting files", + lastEventAt: "2026-04-10T09:30:06.000Z", }); }); diff --git a/server/src/__tests__/heartbeat-runtime-state.test.ts b/server/src/__tests__/heartbeat-runtime-state.test.ts index e6c182b3ce1..24ae9173f1b 100644 --- a/server/src/__tests__/heartbeat-runtime-state.test.ts +++ b/server/src/__tests__/heartbeat-runtime-state.test.ts @@ -148,6 +148,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { const status = await heartbeat.recordRuntimeProgress(run, { phase: "config_sync", message: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: "2026-06-24T00:00:05.000Z", }, issueId); expect(status).toMatchObject({ @@ -157,6 +160,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { runId, phase: "config_sync", message: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), }); expect(heartbeat.decorateActiveRunStatus({ id: runId, @@ -166,6 +172,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { status: "running", })).toMatchObject({ currentStatusMessage: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), }); expect(liveEvents).toContainEqual(expect.objectContaining({ companyId, @@ -176,6 +185,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { issueId, phase: "config_sync", message: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: "2026-06-24T00:00:05.000Z", }), })); diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 05878ed2768..0fe977fca5b 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -75,6 +75,7 @@ const fakeSandboxEnvironmentConfigSchema = z.object({ .min(1, "Fake sandbox environments require an image.") .default("ubuntu:24.04"), reuseLease: z.boolean().optional().default(false), + streamRunLogs: z.boolean().optional(), }).strict(); const pluginSandboxProviderKeySchema = z.string() @@ -89,6 +90,7 @@ const pluginSandboxEnvironmentConfigSchema = z.object({ provider: pluginSandboxProviderKeySchema, timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(), reuseLease: z.boolean().optional().default(false), + streamRunLogs: z.boolean().optional(), }).catchall(z.unknown()); const pluginEnvironmentConfigSchema = z.object({ diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 0e50bd7b6a8..c88c25cc3fb 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -73,6 +73,10 @@ export async function resolveEnvironmentExecutionTarget(input: { environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, timeoutMs, + // Run-log streaming defaults ON for sandbox environments so agent CLI + // output reaches the UI mid-run; `streamRunLogs: false` is an explicit + // opt-out back to batch-at-end delivery. + streamRunLogs: parsed.config.streamRunLogs !== false, runner: input.environmentRuntime && input.lease ? { supportsSingleStreamStdinProgress: false, diff --git a/server/src/services/heartbeat-run-runtime-status.test.ts b/server/src/services/heartbeat-run-runtime-status.test.ts index 457ce76804b..f603900127c 100644 --- a/server/src/services/heartbeat-run-runtime-status.test.ts +++ b/server/src/services/heartbeat-run-runtime-status.test.ts @@ -6,6 +6,7 @@ import { MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS, setHeartbeatRunRuntimeStatus, sweepExpiredHeartbeatRunRuntimeStatuses, + touchHeartbeatRunRuntimeStatus, } from "./heartbeat-run-runtime-status.js"; describe("heartbeat run runtime status store", () => { @@ -22,11 +23,17 @@ describe("heartbeat run runtime status store", () => { runId: "run-1", phase: "config_sync", message: `Syncing workspace with apiKey: "sk-test-secret" ${"x".repeat(300)}`, + currentToolName: `bash apiKey: "sk-tool-secret" ${"x".repeat(120)}`, + lastAssistantSnippet: `Reading apiKey: "sk-snippet-secret" ${"x".repeat(300)}`, + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), updatedAt, }); expect(status?.message).toContain("***REDACTED***"); expect(status?.message.length).toBeLessThanOrEqual(MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS); + expect(status?.currentToolName).toContain("***REDACTED***"); + expect(status?.lastAssistantSnippet).toContain("***REDACTED***"); + expect(status?.lastEventAt).toEqual(new Date("2026-06-24T00:00:05.000Z")); expect(getHeartbeatRunRuntimeStatus("run-1", { companyId: "company-1", issueId: "issue-1", @@ -38,6 +45,9 @@ describe("heartbeat run runtime status store", () => { agentId: "agent-1", runId: "run-1", phase: "config_sync", + currentToolName: expect.stringContaining("***REDACTED***"), + lastAssistantSnippet: expect.stringContaining("***REDACTED***"), + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), }); expect(getHeartbeatRunRuntimeStatus("run-1", { companyId: "other-company" })).toBeNull(); expect(getHeartbeatRunRuntimeStatus("run-1", { @@ -61,6 +71,101 @@ describe("heartbeat run runtime status store", () => { expect(getHeartbeatRunRuntimeStatus("run-1")).toBeNull(); }); + it("touch refreshes timestamps while preserving the existing status context", () => { + setHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + phase: "run_activity", + message: "Using Bash", + currentToolName: "Bash", + lastAssistantSnippet: "Running the tests", + updatedAt: new Date("2026-06-24T00:00:00.000Z"), + lastEventAt: new Date("2026-06-24T00:00:00.000Z"), + }); + + const touched = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:00:45.000Z"), + }); + + expect(touched).toMatchObject({ + runId: "run-1", + phase: "run_activity", + message: "Using Bash", + currentToolName: "Bash", + lastAssistantSnippet: "Running the tests", + updatedAt: new Date("2026-06-24T00:00:45.000Z"), + lastEventAt: new Date("2026-06-24T00:00:45.000Z"), + }); + expect(getHeartbeatRunRuntimeStatus("run-1", { + companyId: "company-1", + now: new Date("2026-06-24T00:02:00.000Z"), + })).toMatchObject({ message: "Using Bash" }); + }); + + it("touch does not move timestamps backwards", () => { + setHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: null, + agentId: "agent-1", + runId: "run-1", + phase: "run_activity", + message: "Using Bash", + updatedAt: new Date("2026-06-24T00:01:00.000Z"), + lastEventAt: new Date("2026-06-24T00:01:00.000Z"), + }); + + const touched = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: null, + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:00:30.000Z"), + }); + + expect(touched).toMatchObject({ + updatedAt: new Date("2026-06-24T00:01:00.000Z"), + lastEventAt: new Date("2026-06-24T00:01:00.000Z"), + }); + }); + + it("touch creates a fallback run_activity status when none is live", () => { + const created = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:00:00.000Z"), + }); + + expect(created).toMatchObject({ + runId: "run-1", + phase: "run_activity", + message: "Receiving agent output", + updatedAt: new Date("2026-06-24T00:00:00.000Z"), + lastEventAt: new Date("2026-06-24T00:00:00.000Z"), + }); + + // An expired entry is replaced with a fresh fallback rather than revived. + const expiredTouch = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:05:00.000Z"), + }); + expect(expiredTouch).toMatchObject({ + phase: "run_activity", + message: "Receiving agent output", + updatedAt: new Date("2026-06-24T00:05:00.000Z"), + }); + }); + it("sweeps expired statuses without touching fresh entries", () => { setHeartbeatRunRuntimeStatus({ companyId: "company-1", diff --git a/server/src/services/heartbeat-run-runtime-status.ts b/server/src/services/heartbeat-run-runtime-status.ts index fd198751f47..d2995337e23 100644 --- a/server/src/services/heartbeat-run-runtime-status.ts +++ b/server/src/services/heartbeat-run-runtime-status.ts @@ -3,6 +3,8 @@ import { redactSensitiveText } from "../redaction.js"; export const HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS = 90_000; export const MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS = 180; +export const MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS = 80; +export const MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS = 220; export interface HeartbeatRunRuntimeStatus { companyId: string; @@ -12,6 +14,9 @@ export interface HeartbeatRunRuntimeStatus { phase: HeartbeatRunStatusPhase; message: string; updatedAt: Date; + currentToolName: string | null; + lastAssistantSnippet: string | null; + lastEventAt: Date | null; } const runtimeStatusesByRunId = new Map(); @@ -20,14 +25,27 @@ function cloneStatus(status: HeartbeatRunRuntimeStatus): HeartbeatRunRuntimeStat return { ...status, updatedAt: new Date(status.updatedAt), + lastEventAt: status.lastEventAt ? new Date(status.lastEventAt) : null, }; } -export function sanitizeHeartbeatRunRuntimeStatusMessage(message: string): string { - const normalized = message.replace(/\s+/g, " ").trim(); +function sanitizeRuntimeStatusText(value: string, maxChars: number): string { + const normalized = value.replace(/\s+/g, " ").trim(); const redacted = redactSensitiveText(normalized); - if (redacted.length <= MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS) return redacted; - return `${redacted.slice(0, MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS - 3)}...`; + if (redacted.length <= maxChars) return redacted; + return `${redacted.slice(0, maxChars - 3)}...`; +} + +export function sanitizeHeartbeatRunRuntimeStatusMessage(message: string): string { + return sanitizeRuntimeStatusText(message, MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS); +} + +export function sanitizeHeartbeatRunRuntimeToolName(toolName: string): string { + return sanitizeRuntimeStatusText(toolName, MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS); +} + +export function sanitizeHeartbeatRunRuntimeAssistantSnippet(snippet: string): string { + return sanitizeRuntimeStatusText(snippet, MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS); } function isExpired(status: HeartbeatRunRuntimeStatus, now: Date, ttlMs: number) { @@ -35,9 +53,15 @@ function isExpired(status: HeartbeatRunRuntimeStatus, now: Date, ttlMs: number) } export function setHeartbeatRunRuntimeStatus( - input: Omit & { + input: Omit< + HeartbeatRunRuntimeStatus, + "message" | "updatedAt" | "currentToolName" | "lastAssistantSnippet" | "lastEventAt" + > & { message: string; updatedAt?: Date; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: Date | null; }, ): HeartbeatRunRuntimeStatus | null { const message = sanitizeHeartbeatRunRuntimeStatusMessage(input.message); @@ -54,11 +78,62 @@ export function setHeartbeatRunRuntimeStatus( phase: input.phase, message, updatedAt: input.updatedAt ? new Date(input.updatedAt) : new Date(), + currentToolName: input.currentToolName + ? sanitizeHeartbeatRunRuntimeToolName(input.currentToolName) + : null, + lastAssistantSnippet: input.lastAssistantSnippet + ? sanitizeHeartbeatRunRuntimeAssistantSnippet(input.lastAssistantSnippet) + : null, + lastEventAt: input.lastEventAt ? new Date(input.lastEventAt) : null, }; runtimeStatusesByRunId.set(status.runId, status); return cloneStatus(status); } +/** + * Refresh the activity timestamps of an existing runtime status without + * discarding its message/tool context, so streamed run-log output keeps the + * "Working... / X ago" line fresh between structured events. When no live + * status exists (first output, or the previous one expired past the TTL), a + * fallback `run_activity` status is created instead. + */ +export function touchHeartbeatRunRuntimeStatus(input: { + companyId: string; + issueId: string | null; + agentId: string; + runId: string; + at?: Date; + fallbackPhase?: HeartbeatRunStatusPhase; + fallbackMessage?: string; +}): HeartbeatRunRuntimeStatus | null { + const at = input.at ?? new Date(); + const existing = runtimeStatusesByRunId.get(input.runId); + if ( + existing && + !isExpired(existing, at, HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS) && + existing.companyId === input.companyId && + existing.agentId === input.agentId + ) { + if (at.getTime() > existing.updatedAt.getTime()) { + existing.updatedAt = new Date(at); + } + if (!existing.lastEventAt || at.getTime() > existing.lastEventAt.getTime()) { + existing.lastEventAt = new Date(at); + } + return cloneStatus(existing); + } + return setHeartbeatRunRuntimeStatus({ + companyId: input.companyId, + issueId: input.issueId, + agentId: input.agentId, + runId: input.runId, + phase: input.fallbackPhase ?? "run_activity", + message: input.fallbackMessage ?? "Receiving agent output", + updatedAt: at, + lastEventAt: at, + }); +} + export function getHeartbeatRunRuntimeStatus( runId: string, expected?: { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bd963ada977..00f9f3ea264 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -204,8 +204,11 @@ import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; import { clearHeartbeatRunRuntimeStatus, getHeartbeatRunRuntimeStatus, + MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS, + MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS, setHeartbeatRunRuntimeStatus, sweepExpiredHeartbeatRunRuntimeStatuses, + touchHeartbeatRunRuntimeStatus, } from "./heartbeat-run-runtime-status.js"; import { assertLowTrustRuntimeServicesAllowed, @@ -266,6 +269,7 @@ export { ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS, } from "./recovery/service.js"; export const ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS = 60 * 1000; +export const ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS = 5 * 1000; export const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS = [ 2 * 60 * 1000, 10 * 60 * 1000, @@ -3985,6 +3989,9 @@ function decorateHeartbeatRunRuntimeStatus, update: RuntimeStatusUpdate, @@ -4023,24 +4062,121 @@ function recordHeartbeatRunRuntimeProgress( runId: run.id, phase: update.phase as HeartbeatRunStatusPhase, message: update.message, + currentToolName: readNonEmptyString(update.currentToolName) ?? null, + lastAssistantSnippet: readNonEmptyString(update.lastAssistantSnippet) ?? null, + lastEventAt: update.lastEventAt ? new Date(update.lastEventAt) : new Date(), }); if (!status) return null; - publishLiveEvent({ - companyId: status.companyId, - type: "heartbeat.run.progress", - payload: { - runId: status.runId, - agentId: status.agentId, - issueId: status.issueId, - phase: status.phase, - message: status.message, - updatedAt: status.updatedAt.toISOString(), - }, - }); + publishHeartbeatRunRuntimeProgress(status); return status; } +function sanitizeLiveRunProgressText(value: string, maxChars: number): string | null { + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) return null; + const redacted = redactSensitiveText(normalized); + if (redacted.length <= maxChars) return redacted; + return `${redacted.slice(0, maxChars - 3)}...`; +} + +function readLiveRunProgressString(value: unknown, maxChars: number): string | null { + return typeof value === "string" ? sanitizeLiveRunProgressText(value, maxChars) : null; +} + +function readFirstLiveRunProgressString(maxChars: number, values: unknown[]): string | null { + for (const value of values) { + const text = readLiveRunProgressString(value, maxChars); + if (text) return text; + } + return null; +} + +function readLiveRunToolName(payload: Record | null, eventType: string): string | null { + const toolCall = parseObject(payload?.tool_call) ?? parseObject(payload?.toolCall); + const message = parseObject(payload?.message); + const direct = readFirstLiveRunProgressString(MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS, [ + payload?.toolName, + payload?.tool_name, + payload?.tool, + payload?.name, + payload?.title, + toolCall?.name, + toolCall?.toolName, + message?.name, + message?.toolName, + ]); + if (direct) return direct; + + const normalizedEventType = eventType.toLowerCase(); + if (!normalizedEventType.includes("tool")) return null; + return readLiveRunProgressString(eventType.replace(/[._-]+/g, " "), MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS); +} + +function readLiveRunAssistantSnippet( + payload: Record | null, + eventType: string, + message: string | null, +): string | null { + const normalizedEventType = eventType.toLowerCase(); + const messagePayload = parseObject(payload?.message); + const direct = readFirstLiveRunProgressString(MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS, [ + payload?.text, + payload?.delta, + payload?.text_delta, + payload?.content, + payload?.summary, + messagePayload?.text, + messagePayload?.content, + ]); + if (direct) return direct; + + if ( + normalizedEventType.includes("assistant") || + normalizedEventType.includes("text_delta") || + normalizedEventType.includes("message.delta") || + normalizedEventType.includes("message_delta") + ) { + return message ? sanitizeLiveRunProgressText(message, MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS) : null; + } + + return null; +} + +function buildRunEventRuntimeProgress(input: { + eventType: string; + message: string | null; + payload: Record | null; + at: Date; +}) { + const normalizedEventType = input.eventType.toLowerCase(); + if (normalizedEventType === "lifecycle" || normalizedEventType === "adapter.invoke") { + return null; + } + + const currentToolName = readLiveRunToolName(input.payload, input.eventType); + const lastAssistantSnippet = readLiveRunAssistantSnippet(input.payload, input.eventType, input.message); + const fallbackMessage = + readLiveRunProgressString(input.message, MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS) ?? + sanitizeLiveRunProgressText( + input.eventType.replace(/[._-]+/g, " "), + MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS, + ); + const message = + currentToolName + ? `Using ${currentToolName}` + : lastAssistantSnippet ?? fallbackMessage; + + if (!message) return null; + return { + phase: "run_activity" as const, + message, + currentToolName, + lastAssistantSnippet, + lastEventAt: input.at, + }; +} + export function buildPaperclipTaskMarkdown(input: { issue: { id: string; @@ -6552,6 +6688,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload?: Record; }, ) { + const eventAt = new Date(); const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); const sanitizedMessage = event.message ? redactCurrentUserText(event.message, currentUserRedactionOptions) @@ -6563,6 +6700,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const sanitizedPayload = secretSanitizedPayload ? redactCurrentUserValue(secretSanitizedPayload, currentUserRedactionOptions) : secretSanitizedPayload; + const issueId = readRuntimeStatusIssueIdCandidate(run) ?? null; + const progress = buildRunEventRuntimeProgress({ + eventType: event.eventType, + message: sanitizedMessage ?? null, + payload: sanitizedPayload ?? null, + at: eventAt, + }); await db.insert(heartbeatRunEvents).values({ companyId: run.companyId, @@ -6583,15 +6727,34 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload: { runId: run.id, agentId: run.agentId, + issueId, seq, eventType: event.eventType, stream: event.stream ?? null, level: event.level ?? null, color: event.color ?? null, message: sanitizedMessage ?? null, + currentToolName: progress?.currentToolName ?? null, + lastAssistantSnippet: progress?.lastAssistantSnippet ?? null, + lastEventAt: (progress?.lastEventAt ?? eventAt).toISOString(), payload: sanitizedPayload ?? null, }, }); + if (progress && isHeartbeatRunRuntimeStatusActive(run.status)) { + const status = setHeartbeatRunRuntimeStatus({ + companyId: run.companyId, + issueId, + agentId: run.agentId, + runId: run.id, + phase: progress.phase, + message: progress.message, + updatedAt: eventAt, + currentToolName: progress.currentToolName, + lastAssistantSnippet: progress.lastAssistantSnippet, + lastEventAt: progress.lastEventAt, + }); + if (status) publishHeartbeatRunRuntimeProgress(status); + } } async function nextRunEventSeq(runId: string) { @@ -10489,6 +10652,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let stderrExcerpt = ""; let outputSeq = Number(run.lastOutputSeq ?? 0); let lastOutputFlushAt: Date | null = run.lastOutputAt ?? null; + let lastLogRuntimeStatusTouchMs = 0; const outputProgressState: { pending: { at: Date; @@ -10629,6 +10793,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; await flushOutputProgress(); + // Streamed CLI output is real run activity: keep the in-memory + // runtime status ("Working... / X ago") fresh between structured + // events so sandbox runs with mid-run log streaming never show a + // minutes-stale timestamp. Throttled to avoid churning the live + // event stream on every 250ms tail chunk. + const logActivityAt = new Date(ts); + if ( + isHeartbeatRunRuntimeStatusActive(run.status) && + logActivityAt.getTime() - lastLogRuntimeStatusTouchMs >= + ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS + ) { + lastLogRuntimeStatusTouchMs = logActivityAt.getTime(); + const touchedStatus = touchHeartbeatRunRuntimeStatus({ + companyId: run.companyId, + issueId, + agentId: run.agentId, + runId: run.id, + at: logActivityAt, + }); + if (touchedStatus) publishHeartbeatRunRuntimeProgress(touchedStatus); + } + const payloadChunk = sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES ? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES) @@ -10640,6 +10826,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload: { runId: run.id, agentId: run.agentId, + issueId, ts, stream, chunk: payloadChunk, diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 9153bc4503a..c3a9f5ac6ff 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -38,6 +38,9 @@ export interface ActiveRunForIssue { outputSilence?: HeartbeatRun["outputSilence"]; currentStatusMessage?: string | null; currentStatusUpdatedAt?: string | Date | null; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: string | Date | null; } export interface LiveRunForIssue { @@ -64,6 +67,9 @@ export interface LiveRunForIssue { outputSilence?: HeartbeatRun["outputSilence"]; currentStatusMessage?: string | null; currentStatusUpdatedAt?: string | null; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: string | null; } export interface WatchdogDecisionInput { diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 29285804d0f..21d6213be90 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -3464,6 +3464,8 @@ describe("IssueChatThread", () => { adapterType: "codex_local", currentStatusMessage: "Syncing git worktree to sandbox", currentStatusUpdatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastEventAt: new Date(Date.now() - 2000).toISOString(), }} onAdd={async () => {}} enableLiveTranscriptPolling={false} @@ -3473,8 +3475,9 @@ describe("IssueChatThread", () => { }); expect(container.textContent).toContain("Working..."); - expect(container.textContent).toContain("Syncing git worktree to sandbox"); - expect(container.querySelector('[title="Syncing git worktree to sandbox"]')).not.toBeNull(); + expect(container.textContent).toContain("Using bash"); + expect(container.textContent).not.toContain("last activity"); + expect(container.textContent).toMatch(/\d+ seconds? ago/); act(() => { root.unmount(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 3aafbf53bef..ab9d88df6e1 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -301,6 +301,59 @@ function useLiveElapsed(startMs: number | null | undefined, active: boolean): st return formatDurationWords(Date.now() - startMs); } +function readCustomString(custom: Record, key: string): string { + return typeof custom[key] === "string" ? custom[key].trim() : ""; +} + +function toTimestampOrNull(value: string): number | null { + if (!value) return null; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function IssueChatLiveRunStatusLine({ + custom, + active, + className, +}: { + custom: Record; + active: boolean; + className?: string; +}) { + const currentStatusMessage = readCustomString(custom, "currentStatusMessage"); + const currentToolName = readCustomString(custom, "currentToolName"); + const lastAssistantSnippet = readCustomString(custom, "lastAssistantSnippet"); + const lastEventAt = readCustomString(custom, "lastEventAt"); + const lastEventAtMs = toTimestampOrNull(lastEventAt); + const lastActivityElapsed = useLiveElapsed(lastEventAtMs, active); + const lastActivityAgeMs = lastEventAtMs ? Date.now() - lastEventAtMs : null; + + if (!active) return null; + + const primary = + currentToolName + ? `Using ${currentToolName}` + : lastAssistantSnippet + ? lastAssistantSnippet + : currentStatusMessage; + const activityText = lastActivityElapsed + ? lastActivityAgeMs !== null && lastActivityAgeMs >= 15_000 + ? `no output for ${lastActivityElapsed} - still running` + : `${lastActivityElapsed} ago` + : ""; + const text = [primary, activityText].filter(Boolean).join(" · "); + if (!text) return null; + + return ( + + {text} + + ); +} + function useStableEvent unknown>(callback: T | undefined): T | undefined { const callbackRef = useRef(callback); useLayoutEffect(() => { @@ -873,8 +926,6 @@ function IssueChatChainOfThought({ const rawSegments = Array.isArray(custom.chainOfThoughtSegments) ? (custom.chainOfThoughtSegments as SegmentTiming[]) : []; - const currentStatusMessage = - typeof custom.currentStatusMessage === "string" ? custom.currentStatusMessage.trim() : ""; const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; const isActive = isCoTSegmentActive({ isMessageRunning, @@ -937,14 +988,7 @@ function IssueChatChainOfThought({ · {toolSummary} ) : null}
- {isActive && currentStatusMessage ? ( - - {currentStatusMessage} - - ) : null} +
{hasContent ? ( @@ -1592,8 +1636,6 @@ function IssueChatAssistantMessage({ ? custom.notices.filter((notice): notice is string => typeof notice === "string" && notice.length > 0) : []; const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; - const currentStatusMessage = - typeof custom.currentStatusMessage === "string" ? custom.currentStatusMessage.trim() : ""; const isRunning = message.role === "assistant" && message.status?.type === "running"; const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null; const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running"); @@ -1881,14 +1923,7 @@ function IssueChatAssistantMessage({ {waitingText} - {isRunning && currentStatusMessage ? ( -
- {currentStatusMessage} -
- ) : null} + ) : null} {notices.length > 0 ? ( @@ -4215,6 +4250,9 @@ export function IssueChatThread({ outputSilence: activeRun.outputSilence, currentStatusMessage: activeRun.currentStatusMessage ?? null, currentStatusUpdatedAt: toIsoString(activeRun.currentStatusUpdatedAt), + currentToolName: activeRun.currentToolName ?? null, + lastAssistantSnippet: activeRun.lastAssistantSnippet ?? null, + lastEventAt: toIsoString(activeRun.lastEventAt), }); } return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx index 46f9f3d956f..15e1d5aed19 100644 --- a/ui/src/components/transcript/RunTranscriptView.test.tsx +++ b/ui/src/components/transcript/RunTranscriptView.test.tsx @@ -2,11 +2,107 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { parseAcpxStdoutLine } from "@paperclipai/adapter-acpx-local/ui"; import type { TranscriptEntry } from "../../adapters"; +import { buildTranscript, type RunLogChunk } from "../../adapters"; import { ThemeProvider } from "../../context/ThemeContext"; import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView"; describe("RunTranscriptView", () => { + it("folds repeated tool_call status updates for the same toolUseId into one block", () => { + const entries: TranscriptEntry[] = [ + { + kind: "tool_call", + ts: "2026-03-12T00:00:00.000Z", + name: "read", + toolUseId: "tool-1", + input: { text: "read README.md", status: "pending" }, + }, + { + kind: "tool_call", + ts: "2026-03-12T00:00:01.000Z", + name: "read", + toolUseId: "tool-1", + input: { status: "in_progress" }, + }, + { + kind: "tool_call", + ts: "2026-03-12T00:00:02.000Z", + name: "search", + toolUseId: "tool-2", + input: { text: "grep TODO", status: "pending" }, + }, + { + kind: "tool_result", + ts: "2026-03-12T00:00:03.000Z", + toolUseId: "tool-1", + toolName: "read", + content: "ok", + isError: false, + }, + ]; + + const blocks = normalizeTranscript(entries, false); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "tool_group", + items: [ + { + name: "Read", + status: "completed", + result: "ok", + // Later status updates merge into the original input instead of + // spawning duplicate "Running" cards. + input: { text: "read README.md", status: "in_progress" }, + }, + { name: "Search", status: "running" }, + ], + }); + }); + + it("renders a streamed acpx tool call as a single card once completed", () => { + const ts = "2026-03-12T00:00:00.000Z"; + const jsonLines = [ + { type: "acpx.tool_call", name: "read", toolCallId: "tool-1", status: "pending", text: "read README.md" }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-1", status: "in_progress", text: "read README.md" }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-1", status: "completed", text: "ok" }, + ]; + const chunks: RunLogChunk[] = [ + { ts, stream: "stdout", chunk: jsonLines.map((line) => JSON.stringify(line)).join("\n") + "\n" }, + ]; + + const entries = buildTranscript(chunks, parseAcpxStdoutLine); + const blocks = normalizeTranscript(entries, false); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "tool_group", + items: [{ name: "Read", status: "completed", result: "ok" }], + }); + }); + + it("renders streamed acpx tool calls with input payloads as readable status cards", () => { + const ts = "2026-03-12T00:00:00.000Z"; + const jsonLines = [ + { type: "acpx.tool_call", name: "read", toolCallId: "tool-2", status: "running", input: { file: "README.md" } }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-2", status: "in_progress", text: "opening file", input: { line: 1 } }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-2", status: "completed", text: "ok" }, + ]; + const chunks: RunLogChunk[] = [ + { ts, stream: "stdout", chunk: jsonLines.map((line) => JSON.stringify(line)).join("\n") + "\n" }, + ]; + + const entries = buildTranscript(chunks, parseAcpxStdoutLine); + const blocks = normalizeTranscript(entries, false); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "tool_group", + items: [{ name: "Read", status: "completed", result: "ok" }], + }); + }); + it("keeps running command stdout inside the command fold instead of a standalone stdout block", () => { const entries: TranscriptEntry[] = [ { diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index 52e9f6bfc85..7b7ac350f1f 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -206,6 +206,21 @@ function summarizeRecord(record: Record, keys: string[]): strin return null; } +/** Merge a streamed tool_call status update into the input captured so far. */ +function mergeToolInput(previous: unknown, incoming: unknown): unknown { + if (incoming === null || incoming === undefined) return previous; + if (typeof incoming === "string") { + return incoming.trim().length > 0 ? incoming : previous; + } + const previousRecord = asRecord(previous); + const incomingRecord = asRecord(incoming); + if (incomingRecord) { + if (Object.keys(incomingRecord).length === 0) return previous; + return previousRecord ? { ...previousRecord, ...incomingRecord } : incoming; + } + return incoming; +} + function summarizeToolInput(name: string, input: unknown, density: TranscriptDensity): string { const compactMax = density === "compact" ? 72 : 120; if (typeof input === "string") { @@ -229,7 +244,7 @@ function summarizeToolInput(name: string, input: unknown, density: TranscriptDen const direct = summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"]) - ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"]) + ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool", "text"]) ?? null; if (direct) return truncate(direct, compactMax); @@ -454,11 +469,20 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole } if (entry.kind === "tool_call") { + const toolUseId = entry.toolUseId ?? extractToolUseId(entry.input); + // Streaming runtimes (e.g. ACPX) re-emit the same tool call as its + // status progresses. Fold updates into the existing running card + // instead of stacking duplicate "Running" blocks. + const pending = toolUseId ? pendingToolBlocks.get(toolUseId) : undefined; + if (pending && pending.status === "running") { + pending.input = mergeToolInput(pending.input, entry.input); + continue; + } const toolBlock: Extract = { type: "tool", ts: entry.ts, name: displayToolName(entry.name, entry.input), - toolUseId: entry.toolUseId ?? extractToolUseId(entry.input), + toolUseId, input: entry.input, status: "running", }; diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 1625fbfc681..1fe52db88c3 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -143,6 +143,133 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("applies heartbeat progress payloads directly to cached visible issue runs", () => { + const cache = new Map([ + [JSON.stringify(queryKeys.liveRuns("company-1")), [{ id: "run-1", currentStatusMessage: null }]], + [JSON.stringify(queryKeys.issues.detail("DEMO-759")), { + id: "issue-1", + identifier: "DEMO-759", + assigneeAgentId: "agent-1", + }], + [JSON.stringify(queryKeys.issues.detail("issue-1")), { + id: "issue-1", + identifier: "DEMO-759", + assigneeAgentId: "agent-1", + }], + [JSON.stringify(queryKeys.issues.activeRun("DEMO-759")), { + id: "run-1", + currentStatusMessage: null, + }], + [JSON.stringify(queryKeys.issues.activeRun("issue-1")), { + id: "run-1", + currentStatusMessage: null, + }], + [JSON.stringify(queryKeys.issues.liveRuns("DEMO-759")), [{ id: "run-1", currentStatusMessage: null }]], + [JSON.stringify(queryKeys.issues.liveRuns("issue-1")), [{ id: "run-1", currentStatusMessage: null }]], + [JSON.stringify(queryKeys.issues.runs("DEMO-759")), [{ runId: "run-1" }]], + ]); + const queryClient = { + getQueryData: (key: unknown) => cache.get(JSON.stringify(key)), + setQueryData: (key: unknown, updater: unknown) => { + const cacheKey = JSON.stringify(key); + const current = cache.get(cacheKey); + cache.set(cacheKey, typeof updater === "function" ? updater(current) : updater); + }, + }; + + const changed = __liveUpdatesTestUtils.applyRunLiveStatusPatchToCaches( + queryClient as never, + "company-1", + "/DEMO/issues/DEMO-759", + { + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: "Syncing workspace", + updatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Reading package.json", + lastEventAt: "2026-04-06T12:00:08.000Z", + }, + { isForegrounded: true }, + ); + + expect(changed).toBe(true); + expect(cache.get(JSON.stringify(queryKeys.liveRuns("company-1")))).toEqual([ + expect.objectContaining({ + id: "run-1", + currentStatusMessage: "Syncing workspace", + currentStatusUpdatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Reading package.json", + lastEventAt: "2026-04-06T12:00:08.000Z", + }), + ]); + expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("DEMO-759")))).toMatchObject({ + currentToolName: "bash", + lastAssistantSnippet: "Reading package.json", + }); + expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("issue-1")))).toEqual([ + expect.objectContaining({ + currentStatusMessage: "Syncing workspace", + currentToolName: "bash", + }), + ]); + }); + + it("uses the heartbeat event timestamp for run event status patches", () => { + expect( + __liveUpdatesTestUtils.readRunLiveStatusPatchFromPayload( + { + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: "Tool started", + currentToolName: "bash", + lastAssistantSnippet: "Checking workspace", + }, + "2026-04-06T12:00:09.000Z", + "heartbeat.run.event", + ), + ).toEqual({ + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: "Tool started", + updatedAt: "2026-04-06T12:00:09.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Checking workspace", + lastEventAt: "2026-04-06T12:00:09.000Z", + }); + }); + + it("does not clear run tool context from null heartbeat event fields", () => { + const patch = __liveUpdatesTestUtils.readRunLiveStatusPatchFromPayload( + { + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: null, + currentToolName: null, + lastAssistantSnippet: null, + lastEventAt: "2026-04-06T12:00:10.000Z", + }, + "2026-04-06T12:00:09.000Z", + "heartbeat.run.event", + ); + + expect(patch).toEqual({ + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + updatedAt: "2026-04-06T12:00:09.000Z", + lastEventAt: "2026-04-06T12:00:10.000Z", + }); + expect(patch).not.toHaveProperty("message"); + expect(patch).not.toHaveProperty("currentToolName"); + expect(patch).not.toHaveProperty("lastAssistantSnippet"); + }); + it("refreshes issue document caches when a document activity event arrives", () => { const invalidations: unknown[] = []; const queryClient = { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 74d66d87081..c480f781b8a 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -306,6 +306,164 @@ function invalidateVisibleIssueRunQueries( return true; } +interface RunLiveStatusPatch { + runId: string; + agentId: string | null; + issueId: string | null; + message?: string | null; + updatedAt?: string | null; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: string | null; +} + +function hasPatchKey( + patch: RunLiveStatusPatch, + key: K, +): patch is RunLiveStatusPatch & Required> { + return Object.prototype.hasOwnProperty.call(patch, key); +} + +function applyRunLiveStatusPatch( + run: T, + patch: RunLiveStatusPatch, +): T { + if (run.id !== patch.runId) return run; + return { + ...run, + ...(hasPatchKey(patch, "message") + ? { currentStatusMessage: patch.message } + : {}), + ...(hasPatchKey(patch, "updatedAt") + ? { currentStatusUpdatedAt: patch.updatedAt } + : {}), + ...(hasPatchKey(patch, "currentToolName") + ? { currentToolName: patch.currentToolName } + : {}), + ...(hasPatchKey(patch, "lastAssistantSnippet") + ? { lastAssistantSnippet: patch.lastAssistantSnippet } + : {}), + ...(hasPatchKey(patch, "lastEventAt") + ? { lastEventAt: patch.lastEventAt } + : {}), + }; +} + +function applyRunLiveStatusPatchToArray( + runs: T[] | undefined, + patch: RunLiveStatusPatch, +): T[] | undefined { + if (!runs) return runs; + let changed = false; + const nextRuns = runs.map((run) => { + if (run.id !== patch.runId) return run; + changed = true; + return applyRunLiveStatusPatch(run, patch); + }); + return changed ? nextRuns : runs; +} + +function readRunLiveStatusPatchFromPayload( + payload: Record, + eventCreatedAt: string, + eventType: string, +): RunLiveStatusPatch | null { + const runId = readString(payload.runId); + if (!runId) return null; + + const patch: RunLiveStatusPatch = { + runId, + agentId: readString(payload.agentId), + issueId: readString(payload.issueId), + }; + + if (eventType === "heartbeat.run.progress") { + patch.message = readString(payload.message); + patch.updatedAt = readString(payload.updatedAt) ?? eventCreatedAt; + patch.currentToolName = readString(payload.currentToolName); + patch.lastAssistantSnippet = readString(payload.lastAssistantSnippet); + patch.lastEventAt = readString(payload.lastEventAt) ?? patch.updatedAt; + return patch; + } + + if (eventType === "heartbeat.run.log") { + patch.lastEventAt = readString(payload.ts) ?? eventCreatedAt; + return patch; + } + + if (eventType === "heartbeat.run.event") { + const message = readString(payload.message); + if (message) patch.message = message; + patch.updatedAt = readString(payload.updatedAt) ?? eventCreatedAt; + const currentToolName = readString(payload.currentToolName); + if (currentToolName) patch.currentToolName = currentToolName; + const lastAssistantSnippet = readString(payload.lastAssistantSnippet); + if (lastAssistantSnippet) patch.lastAssistantSnippet = lastAssistantSnippet; + patch.lastEventAt = readString(payload.lastEventAt) ?? eventCreatedAt; + return patch; + } + + return null; +} + +function applyRunLiveStatusPatchToCaches( + queryClient: QueryClient, + companyId: string, + pathname: string, + patch: RunLiveStatusPatch, + options?: VisibleRouteOptions, +): boolean { + let changed = false; + queryClient.setQueryData( + queryKeys.liveRuns(companyId), + (current: LiveRunForIssue[] | undefined) => { + const nextRuns = applyRunLiveStatusPatchToArray(current, patch); + if (nextRuns !== current) changed = true; + return nextRuns; + }, + ); + + const issueRefs = new Set(); + if (patch.issueId) { + for (const ref of resolveIssueQueryRefs(queryClient, companyId, patch.issueId, null)) { + issueRefs.add(ref); + } + } + + const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); + if ( + context && + ( + context.runIds.has(patch.runId) || + (!!patch.issueId && context.issueRefs.has(patch.issueId)) || + (!!patch.agentId && !!context.assigneeAgentId && patch.agentId === context.assigneeAgentId) + ) + ) { + for (const ref of context.issueRefs) issueRefs.add(ref); + } + + for (const issueRef of issueRefs) { + queryClient.setQueryData( + queryKeys.issues.activeRun(issueRef), + (current: ActiveRunForIssue | null | undefined) => { + if (!current || current.id !== patch.runId) return current; + changed = true; + return applyRunLiveStatusPatch(current, patch); + }, + ); + queryClient.setQueryData( + queryKeys.issues.liveRuns(issueRef), + (current: LiveRunForIssue[] | undefined) => { + const nextRuns = applyRunLiveStatusPatchToArray(current, patch); + if (nextRuns !== current) changed = true; + return nextRuns; + }, + ); + } + + return changed; +} + function shouldSuppressAgentStatusToastForVisibleIssue( queryClient: QueryClient, pathname: string, @@ -870,6 +1028,10 @@ function handleLiveEvent( const nameOf = (id: string) => resolveAgentName(queryClient, expectedCompanyId, id); const payload = event.payload ?? {}; + const liveStatusPatch = readRunLiveStatusPatchFromPayload(payload, event.createdAt, event.type); + if (liveStatusPatch) { + applyRunLiveStatusPatchToCaches(queryClient, expectedCompanyId, pathname, liveStatusPatch); + } if (event.type === "heartbeat.run.log") { return; } @@ -979,10 +1141,12 @@ export const __liveUpdatesTestUtils = { buildAgentStatusToast, buildRunStatusToast, closeSocketQuietly, + applyRunLiveStatusPatchToCaches, hydrateVisibleIssueComment, invalidateActivityQueries, invalidateHeartbeatProgressQueries, invalidateVisibleIssueRunQueries, + readRunLiveStatusPatchFromPayload, resolveLiveCompanyId, shouldDeferIssueRefetchForVisibleAgentActivity, shouldDeferVisibleIssueCommentActivity, diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 1f8a5959c13..0dfc2f1e8ad 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -688,6 +688,9 @@ describe("buildIssueChatMessages", () => { adapterType: "codex_local", currentStatusMessage: "Syncing git worktree to sandbox", currentStatusUpdatedAt: "2026-04-06T12:03:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Checking repository status", + lastEventAt: "2026-04-06T12:03:08.000Z", }; const messages = buildIssueChatMessages({ @@ -709,6 +712,9 @@ describe("buildIssueChatMessages", () => { runId: "run-active-1", currentStatusMessage: "Syncing git worktree to sandbox", currentStatusUpdatedAt: "2026-04-06T12:03:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Checking repository status", + lastEventAt: "2026-04-06T12:03:08.000Z", }, }, }); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 78351d6be54..75f7b9a211e 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -871,6 +871,11 @@ function normalizeLiveRuns( currentStatusUpdatedAt: activeRun.currentStatusUpdatedAt ? toDate(activeRun.currentStatusUpdatedAt).toISOString() : null, + currentToolName: activeRun.currentToolName ?? null, + lastAssistantSnippet: activeRun.lastAssistantSnippet ?? null, + lastEventAt: activeRun.lastEventAt + ? toDate(activeRun.lastEventAt).toISOString() + : null, }); } return [...deduped.values()].sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt)); @@ -911,6 +916,9 @@ function createLiveRunMessage(args: { chainOfThoughtSegments: segments, currentStatusMessage: run.currentStatusMessage ?? null, currentStatusUpdatedAt: run.currentStatusUpdatedAt ?? null, + currentToolName: run.currentToolName ?? null, + lastAssistantSnippet: run.lastAssistantSnippet ?? null, + lastEventAt: run.lastEventAt ?? null, }), }; return message; diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 6fd86efef4f..757ab83b334 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1231,6 +1231,16 @@ export function CompanyEnvironments() { This provider does not declare additional configuration fields. )} + + setEnvironmentForm((current) => ({ + ...current, + sandboxConfig: { ...current.sandboxConfig, streamRunLogs: checked }, + }))} + /> ) : null} From a6b7b12fd779de59d3a2fd8663fb85866fb2e432 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:19:14 -0500 Subject: [PATCH 020/107] Harden work timeline security filters (#8923) Squash merge PR #8923. Verified before merge: - PR head: 1d9a8f22916c65b55490571356b64fb87285de6f - GitHub status/check rollup: all completed successfully - Greptile Review: success, 5/5, no review threads - Scope: server/src/services/work-timeline.ts and server/src/__tests__/work-timeline-service.test.ts --- .../__tests__/work-timeline-service.test.ts | 100 ++++++++++++++++++ server/src/services/work-timeline.ts | 45 +++++--- 2 files changed, 131 insertions(+), 14 deletions(-) diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 160d063f5d8..8825b805e10 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -253,6 +253,72 @@ describeEmbeddedPostgres("work timeline aggregation", () => { ])); }); + it("does not join activity rows to runs from another company", async () => { + const { companyId, agentAId } = await seedBase(); + const otherCompanyId = randomUUID(); + const otherAgentId = randomUUID(); + const issueId = randomUUID(); + const foreignRunId = randomUUID(); + + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other Timeline Co", + issuePrefix: `O${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: otherAgentId, + companyId: otherCompanyId, + name: "Foreign Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Local issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-02T10:00:00Z"), + updatedAt: new Date("2026-03-02T10:00:00Z"), + }); + await db.insert(heartbeatRuns).values({ + id: foreignRunId, + companyId: otherCompanyId, + agentId: otherAgentId, + status: "completed", + invocationSource: "manual", + startedAt: new Date("2026-03-02T11:00:00Z"), + finishedAt: new Date("2026-03-02T11:15:00Z"), + contextSnapshot: {}, + }); + await db.insert(activityLog).values({ + companyId, + actorType: "agent", + actorId: agentAId, + action: "issue.updated", + entityType: "issue", + entityId: issueId, + agentId: agentAId, + runId: foreignRunId, + createdAt: new Date("2026-03-02T11:05:00Z"), + }); + + const result = await workTimelineService(db).getTimeline({ + companyId, + from: new Date("2026-03-02T00:00:00Z"), + to: new Date("2026-03-03T00:00:00Z"), + }); + + expect(result.events.map((event) => event.issueId)).toContain(issueId); + expect(result.spans.map((span) => span.runId)).not.toContain(foreignRunId); + }); + it("applies the user lens as a transitive issue subtree", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const rootIssueId = randomUUID(); @@ -345,6 +411,40 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(result.pagination.totalIssues).toBe(1); }); + it("bounds pre-pagination ACL checks", async () => { + const { companyId, agentAId } = await seedBase(); + const issueCount = 40; + await db.insert(issues).values(Array.from({ length: issueCount }, (_, index) => ({ + id: randomUUID(), + companyId, + title: `Visible ${index}`, + status: "todo", + priority: "medium", + assigneeAgentId: agentAId, + createdAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000), + updatedAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000), + }))); + + let activeChecks = 0; + let maxActiveChecks = 0; + const result = await workTimelineService(db).getTimeline({ + companyId, + from: new Date("2026-03-04T00:00:00Z"), + to: new Date("2026-03-05T00:00:00Z"), + limit: issueCount, + canReadIssue: async () => { + activeChecks += 1; + maxActiveChecks = Math.max(maxActiveChecks, activeChecks); + await new Promise((resolve) => setTimeout(resolve, 1)); + activeChecks -= 1; + return true; + }, + }); + + expect(result.pagination.totalIssues).toBe(issueCount); + expect(maxActiveChecks).toBeLessThanOrEqual(16); + }); + it("serves GET /api/companies/:companyId/timeline", async () => { const { companyId, agentAId } = await seedBase(); const issueId = randomUUID(); diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 1c32baa70e7..56756b6a57d 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -78,6 +78,7 @@ 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; +const ACL_FILTER_CONCURRENCY = 16; function actorId(type: TimelineActorType, id: string) { return `${type}:${id}`; @@ -132,6 +133,34 @@ function runOverlapsWindow(from: Date, to: Date) { } export function workTimelineService(db: Db) { + async function filterReadableIssues( + rows: IssueRow[], + canReadIssue: NonNullable | undefined, + ) { + if (!canReadIssue) return rows; + + const allowedRows: IssueRow[] = []; + for (let index = 0; index < rows.length; index += ACL_FILTER_CONCURRENCY) { + const batch = rows.slice(index, index + ACL_FILTER_CONCURRENCY); + const decisions = await Promise.all(batch.map(async (issue) => ({ + issue, + allowed: await canReadIssue({ + id: issue.id, + companyId: issue.companyId, + projectId: issue.projectId, + parentId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + status: issue.status, + }), + }))); + for (const decision of decisions) { + if (decision.allowed) allowedRows.push(decision.issue); + } + } + return allowedRows; + } + async function collectIssueIds(input: WorkTimelineQuery, from: Date, to: Date) { const ids = new Set(); @@ -403,20 +432,7 @@ export function workTimelineService(db: Db) { 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 accessibleIssues = await filterReadableIssues(userScopedIssues, input.canReadIssue); 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])); @@ -519,6 +535,7 @@ export function workTimelineService(db: Db) { .where( and( eq(activityLog.companyId, input.companyId), + eq(heartbeatRuns.companyId, input.companyId), eq(activityLog.entityType, "issue"), inArray(activityLog.entityId, readableIssueIds), runOverlapsWindow(from, to), From b5c914126e8c4891bab12515d6e63f0b6d6e70b4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:41:45 -0500 Subject: [PATCH 021/107] Redesign environment variables editor (#8930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents for work. > - Environment variables and secrets sit in the configuration surfaces that let agents, projects, routines, and company environments run with the right runtime inputs. > - The previous editor was a single legacy component with cramped row behavior, weak secret conversion affordances, and duplicated handling across several call sites. > - Operators need a clearer editor that handles text values, secret references, draft rows, and sensitive-value warnings consistently wherever environment variables are configured. > - This pull request replaces the legacy editor with a reusable environment variables editor and migrates the existing configuration surfaces to it. > - The benefit is a more reliable editing workflow with targeted test coverage around row state, dotenv parsing, secret selection, and affected page integrations. ## Linked Issues or Issue Description No public GitHub issue exists, so this PR describes the issue inline following the feature request template. ### Subsystem affected ui/ — React + Vite board UI ### Problem or motivation Environment variables are edited in several Paperclip configuration surfaces, including agent config, project properties, stage secrets, routine sections, company environments, and company settings. The legacy editor made common operator work difficult: rows could feel cramped, secret conversion was inconsistent, draft rows and imported dotenv data were easy to mishandle, and sensitive-value warnings did not have a consistent place in the workflow. ### Proposed solution Introduce a reusable environment variables editor component that consistently supports text values, secret references, draft rows, dotenv import parsing, sensitive-value hints, secret picking, secret creation, and conversion to stored secrets. Migrate the existing environment-variable call sites to the shared editor so behavior and tests live in one component family. ### Alternatives considered Keeping the existing `EnvVarEditor` and patching individual call sites would preserve duplication and leave each surface responsible for its own row and secret handling. This PR instead centralizes the behavior so future fixes cover all migrated surfaces. ### Roadmap alignment Checked `ROADMAP.md`; this does not duplicate a named roadmap item. It supports the existing local-first and deployment-oriented product direction by improving the UI where operators configure runtime environment values. ### Additional context The PR includes targeted tests for the editor model, dotenv parsing, sensitive-value detection, component behavior, affected page integrations, and Greptile review regressions around external saves and bulk import immutability. ## What Changed - Replaced the legacy `EnvVarEditor` with a reusable `environment-variables-editor` component family. - Added editor model helpers for draft rows, dotenv parsing, sensitive-value detection, secret picking, secret creation, and conversion to secret references. - Migrated agent config, project properties, stage secrets, routine editable sections, company environments, company settings, design guide examples, and Storybook stories to the new editor. - Added targeted tests for the editor model, parsing, sensitive-value detection, component behavior, and affected company environment/settings integrations. - Fixed the company settings test harness to use the repo’s `flushSync`-based React test helper pattern under the current React build. - Addressed Greptile feedback by flushing pending editor drafts before enclosing form submits or external save-button clicks, cloning bulk-import rows before mutation, and deferring the overflow store-as-secret popover open path. ## Verification - `pnpm exec vitest run ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx ui/src/pages/CompanyEnvironments.test.tsx ui/src/components/AgentConfigForm.render.test.tsx` — passed, 3 files / 40 tests. - Earlier focused Vitest coverage for model, dotenv parsing, sensitive detection, company environments, and company settings passed, 6 files / 78 tests. - `pnpm --filter @paperclipai/ui typecheck` — passed. - GitHub PR checks on head `0aa49c6f8afed1f62d9e26da07fc466fbf299850` — passed; CI checks green, security review neutral, Greptile check success. - Greptile review — 5/5 confidence on head `0aa49c6f8afed1f62d9e26da07fc466fbf299850`; 0 unresolved review threads. ## Risks - Medium UI risk: several environment-variable entry surfaces now share the new editor, so regressions could affect multiple configuration workflows at once. - Secret conversion, draft-row behavior, external save flushing, and bulk import behavior are covered by targeted tests, but reviewer attention should still focus on manual editing flows, focus retention, and save/cancel affordances. - No database or API contract changes. > 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 with tool use and local command execution; medium reasoning mode. Exact context-window metadata is not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Paperclip --- .../AgentConfigForm.render.test.tsx | 61 +- ui/src/components/AgentConfigForm.tsx | 42 +- ui/src/components/EnvVarEditor.tsx | 425 --------- ui/src/components/ProjectProperties.tsx | 4 +- ui/src/components/SearchableSelect.tsx | 105 ++- ui/src/components/StageSecretsPanel.tsx | 8 +- .../ConvertToSecretPopover.tsx | 4 + .../CreateSecretPopover.tsx | 156 ++++ .../EnvironmentVariablesEditor.test.tsx | 859 ++++++++++++++++++ .../environment-variables-editor/Row.tsx | 521 +++++++++++ .../SecretPicker.tsx | 337 +++++++ .../environment-variables-editor/index.tsx | 472 ++++++++++ .../model.test.ts | 196 ++++ .../environment-variables-editor/model.ts | 216 +++++ .../parse-dotenv.test.ts | 66 ++ .../parse-dotenv.ts | 64 ++ .../sensitive.test.ts | 40 + .../environment-variables-editor/sensitive.ts | 46 + .../routine-sections/editable-sections.tsx | 4 +- ui/src/pages/CompanyEnvironments.test.tsx | 24 +- ui/src/pages/CompanyEnvironments.tsx | 20 +- ui/src/pages/CompanySettings.test.tsx | 12 +- ui/src/pages/DesignGuide.tsx | 87 +- .../environment-variables-editor.stories.tsx | 232 +++++ .../stories/forms-editors.stories.tsx | 18 +- .../stories/routine-secrets.stories.tsx | 4 +- ui/storybook/stories/secrets.stories.tsx | 4 +- 27 files changed, 3535 insertions(+), 492 deletions(-) delete mode 100644 ui/src/components/EnvVarEditor.tsx create mode 100644 ui/src/components/environment-variables-editor/ConvertToSecretPopover.tsx create mode 100644 ui/src/components/environment-variables-editor/CreateSecretPopover.tsx create mode 100644 ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx create mode 100644 ui/src/components/environment-variables-editor/Row.tsx create mode 100644 ui/src/components/environment-variables-editor/SecretPicker.tsx create mode 100644 ui/src/components/environment-variables-editor/index.tsx create mode 100644 ui/src/components/environment-variables-editor/model.test.ts create mode 100644 ui/src/components/environment-variables-editor/model.ts create mode 100644 ui/src/components/environment-variables-editor/parse-dotenv.test.ts create mode 100644 ui/src/components/environment-variables-editor/parse-dotenv.ts create mode 100644 ui/src/components/environment-variables-editor/sensitive.test.ts create mode 100644 ui/src/components/environment-variables-editor/sensitive.ts create mode 100644 ui/storybook/stories/environment-variables-editor.stories.tsx diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 79e7bd634e9..50c812de308 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { createRoot, type Root } from "react-dom/client"; -import { act } from "react"; +import { flushSync } from "react-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Agent, Environment } from "@paperclipai/shared"; @@ -117,6 +117,14 @@ vi.mock("./MarkdownEditor", () => ({ // 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; +} + async function flushReact() { await act(async () => { for (let i = 0; i < 4; i += 1) { @@ -169,6 +177,12 @@ function makeEnvironment(overrides: Partial): Environment { }; } +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + async function renderForm( environments: Environment[], agentOverrides: Partial = {}, @@ -357,6 +371,51 @@ describe("AgentConfigForm environment selector", () => { }); }); + it("flushes pending environment variable edits before testing adapter config", async () => { + const result = await renderForm([ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + ], { + adapterConfig: { + model: "gpt-5.4", + env: { API_TOKEN: { type: "plain", value: "old-token" } }, + }, + }, { + showAdapterTestEnvironmentButton: true, + }); + roots.push(result.root); + + const valueInput = result.container.querySelector('input[aria-label="Variable value"]'); + expect(valueInput).toBeTruthy(); + + await act(async () => { + setInputValue(valueInput!, "draft-token"); + }); + await flushReact(); + + const testButton = Array.from(result.container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Test", + ); + expect(testButton).toBeTruthy(); + + await act(async () => { + testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockAgentsApi.testEnvironment).toHaveBeenCalled(); + for (const call of mockAgentsApi.testEnvironment.mock.calls) { + expect(call).toEqual([ + "company-1", + "codex_local", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + env: { API_TOKEN: { type: "plain", value: "draft-token" } }, + }), + }), + ]); + } + }); + it("surfaces request failures instead of converting them into model test checks", async () => { mockAgentsApi.testEnvironment.mockRejectedValueOnce(new Error("Network unavailable")); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 7026078c21f..cf9989dfe07 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -47,7 +47,10 @@ import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { ReportsToPicker } from "./ReportsToPicker"; -import { EnvVarEditor } from "./EnvVarEditor"; +import { + EnvironmentVariablesEditor, + type EnvironmentVariablesEditorHandle, +} from "./environment-variables-editor"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry"; @@ -208,6 +211,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const hideInstructionsFile = props.hideInstructionsFile ?? false; const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); + const environmentVariablesEditorRef = useRef(null); // Sync disabled adapter types from server so dropdown filters them out const disabledTypes = useDisabledAdaptersSync(); @@ -307,14 +311,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) { })); } + function flushEnvironmentDraft() { + return environmentVariablesEditorRef.current?.flushPendingDraft() ?? null; + } + /** Build accumulated patch and send to parent */ const handleCancel = useCallback(() => { setOverlay({ ...emptyOverlay }); }, []); const handleSave = useCallback(async () => { - if (isCreate || !isDirty) return; - await props.onSave(buildAgentUpdatePatch(props.agent, overlay)); + if (isCreate) return; + const flushedEnv = flushEnvironmentDraft(); + const nextOverlay = flushedEnv + ? { + ...overlay, + adapterConfig: { + ...overlay.adapterConfig, + env: flushedEnv, + }, + } + : overlay; + if (!isOverlayDirty(nextOverlay)) return; + await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay)); }, [isCreate, isDirty, overlay, props]); useEffect(() => { @@ -531,7 +550,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { return next; } - function buildCheapAdapterConfigForTest(): Record { + function buildCheapAdapterConfigForTest(adapterConfigPatch?: Record): Record { const adapterDefaultConfig = asObject(adapterCheapDefault?.adapterConfig); const createCheapModel = isCreate ? (val!.cheapModel ?? "").trim() : ""; const cheapAdapterConfig = isCreate @@ -544,12 +563,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ...cheapProfileFromAgent.adapterConfig, ...asObject(cheapOverlay?.adapterConfig), }; - return buildAdapterConfigForTest(cheapAdapterConfig); + return buildAdapterConfigForTest({ ...cheapAdapterConfig, ...adapterConfigPatch }); } - function getCheapModelTestCase(): { model: string; adapterConfig: Record } | null { + function getCheapModelTestCase(adapterConfigPatch?: Record): { model: string; adapterConfig: Record } | null { if (!currentCheapEnabled) return null; - const adapterConfig = buildCheapAdapterConfigForTest(); + const adapterConfig = buildCheapAdapterConfigForTest(adapterConfigPatch); const configModel = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : ""; const model = configModel || currentCheapModel.trim(); if (!model) return null; @@ -616,8 +635,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (!selectedCompanyId) { throw new Error("Select a company to test adapter environment"); } + const flushedEnv = flushEnvironmentDraft(); + const adapterConfigPatch = flushedEnv ? { env: flushedEnv } : undefined; const primaryModel = currentModelId.trim() || null; - const cheapTestCase = getCheapModelTestCase(); + const cheapTestCase = getCheapModelTestCase(adapterConfigPatch); const environmentId = currentDefaultEnvironmentId || null; const testResults: Array<{ label: string; model: string | null; result: AdapterEnvironmentTestResult }> = [ { @@ -626,7 +647,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { result: await runEnvironmentTestCase( "Primary model", primaryModel, - buildAdapterConfigForTest(), + buildAdapterConfigForTest(adapterConfigPatch), environmentId, ), }, @@ -1350,7 +1371,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { - ) diff --git a/ui/src/components/EnvVarEditor.tsx b/ui/src/components/EnvVarEditor.tsx deleted file mode 100644 index faabf9db09f..00000000000 --- a/ui/src/components/EnvVarEditor.tsx +++ /dev/null @@ -1,425 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared"; -import { AlertCircle, KeyRound, X } from "lucide-react"; -import { cn } from "../lib/utils"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -const inputClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; - -// shadcn Select trigger sized to line up with the mono inputs above. -const selectTriggerClass = - "h-[34px] min-h-[34px] rounded-md border-border bg-transparent px-2.5 text-sm font-mono shadow-none"; - -/** Radix Select forbids empty-string item values; use a sentinel for "unset". */ -const SECRET_UNSET = "__unset__"; - -/** Suggest an env-var-style KEY from a secret name (UPPER_SNAKE). */ -function envKeyFromSecretName(name: string): string { - return name - .trim() - .toUpperCase() - .replace(/[^A-Z0-9_]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 64); -} - -type Row = { - key: string; - source: "plain" | "secret"; - plainValue: string; - secretId: string; - version: SecretVersionSelector; -}; - -function emptyRow(): Row { - return { key: "", source: "plain", plainValue: "", secretId: "", version: "latest" }; -} - -function toRows(rec: Record | null | undefined): Row[] { - if (!rec || typeof rec !== "object") { - return [emptyRow()]; - } - const entries = Object.entries(rec).map(([key, binding]) => { - if (typeof binding === "string") { - return { key, source: "plain" as const, plainValue: binding, secretId: "", version: "latest" as const }; - } - if ( - typeof binding === "object" && - binding !== null && - "type" in binding && - (binding as { type?: unknown }).type === "secret_ref" - ) { - const record = binding as { secretId?: unknown; version?: unknown }; - const version: SecretVersionSelector = typeof record.version === "number" - ? record.version - : "latest"; - return { - key, - source: "secret" as const, - plainValue: "", - secretId: typeof record.secretId === "string" ? record.secretId : "", - version, - }; - } - if ( - typeof binding === "object" && - binding !== null && - "type" in binding && - (binding as { type?: unknown }).type === "plain" - ) { - const record = binding as { value?: unknown }; - return { - key, - source: "plain" as const, - plainValue: typeof record.value === "string" ? record.value : "", - secretId: "", - version: "latest" as const, - }; - } - return { key, source: "plain" as const, plainValue: "", secretId: "", version: "latest" as const }; - }); - return [...entries, emptyRow()]; -} - -export function EnvVarEditor({ - value, - secrets, - onCreateSecret, - onChange, - recentlyUsedSecrets, -}: { - value: Record; - secrets: CompanySecret[]; - onCreateSecret: (name: string, value: string) => Promise; - onChange: (env: Record | undefined) => void; - /** - * Optional project-scoped secrets to surface as one-tap "quick bind" chips - * below the editor (§3.4). Already-bound secrets are filtered out. - */ - recentlyUsedSecrets?: CompanySecret[]; -}) { - const [rows, setRows] = useState(() => toRows(value)); - const [sealError, setSealError] = useState(null); - const valueRef = useRef(value); - const emittingRef = useRef(false); - - useEffect(() => { - if (emittingRef.current) { - emittingRef.current = false; - valueRef.current = value; - return; - } - if (value !== valueRef.current) { - valueRef.current = value; - setRows(toRows(value)); - } - }, [value]); - - function emit(nextRows: Row[]) { - const rec: Record = {}; - for (const row of nextRows) { - const key = row.key.trim(); - if (!key) continue; - if (row.source === "secret") { - if (row.secretId) { - rec[key] = { type: "secret_ref", secretId: row.secretId, version: row.version }; - } else { - rec[key] = { type: "plain", value: row.plainValue }; - } - } else { - rec[key] = { type: "plain", value: row.plainValue }; - } - } - emittingRef.current = true; - onChange(Object.keys(rec).length > 0 ? rec : undefined); - } - - function updateRow(index: number, patch: Partial) { - const withPatch: Row[] = rows.map((row, rowIndex) => - rowIndex === index ? { ...row, ...patch, version: patch.version ?? row.version } : row, - ); - if ( - withPatch[withPatch.length - 1].key || - withPatch[withPatch.length - 1].plainValue || - withPatch[withPatch.length - 1].secretId - ) { - withPatch.push(emptyRow()); - } - setRows(withPatch); - emit(withPatch); - } - - function removeRow(index: number) { - const next = rows.filter((_, rowIndex) => rowIndex !== index); - if ( - next.length === 0 || - next[next.length - 1].key || - next[next.length - 1].plainValue || - next[next.length - 1].secretId - ) { - next.push(emptyRow()); - } - setRows(next); - emit(next); - } - - function bindRecentSecret(secret: CompanySecret) { - // Fill the trailing empty row (or append one) with this secret bound. - const next = rows.map((row) => ({ ...row })); - const trailing = next[next.length - 1]; - let target: Row; - if (trailing && !trailing.key && !trailing.plainValue && !trailing.secretId) { - target = trailing; - } else { - target = emptyRow(); - next.push(target); - } - target.source = "secret"; - target.secretId = secret.id; - target.version = "latest"; - if (!target.key) target.key = envKeyFromSecretName(secret.name); - next.push(emptyRow()); - setRows(next); - emit(next); - } - - function defaultSecretName(key: string) { - return key - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 64); - } - - async function sealRow(index: number) { - const row = rows[index]; - if (!row) return; - const key = row.key.trim(); - const plain = row.plainValue; - if (!key || plain.length === 0) return; - - const suggested = defaultSecretName(key) || "secret"; - const name = window.prompt("Secret name", suggested)?.trim(); - if (!name) return; - - try { - setSealError(null); - const created = await onCreateSecret(name, plain); - updateRow(index, { source: "secret", secretId: created.id }); - } catch (error) { - setSealError(error instanceof Error ? error.message : "Failed to create secret"); - } - } - - return ( -
- {rows.map((row, index) => { - const isTrailing = - index === rows.length - 1 && - !row.key && - !row.plainValue && - !row.secretId; - return ( -
- updateRow(index, { key: event.target.value })} - /> - - {row.source === "secret" ? ( - <> - - - - - ) : ( - <> - updateRow(index, { plainValue: event.target.value })} - /> - - - )} - {!isTrailing ? ( - - ) : ( -
- )} -
- ); - })} - {(() => { - const boundIds = new Set( - rows.filter((row) => row.source === "secret" && row.secretId).map((row) => row.secretId), - ); - const quick = (recentlyUsedSecrets ?? []) - .filter((secret) => secret.status === "active" && !boundIds.has(secret.id)) - .slice(0, 8); - if (quick.length === 0) return null; - return ( -
- - - Recently used: - - {quick.map((secret) => ( - - ))} -
- ); - })()} - {sealError &&

{sealError}

} - {(() => { - const issues: { key: string; reason: string }[] = []; - for (const row of rows) { - if (row.source !== "secret" || !row.secretId) continue; - const secret = secrets.find((s) => s.id === row.secretId); - if (!secret) { - issues.push({ key: row.key.trim() || row.secretId, reason: "missing" }); - } else if (secret.status !== "active") { - issues.push({ key: row.key.trim() || secret.name, reason: secret.status }); - } - } - if (!issues.length) return null; - return ( -

- - - {issues.length} secret binding{issues.length === 1 ? "" : "s"} need attention:{" "} - {issues.map((issue, idx) => ( - - {issue.key} - ({issue.reason}) - {idx < issues.length - 1 ? ", " : ""} - - ))} - . Runs will fail until you remap or re-enable. - -

- ); - })()} -

- Set KEY to the env var name the process expects, for example GH_TOKEN. Choose Secret to resolve a stored - value at run start. PAPERCLIP_* variables are injected automatically. -

-
- ); -} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index b2d159e7dbb..e1bb32ca4a2 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -21,7 +21,7 @@ import { ChoosePathButton } from "./PathInstructionsModal"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { DraftInput } from "./agent-config-primitives"; import { InlineEditor } from "./InlineEditor"; -import { EnvVarEditor } from "./EnvVarEditor"; +import { EnvironmentVariablesEditor } from "./environment-variables-editor"; const PROJECT_STATUSES = [ { value: "backlog", label: "Backlog" }, @@ -623,7 +623,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa valueClassName="space-y-2" >
- { diff --git a/ui/src/components/SearchableSelect.tsx b/ui/src/components/SearchableSelect.tsx index d4a29fdcd0a..a827f5cbde4 100644 --- a/ui/src/components/SearchableSelect.tsx +++ b/ui/src/components/SearchableSelect.tsx @@ -17,6 +17,7 @@ export interface SearchableSelectOption { key: string; value: TValue; label: string; + title?: string; searchText?: string; disabled?: boolean; } @@ -37,7 +38,7 @@ export interface SearchableSelectProps< > { value: TValue | ""; groups: readonly SearchableSelectGroup[]; - onValueChange: (value: TValue, option: TOption) => void; + onValueChange: (value: TValue, option: TOption) => void | boolean | { close?: boolean }; placeholder: string; searchPlaceholder?: string; emptyMessage?: string; @@ -51,9 +52,24 @@ export interface SearchableSelectProps< contentWidth?: "trigger" | "auto"; renderValue?: (option: TOption | null) => ReactNode; renderOption?: (option: TOption, state: SearchableSelectRenderState) => ReactNode; + deriveGroups?: ( + query: string, + groups: readonly SearchableSelectGroup[], + ) => readonly SearchableSelectGroup[]; filterOption?: (option: TOption, query: string) => boolean; scoreOption?: (option: TOption, query: string) => number | null; disablePortal?: boolean; + /** + * Optional pinned "creatable" item rendered at the bottom of the list, + * regardless of the query (used e.g. by the secret picker's + * `+ Create secret ""…` affordance). `render` receives the live + * query so the label can echo it; `onSelect` fires with the query and the + * popover closes. + */ + createItem?: { + render: (query: string) => ReactNode; + onSelect: (query: string) => void; + }; } function defaultFilterOption(option: SearchableSelectOption, query: string) { @@ -87,9 +103,11 @@ export function SearchableSelect< contentWidth = "trigger", renderValue, renderOption, + deriveGroups, filterOption = defaultFilterOption, scoreOption, disablePortal, + createItem, }: SearchableSelectProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -104,10 +122,15 @@ export function SearchableSelect< return null; }, [groups, value]); + const visibleGroups = useMemo( + () => (deriveGroups ? deriveGroups(query, groups) : groups), + [deriveGroups, groups, query], + ); + const filteredGroups = useMemo(() => { if (loading) return []; const normalizedQuery = normalizeSearchText(query); - return groups + return visibleGroups .map((group) => { const options = group.options .map((option, index) => { @@ -138,7 +161,7 @@ export function SearchableSelect< }; }) .filter((group) => group.options.length > 0); - }, [filterOption, groups, loading, query, scoreOption]); + }, [filterOption, loading, query, scoreOption, visibleGroups]); const hasOptions = filteredGroups.some((group) => group.options.length > 0); @@ -153,7 +176,10 @@ export function SearchableSelect< function selectOption(option: TOption) { if (option.disabled) return; suppressNextTriggerFocusRef.current = true; - onValueChange(option.value, option); + const result = onValueChange(option.value, option); + if (result === false || (typeof result === "object" && result?.close === false)) { + return; + } closePopover(); } @@ -192,7 +218,7 @@ export function SearchableSelect< role="combobox" className={cn("w-full justify-between overflow-hidden", className, triggerClassName)} > - + {renderValue ? renderValue(selectedOption) : selectedOption?.label ?? placeholder} @@ -233,29 +259,54 @@ export function SearchableSelect< > {loading ? (
{loadingMessage}
- ) : !hasOptions ? ( - {emptyMessage} ) : ( - filteredGroups.map((group) => ( - - {group.options.map((option) => { - const selected = option.value === value; - return ( - selectOption(option)} - > - {renderOption - ? renderOption(option, { selected }) - : {option.label}} - - - ); - })} - - )) + <> + {!hasOptions && !createItem ? {emptyMessage} : null} + {!hasOptions && createItem ? ( +
{emptyMessage}
+ ) : null} + {filteredGroups.map((group) => ( + + {group.options.map((option) => { + const selected = option.value === value; + return ( + selectOption(option)} + > + {renderOption + ? renderOption(option, { selected }) + : {option.label}} + + + ); + })} + + ))} + {createItem ? ( + + { + const q = query; + suppressNextTriggerFocusRef.current = true; + closePopover(); + // Defer to the next macrotask so this popover fully + // closes (and Radix returns focus to the trigger) + // before the consumer opens its own popover. Firing + // synchronously lets the close's focus-return land as a + // `focusOutside` on the newly-opened popover, which + // Radix would immediately dismiss (see PAP-12476). + window.setTimeout(() => createItem.onSelect(q), 0); + }} + > + {createItem.render(query)} + + + ) : null} + )} diff --git a/ui/src/components/StageSecretsPanel.tsx b/ui/src/components/StageSecretsPanel.tsx index 9adf07d0109..c34ee918db6 100644 --- a/ui/src/components/StageSecretsPanel.tsx +++ b/ui/src/components/StageSecretsPanel.tsx @@ -2,7 +2,7 @@ import { KeyRound, Save } from "lucide-react"; import type { CompanySecret, RoutineEnvConfig } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { EmptyState } from "./EmptyState"; -import { EnvVarEditor } from "./EnvVarEditor"; +import { EnvironmentVariablesEditor } from "./environment-variables-editor"; import { AgentIcon } from "./AgentIconPicker"; export interface StageSecretsPanelProps { @@ -28,8 +28,8 @@ export interface StageSecretsPanelProps { * Stage Secrets tab body. Stage secrets are env bindings on the step's backing * automation routine — the same company-secret backbone used by routines, * agents, and projects. This panel is intentionally dense and reuses - * `EnvVarEditor` for secret refs, inline secret creation, version selection, - * and missing/disabled-secret warnings. + * `EnvironmentVariablesEditor` for secret refs, inline secret creation, version + * selection, and missing/disabled-secret warnings. */ export function StageSecretsPanel({ hasAutomation, @@ -80,7 +80,7 @@ export function StageSecretsPanel({ {secretsLoading ? (

Loading secrets…

) : ( - void; + /** Resolves once the secret is created + the row is bound; rejects with a message. */ + onSubmit: (name: string, value: string) => Promise; +} + +/** + * Shared anchored-popover form behind {@link CreateSecretPopover} and + * {@link ConvertToSecretPopover}. Replaces the old `window.prompt` seal flow + * (plan §6.5). Meant to be rendered inside a ``. + */ +export function SecretPopoverForm({ + mode, + initialName, + initialValue, + existingSecretNames, + onCancel, + onSubmit, +}: SecretPopoverFormProps) { + const [name, setName] = useState(initialName); + const [value, setValue] = useState(initialValue); + const [reveal, setReveal] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [touched, setTouched] = useState(false); + + const trimmedName = name.trim(); + const nameError = (() => { + if (!trimmedName) return touched ? "Name is required" : null; + if (!SECRET_NAME_RE.test(trimmedName)) return "Use lowercase letters, digits and _"; + if (existingSecretNames?.some((existing) => existing.toLowerCase() === trimmedName)) { + return "A secret with this name already exists"; + } + return null; + })(); + const valueError = value.length === 0 ? (touched ? "Value is required" : null) : null; + const canSubmit = !submitting && trimmedName.length > 0 && value.length > 0 && !nameError; + + async function handleSubmit() { + setTouched(true); + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + await onSubmit(trimmedName, value); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Failed to create secret"); + setSubmitting(false); + } + } + + const ctaLabel = mode === "create" ? "Create & bind" : "Store & bind"; + const heading = mode === "create" ? "Create secret" : "Store value as secret"; + + return ( +
+
+ {heading} + {mode === "store" ? ( + + Moves the typed value into an encrypted company secret and binds{" "} + {initialName || "this variable"} to it. + + ) : null} +
+ + + + + + {error ?

{error}

: null} + +
+ + +
+
+ ); +} + +/** Create a brand-new secret from the fuzzy picker's creatable item (§6.4/§6.5). */ +export function CreateSecretPopover(props: Omit) { + return ; +} + +/** Store a typed Text value as a secret and bind the row (replaces "Seal", §6.5). */ +export function ConvertToSecretPopover(props: Omit) { + return ; +} diff --git a/ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx b/ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx new file mode 100644 index 00000000000..8518ae86cf3 --- /dev/null +++ b/ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx @@ -0,0 +1,859 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompanySecret, EnvBinding } from "@paperclipai/shared"; +import { EnvironmentVariablesEditor } from "./index"; +import { SecretPicker } from "./SecretPicker"; + +// Radix (DropdownMenu/Popover) relies on Pointer Capture APIs that jsdom omits. +const OriginalPointerEvent = globalThis.PointerEvent; +beforeAll(() => { + if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.PointerEvent = MouseEvent as any; + } + Element.prototype.setPointerCapture ??= () => {}; + Element.prototype.releasePointerCapture ??= () => {}; + Element.prototype.hasPointerCapture ??= () => false; + if (!globalThis.ResizeObserver) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } +}); +afterAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.PointerEvent = OriginalPointerEvent as any; +}); + +function makeSecret(id: string, overrides: Partial = {}): CompanySecret { + return { + id, + companyId: "co", + key: id, + name: id.toUpperCase(), + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 3, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; +} + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +// Radix DropdownMenu opens/selects on the pointerdown→pointerup sequence, not a +// bare click; drive both so jsdom exercises the real dismissal-layer path. +function pointerClick(el: Element) { + el.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 })); + el.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, cancelable: true, button: 0 })); + (el as HTMLElement).click(); +} + +// Deterministic settle for the nested DropdownMenu/combobox → Popover deferred- +// open regression tests. Those flows open the anchored popover from inside a +// closing menu via `window.setTimeout(…, 0)` (PAP-12476/12477/12478). Under real +// timers, jsdom orders the menu's focus-return (a `focusin` that Radix reads as +// `focusOutside` → dismiss) against that deferred open non-deterministically, so +// the popover *sometimes* opens-then-instantly-closes — flaky. `vi.useFakeTimers` +// makes that ordering deterministic (matching the real-browser path where the +// focus-return settles before the macrotask), while still running the deferred +// `setTimeout` — so a *synchronous* (unfixed) open would still be dismissed here. +function settleFakeTimers() { + flushSync(() => {}); + vi.runAllTimers(); + flushSync(() => {}); +} + +describe("EnvironmentVariablesEditor", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + function render(node: React.ReactNode) { + root = createRoot(container); + flushSync(() => root!.render(node)); + } + + function rerender(node: React.ReactNode) { + flushSync(() => root!.render(node)); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(async () => { + // Restore real timers first (the PAP-12478 test swaps in fake timers and may + // exit without restoring if it throws) so unmount cleanup runs on real timers + // and gets drained below. + vi.useRealTimers(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + flushSync(() => root?.unmount()); + root = null; + container.remove(); + // Drain any pending macrotasks (deferred popover-open timers, Radix's + // focus-restoration timers) so a timer scheduled by this test can't fire + // mid-way through the next one and dismiss its freshly-opened popover — that + // cross-test leak is what made the real-timer sibling regression tests flaky. + await flush(); + for (const child of [...document.body.children]) { + child.remove(); + } + document.body.style.pointerEvents = ""; + vi.restoreAllMocks(); + }); + + const secrets = [makeSecret("s1", { name: "GITHUB_TOKEN", latestVersion: 3 })]; + + function nameInputs() { + return [...container.querySelectorAll('input[aria-label="Variable name"]')]; + } + + function saveButton() { + return [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save")!; + } + + it("renders header + a row per binding, no trailing ghost row", () => { + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + expect(nameInputs()).toHaveLength(2); + expect(container.textContent).toContain("Name"); + expect(container.textContent).toContain("Value"); + // Version tag reflects the actual bound version (not a static "latest"). + const versionTag = container.querySelector('button[aria-label="Version"]'); + expect(versionTag?.textContent).toBe("v2"); + }); + + it("keeps long secret names clear of the latest version control", () => { + const longSecret = makeSecret("long", { + name: "/paperclip-cloud/prod/provider/resend/api-key-with-a-very-long-name", + latestVersion: 4, + }); + + render( + {}} + onCreateSecret={async () => longSecret} + />, + ); + + const combobox = container.querySelector('[role="combobox"]')!; + const selectedLabel = combobox.querySelector("[title] span.truncate"); + const versionTag = container.querySelector('button[aria-label="Version"]')!; + + expect(versionTag.textContent).toBe("latest"); + expect(combobox.className).toContain("has-[>svg]:!pr-24"); + expect(selectedLabel?.textContent).toBe(longSecret.name); + expect(selectedLabel?.className).toContain("flex-1"); + }); + + it("shows the empty state with no bindings", () => { + render( {}} onCreateSecret={async () => secrets[0]} />); + expect(container.textContent).toContain("No environment variables"); + expect(nameInputs()).toHaveLength(0); + }); + + it("appends a row when + Add variable is clicked", async () => { + render( {}} onCreateSecret={async () => secrets[0]} />); + const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; + addButton.click(); + await flush(); + expect(nameInputs()).toHaveLength(1); + }); + + it("does not emit when + Add variable only creates an empty draft row", async () => { + const onChange = vi.fn(); + render( secrets[0]} />); + const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; + addButton.click(); + await flush(); + expect(nameInputs()).toHaveLength(1); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("saves plain bindings only when Save is clicked", async () => { + const onChange = vi.fn(); + render( secrets[0]} />); + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(valueInput, "bar"); + valueInput.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + expect(onChange).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Unsaved changes"); + saveButton().click(); + await flush(); + expect(onChange).toHaveBeenLastCalledWith({ FOO: { type: "plain", value: "bar" } }); + }); + + it("flushes unsaved editor changes before an enclosing form submits", async () => { + const submittedValues: Array | undefined> = []; + + function FormHarness() { + const [value, setValue] = useState>({ + FOO: { type: "plain", value: "" }, + }); + return ( +
{ + event.preventDefault(); + submittedValues.push(value); + }} + > + setValue(next ?? {})} + onCreateSecret={async () => secrets[0]} + /> + + + ); + } + + render(); + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(valueInput, "bar"); + valueInput.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + const outerSave = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Outer save"), + )!; + outerSave.click(); + await flush(); + + expect(submittedValues).toEqual([{ FOO: { type: "plain", value: "bar" } }]); + }); + + it("flushes unsaved editor changes before an external save button reads parent state", async () => { + const savedValues: Array> = []; + + function SaveButtonHarness() { + const [value, setValue] = useState>({ + FOO: { type: "plain", value: "" }, + }); + return ( +
+ setValue(next ?? {})} + onCreateSecret={async () => secrets[0]} + /> + +
+ ); + } + + render(); + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(valueInput, "bar"); + valueInput.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + const outerSave = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Save settings"), + )!; + outerSave.click(); + await flush(); + + expect(savedValues).toEqual([{ FOO: { type: "plain", value: "bar" } }]); + }); + + it("makes unsaved fields and save controls prominent while editing", async () => { + render( {}} onCreateSecret={async () => secrets[0]} />); + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(valueInput, "bar"); + valueInput.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + const unsavedBar = [...container.querySelectorAll('[role="status"]')].find((node) => + node.textContent?.includes("Unsaved changes"), + ); + const valueCell = valueInput.closest(".relative.flex"); + const save = saveButton(); + const revert = [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Revert")!; + + expect(unsavedBar?.className).toContain("bg-amber-500/10"); + expect(valueCell?.className).toContain("border-amber-500/70"); + expect(save.className).toContain("h-9"); + expect(save.className).toContain("px-4"); + expect(revert.className).toContain("h-9"); + }); + + it("marks a newly typed variable name as unsaved before saving", async () => { + render( {}} onCreateSecret={async () => secrets[0]} />); + const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; + addButton.click(); + await flush(); + + const nameInput = nameInputs()[0]!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(nameInput, "API_TOKEN"); + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + expect(nameInput.className).toContain("border-amber-500/70"); + expect(container.textContent).toContain("Unsaved changes"); + }); + + it("does not emit or remount while typing a new variable before manual save", async () => { + const onChange = vi.fn(); + const savedValue: Record = { + ZED: { type: "plain", value: "z" }, + ALPHA: { type: "plain", value: "a" }, + }; + render( + secrets[0]} + />, + ); + const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; + addButton.click(); + await flush(); + + const newNameInput = nameInputs().at(-1)!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(newNameInput, "ca"); + newNameInput.dispatchEvent(new Event("input", { bubbles: true })); + newNameInput.focus(); + await flush(); + + expect(onChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(newNameInput); + + rerender( + secrets[0]} + />, + ); + await flush(); + + expect(onChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(newNameInput); + expect(nameInputs().at(-1)).toBe(newNameInput); + + setter.call(newNameInput, "carol"); + newNameInput.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + expect(onChange).not.toHaveBeenCalled(); + + saveButton().click(); + await flush(); + expect(onChange).toHaveBeenLastCalledWith({ + ZED: { type: "plain", value: "z" }, + ALPHA: { type: "plain", value: "a" }, + carol: { type: "plain", value: "" }, + }); + }); + + it("keeps the active row mounted when a manual save echo returns an equivalent value", async () => { + const onChange = vi.fn(); + const emptyValue: Record = {}; + const savedValue: Record = { API_TOKEN: { type: "plain", value: "secret-value" } }; + render( + secrets[0]} + />, + ); + const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; + addButton.click(); + await flush(); + + const [nameInput] = nameInputs(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(nameInput, "API_TOKEN"); + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + setter.call(valueInput, "secret-value"); + valueInput.dispatchEvent(new Event("input", { bubbles: true })); + valueInput.focus(); + await flush(); + expect(document.activeElement).toBe(valueInput); + expect(onChange).not.toHaveBeenCalled(); + saveButton().click(); + await flush(); + expect(onChange).toHaveBeenLastCalledWith(savedValue); + + rerender( + secrets[0]} + />, + ); + await flush(); + + expect(document.activeElement).toBe(valueInput); + expect(container.querySelector('input[aria-label="Variable value"]')).toBe(valueInput); + }); + + it("emits undefined when the last binding is removed", async () => { + const onChange = vi.fn(); + render( secrets[0]} />); + const removeButton = container.querySelector('button[aria-label^="Remove"]')!; + removeButton.click(); + await flush(); + expect(onChange).not.toHaveBeenCalled(); + saveButton().click(); + await flush(); + expect(onChange).toHaveBeenLastCalledWith(undefined); + }); + + it("disables inputs in read-only mode", () => { + render( + {}} + onCreateSecret={async () => secrets[0]} + disabled + />, + ); + expect(container.querySelector('input[aria-label="Variable name"]')!.disabled).toBe(true); + expect(container.querySelector('input[aria-label="Variable value"]')!.disabled).toBe(true); + }); + + it("renders name warnings as a row spanning the name and value columns", () => { + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + + const nameInput = container.querySelector('input[aria-label="Variable name"]')!; + const warning = [...container.querySelectorAll("p")].find((node) => + node.textContent?.includes("Reserved prefix"), + ); + + expect(warning, "reserved-prefix warning should render").toBeTruthy(); + expect(nameInput.getAttribute("aria-describedby")).toBe(warning!.id); + expect(warning!.parentElement?.contains(nameInput), "warning should stay in the row grid").toBe(true); + expect(warning!.parentElement).not.toBe(nameInput.parentElement); + expect(warning!.className).toContain("col-span-2"); + expect(warning!.className).toContain("@[40rem]/env:row-start-2"); + }); + + it("bulk-imports a dotenv paste into an empty name field", async () => { + const onChange = vi.fn(); + render( secrets[0]} />); + // Add an empty row to paste into. + const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; + addButton.click(); + await flush(); + const nameInput = nameInputs()[0]!; + const clipboardData = { getData: () => "A=1\nB=2\nC=3" } as unknown as DataTransfer; + const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as unknown as ClipboardEvent; + Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData }); + nameInput.dispatchEvent(pasteEvent); + await flush(); + expect(onChange).not.toHaveBeenCalled(); + saveButton().click(); + await flush(); + expect(onChange).toHaveBeenLastCalledWith({ + A: { type: "plain", value: "1" }, + B: { type: "plain", value: "2" }, + C: { type: "plain", value: "3" }, + }); + }); + + it("bulk-imports dotenv updates without mutating the committed row baseline", async () => { + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + const addButton = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Add variable"), + )!; + addButton.click(); + await flush(); + + const targetNameInput = nameInputs().at(-1)!; + const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as unknown as ClipboardEvent; + Object.defineProperty(pasteEvent, "clipboardData", { + value: { getData: () => "A=new" } as unknown as DataTransfer, + }); + targetNameInput.dispatchEvent(pasteEvent); + await flush(); + + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + const valueCell = valueInput.closest(".relative.flex"); + expect(valueInput.value).toBe("new"); + expect(valueCell?.className).toContain("border-amber-500/70"); + }); + + it("auto-detects a sensitive value and offers a value-preserving Store-as-secret popover", async () => { + // A sensitive KEY (matches the shared regex) surfaces the ShieldAlert + // affordance and auto-masks the value input (§6.6). + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + expect(valueInput.type).toBe("password"); // auto-masked + const storeButton = container.querySelector('button[title^="This value looks sensitive"]'); + expect(storeButton, "sensitive Store-as-secret affordance should render").toBeTruthy(); + storeButton!.click(); + await flush(); + // The store popover carries the typed value forward (not discarded). + expect(document.body.textContent).toContain("Store value as secret"); + const secretValueField = document.querySelector('input[aria-label="Secret value"]'); + expect(secretValueField?.value).toBe("supersecretvalue"); + }); + + it("opens the create-secret popover from the picker's + Create item (§6.4, PAP-12476)", async () => { + // Regression: selecting the picker's `+ Create secret` item closes the + // combobox popover and (in the same tick) opens the anchored create-secret + // popover. The two Radix popovers must not race — the create popover has to + // survive the combobox's focus-return instead of being dismissed instantly. + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + const combobox = container.querySelector('[role="combobox"]')!; + combobox.focus(); + await flush(); + const createItem = [...document.querySelectorAll("[cmdk-item]")].find((el) => + el.textContent?.includes("Create"), + ); + expect(createItem, "create item should be present in the open picker").toBeTruthy(); + createItem!.click(); + await flush(); + // The create-secret popover is open (heading rendered) and stays open. + expect(document.body.textContent, "create-secret popover should open").toContain("Create secret"); + expect( + document.querySelector('input[aria-label="Secret name"]'), + "create-secret name field should render", + ).toBeTruthy(); + }); + + it("keeps the create-secret popover open when focus returns to a control inside the value cell (§6.4, PAP-12492)", async () => { + // Regression for the *fragile* part of the picker → create transition. + // The picker's combobox and the anchored create popover share the value + // cell as their anchor. When the picker tears down, Radix returns focus to + // the combobox trigger *inside* that anchor — and because the anchor sits + // outside the create popover's content, Radix reads that focus-return as a + // `focusOutside` and dismisses the just-opened create popover. The picker's + // close animation can delay that focus-return past the `setTimeout(0)` open + // defer, so a timing-based fix is not enough; the popover must survive an + // in-anchor focus-return whenever it lands. jsdom does not reproduce the + // animation-delayed focus-return on its own, so we drive it explicitly: + // open the create popover, then fire a late `focusin` on an in-anchor + // control and assert the popover is still open. + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + const combobox = container.querySelector('[role="combobox"]')!; + combobox.focus(); + await flush(); + const createItem = [...document.querySelectorAll("[cmdk-item]")].find((el) => + el.textContent?.includes("Create"), + ); + expect(createItem, "create item should be present in the open picker").toBeTruthy(); + createItem!.click(); + await flush(); + expect(document.body.textContent, "create-secret popover should open").toContain("Create secret"); + + // Simulate the picker's animation-delayed focus-return landing on a control + // that lives inside the value cell (the source-switch trigger is a stable + // in-anchor target that doesn't reopen the picker). Moving focus fires a + // blur off the create popover's content (flipping Radix's "focus inside" + // flag) and then a focusin on the in-anchor control — exactly the sequence + // Radix reads as a `focusOutside`. Without the guard this dismisses the + // create popover; with it, the popover survives. + const sourceButton = container.querySelector('button[aria-label="Value source"]')!; + expect(sourceButton, "in-anchor Value source control should exist").toBeTruthy(); + sourceButton.focus(); + await flush(); + + expect( + document.body.textContent, + "create-secret popover must survive the in-anchor focus-return", + ).toContain("Create secret"); + expect( + document.querySelector('input[aria-label="Secret name"]'), + "create-secret name field should still render", + ).toBeTruthy(); + }); + + it("opens the store-as-secret popover from the ⋯ overflow menu (PAP-12477)", async () => { + // Regression: the ⋯ overflow → "Store as secret…" item is the same nested + // shape as the picker's + Create item — a DropdownMenu whose onSelect opens + // the row's anchored Popover. The menu closing returns focus to its trigger + // (which lives outside the Popover), which must not land as a focusOutside + // that dismisses the just-opened popover. A non-sensitive text row surfaces + // the ⋯ menu (the sensitive-value inline button is a separate path). + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + // The value is non-sensitive, so no inline Store-as-secret button — only ⋯. + expect(container.querySelector('button[title^="This value looks sensitive"]')).toBeNull(); + const overflow = container.querySelector('button[aria-label="More actions"]'); + expect(overflow, "⋯ overflow menu should render for a non-sensitive text row").toBeTruthy(); + pointerClick(overflow!); + await flush(); + const storeItem = [...document.querySelectorAll('[role="menuitem"]')].find((el) => + el.textContent?.includes("Store as secret"), + ); + expect(storeItem, "Store as secret… menu item should be present").toBeTruthy(); + pointerClick(storeItem!); + await flush(); + // The store popover is open (heading rendered) and stays open — it must not + // be dismissed by the menu's focus-return. + expect(document.body.textContent, "store-as-secret popover should open").toContain( + "Store value as secret", + ); + expect( + document.querySelector('input[aria-label="Secret value"]'), + "store-as-secret value field should render", + ).toBeTruthy(); + }); + + it("opens the store popover from the source dropdown Text→Secret (with a value) and keeps it open (PAP-12478)", () => { + // Regression: switching a *non-empty* Text row to "Secret reference" via the + // in-field Value source dropdown must open the anchored store-as-secret + // popover (§6.3) — value preserved, not discarded. This is the same nested + // DropdownMenu→Popover open-while-closing race as the ⋯ and picker paths + // (PAP-12476/12477): opening the popover synchronously inside the menu-item's + // onSelect lets the menu's focus-return dismiss it in the same tick. With a + // *synchronous* open this assertion fails; with the deferred open it passes. + vi.useFakeTimers(); + render( + {}} + onCreateSecret={async () => secrets[0]} + />, + ); + const sourceButton = container.querySelector('button[aria-label="Value source"]'); + expect(sourceButton, "Value source dropdown should render").toBeTruthy(); + pointerClick(sourceButton!); + settleFakeTimers(); + const secretItem = [...document.querySelectorAll('[role="menuitem"]')].find((el) => + el.textContent?.includes("Secret reference"), + ); + expect(secretItem, "Secret reference menu item should be present").toBeTruthy(); + pointerClick(secretItem!); + settleFakeTimers(); + // The store popover is open (heading rendered) and stays open — it must not + // be dismissed by the menu's focus-return. + expect(document.body.textContent, "store-as-secret popover should open").toContain( + "Store value as secret", + ); + // The current value is carried forward (masked), not discarded. + const secretValueField = document.querySelector('input[aria-label="Secret value"]'); + expect(secretValueField, "store-as-secret value field should render").toBeTruthy(); + expect(secretValueField!.value).toBe("production"); + }); + + it("lets the user dismiss the sensitive-value hint, unmasking the value and keeping it plain (§6.6)", async () => { + const onChange = vi.fn(); + render( + secrets[0]} + />, + ); + const valueInput = container.querySelector('input[aria-label="Variable value"]')!; + expect(valueInput.type).toBe("password"); // auto-masked while the hint shows + + const dismissButton = container.querySelector( + 'button[aria-label="Dismiss sensitive-value suggestion"]', + ); + expect(dismissButton, "dismiss affordance should render alongside the hint").toBeTruthy(); + dismissButton!.click(); + await flush(); + + // Hint + its dismiss control are gone, and the value is no longer masked. + expect( + container.querySelector('button[title^="This value looks sensitive"]'), + "store-as-secret hint should be dismissed", + ).toBeNull(); + expect( + container.querySelector('button[aria-label="Dismiss sensitive-value suggestion"]'), + ).toBeNull(); + expect( + container.querySelector('input[aria-label="Variable value"]')!.type, + ).toBe("text"); + + // Dismissal is a local UI concern — the emitted plain value is unchanged. + const lastEmit = onChange.mock.calls.at(-1)?.[0]; + if (lastEmit) { + expect(lastEmit).toEqual({ STRIPE_API_KEY: { type: "plain", value: "supersecretvalue" } }); + } + }); + + it("lets slash-delimited secrets be browsed by folder and exposes full paths on hover", async () => { + const cloudProviderKey = "/paperclip-cloud/prod/provider/aws-access-key-id"; + const cloudMigrationKey = "/paperclip-cloud/prod/migration/postgres-url"; + const onSelect = vi.fn(); + render( + {}} + disablePortal + />, + ); + + const combobox = container.querySelector('[role="combobox"]')!; + combobox.focus(); + await flush(); + + const itemByText = (text: string) => + [...document.querySelectorAll("[cmdk-item]")].find((el) => + el.textContent?.includes(text), + ); + const rowTitle = (item: HTMLElement | undefined) => item?.querySelector("[title]")?.getAttribute("title"); + + expect(rowTitle(itemByText("paperclip-cloud"))).toBe("/paperclip-cloud"); + expect(rowTitle(itemByText("paperclip-page-aws-access-key-id"))).toBe( + "paperclip-page-aws-access-key-id", + ); + + itemByText("paperclip-cloud")!.click(); + await flush(); + expect(rowTitle(itemByText("prod"))).toBe("/paperclip-cloud/prod"); + + itemByText("prod")!.click(); + await flush(); + expect(rowTitle(itemByText("provider"))).toBe("/paperclip-cloud/prod/provider"); + expect(rowTitle(itemByText("migration"))).toBe("/paperclip-cloud/prod/migration"); + + itemByText("provider")!.click(); + await flush(); + const leaf = itemByText("aws-access-key-id"); + expect(rowTitle(leaf)).toBe(cloudProviderKey); + + leaf!.click(); + await flush(); + expect(onSelect).toHaveBeenCalledWith("provider-key"); + }); + + it("keeps secret search global while browsing starts at slash folders", async () => { + const cloudMigrationKey = "/paperclip-cloud/prod/migration/postgres-url"; + render( + {}} + onCreateNew={() => {}} + disablePortal + />, + ); + + const combobox = container.querySelector('[role="combobox"]')!; + combobox.focus(); + await flush(); + expect(document.body.textContent).toContain("paperclip-cloud"); + expect(document.body.textContent).not.toContain("postgres-url"); + + const search = document.querySelector('input[placeholder="Search secrets…"]')!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(search, "postgres"); + search.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + const match = [...document.querySelectorAll("[cmdk-item]")].find((el) => + el.textContent?.includes(cloudMigrationKey), + ); + expect(match?.querySelector("[title]")?.getAttribute("title")).toBe(cloudMigrationKey); + }); +}); diff --git a/ui/src/components/environment-variables-editor/Row.tsx b/ui/src/components/environment-variables-editor/Row.tsx new file mode 100644 index 00000000000..8489c0d8aeb --- /dev/null +++ b/ui/src/components/environment-variables-editor/Row.tsx @@ -0,0 +1,521 @@ +import { useEffect, useRef, useState } from "react"; +import { + ChevronDown, + KeyRound, + MoreHorizontal, + ShieldAlert, + Type as TypeIcon, + X, +} from "lucide-react"; +import type { CompanySecret } from "@paperclipai/shared"; +import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { SecretPicker } from "./SecretPicker"; +import { CreateSecretPopover, ConvertToSecretPopover } from "./CreateSecretPopover"; +import { isSensitiveEnv } from "./sensitive"; +import { + computeRowHealth, + planSourceSwitch, + secretNameFromKey, + type EnvRow, + type NameIssue, +} from "./model"; + +const nameInputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/40"; + +const valueTextInputClass = + "min-w-0 flex-1 bg-transparent px-2 py-1.5 text-sm font-mono outline-none placeholder:text-muted-foreground/40"; + +type SecretPopoverState = { mode: "create" | "store"; name: string; value: string } | null; +export interface EnvironmentVariableDirtyFields { + name: boolean; + value: boolean; +} + +export interface EnvironmentVariableRowProps { + row: EnvRow; + isLast: boolean; + secrets: readonly CompanySecret[]; + recentlyUsedSecrets?: readonly CompanySecret[]; + disabled?: boolean; + nameIssue: NameIssue | null; + showNameIssue: boolean; + dirtyFields: EnvironmentVariableDirtyFields; + onPatch: (patch: Partial) => void; + onRemove: () => void; + onNameBlur: () => void; + /** Handle a multi-line dotenv paste into the (empty) name field. Returns true if consumed. */ + onNamePaste: (text: string) => boolean; + /** Enter pressed in the value of the last row — append a fresh row and focus it. */ + onEnterInValueLast: () => void; + onCreateSecret: (name: string, value: string) => Promise; + onToast: (message: string) => void; + /** Parent-driven focus request for this row (append flow, source switch). */ + focusRequest: "name" | "value" | null; + onFocusConsumed: () => void; +} + +export function EnvironmentVariableRow({ + row, + isLast, + secrets, + recentlyUsedSecrets, + disabled, + nameIssue, + showNameIssue, + dirtyFields, + onPatch, + onRemove, + onNameBlur, + onNamePaste, + onEnterInValueLast, + onCreateSecret, + onToast, + focusRequest, + onFocusConsumed, +}: EnvironmentVariableRowProps) { + const nameInputRef = useRef(null); + const valueInputRef = useRef(null); + const valueCellRef = useRef(null); + const [secretPopover, setSecretPopover] = useState(null); + const [versionOpen, setVersionOpen] = useState(false); + const [undoPrev, setUndoPrev] = useState(null); + + const health = computeRowHealth(row, secrets); + const boundSecret = row.source === "secret" ? secrets.find((s) => s.id === row.secretId) ?? null : null; + const sensitive = + row.source === "text" && !row.sensitiveDismissed && isSensitiveEnv(row.name, row.textValue); + + // Consume parent focus requests (append / source-switch flows). + useEffect(() => { + if (!focusRequest) return; + if (focusRequest === "name") { + nameInputRef.current?.focus(); + } else if (row.source === "text") { + valueInputRef.current?.focus(); + } else { + // Focusing the combobox trigger opens SearchableSelect (non-pointer focus). + valueCellRef.current?.querySelector("[role=combobox]")?.focus(); + } + onFocusConsumed(); + }, [focusRequest, onFocusConsumed, row.source]); + + // Auto-expire the 5s "Undo" affordance after a Secret→Text switch (§6.3). + useEffect(() => { + if (!undoPrev) return; + const handle = window.setTimeout(() => setUndoPrev(null), 5000); + return () => window.clearTimeout(handle); + }, [undoPrev]); + + function switchSource(next: "text" | "secret") { + const plan = planSourceSwitch(row, next); + switch (plan.kind) { + case "noop": + return; + case "open-store": { + // Defer to the next macrotask so the source DropdownMenu fully closes + // (and Radix returns focus to its trigger) before we open the anchored + // store-as-secret popover. Opening synchronously inside the menu-item's + // onSelect lets the menu's focus-return land as a `focusOutside` / + // `interactOutside` on the just-opened popover, which Radix would + // immediately dismiss — the same nested open-while-closing race as the + // ⋯ path and the picker's + Create item (PAP-12476/12477/12478). + const { name, value } = plan; + window.setTimeout(() => setSecretPopover({ mode: "store", name, value }), 0); + return; + } + case "to-secret": + onPatch({ source: "secret" }); + // Auto-open the picker. + window.setTimeout(() => { + valueCellRef.current?.querySelector("[role=combobox]")?.focus(); + }, 0); + return; + case "to-text": + if (plan.undoFrom) setUndoPrev(plan.undoFrom); + onPatch({ source: "text", secretId: "", version: "latest" }); + window.setTimeout(() => valueInputRef.current?.focus(), 0); + return; + } + } + + async function submitSecretPopover(name: string, value: string) { + const created = await onCreateSecret(name, value); + onPatch({ source: "secret", secretId: created.id, version: "latest", textValue: "" }); + onToast(`Secret ${created.name} created`); + setSecretPopover(null); + } + + function openStoreAsSecret() { + const name = secretNameFromKey(row.name) || "secret"; + const { textValue } = row; + window.setTimeout(() => setSecretPopover({ mode: "store", name, value: textValue }), 0); + } + + const sourceLabel = row.source === "text" ? "Text value" : "Secret reference"; + const nameErrorId = `${row.id}-name-error`; + const healthId = `${row.id}-health`; + const isDirty = dirtyFields.name || dirtyFields.value; + + const versions = boundSecret ? Math.max(0, boundSecret.latestVersion) : 0; + const versionTagLabel = row.version === "latest" ? "latest" : `v${row.version}`; + const versionPinned = row.version !== "latest"; + + return ( +
+ {/* Name cell — mobile col 1 / desktop col 1 */} +
+ onPatch({ name: event.target.value })} + onBlur={onNameBlur} + onPaste={(event) => { + if (row.name) return; + const text = event.clipboardData.getData("text"); + if (onNamePaste(text)) event.preventDefault(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (row.source === "text") valueInputRef.current?.focus(); + else valueCellRef.current?.querySelector("[role=combobox]")?.focus(); + } + }} + /> +
+ + {/* Value cell — mobile full-width line 2 / desktop col 2 */} +
+ { + if (!open) setSecretPopover(null); + }} + > + +
+ {/* Source switch (inside the field) */} + + + + + + + + {sourceLabel} + + + switchSource("text")}> + Text value + Store the value inline as plain text. + + switchSource("secret")}> + Secret reference + Resolve a stored company secret at run start. + + + + + {row.source === "text" ? ( + <> + onPatch({ textValue: event.target.value })} + onKeyDown={(event) => { + if (event.key === "Enter" && isLast) { + event.preventDefault(); + onEnterInValueLast(); + } + }} + /> + {sensitive ? ( +
+ + +
+ ) : null} + + ) : ( +
+ onPatch({ secretId: id, version: "latest" })} + onCreateNew={(query) => + setSecretPopover({ mode: "create", name: secretNameFromKey(query) || query.trim(), value: "" }) + } + triggerClassName={cn( + "rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0", + boundSecret && boundSecret.status === "active" && "pr-24 has-[>svg]:!pr-24", + )} + /> + {boundSecret && boundSecret.status === "active" ? ( + + + + + + + {Array.from({ length: versions }, (_, idx) => versions - idx) + .filter((v) => v > 0) + .map((v) => ( + + ))} + + + ) : null} +
+ )} +
+
+ { + // Keep the create/store popover open when the dismissal + // originates from inside the value cell — i.e. the picker's + // combobox trigger or the source-switch dropdown trigger, both + // anchored here. Those controls close by returning focus to + // themselves; because the value cell is this popover's *anchor* + // (outside its content), Radix reads that focus-return as a + // `focusOutside` and would dismiss the just-opened popover. + // + // The picker's close animation can delay its focus-return past + // any single macrotask, so the `setTimeout(0)` open-defers + // (SearchableSelect / switchSource) cannot reliably win the race + // (PAP-12492). Guarding the interaction here makes the create / + // store popover survive that focus-return deterministically, + // independent of timing. Dismissals from anywhere *outside* the + // value cell (real outside clicks, Escape) are untouched. + const target = event.detail.originalEvent.target as Node | null; + if (target && valueCellRef.current?.contains(target)) { + event.preventDefault(); + } + }} + > + {secretPopover?.mode === "store" ? ( + s.name)} + onCancel={() => setSecretPopover(null)} + onSubmit={submitSecretPopover} + /> + ) : secretPopover?.mode === "create" ? ( + s.name)} + onCancel={() => setSecretPopover(null)} + onSubmit={submitSecretPopover} + /> + ) : null} + +
+ + {/* Inline secret-health message */} + {health ? ( +

+ {health.message} +

+ ) : null} + + {/* 5s undo after Secret→Text */} + {undoPrev ? ( +

+ Reverted to text —{" "} + +

+ ) : null} +
+ + {showNameIssue && nameIssue ? ( +

+ {nameIssue.message} +

+ ) : null} + + {/* Actions cell — mobile col 2 line 1 / desktop col 3 */} +
+ {row.source === "text" && !sensitive && (row.name.trim() || row.textValue) ? ( + + + + + + { + // Defer to the next macrotask so this menu fully closes (and + // Radix returns focus to the ⋯ trigger, which sits outside the + // row's Popover) before we open the anchored store-as-secret + // popover. Opening synchronously lets the menu's focus-return + // land as a `focusOutside` on the just-opened popover, which + // Radix would immediately dismiss — the same nested open-while- + // closing race as the picker's + Create item (PAP-12476/12477). + window.setTimeout(openStoreAsSecret, 0); + }} + > + Store as secret… + + + + ) : null} + +
+
+ ); +} diff --git a/ui/src/components/environment-variables-editor/SecretPicker.tsx b/ui/src/components/environment-variables-editor/SecretPicker.tsx new file mode 100644 index 00000000000..0458a106357 --- /dev/null +++ b/ui/src/components/environment-variables-editor/SecretPicker.tsx @@ -0,0 +1,337 @@ +import { useCallback, useMemo, useState } from "react"; +import { CornerUpLeft, Folder, KeyRound, Plus } from "lucide-react"; +import type { CompanySecret, SecretStatus } from "@paperclipai/shared"; +import { + SearchableSelect, + type SearchableSelectGroup, + type SearchableSelectOption, +} from "@/components/SearchableSelect"; +import { Badge } from "@/components/ui/badge"; +import { normalizeSearchText } from "@/lib/searchable-select"; +import { cn } from "@/lib/utils"; + +interface SecretOption extends SearchableSelectOption { + kind?: "secret" | "folder" | "back"; + secret?: CompanySecret; + missing?: boolean; + status?: SecretStatus; + folderPath?: string; + pathHint?: string; +} + +const FOLDER_VALUE_PREFIX = "__secret_folder__:"; + +function statusBadge(status: SecretStatus | undefined) { + if (!status || status === "active") return null; + return ( + + {status} + + ); +} + +function splitSecretPath(name: string) { + return name.split("/").filter((part) => part.length > 0); +} + +function pathKey(parts: readonly string[]) { + return parts.join("/"); +} + +function pathLabel(parts: readonly string[]) { + return parts.length > 0 ? `/${parts.join("/")}` : "/"; +} + +function pathStartsWith(parts: readonly string[], prefix: readonly string[]) { + if (parts.length < prefix.length) return false; + return prefix.every((part, index) => parts[index] === part); +} + +function folderValue(parts: readonly string[]) { + return `${FOLDER_VALUE_PREFIX}${pathKey(parts)}`; +} + +function buildFolderGroup( + secrets: readonly CompanySecret[], + currentPath: readonly string[], + currentSecretId: string, +): SearchableSelectGroup { + const currentLength = currentPath.length; + const folders = new Map(); + const leafSecrets: SecretOption[] = []; + + for (const secret of secrets) { + const parts = splitSecretPath(secret.name); + if (!pathStartsWith(parts, currentPath)) continue; + + if (parts.length > currentLength + 1) { + const folderParts = parts.slice(0, currentLength + 1); + const key = pathKey(folderParts); + if (!folders.has(key)) { + const label = folderParts[folderParts.length - 1] ?? "/"; + const fullPath = pathLabel(folderParts); + folders.set(key, { + key: `folder-${key || "root"}`, + value: folderValue(folderParts), + label, + title: fullPath, + searchText: fullPath, + kind: "folder", + folderPath: key, + pathHint: fullPath, + }); + } + continue; + } + + if (parts.length === currentLength + 1 || (currentLength === 0 && parts.length === 0)) { + const label = parts.at(-1) ?? secret.name; + leafSecrets.push({ + key: `browse-${secret.id}`, + value: secret.id, + label, + title: secret.name, + searchText: `${secret.key} ${secret.name}`, + secret, + status: secret.status, + kind: "secret", + pathHint: secret.name, + disabled: secret.status !== "active" && secret.id !== currentSecretId, + }); + } + } + + const options: SecretOption[] = []; + if (currentPath.length > 0) { + const parentPath = currentPath.slice(0, -1); + options.push({ + key: `folder-up-${pathKey(currentPath)}`, + value: folderValue(parentPath), + label: "Up one folder", + title: pathLabel(parentPath), + searchText: pathLabel(parentPath), + kind: "back", + folderPath: pathKey(parentPath), + pathHint: pathLabel(parentPath), + }); + } + options.push(...folders.values(), ...leafSecrets); + + return { + id: "browse-secrets", + label: currentPath.length > 0 ? pathLabel(currentPath) : "Browse secrets", + options, + }; +} + +export interface SecretPickerProps { + /** Currently-bound secret id, or "" when unbound. */ + secretId: string; + secrets: readonly CompanySecret[]; + recentlyUsedSecrets?: readonly CompanySecret[]; + disabled?: boolean; + onSelect: (secretId: string) => void; + /** Open the create-secret popover, seeded with the current query. */ + onCreateNew: (query: string) => void; + triggerClassName?: string; + /** SearchableSelect auto-opens on focus; suppress for programmatic control. */ + disablePortal?: boolean; +} + +/** + * Fuzzy secret combobox (plan §6.4). Reuses {@link SearchableSelect}, adds the + * Recently-used group, greys non-active secrets (non-selectable for new + * bindings), surfaces a missing-secret sentinel, and pins a `+ Create secret` + * creatable item. + */ +export function SecretPicker({ + secretId, + secrets, + recentlyUsedSecrets, + disabled, + onSelect, + onCreateNew, + triggerClassName, + disablePortal, +}: SecretPickerProps) { + const [currentPathKey, setCurrentPathKey] = useState(""); + const boundSecret = useMemo( + () => secrets.find((secret) => secret.id === secretId) ?? null, + [secrets, secretId], + ); + const boundMissing = Boolean(secretId) && !boundSecret; + const currentPath = useMemo(() => (currentPathKey ? currentPathKey.split("/") : []), [currentPathKey]); + const hasFolderPaths = useMemo(() => secrets.some((secret) => splitSecretPath(secret.name).length > 1), [secrets]); + + const groups = useMemo[]>(() => { + const result: SearchableSelectGroup[] = []; + + // Missing (deleted) secret still needs a resolvable option so the trigger + // can render the destructive "Missing secret" chip. + if (boundMissing) { + result.push({ + id: "current-missing", + label: "Current", + options: [ + { + key: `missing-${secretId}`, + value: secretId, + label: `Missing secret (${secretId.slice(0, 8)}…)`, + title: `Missing secret (${secretId})`, + missing: true, + disabled: true, + }, + ], + }); + } + + const recent = (recentlyUsedSecrets ?? []).filter( + (secret) => secret.status === "active" && secret.id !== secretId, + ); + if (recent.length > 0) { + result.push({ + id: "recently-used", + label: "Recently used", + options: recent.map((secret) => ({ + key: `recent-${secret.id}`, + value: secret.id, + label: secret.name, + title: secret.name, + searchText: `${secret.key} ${secret.name}`, + secret, + status: secret.status, + kind: "secret", + })), + }); + } + + result.push({ + id: "all-secrets", + label: recent.length > 0 ? "All secrets" : undefined, + options: secrets.map((secret) => ({ + key: `all-${secret.id}`, + value: secret.id, + label: secret.name, + title: secret.name, + searchText: `${secret.key} ${secret.name}`, + secret, + status: secret.status, + kind: "secret", + // Non-active secrets are not selectable for new bindings, but the + // already-bound one stays selectable (it's the current value). + disabled: secret.status !== "active" && secret.id !== secretId, + })), + }); + + return result; + }, [boundMissing, recentlyUsedSecrets, secretId, secrets]); + + const deriveGroups = useCallback( + (query: string, baseGroups: readonly SearchableSelectGroup[]) => { + if (!hasFolderPaths) return baseGroups; + if (normalizeSearchText(query)) return baseGroups; + + const browseGroup = buildFolderGroup(secrets, currentPath, secretId); + const stableGroups = baseGroups.filter((group) => group.id === "current-missing" || group.id === "recently-used"); + return browseGroup.options.length > 0 ? [...stableGroups, browseGroup] : stableGroups; + }, + [currentPath, hasFolderPaths, secretId, secrets], + ); + + return ( + + value={secretId || ""} + groups={groups} + onValueChange={(next, option) => { + if (option.folderPath !== undefined) { + setCurrentPathKey(option.folderPath); + return false; + } + setCurrentPathKey(""); + onSelect(next); + }} + deriveGroups={deriveGroups} + disabled={disabled} + disablePortal={disablePortal} + placeholder="Select secret…" + searchPlaceholder="Search secrets…" + emptyMessage="No matching secrets" + triggerClassName={cn( + "h-[34px] min-h-[34px] font-mono text-sm", + boundMissing && "border-destructive text-destructive", + boundSecret && boundSecret.status !== "active" && "border-amber-500/60", + triggerClassName, + )} + renderValue={(option) => { + if (!option) { + return Select secret…; + } + if (option.missing) { + return ( + + + {option.label} + + ); + } + const nonActive = option.status && option.status !== "active"; + return ( + + + {option.label} + {nonActive ? ({option.status}) : null} + + ); + }} + renderOption={(option, { selected }) => { + if (option.kind === "folder" || option.kind === "back") { + const Icon = option.kind === "back" ? CornerUpLeft : Folder; + return ( + + + + {option.label} + {option.pathHint ? ( + {option.pathHint} + ) : null} + + + ); + } + + return ( + + + + + {option.label} + + {option.pathHint && option.pathHint !== option.label ? ( + {option.pathHint} + ) : null} + + {statusBadge(option.status)} + + ); + }} + createItem={{ + render: (query) => ( + + + {query.trim() ? ( + + Create secret “{query.trim()}”… + + ) : ( + Create new secret… + )} + + ), + onSelect: (query) => onCreateNew(query), + }} + /> + ); +} diff --git a/ui/src/components/environment-variables-editor/index.tsx b/ui/src/components/environment-variables-editor/index.tsx new file mode 100644 index 00000000000..b29d7f4e821 --- /dev/null +++ b/ui/src/components/environment-variables-editor/index.tsx @@ -0,0 +1,472 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { flushSync } from "react-dom"; +import { AlertCircle, KeyRound, Plus, RotateCcw, Save } from "lucide-react"; +import type { CompanySecret, EnvBinding } from "@paperclipai/shared"; +import { cn } from "@/lib/utils"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useOptionalToastActions } from "@/context/ToastContext"; +import { EnvironmentVariableRow } from "./Row"; +import { parseDotenv } from "./parse-dotenv"; +import { + computeDuplicateNames, + computeRowHealth, + emptyRow, + envKeyFromSecretName, + rowsFromValue, + validateName, + valueFromRows, + type EnvRow, +} from "./model"; +import type { EnvironmentVariableDirtyFields } from "./Row"; + +const DEFAULT_RESERVED_PREFIXES = ["PAPERCLIP_"]; + +const DEFAULT_HINT = + "Set the KEY to the env var name the process expects, for example GH_TOKEN. Choose a secret to resolve a stored value at run start. PAPERCLIP_* variables are injected automatically."; + +function normalizedEnvKey(value: Record | null | undefined): string { + if (!value || typeof value !== "object") return ""; + const entries = Object.entries(value) + .map(([name, binding]) => { + if (typeof binding === "string") { + return [name, { type: "plain", value: binding }] as const; + } + if (binding?.type === "secret_ref") { + return [ + name, + { + type: "secret_ref", + secretId: typeof binding.secretId === "string" ? binding.secretId : "", + version: typeof binding.version === "number" ? binding.version : "latest", + }, + ] as const; + } + if (binding?.type === "plain") { + return [ + name, + { type: "plain", value: typeof binding.value === "string" ? binding.value : "" }, + ] as const; + } + return [name, { type: "plain", value: "" }] as const; + }) + .sort(([left], [right]) => left.localeCompare(right)); + return JSON.stringify(entries); +} + +function cloneRows(rows: readonly EnvRow[]): EnvRow[] { + return rows.map((row) => ({ ...row })); +} + +function rowDirtyFields(row: EnvRow, committedRow: EnvRow | undefined): EnvironmentVariableDirtyFields { + if (!committedRow) { + return { + name: Boolean(row.name.trim()), + value: + row.source !== "text" || + Boolean(row.textValue) || + Boolean(row.secretId) || + row.version !== "latest", + }; + } + + return { + name: row.name.trim() !== committedRow.name.trim(), + value: + row.source !== committedRow.source || + row.textValue !== committedRow.textValue || + row.secretId !== committedRow.secretId || + row.version !== committedRow.version, + }; +} + +export interface EnvironmentVariablesEditorProps { + value: Record; + onChange: (next: Record | undefined) => void; + secrets: readonly CompanySecret[]; + onCreateSecret: (name: string, value: string) => Promise; + /** Optional "Recently used" picker group + quick-bind chips. */ + recentlyUsedSecrets?: readonly CompanySecret[]; + /** Read-only rendering. */ + disabled?: boolean; + /** Prefixes flagged as reserved/auto-provided. Default `["PAPERCLIP_"]`. */ + reservedPrefixes?: readonly string[]; + /** Context-specific hint line. `null` hides the default copy; omit for default. */ + footerHint?: ReactNode | null; +} + +export interface EnvironmentVariablesEditorHandle { + /** + * Promote the editor-local draft into the controlled value before an outer + * action reads parent state. Returns the promoted value when a draft existed. + */ + flushPendingDraft: () => Record | null; +} + +export const EnvironmentVariablesEditor = forwardRef(function EnvironmentVariablesEditor({ + value, + onChange, + secrets, + onCreateSecret, + recentlyUsedSecrets, + disabled, + reservedPrefixes = DEFAULT_RESERVED_PREFIXES, + footerHint, +}: EnvironmentVariablesEditorProps, ref) { + const toast = useOptionalToastActions(); + const editorRootRef = useRef(null); + const [rows, setRows] = useState(() => rowsFromValue(value)); + const rowsRef = useRef(rows); + const [committedRows, setCommittedRows] = useState(() => cloneRows(rows)); + const initialValueKey = useMemo(() => normalizedEnvKey(value), []); // eslint-disable-line react-hooks/exhaustive-deps + const committedValueKeyRef = useRef(initialValueKey); + const lastPropValueKeyRef = useRef(initialValueKey); + const pendingSaveValueKeyRef = useRef(null); + const [committedValueKey, setCommittedValueKey] = useState(initialValueKey); + // Seeded (already-committed) names are "touched" so a saved reserved/invalid + // var surfaces its message on load; freshly-typed rows wait for blur (§6.2). + const [touchedNames, setTouchedNames] = useState>( + () => new Set(rowsFromValue(value).map((row) => row.name.trim()).filter(Boolean)), + ); + const [pendingFocus, setPendingFocus] = useState<{ rowId: string; field: "name" | "value" } | null>(null); + + useEffect(() => { + rowsRef.current = rows; + }, [rows]); + + function markCommitted(nextValueKey: string, nextRows: readonly EnvRow[] = rowsRef.current) { + committedValueKeyRef.current = nextValueKey; + setCommittedValueKey(nextValueKey); + setCommittedRows(cloneRows(nextRows)); + } + + function touchCommittedNames(nextRows: EnvRow[]) { + setTouchedNames((prev) => { + const next = new Set(prev); + for (const row of nextRows) { + const name = row.name.trim(); + if (name) next.add(name); + } + return next; + }); + } + + function adoptExternalValue(nextValue: Record): EnvRow[] { + const nextRows = rowsFromValue(nextValue); + setRows(nextRows); + touchCommittedNames(nextRows); + return nextRows; + } + + // Controlled sync: clean external changes replace the editor rows, but dirty + // local drafts are never clobbered by refetches. A save echo only advances the + // committed baseline so the focused row keeps its local id. + useEffect(() => { + const incomingValueKey = normalizedEnvKey(value); + if (incomingValueKey === lastPropValueKeyRef.current) { + return; + } + lastPropValueKeyRef.current = incomingValueKey; + + const draftValueKey = normalizedEnvKey(valueFromRows(rowsRef.current)); + const draftIsDirty = draftValueKey !== committedValueKeyRef.current; + const matchesPendingSave = pendingSaveValueKeyRef.current === incomingValueKey; + if (matchesPendingSave) { + pendingSaveValueKeyRef.current = null; + } + + if (!draftIsDirty) { + const nextRows = adoptExternalValue(value); + markCommitted(incomingValueKey, nextRows); + return; + } + + if (matchesPendingSave || draftValueKey === incomingValueKey) { + touchCommittedNames(rowsRef.current); + markCommitted(incomingValueKey); + } + }, [value]); + + const draftValue = useMemo(() => valueFromRows(rows), [rows]); + const draftValueKey = useMemo(() => normalizedEnvKey(draftValue), [draftValue]); + const hasUnsavedChanges = draftValueKey !== committedValueKey; + + const flushPendingDraft = useCallback(() => { + if (disabled || !hasUnsavedChanges) return null; + pendingSaveValueKeyRef.current = draftValueKey; + flushSync(() => { + onChange(draftValue); + }); + return draftValue ?? {}; + }, [disabled, draftValue, draftValueKey, hasUnsavedChanges, onChange]); + + useImperativeHandle(ref, () => ({ flushPendingDraft }), [flushPendingDraft]); + + useEffect(() => { + const form = editorRootRef.current?.closest("form"); + if (!form) return; + + function handleSubmit() { + flushPendingDraft(); + } + + form.addEventListener("submit", handleSubmit, true); + return () => form.removeEventListener("submit", handleSubmit, true); + }, [flushPendingDraft]); + + useEffect(() => { + const root = editorRootRef.current; + if (!root) return; + const currentRoot = root; + + function handleDocumentClick(event: MouseEvent) { + const target = event.target; + if (!(target instanceof Element) || currentRoot.contains(target)) return; + const button = target.closest("button"); + if (!button || button.disabled) return; + const label = `${button.getAttribute("aria-label") ?? ""} ${button.textContent ?? ""}`; + if (button.type === "submit" || /\b(save|create|update|test|import)\b/i.test(label)) { + flushPendingDraft(); + } + } + + document.addEventListener("click", handleDocumentClick, true); + return () => document.removeEventListener("click", handleDocumentClick, true); + }, [flushPendingDraft]); + + function updateDraft(nextRows: EnvRow[]) { + setRows(nextRows); + } + + function patchRow(id: string, patch: Partial) { + updateDraft(rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); + } + + function removeRow(id: string) { + updateDraft(rows.filter((row) => row.id !== id)); + } + + function addRow() { + const row = emptyRow(); + setRows([...rows, row]); + setPendingFocus({ rowId: row.id, field: "name" }); + } + + function markTouched(id: string) { + const rowName = rows.find((row) => row.id === id)?.name.trim(); + if (!rowName) return; + setTouchedNames((prev) => { + if (prev.has(rowName)) return prev; + const next = new Set(prev); + next.add(rowName); + return next; + }); + } + + function bulkImport(text: string, targetRowId: string): boolean { + const pairs = parseDotenv(text); + if (pairs.length === 0) return false; + // Drop the empty row that received the paste, then upsert each pair. + const working = rows.filter((row) => row.id !== targetRowId).map((row) => ({ ...row })); + for (const { key, value: pairValue } of pairs) { + const existing = working.find((row) => row.name.trim() === key); + if (existing) { + existing.name = key; + existing.source = "text"; + existing.textValue = pairValue; + existing.secretId = ""; + existing.sensitiveDismissed = false; + } else { + working.push({ ...emptyRow(), name: key, textValue: pairValue }); + } + } + updateDraft(working); + toast?.pushToast({ title: `Imported ${pairs.length} variable${pairs.length === 1 ? "" : "s"}`, tone: "success" }); + return true; + } + + function bindRecentSecret(secret: CompanySecret) { + const next = rows.map((row) => ({ ...row })); + const trailing = next[next.length - 1]; + let target: EnvRow; + if (trailing && !trailing.name && !trailing.textValue && !trailing.secretId) { + target = trailing; + } else { + target = emptyRow(); + next.push(target); + } + target.source = "secret"; + target.secretId = secret.id; + target.version = "latest"; + if (!target.name) target.name = envKeyFromSecretName(secret.name); + updateDraft(next); + } + + function saveDraft() { + if (!hasUnsavedChanges) return; + pendingSaveValueKeyRef.current = draftValueKey; + onChange(draftValue); + } + + function revertDraft() { + pendingSaveValueKeyRef.current = null; + lastPropValueKeyRef.current = normalizedEnvKey(value); + const nextRows = adoptExternalValue(value); + markCommitted(lastPropValueKeyRef.current, nextRows); + } + + const duplicateNames = useMemo(() => computeDuplicateNames(rows), [rows]); + + const attentionCount = useMemo( + () => rows.reduce((count, row) => (computeRowHealth(row, secrets) ? count + 1 : count), 0), + [rows, secrets], + ); + + const quickBind = useMemo(() => { + const boundIds = new Set(rows.filter((row) => row.source === "secret" && row.secretId).map((row) => row.secretId)); + return (recentlyUsedSecrets ?? []) + .filter((secret) => secret.status === "active" && !boundIds.has(secret.id)) + .slice(0, 8); + }, [recentlyUsedSecrets, rows]); + + const hasRows = rows.length > 0; + const hint = footerHint === undefined ? DEFAULT_HINT : footerHint; + const committedRowsById = useMemo( + () => new Map(committedRows.map((row) => [row.id, row])), + [committedRows], + ); + + return ( + +
+ {attentionCount > 1 ? ( +

+ + {attentionCount} bindings need attention +

+ ) : null} + + {hasRows ? ( + <> + {/* Header (desktop only) */} +
+ Name + Value + +
+ + {rows.map((row, index) => { + const issue = validateName(row.name, duplicateNames, reservedPrefixes); + const touched = touchedNames.has(row.name.trim()); + return ( + patchRow(row.id, patch)} + onRemove={() => removeRow(row.id)} + onNameBlur={() => markTouched(row.id)} + onNamePaste={(text) => bulkImport(text, row.id)} + onEnterInValueLast={addRow} + onCreateSecret={onCreateSecret} + onToast={(message) => toast?.pushToast({ title: message, tone: "success" })} + focusRequest={pendingFocus?.rowId === row.id ? pendingFocus.field : null} + onFocusConsumed={() => setPendingFocus(null)} + /> + ); + })} + + ) : ( +

No environment variables

+ )} + + {/* Footer bar */} +
+ + + {quickBind.length > 0 && !disabled ? ( +
+ + + Recently used: + + {quickBind.map((secret) => ( + + ))} +
+ ) : null} + +
+ + {hasUnsavedChanges && !disabled ? ( +
+
+ + Unsaved changes +
+
+ + +
+
+ ) : null} + + {hint ?

{hint}

: null} +
+
+ ); +}); + +export type { EnvRow } from "./model"; +export { EnvironmentVariableRow } from "./Row"; +export { SecretPicker } from "./SecretPicker"; +export { CreateSecretPopover, ConvertToSecretPopover } from "./CreateSecretPopover"; +export { parseDotenv, looksLikeDotenv } from "./parse-dotenv"; +export { isSensitiveEnv } from "./sensitive"; diff --git a/ui/src/components/environment-variables-editor/model.test.ts b/ui/src/components/environment-variables-editor/model.test.ts new file mode 100644 index 00000000000..2bb016afea0 --- /dev/null +++ b/ui/src/components/environment-variables-editor/model.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import type { CompanySecret } from "@paperclipai/shared"; +import { + computeDuplicateNames, + computeRowHealth, + emptyRow, + envKeyFromSecretName, + planSourceSwitch, + rowsFromValue, + secretNameFromKey, + validateName, + valueFromRows, + type EnvRow, +} from "./model"; + +function makeSecret(overrides: Partial & Pick): CompanySecret { + return { + companyId: "co", + key: overrides.id, + name: overrides.id.toUpperCase(), + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 3, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; +} + +describe("rowsFromValue", () => { + it("returns no rows for empty/undefined input (no ghost row)", () => { + expect(rowsFromValue(undefined)).toEqual([]); + expect(rowsFromValue({})).toEqual([]); + }); + + it("maps legacy string, plain, and secret_ref bindings", () => { + const rows = rowsFromValue({ + LEGACY: "raw", + PLAIN: { type: "plain", value: "v" }, + REF: { type: "secret_ref", secretId: "s1", version: 2 }, + REF_LATEST: { type: "secret_ref", secretId: "s2" }, + }); + expect(rows.map((r) => ({ name: r.name, source: r.source, textValue: r.textValue, secretId: r.secretId, version: r.version }))).toEqual([ + { name: "LEGACY", source: "text", textValue: "raw", secretId: "", version: "latest" }, + { name: "PLAIN", source: "text", textValue: "v", secretId: "", version: "latest" }, + { name: "REF", source: "secret", textValue: "", secretId: "s1", version: 2 }, + { name: "REF_LATEST", source: "secret", textValue: "", secretId: "s2", version: "latest" }, + ]); + }); +}); + +describe("valueFromRows (emit semantics)", () => { + function row(partial: Partial): EnvRow { + return { ...emptyRow(), ...partial }; + } + + it("emits undefined when there are no complete rows", () => { + expect(valueFromRows([])).toBeUndefined(); + expect(valueFromRows([row({ name: "" })])).toBeUndefined(); + }); + + it("drops rows with empty (trimmed) names", () => { + expect(valueFromRows([row({ name: " ", textValue: "x" })])).toBeUndefined(); + }); + + it("drops secret rows without a chosen secret (incomplete ref)", () => { + expect(valueFromRows([row({ name: "A", source: "secret", secretId: "" })])).toBeUndefined(); + }); + + it("emits plain and secret_ref bindings", () => { + expect( + valueFromRows([ + row({ name: "A", source: "text", textValue: "1" }), + row({ name: "B", source: "secret", secretId: "s1", version: 2 }), + ]), + ).toEqual({ + A: { type: "plain", value: "1" }, + B: { type: "secret_ref", secretId: "s1", version: 2 }, + }); + }); + + it("is last-writer-wins on duplicate names", () => { + expect( + valueFromRows([ + row({ name: "A", textValue: "first" }), + row({ name: "A", textValue: "second" }), + ]), + ).toEqual({ A: { type: "plain", value: "second" } }); + }); +}); + +describe("validateName", () => { + const reserved = ["PAPERCLIP_"]; + + it("returns null for empty and valid names", () => { + expect(validateName("", new Set(), reserved)).toBeNull(); + expect(validateName("GH_TOKEN", new Set(), reserved)).toBeNull(); + expect(validateName("_private1", new Set(), reserved)).toBeNull(); + }); + + it("flags invalid charset", () => { + expect(validateName("1BAD", new Set(), reserved)?.level).toBe("error"); + expect(validateName("has-dash", new Set(), reserved)?.message).toMatch(/letters, digits/); + }); + + it("flags duplicates as errors", () => { + expect(validateName("DUP", new Set(["DUP"]), reserved)).toEqual({ level: "error", message: "Duplicate name" }); + }); + + it("flags reserved prefixes as warnings", () => { + const issue = validateName("PAPERCLIP_HOME", new Set(), reserved); + expect(issue?.level).toBe("warn"); + expect(issue?.message).toMatch(/Reserved prefix/); + }); + + it("charset error takes precedence over reserved prefix", () => { + expect(validateName("PAPERCLIP-X", new Set(), reserved)?.level).toBe("error"); + }); +}); + +describe("computeDuplicateNames", () => { + it("collects names appearing more than once", () => { + const rows = [ + { ...emptyRow(), name: "A" }, + { ...emptyRow(), name: "A" }, + { ...emptyRow(), name: "B" }, + { ...emptyRow(), name: "" }, + ]; + expect([...computeDuplicateNames(rows)]).toEqual(["A"]); + }); +}); + +describe("computeRowHealth", () => { + const secrets = [makeSecret({ id: "active" }), makeSecret({ id: "disabled", status: "disabled" })]; + + it("returns null for healthy secret and text rows", () => { + expect(computeRowHealth({ ...emptyRow(), source: "text", name: "A", textValue: "x" }, secrets)).toBeNull(); + expect(computeRowHealth({ ...emptyRow(), source: "secret", secretId: "active" }, secrets)).toBeNull(); + }); + + it("flags a missing secret as an error", () => { + expect(computeRowHealth({ ...emptyRow(), source: "secret", secretId: "gone" }, secrets)?.kind).toBe("missing"); + }); + + it("flags a disabled secret as a warning", () => { + expect(computeRowHealth({ ...emptyRow(), source: "secret", secretId: "disabled" }, secrets)?.kind).toBe("disabled"); + }); +}); + +describe("planSourceSwitch (§6.3)", () => { + it("is a noop when the source is unchanged", () => { + expect(planSourceSwitch({ ...emptyRow(), source: "text" }, "text")).toEqual({ kind: "noop" }); + }); + + it("preserves a non-empty value on Text→Secret by opening the store popover", () => { + const plan = planSourceSwitch({ ...emptyRow(), name: "GH_TOKEN", source: "text", textValue: "abc" }, "secret"); + expect(plan).toEqual({ kind: "open-store", name: "gh_token", value: "abc" }); + }); + + it("switches straight to secret when the text value is empty", () => { + expect(planSourceSwitch({ ...emptyRow(), source: "text", textValue: " " }, "secret")).toEqual({ kind: "to-secret" }); + }); + + it("offers undo on Secret→Text when a secret was bound", () => { + const row = { ...emptyRow(), source: "secret" as const, secretId: "s1", version: 2 as const }; + const plan = planSourceSwitch(row, "text"); + expect(plan.kind).toBe("to-text"); + if (plan.kind === "to-text") expect(plan.undoFrom?.secretId).toBe("s1"); + }); + + it("does not offer undo on Secret→Text when no secret was bound", () => { + const plan = planSourceSwitch({ ...emptyRow(), source: "secret" }, "text"); + expect(plan).toEqual({ kind: "to-text", undoFrom: null }); + }); +}); + +describe("name suggestion helpers", () => { + it("secretNameFromKey lowercases to snake", () => { + expect(secretNameFromKey("GH_TOKEN")).toBe("gh_token"); + expect(secretNameFromKey("Stripe-API-Key!")).toBe("stripe_api_key"); + }); + + it("envKeyFromSecretName uppercases to snake", () => { + expect(envKeyFromSecretName("github token")).toBe("GITHUB_TOKEN"); + }); +}); diff --git a/ui/src/components/environment-variables-editor/model.ts b/ui/src/components/environment-variables-editor/model.ts new file mode 100644 index 00000000000..8a6c7eb60ee --- /dev/null +++ b/ui/src/components/environment-variables-editor/model.ts @@ -0,0 +1,216 @@ +import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared"; + +export type RowSource = "text" | "secret"; + +/** Local, per-row UI state. Only a subset is emitted upward (see {@link valueFromRows}). */ +export interface EnvRow { + /** Stable local id — used as React key and to target popovers/undo. */ + id: string; + name: string; + source: RowSource; + textValue: string; + secretId: string; + version: SecretVersionSelector; + /** Session-local dismissal of the sensitive-value suggestion (§6.6). */ + sensitiveDismissed?: boolean; +} + +let rowCounter = 0; +export function nextRowId(): string { + rowCounter += 1; + return `env-row-${rowCounter}`; +} + +export function emptyRow(source: RowSource = "text"): EnvRow { + return { id: nextRowId(), name: "", source, textValue: "", secretId: "", version: "latest" }; +} + +function isSecretRef(binding: unknown): binding is { type: "secret_ref"; secretId?: unknown; version?: unknown } { + return ( + typeof binding === "object" && + binding !== null && + "type" in binding && + (binding as { type?: unknown }).type === "secret_ref" + ); +} + +function isPlainObj(binding: unknown): binding is { type: "plain"; value?: unknown } { + return ( + typeof binding === "object" && + binding !== null && + "type" in binding && + (binding as { type?: unknown }).type === "plain" + ); +} + +/** Build editor rows from the controlled value. No implicit trailing ghost row. */ +export function rowsFromValue(value: Record | null | undefined): EnvRow[] { + if (!value || typeof value !== "object") return []; + return Object.entries(value).map(([name, binding]) => { + if (typeof binding === "string") { + return { id: nextRowId(), name, source: "text" as const, textValue: binding, secretId: "", version: "latest" as const }; + } + if (isSecretRef(binding)) { + const version: SecretVersionSelector = typeof binding.version === "number" ? binding.version : "latest"; + return { + id: nextRowId(), + name, + source: "secret" as const, + textValue: "", + secretId: typeof binding.secretId === "string" ? binding.secretId : "", + version, + }; + } + if (isPlainObj(binding)) { + return { + id: nextRowId(), + name, + source: "text" as const, + textValue: typeof binding.value === "string" ? binding.value : "", + secretId: "", + version: "latest" as const, + }; + } + return { id: nextRowId(), name, source: "text" as const, textValue: "", secretId: "", version: "latest" as const }; + }); +} + +/** + * Emit semantics (plan §4/§6.1): rows with empty (trimmed) names are dropped; + * secret rows without a chosen secret are incomplete and dropped; an empty + * result emits `undefined`. Duplicate names are last-writer-wins (unchanged). + */ +export function valueFromRows(rows: EnvRow[]): Record | undefined { + const record: Record = {}; + for (const row of rows) { + const name = row.name.trim(); + if (!name) continue; + if (row.source === "secret") { + if (!row.secretId) continue; // incomplete ref — not emitted + record[name] = { type: "secret_ref", secretId: row.secretId, version: row.version }; + } else { + record[name] = { type: "plain", value: row.textValue }; + } + } + return Object.keys(record).length > 0 ? record : undefined; +} + +export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export type NameIssueLevel = "error" | "warn"; +export interface NameIssue { + level: NameIssueLevel; + message: string; +} + +/** + * Validate a single row's name given the full set of names (for duplicate + * detection). Returns null when the name is empty (not yet an error) or valid. + */ +export function validateName( + name: string, + duplicateNames: ReadonlySet, + reservedPrefixes: readonly string[], +): NameIssue | null { + const trimmed = name.trim(); + if (!trimmed) return null; + if (!ENV_NAME_RE.test(trimmed)) { + return { level: "error", message: "Invalid name — use letters, digits and _" }; + } + if (duplicateNames.has(trimmed)) { + return { level: "error", message: "Duplicate name" }; + } + for (const prefix of reservedPrefixes) { + if (prefix && trimmed.startsWith(prefix)) { + return { level: "warn", message: "Reserved prefix — provided automatically and may be overridden" }; + } + } + return null; +} + +/** Names that appear on more than one row (trimmed, non-empty). */ +export function computeDuplicateNames(rows: EnvRow[]): Set { + const counts = new Map(); + for (const row of rows) { + const name = row.name.trim(); + if (!name) continue; + counts.set(name, (counts.get(name) ?? 0) + 1); + } + const dupes = new Set(); + for (const [name, count] of counts) { + if (count > 1) dupes.add(name); + } + return dupes; +} + +/** + * Pure decision for a source switch (§6.3), extracted so the value-preserving + * behaviour is unit-testable without driving the Radix menu. + */ +export type SourceSwitchPlan = + | { kind: "noop" } + /** Text → Secret with a non-empty value: never discard it — open Store-as-secret. */ + | { kind: "open-store"; name: string; value: string } + /** Text → Secret with an empty value: switch and open the picker. */ + | { kind: "to-secret" } + /** Secret → Text: clear the ref; offer undo when a secret was bound. */ + | { kind: "to-text"; undoFrom: EnvRow | null }; + +export function planSourceSwitch(row: EnvRow, next: RowSource): SourceSwitchPlan { + if (next === row.source) return { kind: "noop" }; + if (next === "secret") { + if (row.textValue.trim()) { + return { kind: "open-store", name: secretNameFromKey(row.name) || "secret", value: row.textValue }; + } + return { kind: "to-secret" }; + } + return { kind: "to-text", undoFrom: row.secretId ? { ...row } : null }; +} + +export interface SecretHealth { + level: "error" | "warn"; + message: string; + /** Short label for the summary line at the top of the editor. */ + kind: "missing" | "disabled"; +} + +/** Per-row secret-binding health (plan §6.8). Null when healthy or not a bound ref. */ +export function computeRowHealth(row: EnvRow, secrets: readonly CompanySecret[]): SecretHealth | null { + if (row.source !== "secret" || !row.secretId) return null; + const secret = secrets.find((candidate) => candidate.id === row.secretId); + if (!secret) { + return { + level: "error", + kind: "missing", + message: "This secret no longer exists — runs will fail until you rebind.", + }; + } + if (secret.status !== "active") { + return { + level: "warn", + kind: "disabled", + message: "Runs will fail until re-enabled or rebound.", + }; + } + return null; +} + +/** Suggest a `lower_snake` secret name from an env KEY (plan §6.5). */ +export function secretNameFromKey(key: string): string { + return key + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 64); +} + +/** Suggest an env KEY (UPPER_SNAKE) from a secret name (for quick-bind). */ +export function envKeyFromSecretName(name: string): string { + return name + .trim() + .toUpperCase() + .replace(/[^A-Z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 64); +} diff --git a/ui/src/components/environment-variables-editor/parse-dotenv.test.ts b/ui/src/components/environment-variables-editor/parse-dotenv.test.ts new file mode 100644 index 00000000000..77619bdeaac --- /dev/null +++ b/ui/src/components/environment-variables-editor/parse-dotenv.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeDotenv, parseDotenv } from "./parse-dotenv"; + +describe("parseDotenv", () => { + it("parses simple KEY=VALUE lines in order", () => { + expect(parseDotenv("FOO=bar\nBAZ=qux")).toEqual([ + { key: "FOO", value: "bar" }, + { key: "BAZ", value: "qux" }, + ]); + }); + + it("ignores comments and blank lines", () => { + const text = "# a comment\n\nFOO=bar\n \n# another\nBAZ=qux\n"; + expect(parseDotenv(text)).toEqual([ + { key: "FOO", value: "bar" }, + { key: "BAZ", value: "qux" }, + ]); + }); + + it("tolerates a leading `export`", () => { + expect(parseDotenv("export TOKEN=abc123")).toEqual([{ key: "TOKEN", value: "abc123" }]); + }); + + it("strips a single layer of matching quotes", () => { + expect(parseDotenv('A="hello world"\nB=\'single\'')).toEqual([ + { key: "A", value: "hello world" }, + { key: "B", value: "single" }, + ]); + }); + + it("keeps `=` characters inside the value", () => { + expect(parseDotenv("DB_URL=postgres://u:p@host/db?x=1")).toEqual([ + { key: "DB_URL", value: "postgres://u:p@host/db?x=1" }, + ]); + }); + + it("strips trailing inline comments on unquoted values", () => { + expect(parseDotenv("FOO=bar # trailing")).toEqual([{ key: "FOO", value: "bar" }]); + }); + + it("skips lines with invalid keys", () => { + expect(parseDotenv("1BAD=x\n-also-bad=y\nGOOD=z")).toEqual([{ key: "GOOD", value: "z" }]); + }); + + it("returns an empty array for text with no assignments", () => { + expect(parseDotenv("just some prose\nwith no equals")).toEqual([]); + }); + + it("allows empty values", () => { + expect(parseDotenv("EMPTY=")).toEqual([{ key: "EMPTY", value: "" }]); + }); +}); + +describe("looksLikeDotenv", () => { + it("is true for a single KEY=VALUE", () => { + expect(looksLikeDotenv("FOO=bar")).toBe(true); + }); + + it("is false for a bare token with no equals", () => { + expect(looksLikeDotenv("ghp_1234567890abcdef")).toBe(false); + }); + + it("is false for prose that happens to contain an equals but no valid key", () => { + expect(looksLikeDotenv("1 = 1")).toBe(false); + }); +}); diff --git a/ui/src/components/environment-variables-editor/parse-dotenv.ts b/ui/src/components/environment-variables-editor/parse-dotenv.ts new file mode 100644 index 00000000000..5db4d9665a7 --- /dev/null +++ b/ui/src/components/environment-variables-editor/parse-dotenv.ts @@ -0,0 +1,64 @@ +/** + * Minimal `.env`-style parser for bulk-pasting environment variables into the + * editor (plan §6.9). Deliberately forgiving: it ignores comments and blank + * lines, tolerates a leading `export `, strips one layer of matching quotes, + * and returns key/value pairs in source order. It is NOT a full dotenv + * implementation (no variable interpolation, no multi-line values). + */ +export interface ParsedEnvPair { + key: string; + value: string; +} + +/** A KEY at the start of a line, optionally prefixed with `export `. */ +const LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/; + +function stripInlineComment(value: string): string { + // Only strip a ` #comment` when the value is unquoted. Quoted values are + // handled by the caller before this runs, so here we only see bare values. + const hashIndex = value.indexOf(" #"); + if (hashIndex >= 0) return value.slice(0, hashIndex); + return value; +} + +function unquote(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length >= 2) { + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return trimmed.slice(1, -1); + } + } + return stripInlineComment(trimmed).trim(); +} + +/** + * Returns the parsed pairs, or an empty array when nothing looks like an env + * assignment. Callers can treat an empty result as "not a dotenv paste" and + * fall through to the browser's default paste handling. + */ +export function parseDotenv(text: string): ParsedEnvPair[] { + const pairs: ParsedEnvPair[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = LINE_RE.exec(line); + if (!match) continue; + const key = match[1]; + const value = unquote(match[2]); + pairs.push({ key, value }); + } + return pairs; +} + +/** + * Heuristic: does a paste look like a `.env` block worth importing? We require + * either more than one parsed pair, or a single pair that spans a genuine + * `KEY=VALUE` (so a lone `FOO=bar` typed into an empty name still imports, but + * pasting a bare token like `ghp_xxx` does not hijack the field). + */ +export function looksLikeDotenv(text: string): boolean { + if (!text.includes("=")) return false; + return parseDotenv(text).length > 0; +} diff --git a/ui/src/components/environment-variables-editor/sensitive.test.ts b/ui/src/components/environment-variables-editor/sensitive.test.ts new file mode 100644 index 00000000000..fdc5048d7b6 --- /dev/null +++ b/ui/src/components/environment-variables-editor/sensitive.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { isSensitiveEnv } from "./sensitive"; + +describe("isSensitiveEnv", () => { + it("is false for an empty value regardless of name", () => { + expect(isSensitiveEnv("API_KEY", "")).toBe(false); + }); + + it("flags credential-shaped names (regex shared with the server migration)", () => { + expect(isSensitiveEnv("STRIPE_API_KEY", "x")).toBe(true); + expect(isSensitiveEnv("DB_PASSWORD", "x")).toBe(true); + expect(isSensitiveEnv("MY_SECRET", "x")).toBe(true); + expect(isSensitiveEnv("ACCESS_TOKEN", "x")).toBe(true); + expect(isSensitiveEnv("GH_AUTH_TOKEN", "x")).toBe(true); + }); + + it("does not flag benign names with short values", () => { + expect(isSensitiveEnv("NODE_ENV", "production")).toBe(false); + expect(isSensitiveEnv("PORT", "3000")).toBe(false); + // A bare `TOKEN` in the name is intentionally NOT matched by name alone + // (the server regex only matches access_token / auth_token). + expect(isSensitiveEnv("GH_TOKEN", "short")).toBe(false); + }); + + it("flags known credential value shapes even with benign names", () => { + expect(isSensitiveEnv("CONFIG", "sk-abcdefghijklmnopqrst")).toBe(true); + expect(isSensitiveEnv("CONFIG", "ghp_0123456789abcdef0123456789abcdef")).toBe(true); + expect(isSensitiveEnv("CONFIG", "xoxb-123456789012-abcdefghijkl")).toBe(true); + expect(isSensitiveEnv("CONFIG", "-----BEGIN RSA PRIVATE KEY-----")).toBe(true); + }); + + it("flags long high-entropy tokens", () => { + expect(isSensitiveEnv("CONFIG", "aB3xY7zQ9mN2pR5tK8wL1vC4")).toBe(true); + }); + + it("does not flag long lowercase-only prose/paths", () => { + expect(isSensitiveEnv("PATH", "/usr/local/bin/some/long/path/here")).toBe(false); + expect(isSensitiveEnv("GREETING", "hello there this is a message")).toBe(false); + }); +}); diff --git a/ui/src/components/environment-variables-editor/sensitive.ts b/ui/src/components/environment-variables-editor/sensitive.ts new file mode 100644 index 00000000000..0c7d1cae5d9 --- /dev/null +++ b/ui/src/components/environment-variables-editor/sensitive.ts @@ -0,0 +1,46 @@ +/** + * Sensitive-value detection (plan §6.6). Kept in sync with the server-side + * migration heuristic so the UI flags the same things the backend would. + * Source of the key regex: `scripts/migrate-inline-env-secrets.ts:5`. + */ + +/** Env-var NAMES that conventionally hold credentials. */ +export const SENSITIVE_ENV_KEY_RE = + /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; + +/** Well-known credential value shapes (provider token prefixes, PEM headers). */ +const CREDENTIAL_VALUE_RES: RegExp[] = [ + /^sk-[A-Za-z0-9-_]{16,}$/, // OpenAI / Stripe style + /^gh[pousr]_[A-Za-z0-9]{20,}$/, // GitHub PAT / OAuth / server / refresh + /^github_pat_[A-Za-z0-9_]{20,}$/, // GitHub fine-grained PAT + /^xox[baprs]-[A-Za-z0-9-]{10,}$/, // Slack tokens + /^AKIA[0-9A-Z]{16}$/, // AWS access key id + /^AIza[0-9A-Za-z\-_]{20,}$/, // Google API key + /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/, // PEM private key + /^eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/, // JWT +]; + +function looksHighEntropy(value: string): boolean { + // A long, unbroken, mixed token (base64/hex/opaque secret) with no spaces. + if (value.length < 24) return false; + if (/\s/.test(value)) return false; + if (!/^[A-Za-z0-9+/=_\-.]+$/.test(value)) return false; + const hasLower = /[a-z]/.test(value); + const hasUpper = /[A-Z]/.test(value); + const hasDigit = /[0-9]/.test(value); + // Require at least two character classes so plain lowercase words/paths + // (e.g. a long file path) don't trip the heuristic. + const classes = [hasLower, hasUpper, hasDigit].filter(Boolean).length; + return classes >= 2; +} + +/** + * Returns true when a Text-source binding looks like it holds a secret and + * should be surfaced with the "Store as secret" suggestion. + */ +export function isSensitiveEnv(name: string, value: string): boolean { + if (!value) return false; + if (SENSITIVE_ENV_KEY_RE.test(name)) return true; + if (CREDENTIAL_VALUE_RES.some((re) => re.test(value))) return true; + return looksHighEntropy(value); +} diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index f574a2cb509..2062f7e317b 100644 --- a/ui/src/components/routine-sections/editable-sections.tsx +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -33,7 +33,7 @@ import { MarkdownEditor } from "../MarkdownEditor"; import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor"; import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor"; import { RoutineTriggerCard } from "../RoutineTriggerCard"; -import { EnvVarEditor } from "../EnvVarEditor"; +import { EnvironmentVariablesEditor } from "../environment-variables-editor"; import { createDefaultNewTrigger, useRoutineDetail } from "./context"; import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared"; @@ -649,7 +649,7 @@ export function SecretsSection() {
) : null} - } secrets={availableSecrets} recentlyUsedSecrets={recentlyUsedSecrets} diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 5585ab2e2fb..de4f28833d9 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -44,6 +44,8 @@ vi.mock("@/context/BreadcrumbContext", () => ({ vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: vi.fn() }), + useToastActions: () => ({ pushToast: vi.fn() }), + useOptionalToastActions: () => ({ pushToast: vi.fn() }), })); vi.mock("@/api/environments", () => ({ @@ -114,6 +116,12 @@ function click(element: Element | null | undefined) { element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); } +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + function getOpenDialog(): HTMLElement | null { return document.body.querySelector("[role='dialog']"); } @@ -376,6 +384,16 @@ describe("CompanyEnvironments — test provider button", () => { Array.from(dialog?.querySelectorAll("input") ?? []).some((input) => (input as HTMLInputElement).value === "Alpha"), ).toBe(true); + await act(async () => click(findButton(dialog!, "Add variable"))); + await flushReact(); + const variableName = dialog!.querySelector('input[aria-label="Variable name"]')!; + const variableValue = dialog!.querySelector('input[aria-label="Variable value"]')!; + await act(async () => { + setInputValue(variableName, "API_TOKEN"); + setInputValue(variableValue, "draft-token"); + }); + await flushReact(); + await act(async () => { findButton(document.body, "Save environment")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -383,7 +401,11 @@ describe("CompanyEnvironments — test provider button", () => { expect(mockEnvironmentsApi.update).toHaveBeenCalledExactlyOnceWith( "env-1", - expect.objectContaining({ name: "Alpha", driver: "sandbox" }), + expect.objectContaining({ + name: "Alpha", + driver: "sandbox", + envVars: { API_TOKEN: { type: "plain", value: "draft-token" } }, + }), ); expect(getOpenDialog()).toBeNull(); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 757ab83b334..e8abcff00f3 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Play, RefreshCw, RotateCcw, Trash2, X } from "lucide-react"; import { @@ -25,7 +25,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { + EnvironmentVariablesEditor, + type EnvironmentVariablesEditorHandle, +} from "@/components/environment-variables-editor"; import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; @@ -592,6 +595,7 @@ export function CompanyEnvironments() { const [environmentDialogOpen, setEnvironmentDialogOpen] = useState(false); const [editingEnvironmentId, setEditingEnvironmentId] = useState(null); const [environmentForm, setEnvironmentForm] = useState(createEmptyEnvironmentForm); + const environmentVariablesEditorRef = useRef(null); const [probeResults, setProbeResults] = useState>({}); const [testingEnvironmentId, setTestingEnvironmentId] = useState(null); @@ -830,6 +834,11 @@ export function CompanyEnvironments() { draftEnvironmentProbeMutation.reset(); } + function flushEnvironmentForm(): EnvironmentFormState { + const flushedEnvVars = environmentVariablesEditorRef.current?.flushPendingDraft(); + return flushedEnvVars ? { ...environmentForm, envVars: flushedEnvVars } : environmentForm; + } + const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {}) .filter(([provider, capability]) => provider !== "fake" && capability.supportsRunExecution) .map(([provider, capability]) => ({ @@ -1265,7 +1274,8 @@ export function CompanyEnvironments() { label="Environment variables" hint="Injected into runs that resolve through this environment. Use plain values or company secrets." > - await createSecret.mutateAsync({ name, value })} @@ -1300,14 +1310,14 @@ export function CompanyEnvironments() { {environmentForm.driver !== "local" ? ( ) : null}
); } diff --git a/ui/storybook/stories/environment-variables-editor.stories.tsx b/ui/storybook/stories/environment-variables-editor.stories.tsx new file mode 100644 index 00000000000..62d35faf88c --- /dev/null +++ b/ui/storybook/stories/environment-variables-editor.stories.tsx @@ -0,0 +1,232 @@ +import { useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { CompanySecret, EnvBinding, SecretStatus } from "@paperclipai/shared"; +import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor"; +import { ToastProvider } from "@/context/ToastContext"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +function secret( + id: string, + name: string, + overrides: Partial = {}, +): CompanySecret { + return { + id, + companyId: "company-storybook", + key: name.toLowerCase(), + name, + provider: "local_encrypted", + status: "active" as SecretStatus, + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 3, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdAt: new Date("2026-03-01T10:00:00.000Z"), + updatedAt: new Date("2026-04-01T10:00:00.000Z"), + ...overrides, + }; +} + +const SECRETS: CompanySecret[] = [ + secret("s-github", "GITHUB_TOKEN", { latestVersion: 3 }), + secret("s-db", "DB_CONNECTION", { latestVersion: 3 }), + secret("s-openai", "OPENAI_API_KEY", { latestVersion: 2 }), + secret("s-resend-long", "/paperclip-cloud/prod/provider/resend/api-key-with-a-very-long-name", { latestVersion: 4 }), + secret("s-legacy", "LEGACY_DEPLOY_KEY", { status: "disabled", latestVersion: 2 }), + secret("s-archived", "OLD_STRIPE_KEY", { status: "archived", latestVersion: 4 }), +]; + +const RECENTLY_USED: CompanySecret[] = [SECRETS[2], secret("s-slack", "SLACK_WEBHOOK", { latestVersion: 1 })]; + +function Surface({ title, hint, children }: { title: string; hint?: ReactNode; children: ReactNode }) { + return ( + +
+ + + {title} + + + {hint ?

{hint}

: null} + {children} +
+
+
+
+ ); +} + +function Editor({ + initial, + disabled, + recentlyUsed, +}: { + initial: Record; + disabled?: boolean; + recentlyUsed?: CompanySecret[]; +}) { + const [env, setEnv] = useState>(initial); + return ( + setEnv(next ?? {})} + onCreateSecret={async (name, value) => + secret(`s-new-${name}`, name.toUpperCase(), { key: name, latestVersion: 1, description: `len=${value.length}` }) + } + /> + ); +} + +const meta: Meta = { + title: "Product/Environment Variables Editor", + parameters: { layout: "fullscreen", a11y: { test: "off" } }, +}; +export default meta; +type Story = StoryObj; + +/** Frame 1 — default state with 4 mixed rows incl. pinned version + sensitive hint. */ +export const Default: Story = { + render: () => ( + + + + ), +}; + +/** Frame 2 — empty state. */ +export const Empty: Story = { + render: () => ( + + + + ), +}; + +/** Frame 3 — secret picker open (interactive: click the secret trigger). */ +export const PickerOpen: Story = { + render: () => ( + + + + ), +}; + +/** Frame 4 — long bound secret name truncates before the latest badge. */ +export const LongSecretName: Story = { + render: () => ( + + + + ), +}; + +/** Frame 5 — create-secret popover (interactive: open picker → Create secret). */ +export const CreateSecret: Story = { + render: () => ( + + + + ), +}; + +/** Frame 6 — store-as-secret popover (interactive: click the ShieldAlert on the sensitive row). */ +export const StoreAsSecret: Story = { + render: () => ( + + + + ), +}; + +/** Frame 6 — version popover (interactive: click the amber version tag). */ +export const VersionPopover: Story = { + render: () => ( + + + + ), +}; + +/** Frame 7 — validation trio + attention summary. */ +export const Validation: Story = { + render: () => ( + + + + ), +}; + +/** Frame 8 — bulk-paste result (post-import state, last row flagged sensitive). */ +export const BulkPasteResult: Story = { + render: () => ( + + + + ), +}; + +/** Frame 9 — 390px stacked layout (container-responsive). */ +export const MobileStacked: Story = { + render: () => ( + +
+
+ +
+
+
+ ), +}; + +/** Frame 10 — disabled (read-only) state, health warnings still visible. */ +export const Disabled: Story = { + render: () => ( + + + + ), +}; diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx index 8fa17c20716..fc5f1e983a7 100644 --- a/ui/storybook/stories/forms-editors.stories.tsx +++ b/ui/storybook/stories/forms-editors.stories.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Agent, CompanySecret, EnvBinding, Project, RoutineVariable } from "@paperclipai/shared"; import { Code2, FileText, ListPlus, RotateCcw, Table2 } from "lucide-react"; -import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor"; import { ExecutionParticipantPicker } from "@/components/ExecutionParticipantPicker"; import { FoldCurtain } from "@/components/FoldCurtain"; import { InlineEditor } from "@/components/InlineEditor"; @@ -458,7 +458,7 @@ function InlineEditorGallery() { ); } -function EnvVarEditorGallery() { +function EnvironmentVariablesEditorGallery() { const [emptyEnv, setEmptyEnv] = useState>({}); const [env, setEnv] = useState>(filledEnv); const createSecret = async (name: string): Promise => ({ @@ -469,16 +469,16 @@ function EnvVarEditorGallery() { }); return ( -
+
- - setEmptyEnv(next ?? {})} /> + + setEmptyEnv(next ?? {})} /> - - setEnv(next ?? {})} /> + + setEnv(next ?? {})} /> - undefined} /> + undefined} disabled />
@@ -655,7 +655,7 @@ function FormsEditorsShowcase() { - + diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routine-secrets.stories.tsx index 612145cbad0..e755659c6d7 100644 --- a/ui/storybook/stories/routine-secrets.stories.tsx +++ b/ui/storybook/stories/routine-secrets.stories.tsx @@ -10,7 +10,7 @@ import type { RoutineRevision, RoutineRevisionSnapshotV1, } from "@paperclipai/shared"; -import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor"; import { RoutineHistoryTab } from "@/components/RoutineHistoryTab"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useCompany } from "@/context/CompanyContext"; @@ -82,7 +82,7 @@ function SecretsTabSurface({ Routine secrets apply to every issue this routine creates. They override matching keys in project and agent env. PAPERCLIP_* variables are reserved.

- ({ diff --git a/ui/storybook/stories/secrets.stories.tsx b/ui/storybook/stories/secrets.stories.tsx index 0b787829a9e..f68448e4a9f 100644 --- a/ui/storybook/stories/secrets.stories.tsx +++ b/ui/storybook/stories/secrets.stories.tsx @@ -5,7 +5,7 @@ import { AlertCircle, KeyRound } from "lucide-react"; import type { CompanySecret, EnvBinding } from "@paperclipai/shared"; import { Secrets } from "@/pages/Secrets"; import { SecretBindingPicker, type SecretBindingValue } from "@/components/SecretBindingPicker"; -import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { useCompany } from "@/context/CompanyContext"; @@ -139,7 +139,7 @@ export const EnvEditorWithSecrets: Story = { - ({ From 47448721e16835a1bbb4b90a1b78b118dc0421b1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:40:50 -0500 Subject: [PATCH 022/107] [codex] Clean up issue properties pane (#8941) 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 issue details sidebar is a high-traffic operator surface for scanning task state, ownership, policy, relationships, and external references. > - The previous properties pane mixed too much behavior in one large component and several rows could overflow or become hard to scan with long labels and relation lists. > - The UI needed a narrower, more reusable structure so compact property rows, relation pills, external URL rows, and picker triggers behave consistently. > - This pull request splits the properties pane into focused helper modules and tightens the pane's layout, truncation, popover, and overflow behavior. > - The benefit is a denser, more stable properties pane that stays usable when issues have long titles, many relationships, external URLs, or execution policy IDs. ## Linked Issues or Issue Description No public issue exists for this work. I searched GitHub issues and PRs for `IssueProperties properties pane`; there was no duplicate issue and the matching PRs were unrelated test stabilization or other work. ### Problem or motivation The board issue details properties pane is a frequent operator workflow surface, but it was implemented as one large component and several rows could become hard to scan with long labels, many relationships, external URLs, or execution policy IDs. ### Proposed solution Split the properties pane into focused modules, tighten compact row layout and truncation behavior, add bounded previews with explicit expansion controls for long relation and URL lists, and make execution policy ID generation work on insecure origins as well as normal browser origins. ### Alternatives considered A smaller patch inside the existing monolithic component would fix individual overflow symptoms, but it would keep related primitives, picker behavior, relation controls, and external URL rendering tangled in one file. The split keeps the behavior easier to test and review without changing public APIs. ### Roadmap alignment This is an incremental UI quality improvement to the existing board task details surface. I checked `ROADMAP.md` and did not find overlapping planned core feature work. ## What Changed - Split the issue properties pane into focused modules for helpers, primitives, relation controls, property pickers, and external object rows. - Cleaned up row spacing, truncation, picker trigger alignment, scroll behavior, status color coverage, and long-value titles. - Added bounded previews and expand/collapse controls for blocking, sub-task, related-task, and external URL lists. - Fixed execution policy ID generation so insecure origins fall back to a stable base URL instead of throwing. - Updated Storybook issue-management stories and expanded unit coverage for the new properties pane behavior. ## Verification - `git diff --check origin/master..HEAD` - `pnpm --filter @paperclipai/ui exec vitest run src/components/IssueProperties.test.tsx` — 38 tests passed ## Risks Low to moderate risk. The changes are UI-only, but they touch a frequently used task details surface. The main risk is a subtle layout regression in an untested viewport or issue shape; the branch adds targeted coverage for long relation and external URL lists. > 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 GPT-5 Codex coding agent with repository tool use, shell execution, GitHub CLI access, and local test execution. Context window size was 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 Co-authored-by: Claude Opus 4.8 --- ui/src/components/Identity.tsx | 5 +- ui/src/components/IssueProperties.test.tsx | 212 +- ui/src/components/IssueProperties.tsx | 2759 +---------------- ui/src/components/PriorityIcon.tsx | 2 +- ui/src/components/StatusIcon.tsx | 2 +- .../issue-properties/IssueProperties.tsx | 2277 ++++++++++++++ .../issue-properties/external-object-rows.tsx | 242 ++ ui/src/components/issue-properties/helpers.ts | 129 + ui/src/components/issue-properties/index.ts | 5 + .../issue-properties/primitives.tsx | 85 + .../issue-properties/property-picker.tsx | 66 + .../issue-properties/relation-controls.tsx | 125 + ui/src/components/ui/scroll-area.tsx | 2 +- ui/src/lib/issue-execution-policy.test.ts | 39 + ui/src/lib/issue-execution-policy.ts | 27 +- ui/src/lib/status-colors.ts | 1 + .../stories/issue-management.stories.tsx | 120 +- 17 files changed, 3301 insertions(+), 2797 deletions(-) create mode 100644 ui/src/components/issue-properties/IssueProperties.tsx create mode 100644 ui/src/components/issue-properties/external-object-rows.tsx create mode 100644 ui/src/components/issue-properties/helpers.ts create mode 100644 ui/src/components/issue-properties/index.ts create mode 100644 ui/src/components/issue-properties/primitives.tsx create mode 100644 ui/src/components/issue-properties/property-picker.tsx create mode 100644 ui/src/components/issue-properties/relation-controls.tsx create mode 100644 ui/src/lib/issue-execution-policy.test.ts diff --git a/ui/src/components/Identity.tsx b/ui/src/components/Identity.tsx index dd5e8fb4caa..77e395eb9e7 100644 --- a/ui/src/components/Identity.tsx +++ b/ui/src/components/Identity.tsx @@ -28,7 +28,10 @@ export function Identity({ name, avatarUrl, initials, size = "default", classNam const displayInitials = initials ?? deriveInitials(name); return ( - + {avatarUrl && } {displayInitials} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 3e0d5f22339..bdd85637c4d 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -148,6 +148,17 @@ async function flush() { }); } +/** + * Finds the trigger button for a property row by its (sentence-case) label. + * `PropertyRow` exposes a stable label hook so layout utility changes do not + * affect tests that need to find the corresponding value slot. + */ +function findRowTrigger(container: HTMLElement, label: string): HTMLButtonElement | undefined { + const labelSpan = container.querySelector(`[data-property-label="${label}"]`); + const row = labelSpan?.closest('[data-property-row="true"]'); + return (row?.querySelector("button") as HTMLButtonElement | null) ?? undefined; +} + async function waitForAssertion(assertion: () => void, attempts = 20) { let lastError: unknown; @@ -591,7 +602,8 @@ describe("IssueProperties", () => { await waitForAssertion(() => { expect(container.textContent).toContain("Watchdog"); - expect(container.textContent).toContain("Set watchdog"); + // Empty watchdog uses the uniform muted "None" empty state (ux-spec §6). + expect(findRowTrigger(container, "Watchdog")?.textContent).toContain("None"); }); act(() => root.unmount()); @@ -820,12 +832,12 @@ describe("IssueProperties", () => { expect(container.textContent).not.toContain("SUB-6"); expect( Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.trim() === "and 2 more...", + button.textContent?.trim() === "Show 2 more", ), ).toHaveLength(2); const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.trim() === "and 2 more...", + button.textContent?.trim() === "Show 2 more", ); expect(expandBlockedBy).not.toBeUndefined(); await act(async () => { @@ -835,10 +847,10 @@ describe("IssueProperties", () => { expect(container.textContent).toContain("BLOCK-6"); expect(container.textContent).toContain("BLOCK-7"); expect(container.textContent).not.toContain("SUB-6"); - expect(container.textContent).toContain("show less"); + expect(container.textContent).toContain("Show less"); const expandSubTasks = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.trim() === "and 2 more...", + button.textContent?.trim() === "Show 2 more", ); expect(expandSubTasks).not.toBeUndefined(); await act(async () => { @@ -849,17 +861,17 @@ describe("IssueProperties", () => { expect(container.textContent).toContain("SUB-7"); expect( Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.trim() === "and 2 more...", + button.textContent?.trim() === "Show 2 more", ), ).toHaveLength(0); expect( Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.trim() === "show less", + button.textContent?.trim() === "Show less", ), ).toHaveLength(2); const collapseBlockedBy = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.trim() === "show less", + button.textContent?.trim() === "Show less", ); expect(collapseBlockedBy).not.toBeUndefined(); await act(async () => { @@ -868,7 +880,131 @@ describe("IssueProperties", () => { expect(container.textContent).not.toContain("BLOCK-6"); expect(container.textContent).toContain("SUB-6"); - expect(container.textContent).toContain("and 2 more..."); + expect(container.textContent).toContain("Show 2 more"); + + act(() => root.unmount()); + }); + + it("collapses long blocking and related task lists until the more button is clicked", async () => { + const blocking = Array.from({ length: 7 }, (_, index) => ({ + id: `blocking-${index + 1}`, + identifier: `BLOCKING-${index + 1}`, + title: `Blocking issue ${index + 1}`, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + })) as NonNullable; + const relatedIssues = Array.from({ length: 7 }, (_, index) => ({ + id: `related-${index + 1}`, + identifier: `RELATED-${index + 1}`, + title: `Related issue ${index + 1}`, + status: "todo" as const, + priority: "medium" as const, + assigneeAgentId: null, + assigneeUserId: null, + })); + const root = renderProperties(container, { + issue: createIssue({ + blocks: blocking, + relatedWork: { + outbound: relatedIssues.map((issue) => ({ + issue, + mentionCount: 1, + sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: issue.identifier }], + })), + inbound: [], + }, + }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + expect(container.textContent).toContain("BLOCKING-5"); + expect(container.textContent).not.toContain("BLOCKING-6"); + expect(container.textContent).toContain("RELATED-5"); + expect(container.textContent).not.toContain("RELATED-6"); + expect( + Array.from(container.querySelectorAll("button")).filter((button) => + button.textContent?.trim() === "Show 2 more", + ), + ).toHaveLength(2); + + const expandBlocking = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "Show 2 more", + ); + expect(expandBlocking).not.toBeUndefined(); + await act(async () => { + expandBlocking!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("BLOCKING-6"); + expect(container.textContent).toContain("BLOCKING-7"); + expect(container.textContent).not.toContain("RELATED-6"); + + const expandRelated = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "Show 2 more", + ); + expect(expandRelated).not.toBeUndefined(); + await act(async () => { + expandRelated!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("RELATED-6"); + expect(container.textContent).toContain("RELATED-7"); + + act(() => root.unmount()); + }); + + it("collapses long external URL rows until the more button is clicked", async () => { + const externalObjects = Array.from({ length: 7 }, (_, index) => ({ + mentionCount: 1, + sourceLabels: ["Description"], + pill: { + providerKey: "url" as const, + objectType: "link" as const, + displayKey: null, + iconKey: null, + statusCategory: "unknown" as const, + statusIconKey: null, + statusLabel: null, + liveness: "unknown" as const, + displayTitle: `https://example.com/reference-${index + 1}`, + url: `https://example.com/reference-${index + 1}`, + }, + group: { + object: null, + mentions: [], + mentionCount: 1, + sourceLabels: ["Description"], + }, + })); + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + externalObjects, + }); + await flush(); + + expect(container.textContent).toContain("reference-5"); + expect(container.textContent).not.toContain("reference-6"); + expect(container.textContent).toContain("References"); + const expandUrls = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "Show 2 more", + ); + expect(expandUrls).not.toBeUndefined(); + + await act(async () => { + expandUrls!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("reference-6"); + expect(container.textContent).toContain("reference-7"); + expect(container.textContent).toContain("Show less"); act(() => root.unmount()); }); @@ -905,7 +1041,7 @@ describe("IssueProperties", () => { await flush(); const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.trim() === "and 2 more...", + button.textContent?.trim() === "Show 2 more", ); expect(expandBlockedBy).not.toBeUndefined(); await act(async () => { @@ -929,7 +1065,7 @@ describe("IssueProperties", () => { await flush(); expect(container.textContent).not.toContain("BLOCK-6"); - expect(container.textContent).toContain("and 2 more..."); + expect(container.textContent).toContain("Show 2 more"); act(() => root.unmount()); }); @@ -1134,7 +1270,7 @@ describe("IssueProperties", () => { await flush(); expect(container.textContent).not.toContain("Task ids"); - expect(container.textContent).toContain("Related Tasks"); + expect(container.textContent).toContain("Related tasks"); expect(container.textContent).toContain("PAP-22"); act(() => root.unmount()); @@ -1221,7 +1357,7 @@ describe("IssueProperties", () => { }); await flush(); - expect(container.textContent).not.toContain("Related Tasks"); + expect(container.textContent).not.toContain("Related tasks"); act(() => root.unmount()); }); @@ -1392,11 +1528,21 @@ describe("IssueProperties", () => { }); await flush(); - const clearButton = container.querySelector('button[aria-label="Clear adapter options"]'); - expect(clearButton).not.toBeNull(); + // The trailing "clear" X was removed (ux-spec: one trailing-action style). + // Clearing now happens by selecting the "Primary" model lane inside the picker. + const optionsTrigger = findRowTrigger(container, "Model"); + expect(optionsTrigger).toBeTruthy(); + await act(async () => { + optionsTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const primaryLane = Array.from(container.querySelectorAll('button[role="radio"]')) + .find((button) => button.textContent?.trim() === "Primary"); + expect(primaryLane).not.toBeUndefined(); await act(async () => { - clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + primaryLane!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: null }); @@ -1452,9 +1598,9 @@ describe("IssueProperties", () => { }); await flush(); - const parentTrigger = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("No parent")); - expect(parentTrigger).not.toBeUndefined(); + // Empty parent now uses the uniform muted "None" empty state (ux-spec §6). + const parentTrigger = findRowTrigger(container, "Parent"); + expect(parentTrigger?.textContent).toContain("None"); await act(async () => { parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -1768,8 +1914,7 @@ describe("IssueProperties", () => { let trigger: HTMLButtonElement | undefined; await waitForAssertion(() => { expect(container.textContent).toContain("Watchdog"); - trigger = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Set watchdog")); + trigger = findRowTrigger(container, "Watchdog"); expect(trigger).toBeTruthy(); }); @@ -1840,8 +1985,7 @@ describe("IssueProperties", () => { let trigger: HTMLButtonElement | undefined; await waitForAssertion(() => { - trigger = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Set watchdog")); + trigger = findRowTrigger(container, "Watchdog"); expect(trigger).toBeTruthy(); }); @@ -1917,16 +2061,15 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); - it("allows long watchdog instructions to wrap inside the properties value column", async () => { + it("truncates the watchdog instructions one-line summary in the properties value column", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskWatchdogs: true, }); mockAgentsApi.list.mockResolvedValue([watchdogAgent]); + const instructions = "get greptile to stop re-reviewing the same task unless a fresh code change lands"; const root = renderProperties(container, { issue: createIssue({ - watchdog: createWatchdogSummary({ - instructions: "get greptile to stop re-reviewing the same task unless a fresh code change lands", - }), + watchdog: createWatchdogSummary({ instructions }), }), childIssues: [], onUpdate: vi.fn(), @@ -1934,6 +2077,8 @@ describe("IssueProperties", () => { }); await flush(); + // ux-spec: watchdog row shows agent + a truncated one-line summary; the + // full instructions live in the popover (surfaced here via the row title). let instructionNode: HTMLSpanElement | undefined; await waitForAssertion(() => { instructionNode = Array.from(container.querySelectorAll("span")) @@ -1945,9 +2090,12 @@ describe("IssueProperties", () => { expect(instructionNode).toBeTruthy(); }); - expect(instructionNode!.className).toContain("whitespace-normal"); - expect(instructionNode!.className).toContain("break-words"); - expect(instructionNode!.className).not.toContain("truncate"); + expect(instructionNode!.className).toContain("truncate"); + expect(instructionNode!.className).not.toContain("whitespace-normal"); + expect(instructionNode!.className).not.toContain("break-words"); + + const watchdogTrigger = findRowTrigger(container, "Watchdog"); + expect(watchdogTrigger?.querySelector("[title]")?.getAttribute("title")).toBe(instructions); act(() => root.unmount()); }); @@ -1972,11 +2120,13 @@ describe("IssueProperties", () => { }); await flush(); + // The link is now an icon-only "open task" affordance (ux-spec: one + // trailing-action style), so we assert on href + accessible label. await waitForAssertion(() => { const link = Array.from(container.querySelectorAll("a")) .find((anchor) => anchor.getAttribute("href") === "/issues/issue-wd"); expect(link).toBeTruthy(); - expect(link!.textContent).toContain("PAP-42"); + expect(link!.getAttribute("aria-label")).toBe("Open watchdog task"); }); act(() => root.unmount()); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 8cb1cf1c1c2..1ca7492ceaf 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,2758 +1 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { pickTextColorForPillBg } from "@/lib/color-contrast"; -import { Link } from "@/lib/router"; -import type { Issue, IssueLabel, Project } from "@paperclipai/shared"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { AdapterModel } from "../api/agents"; -import { accessApi } from "../api/access"; -import { agentsApi } from "../api/agents"; -import { authApi } from "../api/auth"; -import { executionWorkspacesApi } from "../api/execution-workspaces"; -import { instanceSettingsApi } from "../api/instanceSettings"; -import { issuesApi } from "../api/issues"; -import { projectsApi } from "../api/projects"; -import { useCompany } from "../context/CompanyContext"; -import { queryKeys } from "../lib/queryKeys"; -import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, isAgentTaskTarget } from "../lib/company-members"; -import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides"; -import { useProjectOrder } from "../hooks/useProjectOrder"; -import { - getRecentAssigneeIds, - sortAgentsByRecency, - trackRecentAssignee, - trackRecentAssigneeUser, -} from "../lib/recent-assignees"; -import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; -import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; -import { formatAssigneeUserLabel } from "../lib/assignees"; -import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy"; -import { formatMonitorOffset } from "../lib/issue-monitor"; -import { formatRetryReason } from "../lib/runRetryState"; -import { useRetryNowMutation } from "../hooks/useRetryNowMutation"; -import { RetryErrorBand } from "./IssueScheduledRetryCard"; -import { extractProviderIdWithFallback } from "../lib/model-utils"; -import { StatusIcon } from "./StatusIcon"; -import { PriorityIcon } from "./PriorityIcon"; -import { Identity } from "./Identity"; -import { IssueReferencePill } from "./IssueReferencePill"; -import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils"; -import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon"; -import type { IssueExternalObjectGroup } from "../hooks/useIssueExternalObjects"; -import { - externalObjectCategoryLabel, - externalObjectDisplayLabel, - externalObjectIconForKey, - externalObjectProviderLabel, - externalObjectToneSeverity, - externalObjectTypeLabel, -} from "../lib/external-objects"; -import { - externalObjectStatusIcon, - externalObjectStatusIconDefault, -} from "../lib/status-colors"; -import { timeAgo } from "../lib/timeAgo"; -import { Button } from "@/components/ui/button"; -import { ToggleSwitch } from "@/components/ui/toggle-switch"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Separator } from "@/components/ui/separator"; -import { Textarea } from "@/components/ui/textarea"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2, ScanEye } from "lucide-react"; -import { AgentIcon } from "./AgentIconPicker"; -import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; -import { - AssigneeRunningBanner, - InterruptAssignConfirm, - type HandoffChipResolvers, -} from "./interrupt-handoff/InterruptHandoffViews"; -import { describeReassignInterrupt } from "../lib/interrupt-handoff"; -import { - buildWorkspaceRuntimeControlSections, - WorkspaceRuntimeQuickControls, - type WorkspaceRuntimeControlRequest, -} from "./WorkspaceRuntimeControls"; - -function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { - const [copied, setCopied] = useState(false); - const timerRef = useRef>(undefined); - useEffect(() => () => clearTimeout(timerRef.current), []); - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setCopied(false), 1500); - } catch { /* noop */ } - }, [value]); - - return ( -
- - - {copied && ( - - - Copied - - )} -
- ); -} - -function defaultProjectWorkspaceIdForProject(project: { - workspaces?: Array<{ id: string; isPrimary: boolean }>; - executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null; -} | null | undefined) { - if (!project) return null; - return project.executionWorkspacePolicy?.defaultProjectWorkspaceId - ?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id - ?? project.workspaces?.[0]?.id - ?? null; -} - -function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) { - const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null; - if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode; - if (defaultMode === "adapter_default") return "agent_default"; - return "shared_workspace"; -} - -function primaryWorkspaceIdForProject(project: Pick | null | undefined) { - return project?.primaryWorkspace?.id - ?? project?.workspaces.find((workspace) => workspace.isPrimary)?.id - ?? project?.workspaces[0]?.id - ?? null; -} - -function isMainIssueWorkspace(input: { - issue: Pick; - project: Pick | null | undefined; -}) { - const workspace = input.issue.currentExecutionWorkspace ?? null; - const primaryWorkspaceId = primaryWorkspaceIdForProject(input.project); - const linkedProjectWorkspaceId = workspace?.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null; - if (workspace) { - if (workspace.mode !== "shared_workspace") return false; - if (!linkedProjectWorkspaceId || !primaryWorkspaceId) return true; - return workspace.mode === "shared_workspace" && linkedProjectWorkspaceId === primaryWorkspaceId; - } - if (!linkedProjectWorkspaceId || !primaryWorkspaceId) return true; - return linkedProjectWorkspaceId === primaryWorkspaceId; -} - -function toDateTimeLocalValue(value: string | null | undefined) { - if (!value) return ""; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; - const offsetMs = date.getTimezoneOffset() * 60_000; - return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); -} - -interface IssuePropertiesProps { - issue: Issue; - childIssues?: Issue[]; - onAddSubIssue?: () => void; - onUpdate: (data: Record) => void; - inline?: boolean; - /** Whether an agent run is currently in flight on this issue, so the assignee - * picker can warn that reassigning will interrupt it. */ - hasActiveRun?: boolean; - externalObjects?: IssueExternalObjectGroup[]; - externalObjectsLoading?: boolean; - externalObjectsError?: boolean; - onRetryExternalObjects?: () => void; -} - -const ISSUE_BLOCKER_SEARCH_LIMIT = 50; -const ISSUE_PROPERTY_RELATION_PREVIEW_COUNT = 5; - -function PropertyRow({ - label, - children, - labelClassName, -}: { - label: React.ReactNode; - children: React.ReactNode; - labelClassName?: string; -}) { - return ( -
- {label} -
{children}
-
- ); -} - -function sortExternalObjectGroups(groups: IssueExternalObjectGroup[]) { - return [...groups].sort((a, b) => { - const aTone = externalObjectToneSeverity(a.group.object?.statusTone); - const bTone = externalObjectToneSeverity(b.group.object?.statusTone); - return bTone - aTone; - }); -} - -function externalObjectRowDisplayKey(group: IssueExternalObjectGroup): string { - const { pill } = group; - const displayKey = pill.displayKey?.trim(); - if (displayKey) return displayKey; - if (pill.providerKey === "github") { - if (pill.objectType === "pull_request") return "Github Pull Request"; - if (pill.objectType === "issue") return "Github Issue"; - } - return externalObjectDisplayLabel(pill.providerKey, pill.objectType); -} - -function externalObjectRowLabel(group: IssueExternalObjectGroup): React.ReactNode { - const { pill } = group; - const displayKey = externalObjectRowDisplayKey(group); - const Icon = externalObjectIconForKey(pill.iconKey); - return ( - - {Icon ? - ); -} - -function githubObjectPropertyValue(url: string | null | undefined): string | null { - if (!url) return null; - try { - const parsed = new URL(url); - if (parsed.hostname !== "github.com") return null; - const [, owner, repo, kind, number] = parsed.pathname.split("/"); - if (!owner || !repo || !number) return null; - if (kind === "pull") return `PR ${number}`; - if (kind === "issues") return `Issue ${number}`; - return null; - } catch { - return null; - } -} - -function externalObjectPropertyStatusLabel(group: IssueExternalObjectGroup): string { - return group.pill.statusLabel ?? externalObjectCategoryLabel(group.pill.statusCategory); -} - -function externalObjectPropertyValue(group: IssueExternalObjectGroup): string { - const { pill } = group; - const statusLabel = externalObjectPropertyStatusLabel(group); - const githubLabel = pill.providerKey === "github" ? githubObjectPropertyValue(pill.url) : null; - const base = githubLabel ?? pill.displayTitle?.trim() ?? externalObjectRowDisplayKey(group); - return statusLabel ? `${base} - ${statusLabel}` : base; -} - -function isMergedExternalObject(group: IssueExternalObjectGroup): boolean { - const statusLabel = externalObjectPropertyStatusLabel(group); - return group.pill.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged"; -} - -function externalObjectPropertyTone(group: IssueExternalObjectGroup): string { - if (isMergedExternalObject(group)) { - return "text-violet-600 dark:text-violet-400"; - } - const tone = externalObjectStatusIcon[group.pill.statusCategory] ?? externalObjectStatusIconDefault; - return tone.split(" ").filter((c) => c.startsWith("text-")).join(" "); -} - -function externalObjectPropertyStatusIconKey(group: IssueExternalObjectGroup): string | null | undefined { - if (isMergedExternalObject(group)) return group.pill.statusIconKey ?? "git-merge"; - return group.pill.statusIconKey; -} - -function externalObjectPropertyTitle(group: IssueExternalObjectGroup): string { - const { pill, sourceLabels } = group; - const base = pill.displayTitle ?? externalObjectPropertyValue(group); - return sourceLabels.length > 0 ? `${base} - ${sourceLabels.join(", ")}` : base; -} - -function ExternalObjectPropertyValue({ group }: { group: IssueExternalObjectGroup }) { - const { pill, mentionCount } = group; - const statusLabel = externalObjectPropertyStatusLabel(group); - const providerLabel = externalObjectProviderLabel(pill.providerKey); - const typeLabel = externalObjectTypeLabel(pill.objectType); - const value = externalObjectPropertyValue(group); - const content = ( - <> - - {value} - {mentionCount > 1 ? ( - x{mentionCount} - ) : null} - - ); - const className = cn( - "inline-flex min-w-0 max-w-full items-center gap-1 text-xs font-medium no-underline", - externalObjectPropertyTone(group), - pill.url ? "hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring" : "", - ); - - if (pill.url) { - return ( - - {content} - - ); - } - - return ( - - {content} - - ); -} - -const ISSUE_THINKING_EFFORT_OPTIONS = { - claude_local: [ - { value: "", label: "Default" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - ], - codex_local: [ - { value: "", label: "Default" }, - { value: "minimal", label: "Minimal" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "X-High" }, - ], - opencode_local: [ - { value: "", label: "Default" }, - { value: "minimal", label: "Minimal" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "X-High" }, - { value: "max", label: "Max" }, - ], -} as const; - -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? value as Record - : {}; -} - -function compactRecord(record: Record) { - return Object.fromEntries( - Object.entries(record).filter(([, value]) => value !== undefined), - ); -} - -function thinkingEffortOptionsFor(adapterType: string | null | undefined) { - if (adapterType === "codex_local") return ISSUE_THINKING_EFFORT_OPTIONS.codex_local; - if (adapterType === "opencode_local") return ISSUE_THINKING_EFFORT_OPTIONS.opencode_local; - return ISSUE_THINKING_EFFORT_OPTIONS.claude_local; -} - -function thinkingEffortKeyFor(adapterType: string | null | undefined) { - if (adapterType === "codex_local") return "modelReasoningEffort"; - if (adapterType === "opencode_local") return "variant"; - return "effort"; -} - -function thinkingEffortValueFor(adapterType: string | null | undefined, adapterConfig: Record) { - if (adapterType === "codex_local") { - return String(adapterConfig.modelReasoningEffort ?? adapterConfig.reasoningEffort ?? adapterConfig.effort ?? ""); - } - if (adapterType === "opencode_local") { - return String(adapterConfig.variant ?? ""); - } - return String(adapterConfig.effort ?? ""); -} - -function overrideLane(overrides: Issue["assigneeAdapterOverrides"]): IssueModelLane { - if (overrides?.modelProfile === "cheap") return "cheap"; - if (overrides?.adapterConfig) return "custom"; - return "primary"; -} - -function sortAdapterModels(models: AdapterModel[]) { - return [...models].sort((a, b) => { - const providerA = extractProviderIdWithFallback(a.id); - const providerB = extractProviderIdWithFallback(b.id); - const byProvider = providerA.localeCompare(providerB); - if (byProvider !== 0) return byProvider; - return a.id.localeCompare(b.id); - }); -} - -function RemovableIssueReferencePill({ - issue, - onRemove, -}: { - issue: NonNullable[number]; - onRemove: (issueId: string) => void; -}) { - const [isConfirmOpen, setIsConfirmOpen] = useState(false); - const issueLabel = issue.identifier ?? issue.title; - const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title; - const chipClassName = cn( - "paperclip-mention-chip paperclip-mention-chip--issue", - "inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline", - issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring", - ); - const content = ( - <> - - {issueLabel} - - ); - const removeLabel = `Remove ${issueLabel} as blocker`; - const handleRemove = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsConfirmOpen(true); - }; - const confirmRemove = () => { - onRemove(issue.id); - setIsConfirmOpen(false); - }; - - return ( - <> - - - {issue.identifier ? ( - - {content} - - ) : ( - - {content} - - )} - - - - - Remove blocker? - - Remove {confirmLabel} as a blocker for this task. - - - - - - - - - - - - ); -} - -function ExpandRelationListButton({ - hiddenCount, - expanded, - onClick, -}: { - hiddenCount: number; - expanded: boolean; - onClick: () => void; -}) { - if (!expanded && hiddenCount <= 0) return null; - return ( - - ); -} - -/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */ -function PropertyPicker({ - inline, - label, - open, - onOpenChange, - triggerContent, - triggerClassName, - popoverClassName, - popoverAlign = "end", - extra, - children, -}: { - inline?: boolean; - label: string; - open: boolean; - onOpenChange: (open: boolean) => void; - triggerContent: React.ReactNode; - triggerClassName?: string; - popoverClassName?: string; - popoverAlign?: "start" | "center" | "end"; - extra?: React.ReactNode; - children: React.ReactNode; -}) { - const btnCn = cn( - "inline-flex items-start gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left", - triggerClassName, - ); - - if (inline) { - return ( -
- - - {extra} - - {open && ( -
- {children} -
- )} -
- ); - } - - return ( - - - - - - - {children} - - - {extra} - - ); -} - -export function IssueProperties({ - issue, - childIssues = [], - onAddSubIssue, - onUpdate, - inline, - hasActiveRun = false, - externalObjects, - externalObjectsLoading, - externalObjectsError, - onRetryExternalObjects, -}: IssuePropertiesProps) { - const { selectedCompanyId } = useCompany(); - const queryClient = useQueryClient(); - const companyId = issue.companyId ?? selectedCompanyId; - const { data: experimentalSettings } = useQuery({ - queryKey: queryKeys.instance.experimentalSettings, - queryFn: () => instanceSettingsApi.getExperimental(), - }); - const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true; - const [assigneeOpen, setAssigneeOpen] = useState(false); - const [assigneeSearch, setAssigneeSearch] = useState(""); - /** When a run is live, a selection is staged here until the operator confirms - * the interrupt rather than applying it immediately. */ - const [pendingAssignee, setPendingAssignee] = useState<{ - assigneeAgentId: string | null; - assigneeUserId: string | null; - label: string; - track?: () => void; - } | null>(null); - const [projectOpen, setProjectOpen] = useState(false); - const [projectSearch, setProjectSearch] = useState(""); - const [blockedByOpen, setBlockedByOpen] = useState(false); - const [blockedBySearch, setBlockedBySearch] = useState(""); - const [blockedByExpanded, setBlockedByExpanded] = useState(false); - const [subTasksExpanded, setSubTasksExpanded] = useState(false); - const [parentOpen, setParentOpen] = useState(false); - const [parentSearch, setParentSearch] = useState(""); - const [reviewersOpen, setReviewersOpen] = useState(false); - const [reviewerSearch, setReviewerSearch] = useState(""); - const [approversOpen, setApproversOpen] = useState(false); - const [approverSearch, setApproverSearch] = useState(""); - const [monitorOpen, setMonitorOpen] = useState(false); - const [scheduledRetryOpen, setScheduledRetryOpen] = useState(false); - const [labelsOpen, setLabelsOpen] = useState(false); - const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); - const [labelSearch, setLabelSearch] = useState(""); - const [newLabelName, setNewLabelName] = useState(""); - const [newLabelColor, setNewLabelColor] = useState("#6366f1"); - const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); - const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? ""); - const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? ""); - const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); - const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); - const [watchdogOpen, setWatchdogOpen] = useState(false); - const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? ""); - const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? ""); - const normalizedBlockedBySearch = blockedBySearch.trim(); - - useEffect(() => { - setBlockedByExpanded(false); - setSubTasksExpanded(false); - }, [issue.id]); - - const { data: session } = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - }); - const currentUserId = session?.user?.id ?? session?.session?.userId; - - const { data: agents } = useQuery({ - queryKey: queryKeys.agents.list(companyId!), - queryFn: () => agentsApi.list(companyId!), - enabled: !!companyId, - }); - const { data: companyMembers } = useQuery({ - queryKey: queryKeys.access.companyUserDirectory(companyId!), - queryFn: () => accessApi.listUserDirectory(companyId!), - enabled: !!companyId, - }); - const { data: projects } = useQuery({ - queryKey: queryKeys.projects.list(companyId!), - queryFn: () => projectsApi.list(companyId!), - enabled: !!companyId, - }); - const activeProjects = useMemo( - () => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId), - [projects, issue.projectId], - ); - const { orderedProjects } = useProjectOrder({ - projects: activeProjects, - companyId, - userId: currentUserId, - }); - - const { data: labels } = useQuery({ - queryKey: queryKeys.issues.labels(companyId!), - queryFn: () => issuesApi.listLabels(companyId!), - enabled: !!companyId, - }); - - const { data: allIssues, isFetching: isFetchingIssuePickerIssues } = useQuery({ - queryKey: queryKeys.issues.list(companyId!), - queryFn: () => issuesApi.list(companyId!), - enabled: !!companyId && (parentOpen || (blockedByOpen && normalizedBlockedBySearch.length === 0)), - }); - - const { data: searchedBlockedByIssues, isFetching: isFetchingSearchedBlockedByIssues } = useQuery({ - queryKey: companyId - ? queryKeys.issues.search(companyId, normalizedBlockedBySearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT) - : ["issues", "blocker-search", normalizedBlockedBySearch, ISSUE_BLOCKER_SEARCH_LIMIT], - queryFn: () => issuesApi.list(companyId!, { - q: normalizedBlockedBySearch, - limit: ISSUE_BLOCKER_SEARCH_LIMIT, - }), - enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0, - }); - - const createLabel = useMutation({ - mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data), - onSuccess: async (created) => { - queryClient.setQueryData( - queryKeys.issues.labels(companyId!), - (current) => { - if (!current) return [created]; - if (current.some((label) => label.id === created.id)) return current; - return [...current, created]; - }, - ); - onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] }); - void queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) }); - setNewLabelName(""); - }, - }); - - const toggleLabel = (labelId: string) => { - const ids = issue.labelIds ?? []; - const next = ids.includes(labelId) - ? ids.filter((id) => id !== labelId) - : [...ids, labelId]; - onUpdate({ labelIds: next }); - }; - - const agentName = (id: string | null) => { - if (!id || !agents) return null; - const agent = agents.find((a) => a.id === id); - return agent?.name ?? id.slice(0, 8); - }; - - const projectName = (id: string | null) => { - if (!id) return id?.slice(0, 8) ?? "None"; - const project = orderedProjects.find((p) => p.id === id); - return project?.name ?? id.slice(0, 8); - }; - const currentProject = issue.projectId - ? orderedProjects.find((project) => project.id === issue.projectId) ?? null - : null; - const issueProject = issue.project ?? currentProject; - const issueUsesMainWorkspace = useMemo( - () => isMainIssueWorkspace({ issue, project: issueProject }), - [issue, issueProject], - ); - const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace; - const workspaceRuntimeConfig = issueUsesMainWorkspace - ? null - : issue.currentExecutionWorkspace?.config?.workspaceRuntime ?? null; - const workspaceRuntimeServices = issue.currentExecutionWorkspace?.runtimeServices ?? []; - const workspaceCanRunCommands = Boolean(issue.currentExecutionWorkspace?.cwd); - const workspaceCanStartServices = Boolean(workspaceRuntimeConfig) && workspaceCanRunCommands; - const workspaceRuntimeSections = useMemo(() => buildWorkspaceRuntimeControlSections({ - runtimeConfig: workspaceRuntimeConfig, - runtimeServices: workspaceRuntimeServices, - canStartServices: workspaceCanStartServices, - canRunJobs: workspaceCanRunCommands, - }), [workspaceCanRunCommands, workspaceCanStartServices, workspaceRuntimeConfig, workspaceRuntimeServices]); - const hasWorkspaceRuntimeControls = !issueUsesMainWorkspace && ( - workspaceRuntimeSections.services.length > 0 - || workspaceRuntimeSections.otherServices.length > 0 - ); - const controlWorkspaceRuntime = useMutation({ - mutationFn: (request: WorkspaceRuntimeControlRequest) => { - const workspaceId = issue.currentExecutionWorkspace?.id ?? issue.executionWorkspaceId; - if (!workspaceId) throw new Error("This task is not attached to a workspace."); - return executionWorkspacesApi.controlRuntimeCommands(workspaceId, request.action, request); - }, - onSuccess: (result, request) => { - queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace); - void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(result.workspace.companyId) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) }); - if (companyId) { - void queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) }); - } - setRuntimeActionErrorMessage(null); - setRuntimeActionMessage( - request.action === "run" - ? "Workspace job completed." - : request.action === "stop" - ? "Workspace service stopped." - : request.action === "restart" - ? "Workspace service restarted." - : "Workspace service started.", - ); - }, - onError: (error) => { - setRuntimeActionMessage(null); - setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands."); - }, - }); - const pendingWorkspaceRuntimeAction = controlWorkspaceRuntime.isPending ? controlWorkspaceRuntime.variables ?? null : null; - const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? []; - const relatedTasks = useMemo(() => { - const excluded = new Set(); - const addExcluded = (candidate: { id: string; identifier?: string | null }) => { - excluded.add(candidate.id); - if (candidate.identifier) excluded.add(candidate.identifier); - }; - - for (const blocker of issue.blockedBy ?? []) addExcluded(blocker); - for (const blocked of issue.blocks ?? []) addExcluded(blocked); - for (const child of childIssues) addExcluded(child); - - const referencedIssues = issue.relatedWork?.outbound.map((item) => item.issue) ?? []; - if (referencedIssues.length > 0) { - return referencedIssues.filter((referenced) => { - const label = referenced.identifier ?? referenced.id; - return !excluded.has(referenced.id) && !excluded.has(label); - }); - } - - return referencedIssueIdentifiers - .filter((identifier) => !excluded.has(identifier)) - .map((identifier) => ({ id: identifier, identifier, title: identifier })); - }, [childIssues, issue.blockedBy, issue.blocks, issue.relatedWork?.outbound, referencedIssueIdentifiers]); - const projectLink = (id: string | null) => { - if (!id) return null; - const project = projects?.find((p) => p.id === id) ?? null; - return project ? projectUrl(project) : `/projects/${id}`; - }; - - const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); - const sortedAgents = useMemo( - () => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds), - [agents, recentAssigneeIds], - ); - const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]); - const userLabelMap = useMemo( - () => buildCompanyUserLabelMap(companyMembers?.users), - [companyMembers?.users], - ); - const otherUserOptions = useMemo( - () => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }), - [companyMembers?.users, currentUserId, issue.createdByUserId], - ); - - const assignee = issue.assigneeAgentId - ? agents?.find((a) => a.id === issue.assigneeAgentId) - : null; - const assigneeAdapterType = assignee?.adapterType ?? null; - const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null; - const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null; - const supportsAssigneeOverrides = Boolean( - assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), - ); - const assigneeSupportsCheapLane = Boolean( - supportsAssigneeOverrides - && (assigneeAdapterType === "claude_local" - || assigneeAdapterType === "codex_local" - || assigneeAdapterType === "opencode_local"), - ); - const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides); - const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig); - const assigneeOverrideModel = - typeof assigneeOverrideAdapterConfig.model === "string" ? assigneeOverrideAdapterConfig.model : ""; - const assigneeOverrideThinkingEffort = thinkingEffortValueFor( - assigneeAdapterType, - assigneeOverrideAdapterConfig, - ); - const assigneeOverrideChrome = assigneeAdapterType === "claude_local" - && assigneeOverrideAdapterConfig.chrome === true; - const { data: assigneeAdapterModels } = useQuery({ - queryKey: - companyId && assigneeAdapterType - ? queryKeys.agents.adapterModels(companyId, assigneeAdapterType) - : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"], - queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!), - enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides, - }); - const { data: assigneeCheapProfiles } = useQuery({ - queryKey: companyId && assigneeAdapterType - ? queryKeys.agents.adapterModelProfiles(companyId, assigneeAdapterType) - : ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"], - queryFn: () => agentsApi.adapterModelProfiles(companyId!, assigneeAdapterType!), - enabled: Boolean(companyId) && showAssigneeAdapterOptions && assigneeSupportsCheapLane, - }); - const assigneeCheapProfile = useMemo( - () => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null, - [assigneeCheapProfiles], - ); - const modelOverrideOptions = useMemo(() => { - const models = sortAdapterModels(assigneeAdapterModels ?? []); - const options = models.map((model) => ({ - id: model.id, - label: model.label, - searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`, - })); - if (assigneeOverrideModel && !options.some((option) => option.id === assigneeOverrideModel)) { - options.unshift({ - id: assigneeOverrideModel, - label: assigneeOverrideModel, - searchText: assigneeOverrideModel, - }); - } - return options; - }, [assigneeAdapterModels, assigneeOverrideModel]); - const updateAssigneeAdapterOverrides = (next: Issue["assigneeAdapterOverrides"]) => { - onUpdate({ assigneeAdapterOverrides: next }); - }; - const buildAssigneeOverrideWithConfig = (adapterConfig: Record) => { - const nextConfig = compactRecord(adapterConfig); - const next = compactRecord({ - useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace, - ...(Object.keys(nextConfig).length > 0 ? { adapterConfig: nextConfig } : {}), - }); - return Object.keys(next).length > 0 ? next : null; - }; - const updateAssigneeOverrideConfig = (patch: Record) => { - updateAssigneeAdapterOverrides( - buildAssigneeOverrideWithConfig({ - ...assigneeOverrideAdapterConfig, - ...patch, - }), - ); - }; - const updateAssigneeOverrideThinkingEffort = (nextValue: string) => { - const nextConfig = { ...assigneeOverrideAdapterConfig }; - delete nextConfig.modelReasoningEffort; - delete nextConfig.reasoningEffort; - delete nextConfig.effort; - delete nextConfig.variant; - if (nextValue) { - nextConfig[thinkingEffortKeyFor(assigneeAdapterType)] = nextValue; - } - updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(nextConfig)); - }; - const setAssigneeOverrideLane = (lane: IssueModelLane) => { - if (lane === "primary") { - updateAssigneeAdapterOverrides(null); - return; - } - if (lane === "cheap") { - updateAssigneeAdapterOverrides( - compactRecord({ - useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace, - modelProfile: "cheap", - }), - ); - return; - } - updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(assigneeOverrideAdapterConfig) ?? { adapterConfig: {} }); - }; - const assigneeOptionsTrigger = (() => { - if (assigneeOverrideLane === "cheap") { - return Cheap model; - } - if (assigneeOverrideLane === "custom") { - const details = [ - assigneeOverrideModel, - assigneeOverrideThinkingEffort, - assigneeOverrideChrome ? "Chrome" : "", - ].filter(Boolean); - return ( - - Custom{details.length > 0 ? ` · ${details.join(" · ")}` : " adapter options"} - - ); - } - return Primary model; - })(); - const assigneeOptionsContent = supportsAssigneeOverrides ? ( -
-
-
Model lane
-
- {(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => ( - - ))} -
- {assigneeOverrideLane === "cheap" ? ( -

- Sends modelProfile: "cheap"{" "} - {assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record).model === "string" - ? <>· adapter default {String((assigneeCheapProfile.adapterConfig as Record).model)} - : assigneeCheapProfile - ? <>· uses the agent's configured cheap profile - : <>· falls back to the primary model if no cheap profile is configured} -

- ) : null} -
- {assigneeOverrideLane === "custom" ? ( - <> -
-
Model
- updateAssigneeOverrideConfig({ model: model || undefined })} - /> -
-
-
Thinking effort
-
- {thinkingEffortOptionsFor(assigneeAdapterType).map((option) => ( - - ))} -
-
- {assigneeAdapterType === "claude_local" ? ( -
-
Enable Chrome (--chrome)
- updateAssigneeOverrideConfig({ chrome: next ? true : undefined })} - /> -
- ) : null} - - ) : null} -
- ) : ( -
-

- {assignee - ? "This assignee's adapter does not expose editable task overrides." - : "Select a compatible agent assignee to edit these overrides."} -

- -
- ); - const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); - const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); - const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap); - const assigneeUserLabel = userLabel(issue.assigneeUserId); - const creatorUserLabel = userLabel(issue.createdByUserId); - const selectedAssigneeValue = issue.assigneeAgentId - ? `agent:${issue.assigneeAgentId}` - : issue.assigneeUserId - ? `user:${issue.assigneeUserId}` - : ""; - - // --- Interrupt-handoff clarity for the assignee picker (design surface 2) --- - const handoffResolvers: HandoffChipResolvers = useMemo( - () => ({ - agentMap: new Map((agents ?? []).map((agent) => [agent.id, { name: agent.name, icon: agent.icon }])), - resolveUserLabel: (id) => userLabel(id), - }), - // userLabel closes over userLabelMap + currentUserId, both reflected here. - [agents, userLabelMap, currentUserId], - ); - const reassignInterruptCopy = useMemo( - () => describeReassignInterrupt({ runningAgentName: assignee?.name ?? null }), - [assignee?.name], - ); - const closeAssigneePicker = () => { - setAssigneeOpen(false); - setAssigneeSearch(""); - setPendingAssignee(null); - }; - const applyAssignee = (next: { assigneeAgentId: string | null; assigneeUserId: string | null }, track?: () => void) => { - track?.(); - onUpdate(next); - closeAssigneePicker(); - }; - /** Apply a selection immediately, or stage it for confirmation while a run is live. */ - const selectAssignee = ( - next: { assigneeAgentId: string | null; assigneeUserId: string | null }, - label: string, - track?: () => void, - ) => { - const nextValue = next.assigneeAgentId - ? `agent:${next.assigneeAgentId}` - : next.assigneeUserId - ? `user:${next.assigneeUserId}` - : ""; - if (nextValue === selectedAssigneeValue) { - closeAssigneePicker(); - return; - } - if (hasActiveRun) { - setPendingAssignee({ ...next, label, track }); - return; - } - applyAssignee(next, track); - }; - const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { - onUpdate({ - executionPolicy: buildExecutionPolicy({ - existingPolicy: issue.executionPolicy ?? null, - reviewerValues: nextReviewers, - approverValues: nextApprovers, - }), - }); - }; - const toggleExecutionParticipant = (stageType: "review" | "approval", value: string) => { - const currentValues = stageType === "review" ? reviewerValues : approverValues; - const nextValues = currentValues.includes(value) - ? currentValues.filter((candidate) => candidate !== value) - : [...currentValues, value]; - updateExecutionPolicy( - stageType === "review" ? nextValues : reviewerValues, - stageType === "approval" ? nextValues : approverValues, - ); - }; - const executionParticipantLabel = (value: string) => { - if (value.startsWith("agent:")) { - return agentName(value.slice("agent:".length)) ?? value.slice("agent:".length, "agent:".length + 8); - } - if (value.startsWith("user:")) { - return userLabel(value.slice("user:".length)) ?? "User"; - } - return value; - }; - const reviewerTrigger = reviewerValues.length > 0 - ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} - : None; - const approverTrigger = approverValues.length > 0 - ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} - : None; - const nextRunnableExecutionStage = (() => { - if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) { - return issue.executionState.currentStageType; - } - if (issue.executionState) return null; - if (reviewerValues.length > 0) return "review"; - if (approverValues.length > 0) return "approval"; - return null; - })(); - const runExecutionButton = (stageType: "review" | "approval") => ( - - - - ); - const currentExecutionLabel = (() => { - if (!issue.executionState?.currentStageType) return null; - const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval"; - const participant = issue.executionState.currentParticipant; - const participantLabel = participant - ? (participant.type === "agent" - ? agentName(participant.agentId ?? null) - : userLabel(participant.userId ?? null)) - : null; - if (issue.executionState.status === "changes_requested") { - return `${stageLabel} requested changes${participantLabel ? ` by ${participantLabel}` : ""}`; - } - return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`; - })(); - useEffect(() => { - setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); - setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? ""); - setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? ""); - }, [ - issue.executionPolicy?.monitor?.nextCheckAt, - issue.executionPolicy?.monitor?.notes, - issue.executionPolicy?.monitor?.serviceName, - ]); - // Re-sync watchdog editor inputs when the persisted watchdog changes (and reset on close). - useEffect(() => { - if (watchdogOpen) return; - setWatchdogAgentInput(issue.watchdog?.watchdogAgentId ?? ""); - setWatchdogInstructionsInput(issue.watchdog?.instructions ?? ""); - }, [issue.watchdog?.watchdogAgentId, issue.watchdog?.instructions, watchdogOpen]); - - const watchdogAgentOptions = useMemo( - () => - (agents ?? []) - .filter(isAgentTaskTarget) - .map((agent) => ({ - id: agent.id, - label: agent.name, - searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, - })), - [agents], - ); - const upsertWatchdog = useMutation({ - mutationFn: (data: { agentId: string; instructions: string | null }) => - issuesApi.upsertWatchdog(issue.id, data), - onSuccess: (watchdog) => { - queryClient.setQueryData(queryKeys.issues.detail(issue.id), (current) => - current ? { ...current, watchdog } : current, - ); - void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - setWatchdogOpen(false); - }, - }); - const deleteWatchdog = useMutation({ - mutationFn: () => issuesApi.deleteWatchdog(issue.id), - onSuccess: () => { - queryClient.setQueryData(queryKeys.issues.detail(issue.id), (current) => - current ? { ...current, watchdog: null } : current, - ); - void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - setWatchdogOpen(false); - }, - }); - const saveWatchdog = () => { - if (!watchdogAgentInput) return; - upsertWatchdog.mutate({ - agentId: watchdogAgentInput, - instructions: watchdogInstructionsInput.trim() || null, - }); - }; - const removeWatchdog = () => { - if (issue.watchdog) { - deleteWatchdog.mutate(); - } else { - setWatchdogOpen(false); - } - setWatchdogAgentInput(""); - setWatchdogInstructionsInput(""); - }; - const watchdogMutationError = - upsertWatchdog.error instanceof Error - ? upsertWatchdog.error.message - : deleteWatchdog.error instanceof Error - ? deleteWatchdog.error.message - : null; - const watchdogIssueRef = (childIssues ?? []).find( - (child) => child.id === issue.watchdog?.watchdogIssueId, - ); - const watchdogTrigger = issue.watchdog ? ( - - {(() => { - const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId); - return agent ? : null; - })()} - {agentName(issue.watchdog.watchdogAgentId)} - {issue.watchdog.instructions?.trim() ? ( - - · {issue.watchdog.instructions.trim()} - - ) : null} - {issue.watchdog.status === "disabled" ? ( - (disabled) - ) : null} - - ) : ( - Set watchdog - ); - const watchdogContent = ( -
-
-
Watchdog agent
- { - if (!option) return Select agent; - const agent = (agents ?? []).find((candidate) => candidate.id === option.id); - return ( - <> - {agent ? : null} - {option.label} - - ); - }} - renderOption={(option) => { - const agent = (agents ?? []).find((candidate) => candidate.id === option.id); - return ( - <> - {agent ? : null} - {option.label} - - ); - }} - /> -
-
-
- Instructions (optional) -
-