Skip to content

Commit 0436888

Browse files
feat: render workspace-ready comments as compact notices
Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent ee851fc commit 0436888

4 files changed

Lines changed: 354 additions & 16 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { RealizedExecutionWorkspace, RuntimeServiceRef } from "../services/workspace-runtime.js";
3+
import { postWorkspaceReadyComment } from "../services/heartbeat.js";
4+
5+
describe("heartbeat workspace-ready comment", () => {
6+
it("passes presentation and metadata in the addComment options argument", async () => {
7+
const workspace: RealizedExecutionWorkspace = {
8+
baseCwd: "/repo",
9+
source: "project_primary",
10+
projectId: "project-id",
11+
workspaceId: "project-workspace-id",
12+
repoUrl: null,
13+
repoRef: "main",
14+
strategy: "git_worktree",
15+
cwd: "/repo/.paperclip/worktrees/PAP-16051",
16+
branchName: "PAP-16051-workspace-ready-notice",
17+
worktreePath: "/repo/.paperclip/worktrees/PAP-16051",
18+
warnings: [],
19+
created: true,
20+
};
21+
const runtimeServices: RuntimeServiceRef[] = [];
22+
const addComment = vi.fn().mockResolvedValue({ id: "comment-id" });
23+
24+
await postWorkspaceReadyComment({
25+
issuesSvc: { addComment },
26+
issueId: "issue-id",
27+
agentId: "agent-id",
28+
runId: "run-id",
29+
workspace,
30+
runtimeServices,
31+
});
32+
33+
expect(addComment).toHaveBeenCalledOnce();
34+
expect(addComment).toHaveBeenCalledWith(
35+
"issue-id",
36+
[
37+
"## Workspace Ready",
38+
"",
39+
"- Strategy: `git_worktree`",
40+
"- Branch: `PAP-16051-workspace-ready-notice`",
41+
"- CWD: `/repo/.paperclip/worktrees/PAP-16051`",
42+
].join("\n"),
43+
{ agentId: "agent-id", runId: "run-id" },
44+
{
45+
presentation: {
46+
kind: "system_notice",
47+
tone: "info",
48+
title: "Workspace ready · PAP-16051-workspace-ready-notice",
49+
density: "compact",
50+
detailsDefaultOpen: false,
51+
},
52+
metadata: {
53+
version: 1,
54+
sections: [{
55+
title: "Workspace",
56+
rows: [
57+
{ type: "key_value", label: "Strategy", value: "git_worktree" },
58+
{ type: "key_value", label: "Branch", value: "PAP-16051-workspace-ready-notice" },
59+
{ type: "key_value", label: "CWD", value: "/repo/.paperclip/worktrees/PAP-16051" },
60+
],
61+
}],
62+
},
63+
},
64+
);
65+
});
66+
});

server/src/services/heartbeat.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ import {
120120
import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./activity-log.js";
121121
import {
122122
buildWorkspaceReadyComment,
123+
buildWorkspaceReadyMetadata,
124+
buildWorkspaceReadyPresentation,
123125
cleanupExecutionWorkspaceArtifacts,
124126
ensureGitWorktreeBranchCoherent,
125127
ensurePersistedExecutionWorkspaceAvailable,
@@ -131,6 +133,7 @@ import {
131133
releaseRuntimeServicesForRun,
132134
type ExecutionWorkspaceInput,
133135
type RealizedExecutionWorkspace,
136+
type RuntimeServiceRef,
134137
sanitizeRuntimeServiceBaseEnv,
135138
} from "./workspace-runtime.js";
136139
import { issueService } from "./issues.js";
@@ -6193,6 +6196,41 @@ export interface HeartbeatServiceOptions {
61936196
runtimeEnv?: Record<string, string | undefined>;
61946197
}
61956198

6199+
type WorkspaceReadyCommentWriter = {
6200+
addComment: (
6201+
issueId: string,
6202+
body: string,
6203+
actor: { agentId?: string; userId?: string; runId?: string | null },
6204+
options?: {
6205+
presentation?: ReturnType<typeof buildWorkspaceReadyPresentation>;
6206+
metadata?: ReturnType<typeof buildWorkspaceReadyMetadata>;
6207+
},
6208+
) => Promise<unknown>;
6209+
};
6210+
6211+
export function postWorkspaceReadyComment(input: {
6212+
issuesSvc: WorkspaceReadyCommentWriter;
6213+
issueId: string;
6214+
agentId: string;
6215+
runId: string;
6216+
workspace: RealizedExecutionWorkspace;
6217+
runtimeServices: RuntimeServiceRef[];
6218+
}) {
6219+
const workspaceReadyInput = {
6220+
workspace: input.workspace,
6221+
runtimeServices: input.runtimeServices,
6222+
};
6223+
return input.issuesSvc.addComment(
6224+
input.issueId,
6225+
buildWorkspaceReadyComment(workspaceReadyInput),
6226+
{ agentId: input.agentId, runId: input.runId },
6227+
{
6228+
presentation: buildWorkspaceReadyPresentation(workspaceReadyInput),
6229+
metadata: buildWorkspaceReadyMetadata(workspaceReadyInput),
6230+
},
6231+
);
6232+
}
6233+
61966234
function isTruthyRuntimeEnvValue(value: string | undefined) {
61976235
return value === "true" || value === "1" || value === "yes" || value === "on";
61986236
}
@@ -14333,14 +14371,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
1433314371
}
1433414372
if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) {
1433514373
try {
14336-
await issuesSvc.addComment(
14374+
await postWorkspaceReadyComment({
14375+
issuesSvc,
1433714376
issueId,
14338-
buildWorkspaceReadyComment({
14339-
workspace: executionWorkspace,
14340-
runtimeServices,
14341-
}),
14342-
{ agentId: agent.id, runId: run.id },
14343-
);
14377+
agentId: agent.id,
14378+
runId: run.id,
14379+
workspace: executionWorkspace,
14380+
runtimeServices,
14381+
});
1434414382
} catch (err) {
1434514383
await onLog(
1434614384
"stderr",
@@ -14740,14 +14778,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
1474014778
.where(eq(heartbeatRuns.id, run.id));
1474114779
if (issueId) {
1474214780
try {
14743-
await issuesSvc.addComment(
14781+
await postWorkspaceReadyComment({
14782+
issuesSvc,
1474414783
issueId,
14745-
buildWorkspaceReadyComment({
14746-
workspace: executionWorkspace,
14747-
runtimeServices: adapterManagedRuntimeServices,
14748-
}),
14749-
{ agentId: agent.id, runId: run.id },
14750-
);
14784+
agentId: agent.id,
14785+
runId: run.id,
14786+
workspace: executionWorkspace,
14787+
runtimeServices: adapterManagedRuntimeServices,
14788+
});
1475114789
} catch (err) {
1475214790
await onLog(
1475314791
"stderr",
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildWorkspaceReadyComment,
4+
buildWorkspaceReadyMetadata,
5+
buildWorkspaceReadyPresentation,
6+
type RealizedExecutionWorkspace,
7+
type RuntimeServiceRef,
8+
} from "./workspace-runtime.js";
9+
10+
function workspace(
11+
overrides: Partial<RealizedExecutionWorkspace> = {},
12+
): RealizedExecutionWorkspace {
13+
return {
14+
baseCwd: "/repo",
15+
source: "project_primary",
16+
projectId: "project-id",
17+
workspaceId: "project-workspace-id",
18+
repoUrl: null,
19+
repoRef: "main",
20+
strategy: "git_worktree",
21+
cwd: "/repo/.paperclip/worktrees/PAP-16051",
22+
branchName: "PAP-16051-workspace-ready-notice",
23+
worktreePath: "/repo/.paperclip/worktrees/PAP-16051",
24+
warnings: [],
25+
created: true,
26+
...overrides,
27+
};
28+
}
29+
30+
function runtimeService(
31+
overrides: Partial<RuntimeServiceRef> = {},
32+
): RuntimeServiceRef {
33+
return {
34+
id: "service-id",
35+
companyId: "company-id",
36+
projectId: "project-id",
37+
projectWorkspaceId: "project-workspace-id",
38+
executionWorkspaceId: "execution-workspace-id",
39+
issueId: "issue-id",
40+
serviceName: "web",
41+
status: "running",
42+
lifecycle: "ephemeral",
43+
scopeType: "run",
44+
scopeId: "run-id",
45+
reuseKey: null,
46+
command: "pnpm dev",
47+
cwd: "/repo/.paperclip/worktrees/PAP-16051",
48+
port: 3100,
49+
url: "http://localhost:3100",
50+
provider: "local_process",
51+
providerRef: null,
52+
ownerAgentId: "agent-id",
53+
startedByRunId: "run-id",
54+
lastUsedAt: "2026-08-01T00:00:00.000Z",
55+
startedAt: "2026-08-01T00:00:00.000Z",
56+
stoppedAt: null,
57+
stopPolicy: null,
58+
healthStatus: "healthy",
59+
reused: false,
60+
...overrides,
61+
};
62+
}
63+
64+
describe("workspace-ready comment builders", () => {
65+
it("uses a compact info notice that is collapsed when the workspace has no warnings", () => {
66+
const input = { workspace: workspace(), runtimeServices: [] };
67+
68+
expect(buildWorkspaceReadyPresentation(input)).toEqual({
69+
kind: "system_notice",
70+
tone: "info",
71+
title: "Workspace ready · PAP-16051-workspace-ready-notice",
72+
density: "compact",
73+
detailsDefaultOpen: false,
74+
});
75+
});
76+
77+
it("uses a warning notice that is expanded when warnings are present", () => {
78+
const input = {
79+
workspace: workspace({ warnings: ["The worktree was restored from a stale reference."] }),
80+
runtimeServices: [],
81+
};
82+
83+
expect(buildWorkspaceReadyPresentation(input)).toMatchObject({
84+
tone: "warning",
85+
detailsDefaultOpen: true,
86+
});
87+
expect(buildWorkspaceReadyMetadata(input).sections.at(-1)).toEqual({
88+
title: "Warnings",
89+
rows: [{ type: "text", text: "The worktree was restored from a stale reference." }],
90+
});
91+
});
92+
93+
it("truncates the presentation title to 160 characters", () => {
94+
const presentation = buildWorkspaceReadyPresentation({
95+
workspace: workspace({ branchName: "b".repeat(200) }),
96+
runtimeServices: [],
97+
});
98+
99+
expect(presentation.title).toHaveLength(160);
100+
expect(presentation.title).toBe(`${`Workspace ready · ${"b".repeat(200)}`.slice(0, 159)}…`);
101+
});
102+
103+
it("falls back to the workspace strategy when no branch is available", () => {
104+
const presentation = buildWorkspaceReadyPresentation({
105+
workspace: workspace({ branchName: null, strategy: "project_primary" }),
106+
runtimeServices: [],
107+
});
108+
109+
expect(presentation.title).toBe("Workspace ready · project_primary");
110+
});
111+
112+
it("builds structured workspace and service sections without an empty warnings section", () => {
113+
const input = {
114+
workspace: workspace(),
115+
runtimeServices: [
116+
runtimeService(),
117+
runtimeService({
118+
id: "worker-service-id",
119+
serviceName: "worker",
120+
url: null,
121+
reused: true,
122+
}),
123+
],
124+
};
125+
126+
expect(buildWorkspaceReadyMetadata(input)).toEqual({
127+
version: 1,
128+
sections: [
129+
{
130+
title: "Workspace",
131+
rows: [
132+
{ type: "key_value", label: "Strategy", value: "git_worktree" },
133+
{ type: "key_value", label: "Branch", value: "PAP-16051-workspace-ready-notice" },
134+
{ type: "key_value", label: "CWD", value: "/repo/.paperclip/worktrees/PAP-16051" },
135+
],
136+
},
137+
{
138+
title: "Services",
139+
rows: [
140+
{ type: "key_value", label: "web", value: "http://localhost:3100" },
141+
{ type: "key_value", label: "worker", value: "running (reused)" },
142+
],
143+
},
144+
],
145+
});
146+
});
147+
148+
it("includes a distinct worktree row and preserves the existing markdown body", () => {
149+
const input = {
150+
workspace: workspace({
151+
cwd: "/repo/runtime",
152+
worktreePath: "/repo/.paperclip/worktrees/PAP-16051",
153+
warnings: ["Warning text"],
154+
}),
155+
runtimeServices: [runtimeService({ reused: true })],
156+
};
157+
158+
expect(buildWorkspaceReadyMetadata(input).sections[0]).toEqual({
159+
title: "Workspace",
160+
rows: [
161+
{ type: "key_value", label: "Strategy", value: "git_worktree" },
162+
{ type: "key_value", label: "Branch", value: "PAP-16051-workspace-ready-notice" },
163+
{ type: "key_value", label: "CWD", value: "/repo/runtime" },
164+
{ type: "key_value", label: "Worktree", value: "/repo/.paperclip/worktrees/PAP-16051" },
165+
],
166+
});
167+
expect(buildWorkspaceReadyComment(input)).toBe([
168+
"## Workspace Ready",
169+
"",
170+
"- Strategy: `git_worktree`",
171+
"- Branch: `PAP-16051-workspace-ready-notice`",
172+
"- CWD: `/repo/runtime`",
173+
"- Worktree: `/repo/.paperclip/worktrees/PAP-16051`",
174+
"- Warning: Warning text",
175+
"- Service: web: http://localhost:3100 (reused)",
176+
].join("\n"));
177+
});
178+
});

0 commit comments

Comments
 (0)