Skip to content

Commit d0e9cc7

Browse files
Show workspace changes and stale notices in issue threads (paperclipai#5356)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The issue thread is the operator's durable audit trail for what changed and why > - Workspace changes and stale disposition notices need to be visible in that same timeline without noisy or misleading rendering > - The local branch already contained backend activity details, timeline conversion, and UI rendering work for those events > - This pull request isolates the issue-thread activity work into a standalone branch against `origin/master` > - The benefit is a focused audit-trail PR that can merge independently of the sidebar/operator UI polish branch ## What Changed - Adds readable workspace-change activity details to issue update activity events. - Surfaces workspace-change events in issue chat/timeline rendering. - Makes the existing issue comment migration idempotent. - Folds and renders stale disposition notices inline so they match activity-log styling and spacing. - Adds focused route, timeline, and issue-thread system notice coverage. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/issue-activity-events-routes.test.ts ui/src/lib/issue-timeline-events.test.ts ui/src/components/IssueChatThreadSystemNotice.test.tsx` — 3 files passed, 22 tests passed. - Confirmed the PR changes 9 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. - `pnpm exec vitest run server/src/__tests__/issue-closed-workspace-routes.test.ts` — 1 file passed, 4 tests passed. - `pnpm exec vitest run server/src/__tests__/issue-activity-events-routes.test.ts ui/src/lib/issue-timeline-events.test.ts ui/src/components/IssueChatThreadSystemNotice.test.tsx server/src/services/recovery/successful-run-handoff.test.ts packages/shared/src/validators/issue.test.ts` — 5 files passed, 54 tests passed. - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck`. - `pnpm --filter @paperclipai/ui typecheck` after adding the Storybook screenshot fixture. - Captured Storybook screenshots for the new UI rendering paths: - Collapsed stale notice + workspace-change row: `docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png` - Expanded stale notice details: `docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png` ### Screenshots Collapsed stale notice with workspace-change row: ![Collapsed stale notice with workspace-change row](docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png) Expanded stale notice details: ![Expanded stale notice details](docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png) ## Risks - Moderate risk: this touches issue activity serialization and issue-thread rendering, both of which are central operator surfaces. - Migration risk is low: the only migration change makes an existing migration idempotent. - No new migrations are introduced, so there is no cross-PR migration ordering requirement. > 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, shell/tool-use enabled, used to split the existing branch, verify the isolated PR branch, and create this PR. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 4103978 commit d0e9cc7

17 files changed

Lines changed: 852 additions & 36 deletions
42.7 KB
Loading
61.5 KB
Loading
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
ALTER TABLE "issue_comments" ADD COLUMN "author_type" text;--> statement-breakpoint
2-
ALTER TABLE "issue_comments" ADD COLUMN "presentation" jsonb;--> statement-breakpoint
3-
ALTER TABLE "issue_comments" ADD COLUMN "metadata" jsonb;
1+
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "author_type" text;--> statement-breakpoint
2+
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "presentation" jsonb;--> statement-breakpoint
3+
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "metadata" jsonb;

packages/shared/src/types/issue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ export interface IssueCommentMetadataSection {
434434

435435
export interface IssueCommentMetadata {
436436
version: 1;
437+
sourceRunId?: string | null;
437438
sections: IssueCommentMetadataSection[];
438439
}
439440

packages/shared/src/validators/issue.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ describe("issue validators", () => {
6565
},
6666
metadata: {
6767
version: 1,
68+
sourceRunId: "11111111-1111-4111-8111-111111111111",
6869
sections: [
6970
{
7071
title: "Evidence",
@@ -79,6 +80,7 @@ describe("issue validators", () => {
7980
});
8081

8182
expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
83+
expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111");
8284
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
8385
});
8486

packages/shared/src/validators/issue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ export const issueCommentMetadataSectionSchema = z.object({
318318

319319
export const issueCommentMetadataSchema = z.object({
320320
version: z.literal(1),
321+
sourceRunId: z.string().uuid().nullable().optional(),
321322
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
322323
}).strict();
323324

server/src/__tests__/issue-activity-events-routes.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from "express";
22
import request from "supertest";
3+
import { getTableName } from "drizzle-orm";
34
import { beforeEach, describe, expect, it, vi } from "vitest";
45
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
56

@@ -266,6 +267,76 @@ describe("issue activity event routes", () => {
266267
});
267268
}, 15_000);
268269

270+
it("logs readable workspace change activity details for issue updates", async () => {
271+
const previousProjectWorkspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
272+
const nextExecutionWorkspaceId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
273+
const issue = {
274+
...makeIssue(),
275+
projectId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
276+
projectWorkspaceId: previousProjectWorkspaceId,
277+
executionWorkspaceId: null,
278+
executionWorkspacePreference: "shared_workspace",
279+
executionWorkspaceSettings: { mode: "shared_workspace" },
280+
};
281+
mockIssueService.getById.mockResolvedValue(issue);
282+
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
283+
...issue,
284+
...patch,
285+
updatedAt: new Date(),
286+
}));
287+
288+
const dbMock = {
289+
select: vi.fn(() => ({
290+
from: (table: unknown) => ({
291+
where: async () => {
292+
const tableName = getTableName(table as Parameters<typeof getTableName>[0]);
293+
if (tableName === "project_workspaces") {
294+
return [{ id: previousProjectWorkspaceId, name: "Main workspace" }];
295+
}
296+
if (tableName === "execution_workspaces") {
297+
return [{ id: nextExecutionWorkspaceId, name: "Feature workspace" }];
298+
}
299+
return [];
300+
},
301+
}),
302+
})),
303+
};
304+
305+
const res = await request(await createApp(dbMock))
306+
.patch(`/api/issues/${issue.id}`)
307+
.send({ executionWorkspaceId: nextExecutionWorkspaceId });
308+
309+
expect(res.status).toBe(200);
310+
await vi.waitFor(() => {
311+
expect(mockLogActivity).toHaveBeenCalledWith(
312+
expect.anything(),
313+
expect.objectContaining({
314+
action: "issue.updated",
315+
details: expect.objectContaining({
316+
executionWorkspaceId: nextExecutionWorkspaceId,
317+
workspaceChange: {
318+
from: {
319+
label: "Main workspace",
320+
projectWorkspaceId: previousProjectWorkspaceId,
321+
executionWorkspaceId: null,
322+
mode: "shared_workspace",
323+
},
324+
to: {
325+
label: "Feature workspace",
326+
projectWorkspaceId: previousProjectWorkspaceId,
327+
executionWorkspaceId: nextExecutionWorkspaceId,
328+
mode: "shared_workspace",
329+
},
330+
},
331+
_previous: expect.objectContaining({
332+
executionWorkspaceId: null,
333+
}),
334+
}),
335+
}),
336+
);
337+
});
338+
});
339+
269340
it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => {
270341
const issue = { ...makeIssue(), status: "in_progress" };
271342
mockIssueService.getById.mockResolvedValue(issue);

server/src/routes/issues.ts

Lines changed: 154 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import multer from "multer";
44
import { z } from "zod";
55
import { and, desc, eq, inArray } from "drizzle-orm";
66
import type { Db } from "@paperclipai/db";
7-
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
7+
import { activityLog, executionWorkspaces, issueExecutionDecisions, projectWorkspaces } from "@paperclipai/db";
88
import {
99
addIssueCommentSchema,
1010
acceptIssueThreadInteractionSchema,
@@ -96,6 +96,7 @@ import {
9696
redactIssueMonitorExternalRef,
9797
setIssueExecutionPolicyMonitorScheduledBy,
9898
} from "../services/issue-execution-policy.js";
99+
import { parseIssueExecutionWorkspaceSettings } from "../services/execution-workspace-policy.js";
99100
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
100101

101102
const MAX_ISSUE_COMMENT_LIMIT = 500;
@@ -142,10 +143,148 @@ const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
142143
"issue.successful_run_handoff_escalated",
143144
] as const;
144145

146+
const ISSUE_WORKSPACE_AUDIT_FIELDS = new Set([
147+
"projectWorkspaceId",
148+
"executionWorkspaceId",
149+
"executionWorkspacePreference",
150+
"executionWorkspaceSettings",
151+
]);
152+
145153
function readNonEmptyString(value: unknown): string | null {
146154
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
147155
}
148156

157+
function hasIssueWorkspaceAuditChange(previous: Record<string, unknown>) {
158+
return Object.keys(previous).some((key) => ISSUE_WORKSPACE_AUDIT_FIELDS.has(key));
159+
}
160+
161+
function labelIssueWorkspaceMode(mode: string | null) {
162+
switch (mode) {
163+
case "shared_workspace":
164+
return "Project default";
165+
case "isolated_workspace":
166+
return "New isolated workspace";
167+
case "operator_branch":
168+
return "Operator branch";
169+
case "reuse_existing":
170+
return "Reuse existing workspace";
171+
case "agent_default":
172+
return "Agent default";
173+
case "inherit":
174+
return "Inherited workspace";
175+
default:
176+
return "No workspace";
177+
}
178+
}
179+
180+
type IssueWorkspaceAuditInput = {
181+
projectWorkspaceId?: string | null;
182+
executionWorkspaceId?: string | null;
183+
executionWorkspacePreference?: string | null;
184+
executionWorkspaceSettings?: unknown;
185+
};
186+
187+
type WorkspaceNameMaps = {
188+
projectWorkspaceNames: Map<string, string>;
189+
executionWorkspaceNames: Map<string, string>;
190+
};
191+
192+
function emptyWorkspaceNameMaps(): WorkspaceNameMaps {
193+
return {
194+
projectWorkspaceNames: new Map(),
195+
executionWorkspaceNames: new Map(),
196+
};
197+
}
198+
199+
function summarizeIssueWorkspaceForActivity(
200+
issue: IssueWorkspaceAuditInput,
201+
names: WorkspaceNameMaps,
202+
) {
203+
const settings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings);
204+
const mode = settings?.mode ?? issue.executionWorkspacePreference ?? null;
205+
const executionWorkspaceId = issue.executionWorkspaceId ?? null;
206+
const projectWorkspaceId = issue.projectWorkspaceId ?? null;
207+
208+
const label = (() => {
209+
if (executionWorkspaceId) {
210+
return names.executionWorkspaceNames.get(executionWorkspaceId) ?? `Workspace ${executionWorkspaceId.slice(0, 8)}`;
211+
}
212+
if (projectWorkspaceId) {
213+
return names.projectWorkspaceNames.get(projectWorkspaceId) ?? `Workspace ${projectWorkspaceId.slice(0, 8)}`;
214+
}
215+
return labelIssueWorkspaceMode(mode);
216+
})();
217+
218+
return {
219+
label,
220+
projectWorkspaceId,
221+
executionWorkspaceId,
222+
mode,
223+
};
224+
}
225+
226+
async function buildIssueWorkspaceChangeActivityDetails(
227+
db: Db,
228+
companyId: string,
229+
previousIssue: IssueWorkspaceAuditInput,
230+
nextIssue: IssueWorkspaceAuditInput,
231+
) {
232+
const projectWorkspaceIds = [
233+
previousIssue.projectWorkspaceId,
234+
nextIssue.projectWorkspaceId,
235+
].filter((value): value is string => typeof value === "string" && value.length > 0);
236+
const executionWorkspaceIds = [
237+
previousIssue.executionWorkspaceId,
238+
nextIssue.executionWorkspaceId,
239+
].filter((value): value is string => typeof value === "string" && value.length > 0);
240+
241+
const [projectRows, executionRows] = await Promise.all([
242+
projectWorkspaceIds.length > 0
243+
? db
244+
.select({ id: projectWorkspaces.id, name: projectWorkspaces.name })
245+
.from(projectWorkspaces)
246+
.where(and(eq(projectWorkspaces.companyId, companyId), inArray(projectWorkspaces.id, projectWorkspaceIds)))
247+
: Promise.resolve([]),
248+
executionWorkspaceIds.length > 0
249+
? db
250+
.select({ id: executionWorkspaces.id, name: executionWorkspaces.name })
251+
.from(executionWorkspaces)
252+
.where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, executionWorkspaceIds)))
253+
: Promise.resolve([]),
254+
]);
255+
256+
const names: WorkspaceNameMaps = {
257+
projectWorkspaceNames: new Map(projectRows.map((row) => [row.id, row.name])),
258+
executionWorkspaceNames: new Map(executionRows.map((row) => [row.id, row.name])),
259+
};
260+
261+
return {
262+
from: summarizeIssueWorkspaceForActivity(previousIssue, names),
263+
to: summarizeIssueWorkspaceForActivity(nextIssue, names),
264+
};
265+
}
266+
267+
function hasExecutionParticipant(value: unknown) {
268+
const state = parseIssueExecutionState(value);
269+
if (!state || state.status !== "pending") return false;
270+
const participant = state.currentParticipant;
271+
if (!participant) return false;
272+
if (participant.type === "agent") return Boolean(participant.agentId);
273+
if (participant.type === "user") return Boolean(participant.userId);
274+
return false;
275+
}
276+
277+
function hasScheduledMonitor(input: {
278+
existingMonitorNextCheckAt?: Date | null;
279+
patchMonitorNextCheckAt?: unknown;
280+
executionPolicy?: unknown;
281+
}) {
282+
if (input.patchMonitorNextCheckAt instanceof Date && !Number.isNaN(input.patchMonitorNextCheckAt.getTime())) return true;
283+
if (input.patchMonitorNextCheckAt === undefined && input.existingMonitorNextCheckAt) return true;
284+
const policy = normalizeIssueExecutionPolicy(input.executionPolicy ?? null);
285+
return Boolean(policy?.monitor?.nextCheckAt);
286+
}
287+
149288
function successfulRunHandoffStateFromActivity(row: {
150289
action: string;
151290
agentId: string | null;
@@ -236,27 +375,6 @@ const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
236375
"link or request a pending approval, assign a human reviewer with assigneeUserId, set a typed executionState.currentParticipant through an execution policy, " +
237376
"or schedule an issue monitor for an external review/check. After creating one of those review paths, retry the status update.";
238377

239-
function hasExecutionParticipant(value: unknown) {
240-
const state = parseIssueExecutionState(value);
241-
if (!state || state.status !== "pending") return false;
242-
const participant = state.currentParticipant;
243-
if (!participant) return false;
244-
if (participant.type === "agent") return Boolean(participant.agentId);
245-
if (participant.type === "user") return Boolean(participant.userId);
246-
return false;
247-
}
248-
249-
function hasScheduledMonitor(input: {
250-
existingMonitorNextCheckAt?: Date | null;
251-
patchMonitorNextCheckAt?: unknown;
252-
executionPolicy?: unknown;
253-
}) {
254-
if (input.patchMonitorNextCheckAt instanceof Date && !Number.isNaN(input.patchMonitorNextCheckAt.getTime())) return true;
255-
if (input.patchMonitorNextCheckAt === undefined && input.existingMonitorNextCheckAt) return true;
256-
const policy = normalizeIssueExecutionPolicy(input.executionPolicy ?? null);
257-
return Boolean(policy?.monitor?.nextCheckAt);
258-
}
259-
260378
function executionPrincipalsEqual(
261379
left: ParsedExecutionState["currentParticipant"] | null,
262380
right: ParsedExecutionState["currentParticipant"] | null,
@@ -2673,6 +2791,19 @@ export function issueRoutes(
26732791
}
26742792

26752793
const hasFieldChanges = Object.keys(previous).length > 0;
2794+
let workspaceChange = null;
2795+
if (hasIssueWorkspaceAuditChange(previous)) {
2796+
try {
2797+
workspaceChange = await buildIssueWorkspaceChangeActivityDetails(db, issue.companyId, existing, issue);
2798+
} catch (err) {
2799+
logger.warn({ err, issueId: issue.id }, "failed to enrich issue workspace change activity details");
2800+
const fallbackNames = emptyWorkspaceNameMaps();
2801+
workspaceChange = {
2802+
from: summarizeIssueWorkspaceForActivity(existing, fallbackNames),
2803+
to: summarizeIssueWorkspaceForActivity(issue, fallbackNames),
2804+
};
2805+
}
2806+
}
26762807
const reopened =
26772808
commentBody &&
26782809
effectiveMoveToTodoRequested &&
@@ -2697,6 +2828,7 @@ export function issueRoutes(
26972828
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
26982829
...(interruptedRunId ? { interruptedRunId } : {}),
26992830
...(cancelledStatusRunId ? { cancelledStatusRunId } : {}),
2831+
...(workspaceChange ? { workspaceChange } : {}),
27002832
_previous: hasFieldChanges ? previous : undefined,
27012833
...summarizeIssueReferenceActivityDetails(
27022834
updateReferenceDiff

server/src/services/recovery/successful-run-handoff.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ describe("successful run handoff decision", () => {
218218
title: "Missing issue disposition",
219219
detailsDefaultOpen: false,
220220
});
221+
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
221222
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
222223
expect.objectContaining({
223224
title: "Required action",
@@ -267,6 +268,7 @@ describe("successful run handoff decision", () => {
267268
tone: "danger",
268269
detailsDefaultOpen: false,
269270
});
271+
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
270272
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
271273
expect.objectContaining({
272274
title: "Recovery owner",

server/src/services/recovery/successful-run-handoff.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export function buildSuccessfulRunHandoffRequiredNotice(input: {
146146
}),
147147
metadata: {
148148
version: 1,
149+
sourceRunId: input.run.id,
149150
sections: [
150151
{
151152
title: "Required action",
@@ -193,6 +194,7 @@ export function buildSuccessfulRunHandoffExhaustedNotice(input: {
193194
}),
194195
metadata: {
195196
version: 1,
197+
sourceRunId: input.sourceRun?.id ?? null,
196198
sections: [
197199
{
198200
title: "Recovery owner",

0 commit comments

Comments
 (0)