From a388ea1cac073f41b0ce00ee3293f17199dd9e09 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:04:43 -0700 Subject: [PATCH] Render workspace-ready comments as compact system notices (#10636) 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 server posts a workspace-ready comment after it prepares an execution workspace or runtime service. > - The full Markdown card uses too much space in the task thread. > - The existing system-notice presentation can show the same comment as a compact row. > - The server must keep the original body for API clients and expanded details. > - This pull request adds structured presentation data at both workspace-ready call sites. > - The benefit is a quieter thread with no data loss and no migration. ## Linked Issues or Issue Description **What existing behavior does this improve?** The server posts the workspace-ready task comment after workspace provisioning and adapter-managed runtime startup. **Current behavior** The task thread shows a full Markdown comment with strategy, branch, working directory, services, and warnings. Long branch names can make this card dominate the thread. **Proposed behavior** Show the comment as a compact system-notice row. Expand the row in place to show the original Markdown body and structured workspace, service, and warning details. Use a warning tone and open the details by default when warnings exist. **Reason and benefit** The same workspace data is available in the task properties. The compact row keeps the thread easy to scan while it preserves the full comment for API consumers and expanded inspection. **Breaking changes** None. The comment body stays unchanged. Existing comments without presentation data keep their current rendering. ## What Changed - Added workspace-ready presentation and metadata builders. - Added structured workspace, service, reuse, and warning details. - Wired both workspace-ready comment paths to send presentation and metadata options. - Added focused unit and heartbeat-level tests. Collapsed notice: ![Collapsed Workspace Ready compact notice](https://pages.paperclip.ing/pap-16051-workspace-ready-notice-20260801/collapsed.png) Expanded notice: ![Expanded Workspace Ready compact notice](https://pages.paperclip.ing/pap-16051-workspace-ready-notice-20260801/expanded.png) ## Verification - `pnpm exec vitest run server/src/services/workspace-runtime-ready-comment.test.ts server/src/__tests__/heartbeat-workspace-ready-comment.test.ts` — 8 tests passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm -r typecheck` — passed. - `pnpm check:token-gates` — passed. - `pnpm build` — passed. - `pnpm test:run` — 300 server files and 405 UI files passed. One unrelated CLI AWS doctor test detected static credentials from the runner environment. The same test passed with `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` removed. - Built the existing system-notice Storybook story and captured both compact and expanded states. - GitHub CI — all latest-head gates passed. One signoff-policy e2e shard hit a transient checkout-state race and passed on its single rerun. ## Risks Low risk. This change only adds optional comment presentation data in two server paths. The body, database schema, API contract, and old comments remain unchanged. Incorrect metadata would affect only expanded structured details; focused tests cover the shape and both warning states. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with GPT-5 (`gpt-5`, the exact snapshot and context-window size are not exposed by this runtime). The agent used reasoning, repository tools, code execution, and visual inspection. ## 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 --- .../heartbeat-workspace-ready-comment.test.ts | 66 ++++++ server/src/services/heartbeat.ts | 66 ++++-- .../workspace-runtime-ready-comment.test.ts | 192 ++++++++++++++++++ server/src/services/workspace-runtime.ts | 69 ++++++- 4 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 server/src/__tests__/heartbeat-workspace-ready-comment.test.ts create mode 100644 server/src/services/workspace-runtime-ready-comment.test.ts diff --git a/server/src/__tests__/heartbeat-workspace-ready-comment.test.ts b/server/src/__tests__/heartbeat-workspace-ready-comment.test.ts new file mode 100644 index 0000000000..29f87a05bb --- /dev/null +++ b/server/src/__tests__/heartbeat-workspace-ready-comment.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RealizedExecutionWorkspace, RuntimeServiceRef } from "../services/workspace-runtime.js"; +import { postWorkspaceReadyComment } from "../services/heartbeat.js"; + +describe("heartbeat workspace-ready comment", () => { + it("passes presentation and metadata in the addComment options argument", async () => { + const workspace: RealizedExecutionWorkspace = { + baseCwd: "/repo", + source: "project_primary", + projectId: "project-id", + workspaceId: "project-workspace-id", + repoUrl: null, + repoRef: "main", + strategy: "git_worktree", + cwd: "/repo/.paperclip/worktrees/PAP-16051", + branchName: "PAP-16051-workspace-ready-notice", + worktreePath: "/repo/.paperclip/worktrees/PAP-16051", + warnings: [], + created: true, + }; + const runtimeServices: RuntimeServiceRef[] = []; + const addComment = vi.fn().mockResolvedValue({ id: "comment-id" }); + + await postWorkspaceReadyComment({ + issuesSvc: { addComment }, + issueId: "issue-id", + agentId: "agent-id", + runId: "run-id", + workspace, + runtimeServices, + }); + + expect(addComment).toHaveBeenCalledOnce(); + expect(addComment).toHaveBeenCalledWith( + "issue-id", + [ + "## Workspace Ready", + "", + "- Strategy: `git_worktree`", + "- Branch: `PAP-16051-workspace-ready-notice`", + "- CWD: `/repo/.paperclip/worktrees/PAP-16051`", + ].join("\n"), + { agentId: "agent-id", runId: "run-id" }, + { + presentation: { + kind: "system_notice", + tone: "info", + title: "Workspace ready · PAP-16051-workspace-ready-notice", + density: "compact", + detailsDefaultOpen: false, + }, + metadata: { + version: 1, + sections: [{ + title: "Workspace", + rows: [ + { type: "key_value", label: "Strategy", value: "git_worktree" }, + { type: "key_value", label: "Branch", value: "PAP-16051-workspace-ready-notice" }, + { type: "key_value", label: "CWD", value: "/repo/.paperclip/worktrees/PAP-16051" }, + ], + }], + }, + }, + ); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 491d804a0e..4ae1857aaa 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -120,6 +120,8 @@ import { import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./activity-log.js"; import { buildWorkspaceReadyComment, + buildWorkspaceReadyMetadata, + buildWorkspaceReadyPresentation, cleanupExecutionWorkspaceArtifacts, ensureGitWorktreeBranchCoherent, ensurePersistedExecutionWorkspaceAvailable, @@ -131,6 +133,7 @@ import { releaseRuntimeServicesForRun, type ExecutionWorkspaceInput, type RealizedExecutionWorkspace, + type RuntimeServiceRef, sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; @@ -6193,6 +6196,41 @@ export interface HeartbeatServiceOptions { runtimeEnv?: Record; } +type WorkspaceReadyCommentWriter = { + addComment: ( + issueId: string, + body: string, + actor: { agentId?: string; userId?: string; runId?: string | null }, + options?: { + presentation?: ReturnType; + metadata?: ReturnType; + }, + ) => Promise; +}; + +export function postWorkspaceReadyComment(input: { + issuesSvc: WorkspaceReadyCommentWriter; + issueId: string; + agentId: string; + runId: string; + workspace: RealizedExecutionWorkspace; + runtimeServices: RuntimeServiceRef[]; +}) { + const workspaceReadyInput = { + workspace: input.workspace, + runtimeServices: input.runtimeServices, + }; + return input.issuesSvc.addComment( + input.issueId, + buildWorkspaceReadyComment(workspaceReadyInput), + { agentId: input.agentId, runId: input.runId }, + { + presentation: buildWorkspaceReadyPresentation(workspaceReadyInput), + metadata: buildWorkspaceReadyMetadata(workspaceReadyInput), + }, + ); +} + function isTruthyRuntimeEnvValue(value: string | undefined) { return value === "true" || value === "1" || value === "yes" || value === "on"; } @@ -14333,14 +14371,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) { try { - await issuesSvc.addComment( + await postWorkspaceReadyComment({ + issuesSvc, issueId, - buildWorkspaceReadyComment({ - workspace: executionWorkspace, - runtimeServices, - }), - { agentId: agent.id, runId: run.id }, - ); + agentId: agent.id, + runId: run.id, + workspace: executionWorkspace, + runtimeServices, + }); } catch (err) { await onLog( "stderr", @@ -14740,14 +14778,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(eq(heartbeatRuns.id, run.id)); if (issueId) { try { - await issuesSvc.addComment( + await postWorkspaceReadyComment({ + issuesSvc, issueId, - buildWorkspaceReadyComment({ - workspace: executionWorkspace, - runtimeServices: adapterManagedRuntimeServices, - }), - { agentId: agent.id, runId: run.id }, - ); + agentId: agent.id, + runId: run.id, + workspace: executionWorkspace, + runtimeServices: adapterManagedRuntimeServices, + }); } catch (err) { await onLog( "stderr", diff --git a/server/src/services/workspace-runtime-ready-comment.test.ts b/server/src/services/workspace-runtime-ready-comment.test.ts new file mode 100644 index 0000000000..74a2219cf9 --- /dev/null +++ b/server/src/services/workspace-runtime-ready-comment.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorkspaceReadyComment, + buildWorkspaceReadyMetadata, + buildWorkspaceReadyPresentation, + type RealizedExecutionWorkspace, + type RuntimeServiceRef, +} from "./workspace-runtime.js"; + +function workspace( + overrides: Partial = {}, +): RealizedExecutionWorkspace { + return { + baseCwd: "/repo", + source: "project_primary", + projectId: "project-id", + workspaceId: "project-workspace-id", + repoUrl: null, + repoRef: "main", + strategy: "git_worktree", + cwd: "/repo/.paperclip/worktrees/PAP-16051", + branchName: "PAP-16051-workspace-ready-notice", + worktreePath: "/repo/.paperclip/worktrees/PAP-16051", + warnings: [], + created: true, + ...overrides, + }; +} + +function runtimeService( + overrides: Partial = {}, +): RuntimeServiceRef { + return { + id: "service-id", + companyId: "company-id", + projectId: "project-id", + projectWorkspaceId: "project-workspace-id", + executionWorkspaceId: "execution-workspace-id", + issueId: "issue-id", + serviceName: "web", + status: "running", + lifecycle: "ephemeral", + scopeType: "run", + scopeId: "run-id", + reuseKey: null, + command: "pnpm dev", + cwd: "/repo/.paperclip/worktrees/PAP-16051", + port: 3100, + url: "http://localhost:3100", + provider: "local_process", + providerRef: null, + ownerAgentId: "agent-id", + startedByRunId: "run-id", + lastUsedAt: "2026-08-01T00:00:00.000Z", + startedAt: "2026-08-01T00:00:00.000Z", + stoppedAt: null, + stopPolicy: null, + healthStatus: "healthy", + reused: false, + ...overrides, + }; +} + +describe("workspace-ready comment builders", () => { + it("uses a compact info notice that is collapsed when the workspace has no warnings", () => { + const input = { workspace: workspace(), runtimeServices: [] }; + + expect(buildWorkspaceReadyPresentation(input)).toEqual({ + kind: "system_notice", + tone: "info", + title: "Workspace ready · PAP-16051-workspace-ready-notice", + density: "compact", + detailsDefaultOpen: false, + }); + }); + + it("uses a warning notice that is expanded when warnings are present", () => { + const input = { + workspace: workspace({ warnings: ["The worktree was restored from a stale reference."] }), + runtimeServices: [], + }; + + expect(buildWorkspaceReadyPresentation(input)).toMatchObject({ + tone: "warning", + detailsDefaultOpen: true, + }); + expect(buildWorkspaceReadyMetadata(input).sections.at(-1)).toEqual({ + title: "Warnings", + rows: [{ type: "text", text: "The worktree was restored from a stale reference." }], + }); + }); + + it("truncates the presentation title to 160 characters", () => { + const presentation = buildWorkspaceReadyPresentation({ + workspace: workspace({ branchName: "b".repeat(200) }), + runtimeServices: [], + }); + + expect(presentation.title).toHaveLength(160); + expect(presentation.title).toBe(`${`Workspace ready · ${"b".repeat(200)}`.slice(0, 159)}…`); + }); + + it("falls back to the workspace strategy when no branch is available", () => { + const presentation = buildWorkspaceReadyPresentation({ + workspace: workspace({ branchName: null, strategy: "project_primary" }), + runtimeServices: [], + }); + + expect(presentation.title).toBe("Workspace ready · project_primary"); + }); + + it("builds structured workspace and service sections without an empty warnings section", () => { + const input = { + workspace: workspace(), + runtimeServices: [ + runtimeService(), + runtimeService({ + id: "worker-service-id", + serviceName: "worker", + url: null, + reused: true, + }), + ], + }; + + expect(buildWorkspaceReadyMetadata(input)).toEqual({ + version: 1, + sections: [ + { + title: "Workspace", + rows: [ + { type: "key_value", label: "Strategy", value: "git_worktree" }, + { type: "key_value", label: "Branch", value: "PAP-16051-workspace-ready-notice" }, + { type: "key_value", label: "CWD", value: "/repo/.paperclip/worktrees/PAP-16051" }, + ], + }, + { + title: "Services", + rows: [ + { type: "key_value", label: "web", value: "http://localhost:3100" }, + { type: "key_value", label: "worker", value: "running (reused)" }, + ], + }, + ], + }); + }); + + it("keeps service labels within the comment metadata boundary", () => { + const metadata = buildWorkspaceReadyMetadata({ + workspace: workspace(), + runtimeServices: [runtimeService({ serviceName: ` ${"s".repeat(150)} ` })], + }); + const serviceLabel = metadata.sections[1]?.rows[0]; + + expect(serviceLabel).toEqual({ + type: "key_value", + label: `${"s".repeat(119)}…`, + value: "http://localhost:3100", + }); + }); + + it("includes a distinct worktree row and preserves the existing markdown body", () => { + const input = { + workspace: workspace({ + cwd: "/repo/runtime", + worktreePath: "/repo/.paperclip/worktrees/PAP-16051", + warnings: ["Warning text"], + }), + runtimeServices: [runtimeService({ reused: true })], + }; + + expect(buildWorkspaceReadyMetadata(input).sections[0]).toEqual({ + title: "Workspace", + rows: [ + { type: "key_value", label: "Strategy", value: "git_worktree" }, + { type: "key_value", label: "Branch", value: "PAP-16051-workspace-ready-notice" }, + { type: "key_value", label: "CWD", value: "/repo/runtime" }, + { type: "key_value", label: "Worktree", value: "/repo/.paperclip/worktrees/PAP-16051" }, + ], + }); + expect(buildWorkspaceReadyComment(input)).toBe([ + "## Workspace Ready", + "", + "- Strategy: `git_worktree`", + "- Branch: `PAP-16051-workspace-ready-notice`", + "- CWD: `/repo/runtime`", + "- Worktree: `/repo/.paperclip/worktrees/PAP-16051`", + "- Warning: Warning text", + "- Service: web: http://localhost:3100 (reused)", + ].join("\n")); + }); +}); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 71ee337052..2bbc0cb8d2 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -13,6 +13,8 @@ import { type GitWorktreeBranchAncestryVerdict, type GitWorktreeBranchIncoherenceEvidence as SharedGitWorktreeBranchIncoherenceEvidence, type GitWorktreeInProgressOperation, + type IssueCommentMetadata, + type IssueCommentPresentation, type WorkspaceOperationPhase, type WorkspaceRuntimeDesiredState, type WorkspaceRuntimeServiceStateMap, @@ -5090,10 +5092,73 @@ export async function persistAdapterManagedRuntimeServices(input: { return refs; } -export function buildWorkspaceReadyComment(input: { +type WorkspaceReadyCommentInput = { workspace: RealizedExecutionWorkspace; runtimeServices: RuntimeServiceRef[]; -}) { +}; + +const COMMENT_METADATA_LABEL_MAX_LENGTH = 120; + +function workspaceReadyServiceLabel(serviceName: string): string { + const label = serviceName.trim() || "Service"; + return label.length > COMMENT_METADATA_LABEL_MAX_LENGTH + ? `${label.slice(0, COMMENT_METADATA_LABEL_MAX_LENGTH - 1)}…` + : label; +} + +export function buildWorkspaceReadyPresentation( + input: WorkspaceReadyCommentInput, +): IssueCommentPresentation { + const workspaceLabel = input.workspace.branchName ?? input.workspace.strategy; + const title = `Workspace ready · ${workspaceLabel}`; + const hasWarnings = input.workspace.warnings.length > 0; + + return { + kind: "system_notice", + tone: hasWarnings ? "warning" : "info", + title: title.length > 160 ? `${title.slice(0, 159)}…` : title, + density: "compact", + detailsDefaultOpen: hasWarnings, + }; +} + +export function buildWorkspaceReadyMetadata( + input: WorkspaceReadyCommentInput, +): IssueCommentMetadata { + const workspaceRows: IssueCommentMetadata["sections"][number]["rows"] = [ + { type: "key_value", label: "Strategy", value: input.workspace.strategy }, + ...(input.workspace.branchName + ? [{ type: "key_value" as const, label: "Branch", value: input.workspace.branchName }] + : []), + { type: "key_value", label: "CWD", value: input.workspace.cwd }, + ...(input.workspace.worktreePath && input.workspace.worktreePath !== input.workspace.cwd + ? [{ type: "key_value" as const, label: "Worktree", value: input.workspace.worktreePath }] + : []), + ]; + const serviceRows: IssueCommentMetadata["sections"][number]["rows"] = input.runtimeServices.map( + (service) => ({ + type: "key_value", + label: workspaceReadyServiceLabel(service.serviceName), + value: `${service.url ?? "running"}${service.reused ? " (reused)" : ""}`, + }), + ); + + return { + version: 1, + sections: [ + { title: "Workspace", rows: workspaceRows }, + ...(serviceRows.length > 0 ? [{ title: "Services", rows: serviceRows }] : []), + ...(input.workspace.warnings.length > 0 + ? [{ + title: "Warnings", + rows: input.workspace.warnings.map((warning) => ({ type: "text" as const, text: warning })), + }] + : []), + ], + }; +} + +export function buildWorkspaceReadyComment(input: WorkspaceReadyCommentInput) { const lines = ["## Workspace Ready", ""]; lines.push(`- Strategy: \`${input.workspace.strategy}\``); if (input.workspace.branchName) lines.push(`- Branch: \`${input.workspace.branchName}\``);