mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Ephemeral web servers need retained run history in shared storage.
> - Some old native runs have surviving local logs but no completion
digest or verifiable native source coverage.
> - Enrollment must preserve those bytes without calling them a complete
native transcript.
> - This pull request adds a read-only archive path to offline
enrollment.
> - Users can still read old output after the original server filesystem
is removed.
## Linked Issues or Issue Description
**What happened?**
Offline enrollment blocks on interrupted runs and unfinalized historical
native logs. The current importer requires canonical native-event
coverage even when the original local file survives.
**Expected behavior**
Preserve the exact surviving file in shared storage. Keep the original
run status, completion metadata, and source events. Do not allow writers
or native repair to modify archived history.
**Steps to reproduce**
Use an S3-configured instance with a terminal native run whose local log
lacks a final size or digest. Stop all app deployments and run ephemeral
maintenance. Before this change, interrupted status or unproven native
coverage prevents enrollment.
Related preservation work: #18826, #18835, #18841. This is a bounded
migration fix, not a new live logging protocol.
## What Changed
- The run-evidence migration owner can archive unfinalized native local
files during offline enrollment.
- Require the offline migration environment, zero active or queued runs
across the instance, and expired controller/finalizer leases. Check
global quiescence at each page boundary. Recheck each run's ownership
and metadata before publication.
- Verify stable local file identity and metadata, any recorded
size/digest, uploaded prefixes, and remote SHA-256.
- Store raw history in a separate `<run>.archive` journal with source
cursor zero and backend `s3_archive`. Leave completion fields and native
events unchanged.
- The existing log store reads archives but rejects begin, append,
enqueue, flush, and finalize. Native source repair does not run for
archives.
- Keep the default background importer's coverage checks. Continue to
reject incomplete mirrors and corrupt files.
- Add real PostgreSQL/S3 and packaged maintenance coverage. Correct a
pre-existing large-chunk fixture: the current verifier can prove exact
compactor output, so the negative case now uses a different canonical
source.
## Verification
- Passed on final head b96e80dbb3: 13
migration/storage/worker tests against disposable local PostgreSQL and
S3.
- Passed: 76 log-store, cancellation, object-store, native watermark,
and maintenance-service tests.
- Passed: server typecheck and server build.
- The archive journey removes original log files, reads exact saved
bytes through the normal recovery-aware reader, checks unchanged
run/source metadata, and verifies read-only behavior.
- Negative cases cover ordinary-process use, another company's running
execution, live controller/finalizer leases, an invalid recorded digest,
an incomplete mirror, and a changed local source.
- Review follow-up: move the global run-table scan from every item to
page boundaries. A real PostgreSQL trigger admits new execution during
publication; the page then fails, retains verified copies, and resumes
after quiescence. All 5 migration cases passed. After rebasing onto
merged #18841, all 13 migration/storage/worker cases and all 7 packaged
maintenance cases passed, along with server typecheck, build, and
runtime bundle.
- Preserved failure: the old large-chunk expectation failed with both
this change and the unmodified base importer. The updated fixture checks
the verifier's current compaction contract and still blocks mismatched
source output.
- Passed: all 7 real PostgreSQL/S3 enrollment cases, including the
bundled maintenance process under plain Node. Its interrupted native log
is archived, the original log directory is removed, and the saved bytes
remain readable.
- Passed: packaged runtime build and full repository CI (54 successful
checks). Four CI jobs lost their runners; a separate voice-recovery
timing assertion failed. The unchanged voice case passed locally, and
all failed/interrupted CI jobs passed on retry. Greptile reviewed the
final head at 5/5 with no unresolved threads. The separate Paperclip
Review app remains queued/paused. No live tenant migration or volume
detachment is claimed by this PR.
## Risks
- Rollback after enrollment requires a reader that supports
`s3_archive`; old binaries cannot read the new backend.
- Offline mode relies on the deployment owner stopping all old app
processes. Database and file checks are additional fences, not a
substitute for that prerequisite.
- Archives may be incomplete transcripts. The implementation preserves
that uncertainty and does not fabricate a successful run or completion
digest.
- No schema change, new background polling, or telemetry change.
## Model Used
OpenAI Codex (GPT-6 family), with reasoning, repository tool use, and
code execution. The exact serving model ID and context window are not
exposed in 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 (e.g. `docs/...`, `fix/...`)
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
643 lines
28 KiB
TypeScript
643 lines
28 KiB
TypeScript
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { addAbortSignal } from "node:stream";
|
|
import { notFound } from "../errors.js";
|
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
|
import { createS3StorageProvider } from "../storage/s3-provider.js";
|
|
import type { StorageProvider } from "../storage/types.js";
|
|
import { createObjectRunLogStore } from "./object-run-log-store.js";
|
|
import { resolveRunEvidenceStorage } from "./run-evidence-storage.js";
|
|
|
|
export type RunLogStoreType = "local_file" | "s3_segments" | "s3_archive";
|
|
export function isRunLogStoreType(value: unknown): value is RunLogStoreType {
|
|
return value === "local_file" || value === "s3_segments" || value === "s3_archive";
|
|
}
|
|
|
|
export interface RunLogHandle {
|
|
store: RunLogStoreType;
|
|
logRef: string;
|
|
attemptId?: string;
|
|
initialBytes?: number;
|
|
initialSequence?: number;
|
|
initialSourceCursor?: number;
|
|
/** Exact immutable prefix has a durable source-coverage proof, not just a max cursor. */
|
|
initialSourcePrefixVerified?: boolean;
|
|
}
|
|
|
|
export interface RunLogReadOptions {
|
|
offset?: number;
|
|
limitBytes?: number;
|
|
/** Include the start of the first NDJSON record when reading a log tail. */
|
|
alignStartToRecord?: boolean;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface RunLogReadResult {
|
|
content: string;
|
|
nextOffset?: number;
|
|
}
|
|
|
|
interface RunLogByteRange {
|
|
bytes: Buffer;
|
|
nextOffset?: number;
|
|
}
|
|
|
|
export interface RunLogFinalizeSummary {
|
|
// Null means a stalled write prevented a verified final snapshot. Callers
|
|
// can still settle the run without recording a false byte count or hash.
|
|
bytes: number | null;
|
|
sha256?: string | null;
|
|
compressed: boolean;
|
|
}
|
|
|
|
export interface RunLogBeginInput {
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
// Set only when persisted run lifecycle state proves no earlier attempt
|
|
// began this log, so no durable prefix can exist to restore. Omitted means
|
|
// the run may be a retry or recovery and the durable mirror is checked.
|
|
newRun?: boolean;
|
|
|
|
assertWriterAuthority?: () => Promise<void>;
|
|
previousStore?: RunLogStoreType;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface RunLogStore {
|
|
begin(input: RunLogBeginInput): Promise<RunLogHandle>;
|
|
append(
|
|
handle: RunLogHandle,
|
|
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string; seq?: number; sourceCursor?: number;
|
|
/** Caller proved this is ordered canonical output or a non-native diagnostic. */
|
|
preservesSourceCoverage?: boolean },
|
|
): Promise<number>;
|
|
enqueue?(
|
|
handle: RunLogHandle,
|
|
event: Parameters<RunLogStore["append"]>[1],
|
|
): Promise<{ committed: Promise<number> }>;
|
|
flush?(handle: RunLogHandle): Promise<void>;
|
|
/** Record the caller's complete ordered source-prefix proof without changing log bytes. */
|
|
recordVerifiedSourcePrefix?(handle: RunLogHandle, sourceCursor: number): Promise<void>;
|
|
finalize(handle: RunLogHandle, options?: { computeDigest?: boolean }): Promise<RunLogFinalizeSummary>;
|
|
read(handle: RunLogHandle, opts?: RunLogReadOptions): Promise<RunLogReadResult>;
|
|
// Optional so existing fakes/fixtures keep compiling: uploads every dirty
|
|
// in-flight mirror immediately (graceful-shutdown path). No-op when the
|
|
// in-flight mirror is not enabled.
|
|
flushInflightMirrors?(): Promise<void>;
|
|
}
|
|
|
|
function safeSegments(...segments: string[]) {
|
|
return segments.map((segment) => segment.replace(/[^a-zA-Z0-9._-]/g, "_"));
|
|
}
|
|
|
|
function resolveWithin(basePath: string, relativePath: string) {
|
|
const resolved = path.resolve(basePath, relativePath);
|
|
const base = path.resolve(basePath) + path.sep;
|
|
if (!resolved.startsWith(base) && resolved !== path.resolve(basePath)) {
|
|
throw new Error("Invalid log path");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function normalizeKeyPrefix(prefix: string | undefined): string {
|
|
if (!prefix) return "";
|
|
return prefix.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
}
|
|
|
|
export interface DurableRunLogStoreOptions {
|
|
basePath: string;
|
|
// When provided, completed logs are mirrored to object storage on finalize and
|
|
// served from there on read whenever the local file is missing (e.g. the pod
|
|
// rolled and wiped the emptyDir). When omitted, the store is local-only (the
|
|
// historical behaviour: a restart loses the log).
|
|
s3?: {
|
|
provider: StorageProvider;
|
|
keyPrefix?: string;
|
|
// When > 0, ALSO mirror the still-running log to the same object key at
|
|
// most once per this interval (plus a flush hook for graceful shutdown),
|
|
// so a crash mid-run loses at most one interval's tail instead of the
|
|
// whole log. Off (undefined/0) preserves the historical finalize-only
|
|
// mirroring: no extra PUT traffic unless explicitly opted in.
|
|
inflightMirrorMs?: number;
|
|
};
|
|
}
|
|
|
|
// Run-log store with TRANSPARENT durability. The store id stays "local_file" so
|
|
// nothing downstream (feedback.ts, the heartbeat read cast, fixtures) changes;
|
|
// the S3 mirror is keyed by the same logRef and is purely an implementation
|
|
// detail. Live append/tail stays on the pod-local file (fast, no per-chunk PUT);
|
|
// on finalize the complete .ndjson is uploaded to object storage; on read we try
|
|
// local first and fall back to S3 when the local file is gone. This is the fix
|
|
// for "Run log not found" after a deploy/restart (the /paperclip data dir is an
|
|
// emptyDir in cloud_tenant mode -- persistence is disabled to avoid the
|
|
// operator's privileged selinux-relabel init container in our hardened ns).
|
|
//
|
|
// Optionally (inflightMirrorMs > 0) the still-running log is ALSO mirrored to
|
|
// the same key at a throttled cadence and flushed on graceful shutdown, so a
|
|
// restart mid-run preserves the tail up to the last mirror instead of losing
|
|
// the whole in-flight log. Finalize retires the in-flight bookkeeping (waiting
|
|
// out any upload already on the wire) before writing the complete file, so a
|
|
// stale partial can never overwrite a finalized log.
|
|
export function createDurableRunLogStore(options: DurableRunLogStoreOptions): RunLogStore {
|
|
const { basePath } = options;
|
|
const s3 = options.s3;
|
|
const s3Prefix = normalizeKeyPrefix(s3?.keyPrefix);
|
|
const inflightMirrorMs = s3?.inflightMirrorMs && s3.inflightMirrorMs > 0 ? s3.inflightMirrorMs : 0;
|
|
// A run owns the write handle returned by begin(). Diagnostics can be
|
|
// dispatched without awaiting the rest of onLog (DB progress/live events),
|
|
// but finalize must include every file append already accepted on that handle.
|
|
// Weak collections let completed handles disappear with their owning runs.
|
|
const pendingAppends = new WeakMap<RunLogHandle, Set<Promise<void>>>();
|
|
const closingHandles = new WeakSet<RunLogHandle>();
|
|
const abandonedHandles = new WeakSet<RunLogHandle>();
|
|
|
|
function s3Key(logRef: string): string {
|
|
return s3Prefix ? `${s3Prefix}/${logRef}` : logRef;
|
|
}
|
|
|
|
// In-flight mirror bookkeeping, keyed by logRef. The mirror uploads the
|
|
// CURRENT (partial) file to the SAME key finalize uses: readers already
|
|
// range-read that key, so a partial object is served exactly like a live
|
|
// tail, and finalize simply overwrites it with the complete file. One
|
|
// entry exists only between the first post-interval-eligible append and
|
|
// finalize.
|
|
interface InflightMirrorEntry {
|
|
dirty: boolean;
|
|
lastMirrorAt: number;
|
|
timer: NodeJS.Timeout | null;
|
|
upload: Promise<boolean> | null;
|
|
}
|
|
const inflightMirrors = new Map<string, InflightMirrorEntry>();
|
|
|
|
function mirrorInflightNow(logRef: string, entry: InflightMirrorEntry): Promise<boolean> {
|
|
entry.dirty = false;
|
|
const upload = (async () => {
|
|
const absPath = resolveWithin(basePath, logRef);
|
|
const stat = await fs.stat(absPath);
|
|
if (stat.size === 0) return true;
|
|
await s3!.provider.putObject({
|
|
objectKey: s3Key(logRef),
|
|
// Bound the stream to the stat'ed size: the run is still appending,
|
|
// and an unbounded stream that grows past stat.size would violate
|
|
// the declared contentLength and fail (or truncate) the upload.
|
|
// Bytes appended after the stat stay dirty and ride the next mirror.
|
|
body: createReadStream(absPath, { start: 0, end: stat.size - 1 }),
|
|
contentType: "application/x-ndjson",
|
|
contentLength: stat.size,
|
|
});
|
|
return true;
|
|
})().catch((err) => {
|
|
// Best-effort like the finalize mirror: a failing upload must never
|
|
// break the run, but a persistently broken mirror should be visible.
|
|
console.warn(
|
|
`[run-log-store] Failed to mirror in-flight run log to object storage (key: ${s3Key(logRef)}):`,
|
|
err,
|
|
);
|
|
// Re-dirty so the tail retries next interval even without new appends;
|
|
// the lastMirrorAt stamp below bounds retries to one per interval.
|
|
entry.dirty = true;
|
|
return false;
|
|
}).finally(() => {
|
|
// Stamp AFTER the attempt so a slow or failing endpoint self-throttles
|
|
// to one attempt per interval instead of hot-looping.
|
|
entry.lastMirrorAt = Date.now();
|
|
entry.upload = null;
|
|
if (entry.dirty) scheduleInflightMirror(logRef, entry);
|
|
});
|
|
entry.upload = upload;
|
|
return upload;
|
|
}
|
|
|
|
function scheduleInflightMirror(logRef: string, entry: InflightMirrorEntry): void {
|
|
if (inflightMirrors.get(logRef) !== entry) return;
|
|
if (entry.timer || entry.upload) return;
|
|
const delay = Math.max(0, inflightMirrorMs - (Date.now() - entry.lastMirrorAt));
|
|
entry.timer = setTimeout(() => {
|
|
entry.timer = null;
|
|
void mirrorInflightNow(logRef, entry);
|
|
}, delay);
|
|
// Never keep the process alive just to mirror a tail.
|
|
entry.timer.unref?.();
|
|
}
|
|
|
|
function noteInflightAppend(logRef: string): void {
|
|
if (!s3 || inflightMirrorMs <= 0) return;
|
|
let entry = inflightMirrors.get(logRef);
|
|
if (!entry) {
|
|
// First mirror lands one full interval after the first append: a run
|
|
// that finalizes sooner is covered by the finalize upload, and this
|
|
// keeps the steady-state cost at one PUT per interval per active run.
|
|
entry = { dirty: false, lastMirrorAt: Date.now(), timer: null, upload: null };
|
|
inflightMirrors.set(logRef, entry);
|
|
}
|
|
entry.dirty = true;
|
|
scheduleInflightMirror(logRef, entry);
|
|
}
|
|
|
|
async function retireInflightMirror(logRef: string): Promise<void> {
|
|
const entry = inflightMirrors.get(logRef);
|
|
if (!entry) return;
|
|
inflightMirrors.delete(logRef);
|
|
if (entry.timer) {
|
|
clearTimeout(entry.timer);
|
|
entry.timer = null;
|
|
}
|
|
// An upload still in flight could otherwise finish AFTER finalize's
|
|
// complete-file upload and overwrite it with a stale partial.
|
|
if (entry.upload) await entry.upload;
|
|
}
|
|
|
|
async function ensureDir(relativeDir: string) {
|
|
const dir = resolveWithin(basePath, relativeDir);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
async function readLocalRange(
|
|
filePath: string,
|
|
offset: number,
|
|
limitBytes: number,
|
|
signal?: AbortSignal,
|
|
): Promise<RunLogByteRange | null> {
|
|
signal?.throwIfAborted();
|
|
const start = Math.max(0, offset);
|
|
// Read one extra byte to discover whether another page exists. A single
|
|
// abortable stream avoids an uncancellable stat before opening the file.
|
|
const chunks: Buffer[] = [];
|
|
try {
|
|
const stream = createReadStream(filePath, { start, end: start + limitBytes, signal });
|
|
for await (const chunk of stream) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
} catch (err) {
|
|
signal?.throwIfAborted();
|
|
// A missing file, including deletion before open, falls back to S3.
|
|
if ((err as NodeJS.ErrnoException | null)?.code === "ENOENT") return null;
|
|
throw err;
|
|
}
|
|
signal?.throwIfAborted();
|
|
const bytes = Buffer.concat(chunks);
|
|
const nextOffset = bytes.length > limitBytes ? start + limitBytes : undefined;
|
|
return { bytes: bytes.subarray(0, limitBytes), nextOffset };
|
|
}
|
|
|
|
async function readS3Range(
|
|
logRef: string,
|
|
offset: number,
|
|
limitBytes: number,
|
|
signal?: AbortSignal,
|
|
): Promise<RunLogByteRange> {
|
|
signal?.throwIfAborted();
|
|
if (!s3) throw notFound("Run log not found");
|
|
const key = s3Key(logRef);
|
|
const head = await s3.provider.headObject({ objectKey: key, signal });
|
|
signal?.throwIfAborted();
|
|
if (!head.exists) throw notFound("Run log not found");
|
|
const total = head.contentLength ?? 0;
|
|
const start = Math.max(0, Math.min(offset, total));
|
|
// Unlike local file streams, S3 rejects a range that starts at or past
|
|
// EOF with 416 InvalidRange, so a caught-up reader (offset === total)
|
|
// must short-circuit to an empty read instead of clamping end up to
|
|
// start and requesting `bytes=total-total`.
|
|
const end = Math.min(start + limitBytes - 1, total - 1);
|
|
if (total === 0 || start > end) return { bytes: Buffer.alloc(0), nextOffset: start < total ? start : undefined };
|
|
|
|
const result = await s3.provider.getObject({ objectKey: key, range: { start, end }, signal });
|
|
// Destroy a body that stalls after headers arrive, including a body
|
|
// returned just after the caller cancelled the request.
|
|
if (signal) addAbortSignal(signal, result.stream);
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of result.stream) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
signal?.throwIfAborted();
|
|
const nextOffset = end + 1 < total ? end + 1 : undefined;
|
|
return { bytes: Buffer.concat(chunks), nextOffset };
|
|
}
|
|
|
|
async function sha256File(filePath: string): Promise<string> {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const hash = createHash("sha256");
|
|
const stream = createReadStream(filePath);
|
|
stream.on("data", (chunk) => hash.update(chunk));
|
|
stream.on("error", reject);
|
|
stream.on("end", () => resolve(hash.digest("hex")));
|
|
});
|
|
}
|
|
|
|
return {
|
|
async begin(input) {
|
|
const [companyId, agentId] = safeSegments(input.companyId, input.agentId);
|
|
const runId = safeSegments(input.runId)[0]!;
|
|
const relDir = path.join(companyId, agentId);
|
|
const relPath = path.join(relDir, `${runId}.ndjson`);
|
|
await ensureDir(relDir);
|
|
const absPath = resolveWithin(basePath, relPath);
|
|
await retireInflightMirror(relPath);
|
|
// Retries share a run ID. Never truncate their earlier evidence. On a
|
|
// new pod, restore the durable prefix before opening another attempt.
|
|
// A proven-new run has no prefix, so skip the remote round trip.
|
|
const exists = await fs.stat(absPath).then(() => true, (error) => {
|
|
if (error.code === "ENOENT") return false;
|
|
throw error;
|
|
});
|
|
if (!exists && s3 && input.newRun !== true) {
|
|
const key = s3Key(relPath);
|
|
const head = await s3.provider.headObject({ objectKey: key });
|
|
if (head.exists) {
|
|
const temporary = `${absPath}.${randomUUID()}.restore`;
|
|
try {
|
|
const object = await s3.provider.getObject({ objectKey: key });
|
|
await pipeline(object.stream, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
|
|
if (typeof head.contentLength === "number" && (await fs.stat(temporary)).size !== head.contentLength) {
|
|
throw new Error("Durable run log restore returned an incomplete prefix");
|
|
}
|
|
// Publish the complete prefix without replacing a file another
|
|
// attempt has already restored and started appending to.
|
|
await fs.link(temporary, absPath).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "EEXIST") throw error;
|
|
});
|
|
} finally {
|
|
await fs.rm(temporary, { force: true });
|
|
}
|
|
}
|
|
}
|
|
const file = await fs.open(absPath, "a", 0o600);
|
|
await file.close();
|
|
return { store: "local_file", logRef: relPath, attemptId: randomUUID() };
|
|
},
|
|
|
|
async append(handle, event) {
|
|
if (handle.store !== "local_file" || closingHandles.has(handle)) return 0;
|
|
const absPath = resolveWithin(basePath, handle.logRef);
|
|
const line = JSON.stringify({
|
|
ts: event.ts,
|
|
...(handle.attemptId ? { attemptId: handle.attemptId } : {}),
|
|
stream: event.stream,
|
|
chunk: event.chunk,
|
|
// Monotonic per-run sequence so readers can dedupe and order records
|
|
// even when several identical chunks share the same millisecond ts
|
|
// (common for ACP-style token deltas).
|
|
...(typeof event.seq === "number" && Number.isFinite(event.seq) ? { seq: event.seq } : {}),
|
|
});
|
|
const persisted = `${line}\n`;
|
|
let pending = pendingAppends.get(handle);
|
|
if (!pending) {
|
|
pending = new Set();
|
|
pendingAppends.set(handle, pending);
|
|
}
|
|
const write = fs.appendFile(absPath, persisted, "utf8").then(() => {
|
|
if (!closingHandles.has(handle)) noteInflightAppend(handle.logRef);
|
|
});
|
|
pending.add(write);
|
|
try {
|
|
await write;
|
|
} finally {
|
|
pending.delete(write);
|
|
}
|
|
return Buffer.byteLength(persisted, "utf8");
|
|
},
|
|
|
|
async finalize(handle) {
|
|
if (handle.store !== "local_file") return { bytes: 0, compressed: false };
|
|
if (abandonedHandles.has(handle)) return { bytes: null, sha256: null, compressed: false };
|
|
// Close admission before the first await. Drain file writes only, not
|
|
// heartbeat's later DB/live-event persistence, then freeze one consistent
|
|
// byte count, hash, and durable copy. Late diagnostics cannot mutate it.
|
|
closingHandles.add(handle);
|
|
let drainTimer: NodeJS.Timeout | undefined;
|
|
const drained = await Promise.race([
|
|
Promise.allSettled(pendingAppends.get(handle) ?? []).then(() => true),
|
|
new Promise<boolean>((resolve) => {
|
|
drainTimer = setTimeout(() => resolve(false), 3_000);
|
|
drainTimer.unref?.();
|
|
}),
|
|
]).finally(() => clearTimeout(drainTimer));
|
|
if (!drained) {
|
|
// An in-flight fs append cannot be cancelled safely. Do not hash or
|
|
// mirror a file it may still change, and never re-arm mirroring when
|
|
// that write eventually finishes. Terminal run status can still settle.
|
|
abandonedHandles.add(handle);
|
|
void retireInflightMirror(handle.logRef).catch(() => undefined);
|
|
console.warn("[run-log-store] Pending log writes did not settle within 3000ms; final log size and hash are unknown.");
|
|
return { bytes: null, sha256: null, compressed: false };
|
|
}
|
|
await retireInflightMirror(handle.logRef);
|
|
const absPath = resolveWithin(basePath, handle.logRef);
|
|
const stat = await fs.stat(absPath).catch(() => null);
|
|
if (!stat) throw notFound("Run log not found");
|
|
const hash = await sha256File(absPath);
|
|
|
|
// Mirror the completed log to object storage so it survives a pod roll.
|
|
// Best-effort upload failures must NOT fail run finalization (which also
|
|
// records cost/usage); the local copy still serves reads until the pod
|
|
// rolls, and a failed mirror only loses durability for that one run.
|
|
if (s3) {
|
|
try {
|
|
// Stream from disk instead of buffering the whole .ndjson in the
|
|
// heap; long agent sessions can produce large logs. The file is
|
|
// complete at this point, so stat.size is the exact content length.
|
|
await s3.provider.putObject({
|
|
objectKey: s3Key(handle.logRef),
|
|
body: createReadStream(absPath),
|
|
contentType: "application/x-ndjson",
|
|
contentLength: stat.size,
|
|
});
|
|
} catch (err) {
|
|
// Best-effort: finalization must not break, but a persistently
|
|
// failing mirror (bad creds/bucket/endpoint) should be visible to
|
|
// operators before a pod roll makes the logs unreadable.
|
|
console.warn(
|
|
`[run-log-store] Failed to mirror run log to object storage (key: ${s3Key(handle.logRef)}):`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { bytes: stat.size, sha256: hash, compressed: false };
|
|
},
|
|
|
|
async read(handle, opts) {
|
|
opts?.signal?.throwIfAborted();
|
|
if (handle.store !== "local_file") throw notFound("Run log not found");
|
|
const absPath = resolveWithin(basePath, handle.logRef);
|
|
const offset = Math.max(0, opts?.offset ?? 0);
|
|
const limitBytes = opts?.limitBytes ?? 256_000;
|
|
const readRange = async (start: number, length: number) =>
|
|
await readLocalRange(absPath, start, length, opts?.signal)
|
|
// Local file gone (pod rolled) -> serve from the S3 mirror if configured.
|
|
?? await readS3Range(handle.logRef, start, length, opts?.signal);
|
|
const result = await readRange(offset, limitBytes);
|
|
if (!opts?.alignStartToRecord || offset === 0 || result.bytes.length === 0) {
|
|
return { content: result.bytes.toString("utf8"), nextOffset: result.nextOffset };
|
|
}
|
|
// Walk back only to the preceding record boundary. Keep bytes intact
|
|
// until decoding so a tail beginning inside UTF-8 text is not corrupted.
|
|
const parts = [result.bytes];
|
|
let before = offset;
|
|
while (before > 0) {
|
|
const start = Math.max(0, before - 32_000);
|
|
const prefix = await readRange(start, before - start);
|
|
if (prefix.bytes.length !== before - start) throw new Error("Run log changed while reading its tail");
|
|
const boundary = prefix.bytes.lastIndexOf(10);
|
|
parts.unshift(prefix.bytes.subarray(boundary + 1));
|
|
if (boundary >= 0) break;
|
|
before = start;
|
|
}
|
|
return {
|
|
content: Buffer.concat(parts).toString("utf8"),
|
|
nextOffset: result.nextOffset ?? offset + result.bytes.length,
|
|
};
|
|
},
|
|
|
|
async flushInflightMirrors() {
|
|
if (!s3 || inflightMirrorMs <= 0) return;
|
|
const flushEntry = async (logRef: string, entry: InflightMirrorEntry) => {
|
|
// Loop until the entry is clean: an append that lands while an
|
|
// upload is on the wire re-dirties the entry, and its follow-up
|
|
// mirror sits on an unref'ed timer that would never fire once the
|
|
// process exits — so re-check after every await instead of trusting
|
|
// a single pass. A FAILED attempt ends the loop instead of retrying:
|
|
// hot-looping a down endpoint at shutdown would spin forever, and
|
|
// the flush is best-effort by design.
|
|
for (;;) {
|
|
if (entry.timer) {
|
|
clearTimeout(entry.timer);
|
|
entry.timer = null;
|
|
}
|
|
if (entry.upload) {
|
|
await entry.upload;
|
|
continue;
|
|
}
|
|
if (!entry.dirty) return;
|
|
const uploaded = await mirrorInflightNow(logRef, entry);
|
|
if (!uploaded) {
|
|
if (entry.timer) {
|
|
clearTimeout(entry.timer);
|
|
entry.timer = null;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
await Promise.all([...inflightMirrors].map(([logRef, entry]) => flushEntry(logRef, entry)));
|
|
},
|
|
};
|
|
}
|
|
|
|
// Build the run-log S3 mirror from dedicated RUN_LOG_S3_* env. Deliberately
|
|
// separate from PAPERCLIP_STORAGE_PROVIDER so enabling durable run logs does
|
|
// NOT redirect the product's workspace/file storage (smaller blast radius).
|
|
// Unset RUN_LOG_S3_BUCKET -> no mirror -> local-only (safe degrade). Creds come
|
|
// from the standard AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY chain.
|
|
export function resolveRunLogS3(): DurableRunLogStoreOptions["s3"] {
|
|
const bucket = process.env.RUN_LOG_S3_BUCKET?.trim();
|
|
if (!bucket) return undefined;
|
|
const provider = createS3StorageProvider({
|
|
bucket,
|
|
region: process.env.RUN_LOG_S3_REGION?.trim() || "us-east-1",
|
|
endpoint: process.env.RUN_LOG_S3_ENDPOINT?.trim() || undefined,
|
|
prefix: undefined, // prefixing is handled by keyPrefix below (kept off the provider)
|
|
forcePathStyle: process.env.RUN_LOG_S3_FORCE_PATH_STYLE
|
|
? process.env.RUN_LOG_S3_FORCE_PATH_STYLE === "true"
|
|
: true, // Cubbit (and most S3-compatible endpoints) need path-style
|
|
});
|
|
// Opt-in in-flight tail mirroring: at most one partial upload per interval
|
|
// per active run, so a crash loses at most one interval's tail. Unset/0
|
|
// keeps the historical finalize-only mirroring.
|
|
const inflightSeconds = Number.parseFloat(process.env.RUN_LOG_S3_INFLIGHT_MIRROR_SECONDS ?? "");
|
|
return {
|
|
provider,
|
|
keyPrefix: process.env.RUN_LOG_S3_PREFIX?.trim() || "run-logs",
|
|
inflightMirrorMs:
|
|
Number.isFinite(inflightSeconds) && inflightSeconds > 0 ? Math.round(inflightSeconds * 1000) : undefined,
|
|
};
|
|
}
|
|
|
|
let cachedStore: RunLogStore | null = null;
|
|
|
|
export function getRunLogStore() {
|
|
if (cachedStore) return cachedStore;
|
|
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs");
|
|
const local = createDurableRunLogStore({ basePath, s3: resolveRunLogS3() });
|
|
const s3 = resolveRunEvidenceStorage();
|
|
const external = s3 ? createObjectRunLogStore({
|
|
provider: s3.provider, keyPrefix: s3.keyPrefix || "run-logs",
|
|
assertWriterAuthority: async input => {
|
|
if (!input.assertWriterAuthority) throw new Error("Object run logs require admitted writer authority");
|
|
await input.assertWriterAuthority();
|
|
},
|
|
}) : null;
|
|
const writer = external ?? local;
|
|
function writable(handle: RunLogHandle) {
|
|
if (handle.store === "s3_archive") throw new Error("Historical run log archives are read-only");
|
|
if (handle.store === "local_file") return local;
|
|
if (!external) throw new Error("Object run log storage is not configured");
|
|
return external;
|
|
}
|
|
cachedStore = {
|
|
begin: input => {
|
|
if (input.previousStore === "s3_archive") throw new Error("Historical run log archives are read-only");
|
|
if (input.previousStore === "local_file") return local.begin(input);
|
|
if (input.previousStore === "s3_segments") {
|
|
if (!external) throw new Error("Object run log storage is not configured");
|
|
return external.begin(input);
|
|
}
|
|
return writer.begin(input);
|
|
},
|
|
append: (handle, event) => writable(handle).append(handle, event),
|
|
enqueue: (handle, event) => {
|
|
const store = writable(handle);
|
|
return store.enqueue?.(handle, event) ?? Promise.resolve({ committed: store.append(handle, event) });
|
|
},
|
|
flush: async handle => { await writable(handle).flush?.(handle); },
|
|
finalize: (handle, options) => writable(handle).finalize(handle, options),
|
|
read: (handle, opts) => {
|
|
if (handle.store === "s3_segments" || handle.store === "s3_archive") {
|
|
if (!external) throw new Error("Object run log storage is not configured");
|
|
return external.read(handle, opts);
|
|
}
|
|
return local.read(handle, opts);
|
|
},
|
|
flushInflightMirrors: async () => { await Promise.all([local.flushInflightMirrors?.(), external?.flushInflightMirrors?.()]); },
|
|
};
|
|
return cachedStore;
|
|
}
|
|
|
|
// Graceful-shutdown hook: upload every dirty in-flight run-log tail before
|
|
// the process exits, so an orderly restart (deploy, SIGTERM) loses nothing
|
|
// even for runs that never reach finalize. No-op when the store was never
|
|
// created or in-flight mirroring is off.
|
|
export async function flushInFlightRunLogMirrors(): Promise<void> {
|
|
await cachedStore?.flushInflightMirrors?.();
|
|
}
|
|
|
|
|
|
export async function openLegacyRunLogForMigration(logRef: string) {
|
|
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs");
|
|
const file = resolveWithin(basePath, logRef);
|
|
try {
|
|
const stat = await fs.stat(file);
|
|
if (!stat.isFile()) throw new Error("Legacy run log is not a regular file");
|
|
return {
|
|
stream: createReadStream(file), bytes: stat.size, source: "local" as const,
|
|
async assertUnchanged() {
|
|
const current = await fs.stat(file);
|
|
if (current.dev !== stat.dev || current.ino !== stat.ino || current.size !== stat.size ||
|
|
current.mtimeMs !== stat.mtimeMs || current.ctimeMs !== stat.ctimeMs) {
|
|
throw new Error("legacy_log_source_changed");
|
|
}
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
const config = resolveRunLogS3();
|
|
if (!config) throw notFound("Legacy run log not found");
|
|
const key = `${config.keyPrefix?.replace(/\/+$/, "") || "run-logs"}/${logRef}`;
|
|
const object = await config.provider.getObject({ objectKey: key, signal: AbortSignal.timeout(30_000) });
|
|
return { stream: object.stream, bytes: object.contentLength, source: "mirror" as const };
|
|
}
|