Files
PaperClipAI/server/src/services/native-runtime/native-log-archive-source.ts
T
DottaandPaperclip 1a60d47804 Preserve settled native log files during online migration (#19719)
## Thinking Path

> - Paperclip manages work for AI agents.
> - The web server must retain run history across replacements.
> - Older native runs can have local log files with no final size or
digest.
> - The normal importer correctly refuses to call those files complete.
> - The existing raw archive mode requires the entire instance to stop
work.
> - This change gives settled sources a separate, guarded online archive
path.
> - Active work can continue while old evidence moves to object storage.

## Linked Issues or Issue Description

Refs #18864, #19678, #19688.

The offline historical log archive path cannot migrate a busy instance.
This change preserves exact surviving bytes without claiming complete
native-event coverage or changing the original run outcome.

## What Changed

- Add an explicit online option for settled native log files. Keep the
default importer and offline option unchanged.
- Require a released, closed native finalizer and no pending attempt,
allocation, physical owner, or task binding. A stale lease or terminal
run status alone is insufficient.
- Use the canonical physical finalization lock and row locks. Check the
source generation before claims and publication.
- Read only the canonical, private local file. Reject aliases, symlinks,
hard links, changed files, and changed owners.
- Verify remote bytes before publishing a read-only archive and activity
receipt in one transaction. Keep original files, errors, run status,
event rows, and completeness metadata.
- Document the command and its authority limits. Add real PostgreSQL and
loopback S3 transport tests.

## Verification

- The original implementation failed the new archive regression. It
returned blocked instead of migrating the settled source.
- Final source integration suite: 15 tests pass on disposable PostgreSQL
and the bounded loopback S3 fixture. Coverage includes active unrelated
work, failed and successful sources, a 4 MiB segment boundary, owner
changes, file changes, unresolved owners, task bindings, allocations,
and filesystem aliases.
- The qualified source TypeScript check and module boundary check pass.
- All 73 tests in five affected existing log-store, object-journal,
cancellation, and migration-worker suites pass. The first borrowed
dependency setup loaded work contexts from another worktree and failed
four tests. Explicit database subpath aliases resolved that setup fault;
no worker code changed.
- Earlier test fault injection used the wrong storage method, and the
first segment test reused a closed fixture endpoint. Those fixture
defects were corrected before the final 15-test pass.
- Local tests use Node 26. CI supplies the standard full typecheck,
build, and test gates. Production object-storage credentials, live
Railway replacement, and provider continuation are separate deployment
acceptance steps.

- Final exact-head CI: 59 successful contexts and 3 skipped, with all
typecheck, build, test, E2E, security, and review gates complete.
[Workflow
evidence](https://github.com/paperclipai/paperclip/actions/runs/42332349644).
- The first CI attempt had 12 jobs interrupted by runner shutdown. Their
logs end in cancellation, with no test assertion found. The
failed/cancelled-only retry passed all 12 jobs and recomputed
aggregates. The 38 original successful job records kept their original
execution times and were not rerun. Original interruption logs remain
retained.

## Risks

- This path preserves raw history. It cannot recover missing source
bytes or prove a complete native transcript.
- Uploads can finish before a later source check fails. The run
reference stays local in that case; a retry must still satisfy all
authority checks.
- The new option is explicit. It adds no startup worker, recurring poll,
or background scan.
- Source transactions hold scoped locks only for ownership checks and
publication. Streaming and remote verification run outside those
transactions.

## Model Used

OpenAI GPT-6 family through Codex, with reasoning, tool use, and code
execution. The host does not expose the exact model ID or context window
to this session.

## 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 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>
2026-11-06 21:37:18 -06:00

70 lines
5.2 KiB
TypeScript

import { and, eq, ne, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { nativeSha256 } from "./canonical.js";
import { tryLockNativeWorkspaceFinalization } from "./native-workspace-finalization-ownership.js";
export type NativeLogArchiveSource = {
id: string; companyId: string; agentId: string; status: string;
logRef: string | null; bytes: number | null; sha256: string | null;
};
async function readSource(db: Db, source: NativeLogArchiveSource, lock: boolean, expected?: string) {
function fail(): never { throw new Error("native_log_archive_source_unsettled"); }
if (source.logRef !== `${source.companyId}/${source.agentId}/${source.id}.ndjson`) fail();
if (lock && !await tryLockNativeWorkspaceFinalization(db, { companyId: source.companyId, runId: source.id }))
throw new Error("native_log_archive_source_busy");
const scope = and(eq(heartbeatRuns.id, source.id), eq(heartbeatRuns.companyId, source.companyId));
const owners = db.select({ issueId: nativeRunFinalizations.issueId, phase: nativeRunFinalizations.phase,
resultId: nativeRunFinalizations.resultId, recoveryState: nativeRunFinalizations.recoveryState,
leaseOwner: nativeRunFinalizations.leaseOwner, leaseExpiresAt: nativeRunFinalizations.leaseExpiresAt,
nextAttemptAt: nativeRunFinalizations.nextAttemptAt, controllerGeneration: nativeRunFinalizations.controllerGeneration,
attempt: nativeRunFinalizations.attempt, controllerBootId: nativeRunFinalizations.controllerBootId,
controllerPid: nativeRunFinalizations.controllerPid, controllerProcessStartedAt: nativeRunFinalizations.controllerProcessStartedAt,
}).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.runId, source.id), eq(nativeRunFinalizations.companyId, source.companyId)));
const [owner] = await (lock ? owners.for("update") : owners);
const rows = db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status, finishedAt: heartbeatRuns.finishedAt, runtimeMode: heartbeatRuns.runtimeMode,
issueId: heartbeatRuns.nativeIssueId, sessionId: heartbeatRuns.nativeSessionId,
logStore: heartbeatRuns.logStore, logRef: heartbeatRuns.logRef, bytes: heartbeatRuns.logBytes, sha256: heartbeatRuns.logSha256,
processGroupId: heartbeatRuns.processGroupId,
physicalOwner: sql<boolean>`coalesce(${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceFinalizationOwner' <> 'null'::jsonb, false)`,
}).from(heartbeatRuns).where(scope);
const [run] = await (lock ? rows.for("update") : rows);
if (!run || !owner || !run.issueId || !run.sessionId || run.runtimeMode !== "native" || !run.finishedAt
|| run.agentId !== source.agentId || run.status !== source.status
|| !["succeeded", "failed", "cancelled", "timed_out", "interrupted"].includes(run.status)
|| run.logStore !== "local_file" || run.logRef !== source.logRef || run.bytes !== source.bytes || run.sha256 !== source.sha256
|| run.processGroupId !== null || run.physicalOwner || owner.issueId !== run.issueId
|| owner.leaseOwner !== null || owner.leaseExpiresAt !== null || owner.nextAttemptAt !== null
|| !(owner.phase === "committed" && owner.resultId !== null && owner.recoveryState === null
|| owner.phase === "terminal_failure" && run.status !== "succeeded"
&& (owner.recoveryState === null || owner.recoveryState === "blocked"))) fail();
const [task] = await db.select({ executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId })
.from(issues).where(and(eq(issues.companyId, run.companyId), eq(issues.id, run.issueId)));
const [allocation] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
sql`${environmentLeases.releasedAt} is null`)).limit(1);
const [alias] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
ne(heartbeatRuns.id, run.id), eq(heartbeatRuns.logStore, "local_file"), eq(heartbeatRuns.logRef, run.logRef!))).limit(1);
if (!task || task.executionRunId === run.id || task.checkoutRunId === run.id || allocation || alias) fail();
const fence = nativeSha256({ ...run, finishedAt: run.finishedAt.toISOString(), owner: { phase: owner.phase, generation: owner.controllerGeneration,
attempt: owner.attempt, bootId: owner.controllerBootId, pid: owner.controllerPid,
startedAt: owner.controllerProcessStartedAt?.toISOString() ?? null, resultId: owner.resultId, recoveryState: owner.recoveryState } });
if (expected && fence !== expected) throw new Error("native_log_archive_source_changed");
return fence;
}
export async function inspectNativeLogArchiveSource(db: Db, source: NativeLogArchiveSource) {
return readSource(db, source, false);
}
export async function withNativeLogArchiveSource<T>(db: Db, source: NativeLogArchiveSource, expected: string | undefined,
action: (tx: Db, fence: string) => Promise<T>): Promise<T> {
return db.transaction(async tx => {
await tx.execute(sql`set local lock_timeout='1s'`);
const fence = await readSource(tx as unknown as Db, source, true, expected);
return action(tx as unknown as Db, fence);
});
}