Render workspace-ready comments as compact system notices (#10636)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-01 14:04:43 -07:00 committed by GitHub
parent 8676795188
commit a388ea1cac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 377 additions and 16 deletions

View File

@ -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" },
],
}],
},
},
);
});
});

View File

@ -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<string, string | undefined>;
}
type WorkspaceReadyCommentWriter = {
addComment: (
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
options?: {
presentation?: ReturnType<typeof buildWorkspaceReadyPresentation>;
metadata?: ReturnType<typeof buildWorkspaceReadyMetadata>;
},
) => Promise<unknown>;
};
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",

View File

@ -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> = {},
): 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> = {},
): 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"));
});
});

View File

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