mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
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");
|
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 () => {
|
it("includes blockedBy summaries on list rows in one batched pass", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const blockerId = randomUUID();
|
const blockerId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ import {
|
||||||
isUuidLike,
|
isUuidLike,
|
||||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||||
} from "@paperclipai/shared";
|
} 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 { parseObject } from "../adapters/utils.js";
|
||||||
import {
|
import {
|
||||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||||
|
|
@ -2804,6 +2805,7 @@ export function issueService(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readRunLogText(run: {
|
async function readRunLogText(run: {
|
||||||
|
runId?: string | null;
|
||||||
logStore: string | null;
|
logStore: string | null;
|
||||||
logRef: string | null;
|
logRef: string | null;
|
||||||
logBytes: number | null;
|
logBytes: number | null;
|
||||||
|
|
@ -2817,19 +2819,30 @@ export function issueService(db: Db) {
|
||||||
let content = "";
|
let content = "";
|
||||||
let nextOffset: number | undefined = 0;
|
let nextOffset: number | undefined = 0;
|
||||||
|
|
||||||
while (nextOffset !== undefined) {
|
try {
|
||||||
const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8");
|
while (nextOffset !== undefined) {
|
||||||
if (remainingBytes <= 0) break;
|
const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8");
|
||||||
const chunk = await store.read(
|
if (remainingBytes <= 0) break;
|
||||||
{ store: "local_file", logRef: run.logRef },
|
const chunk = await store.read(
|
||||||
{
|
{ store: "local_file", logRef: run.logRef },
|
||||||
offset,
|
{
|
||||||
limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes),
|
offset,
|
||||||
},
|
limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes),
|
||||||
);
|
},
|
||||||
content += chunk.content;
|
);
|
||||||
nextOffset = chunk.nextOffset;
|
content += chunk.content;
|
||||||
offset = chunk.nextOffset ?? 0;
|
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;
|
return content;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue