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}\``);