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 <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-14 08:37:04 -05:00
committed by GitHub
parent 1bd44c8a0d
commit 333a16b035
2 changed files with 93 additions and 14 deletions
@@ -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();
+27 -14
View File
@@ -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;