mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Runner gives each provider durable execution, tool, and completion contracts. > - Cursor Cloud currently has a direct adapter outside that native contract. > - Cloud work needs exact ownership across HTTP, SSE, Stop, and recovery. > - This PR adds a qualified Cloud API driver and verified proxy to Runner. > - Product setup and hosted placement remain separate dependent changes. ## Linked Issues or Issue Description Refs #5664, #11803, #17299, and #17471. Product integration is #18069. Managed-only API placement is #18310. The related direct-adapter model/transcript work is not replaced. No duplicate native Cursor Cloud integration was found. ## What Changed - Add native `cursor_cloud` / `cursor_cloud_api` with explicit model/repository validation, bounded HTTP/SSE, durable per-turn checkpoints and a qualified Node proxy launched through the Rust facade. - Commit terminal custody before publication/release; coalesce consumers, preserve ambiguous create/Stop/recovery fences, reject late tools, and distinguish definitely-unsent oversized requests from ambiguous responses. - Support scoped cold attachment, current authority, reconnect and verified artifact staging. Keep unsupported warm/external and Windows paths closed. - Preserve early source cursors and verify immutable legacy prefixes before suffix replay, including supported compaction/masking. Optional proof publication failure falls back to complete cold verification. - Preserve canonical records at the durable native producer with JSON-safe privacy and the existing 8MiB bound; drain admitted successor flushes. Reconcile upstream semantic/pack/schema/lifecycle changes and retain S3 and legacy CLI checks. ## Verification Core head `37c442ec8dd4`: all 66 check contexts are terminal successful/neutral/skipped, including required `ci / verify` and `ci / e2e`; fresh exact-head Greptile is 5/5 with zero unresolved threads. Dependent Product `81e828d20a77` and Top `dd2959689627` include the later client handoff correction and are running their own fresh gates. Core: 47/47 Rust, 4/4 compiled-native retirement, 19/19 settlement and normal Runner build passed. Runtime Top `9af1a5de42a2`: build and recursive typecheck passed. Earlier source-scoped evidence includes 398 focused tests, five relay tests and token/type gates. The 251-assertion, 7/8-suite run failed physical PostgreSQL teardown; the unchanged stable WSP suite passed 128 through the documented wrapper. The downstream fixture corrections passed 7/7 browser and 121/121 startup tests without timeout/oracle relaxation; a broken-unsubscribe control fails. Hosted runtime `9af1a5de42a2` has a passing normal build and recursive typecheck. Latest dependent heads additionally contain a pure client fix for stale live/history overlap, with 48 focused tests, UI typecheck and token gates passing. Its new client is not deployed in the frozen hosted image; server/native/API/provider source is unchanged by these four downstream files. Fresh hosted Linux admission on `9af1a5de42a2` retired its owned runner/proxy/socket with zero prompts. The saved configuration survived reopening, and Test authenticated the bound key/catalog/selected model without a prompt. Artifact with `claude-sonnet-4-5` completed, downloaded the exact 27 bytes, matched 121 canonical identities against complete logs and independently reached FINISHED. The active Stop retry was clicked within its immutable boundary, produced one canonical interrupted turn and acknowledged run-only cancellation, retained a valid 35.076s quiet window (maximum gap 4.584s), and independently remained CANCELLED/artifact-absent beyond the original 180-second command deadline. All provider-observer credential callbacks, DB clients and loaders closed; accounting is settled and explicitly unpriced. The first fresh Stop retains its failed 10.373s sampling gap and every sample. On the upgraded9af server, the historical F226 artifact was reopened and downloaded with its exact original hash after its provider agent had been deleted/GET404 earlier today. Historical F226 full Artifact/Stop cleanup, Question, corrected Plan and reconnect retain their original identities. Deleting the three newly created disposable provider agents and the new-case post-delete grade remain blocked on explicit user approval after automatic review rejected expanding the two original approved IDs. No new-case cleanup or complete fresh matrix pass is claimed; this is separate from the directly observed task/Stop behavior and retained exact cleanup proof. Retained failures: stale fixture 132/133; E8 48/135 with 87 reset-hook failures; Mac 17 SQL failures/skips; earlier startup-title, title-save, custody/Stop and deletion failures with unknown causes and bounded recoveries. `dea023` Artifact passed 17 checks but Stop failed its false warning and 50 duplicate log identities. Pre-repair ACK failure and original Core CI failure remain recorded; retry alone establishes no cause or full local-suite pass. Provider dollar cost stays UNKNOWN. ## Risks Experimental: Cursor independently acknowledges neither the effective model nor instruction digest before the first prompt. The caller’s explicit values are submitted and behavior checked live. Unpriced provider dollar cost stays UNKNOWN. Only qualified per-turn controllers are admitted. Uncertain work and incomplete cleanup stay fenced. Existing damaged/duplicate archives remain failed evidence. Prefix proof adds bounded object-store I/O; the 8MiB producer bound does not guarantee future providers’ output fits. ## Model Used OpenAI Codex, GPT-6 family, assisted with reasoning, repository tools, code execution, browser verification, and delegated code review. The serving model’s exact API ID and context window are not exposed. No value is inferred from the model catalog. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used and the limits of available model metadata - [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 linked public issues or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch describes the change and contains no internal ticket ID - [x] I have run the scoped local tests reported above; pending gates are stated separately - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All required CI gates pass on the current head - [x] Fresh Greptile is 5/5 with no unresolved findings on the current head - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
626 lines
27 KiB
TypeScript
626 lines
27 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";
|
|
export function isRunLogStoreType(value: unknown): value is RunLogStoreType {
|
|
return value === "local_file" || value === "s3_segments";
|
|
}
|
|
|
|
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;
|
|
cachedStore = {
|
|
begin: input => {
|
|
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) => handle.store === "s3_segments" ? external!.append(handle, event) : local.append(handle, event),
|
|
enqueue: (handle, event) => handle.store === "s3_segments"
|
|
? external!.enqueue!(handle, event)
|
|
: Promise.resolve({ committed: local.append(handle, event) }),
|
|
flush: async handle => { if (handle.store === "s3_segments") await external!.flush!(handle); },
|
|
finalize: (handle, options) => handle.store === "s3_segments" ? external!.finalize(handle, options) : local.finalize(handle),
|
|
read: (handle, opts) => {
|
|
if (handle.store === "s3_segments") {
|
|
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 };
|
|
} 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 };
|
|
}
|