From 333a16b035c0811c39418ecda2c021a60247840e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 14 May 2026 08:37:04 -0500 Subject: [PATCH] Fix company export with missing run logs (#5960) ## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - Company export/import lets operators move company state, including issue threads and agent execution context, between Paperclip instances. > - Issue comments can be enriched by nearby heartbeat run logs so exported threads preserve useful agent/run attribution metadata. > - Some local instances can have heartbeat run database rows whose local log files were deleted or never copied into the current workspace. > - The export path should still include the original user comments instead of failing because optional run-log metadata is unavailable. > - This pull request makes comment run-log metadata derivation tolerate missing local log files, logs the missing-file condition for operators, and adds a regression test. > - The benefit is safer company exports for real instances with incomplete local run-log storage. ## What Changed - Treat missing local heartbeat run logs as absent optional metadata while listing issue comments. - Emit a structured warning with `runId` and `logRef` when optional comment-attribution log content is missing. - Preserve the existing error behavior for non-404 run-log read failures. - Added a regression test proving user comments still list when a candidate attribution run has a missing local log reference. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "candidate attribution run log is missing"` passed: 1 selected test passed, 47 skipped. - `pnpm --filter @paperclipai/server typecheck` passed. - Greptile Review passed with Confidence Score 5/5 and zero unresolved threads on commit `f68cac02bf98d7d31e7831e5bdfa95cffa85e254`. - GitHub PR workflow run succeeded: `policy`, `verify`, four serialized server suites, `e2e`, and `Canary Dry Run` all passed. - `security/snyk (cryppadotta)` passed. - Confirmed this branch is on top of `public-gh/master` and `pnpm-lock.yaml` is not in the PR diff. ## Risks - Low risk. The change only softens optional comment metadata derivation for 404/missing local log files; other log read errors still throw. - Exported comments in this edge case may lack derived run metadata, but they remain visible/exportable instead of failing the request. - Operators may see new warnings when historical run-log references point to missing local files; those warnings indicate degraded optional metadata, not data loss. ## Model Used - OpenAI Codex, GPT-5 coding agent in this Paperclip heartbeat, with shell/git/GitHub CLI tool use. ## 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 --- server/src/__tests__/issues-service.test.ts | 66 +++++++++++++++++++++ server/src/services/issues.ts | 41 ++++++++----- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 44ac15e6..077332c6 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1222,6 +1222,72 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(comments[0]?.body).toBe("Comment should be visible"); }); + it("lists user comments when a candidate attribution run log is missing", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const commentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Comments issue with missing run log", + status: "todo", + priority: "medium", + }); + + await db.insert(heartbeatRuns).values({ + id: randomUUID(), + companyId, + agentId, + contextSnapshot: { issueId }, + createdAt: new Date("2026-05-12T22:58:00.000Z"), + startedAt: new Date("2026-05-12T22:58:00.000Z"), + finishedAt: new Date("2026-05-12T23:14:00.000Z"), + logStore: "local_file", + logRef: "missing/run-log.ndjson", + logBytes: 128, + }); + + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "user-1", + body: "Comment should still be visible", + createdAt: new Date("2026-05-12T23:00:00.000Z"), + updatedAt: new Date("2026-05-12T23:00:00.000Z"), + }); + + const comments = await svc.listComments(issueId, { + order: "desc", + limit: 50, + }); + + expect(comments.map((comment) => comment.id)).toEqual([commentId]); + expect(comments[0]?.body).toBe("Comment should still be visible"); + expect(comments[0]?.metadata).toBeNull(); + }); + it("includes blockedBy summaries on list rows in one batched pass", async () => { const companyId = randomUUID(); const blockerId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e0a3f1a5..fb47582f 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -50,7 +50,8 @@ import { isUuidLike, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, } from "@paperclipai/shared"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { parseObject } from "../adapters/utils.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, @@ -2804,6 +2805,7 @@ export function issueService(db: Db) { } async function readRunLogText(run: { + runId?: string | null; logStore: string | null; logRef: string | null; logBytes: number | null; @@ -2817,19 +2819,30 @@ export function issueService(db: Db) { let content = ""; let nextOffset: number | undefined = 0; - while (nextOffset !== undefined) { - const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); - if (remainingBytes <= 0) break; - const chunk = await store.read( - { store: "local_file", logRef: run.logRef }, - { - offset, - limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), - }, - ); - content += chunk.content; - nextOffset = chunk.nextOffset; - offset = chunk.nextOffset ?? 0; + try { + while (nextOffset !== undefined) { + const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); + if (remainingBytes <= 0) break; + const chunk = await store.read( + { store: "local_file", logRef: run.logRef }, + { + offset, + limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), + }, + ); + content += chunk.content; + nextOffset = chunk.nextOffset; + offset = chunk.nextOffset ?? 0; + } + } catch (err) { + if (err instanceof HttpError && err.status === 404) { + logger.warn( + { err, runId: run.runId ?? undefined, logRef: run.logRef }, + "missing heartbeat run log while deriving issue comment metadata", + ); + return content; + } + throw err; } return content;