mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Every agent run streams its stdout/stderr/system output into the run-log store (`server/src/services/run-log-store.ts`), and the run-log API serves those logs back for review and debugging > - The only store implementation is `local_file`: logs live on the server pod's filesystem under `PAPERCLIP_HOME` > - In hardened / ephemeral deployments, `PAPERCLIP_HOME` is an `emptyDir` with no persistent volume, so every pod restart wipes the log files while the DB row still references them — the run-log API then returns "Run log not found" for every completed run after any redeploy > - Run logs are the primary audit/debugging trail for agent work; losing them on routine redeploys undermines trust in the platform > - This pull request adds transparent durability: when `RUN_LOG_S3_BUCKET` is set, the store mirrors each completed log to object storage on `finalize` (same `logRef` key) and falls back to it on `read` when the local file is gone; live append/tail stays on the fast pod-local file > - The benefit is that completed run logs survive pod restarts and redeploys with zero changes for existing deployments (unset bucket = today's behaviour) and zero downstream changes (store id stays `local_file`) ## Linked Issues or Issue Description No existing public issue — inline description following the bug report template: **What happened?** After any server pod restart/redeploy, the run-log API returns "Run log not found" for all previously completed runs. The DB still references the log file, but the file is gone because run logs are written only to the pod-local filesystem. **Expected behavior:** Completed run logs remain readable across pod restarts and redeploys. **Steps to reproduce:** 1. Deploy the server with `PAPERCLIP_HOME` on an `emptyDir` (no persistent volume — common in hardened/ephemeral Kubernetes deployments). 2. Complete an agent run and confirm its log is readable via the run-log API. 3. Restart or redeploy the server pod. 4. Request the same run's log — the API throws "Run log not found". **Paperclip version or commit:** reproducible on current `master`. **Deployment mode:** Kubernetes (server pod without persistent volume). **Agent adapter(s) involved:** Not adapter-specific (core bug). Supersedes #8795. ## What Changed - `server/src/services/run-log-store.ts`: the local-file store becomes a durable store with an optional object-storage mirror - `finalize` mirrors the completed NDJSON log to S3-compatible object storage (keyed by the same `logRef`), best-effort so a failed upload can never break run finalization; upload failures are logged via `console.warn` so operators can detect a persistently broken mirror before a pod roll makes logs unreadable - `read` serves the pod-local file when present and falls back to a ranged object-storage read (with correct `nextOffset`) when the local file is gone - Live `append`/tail stays on the pod-local file — fast path unchanged, no per-chunk PUT - Store id stays `local_file`, so nothing downstream changes (feedback pipeline, read casts, fixtures untouched) - New optional config, all read at store construction: `RUN_LOG_S3_BUCKET`, `RUN_LOG_S3_ENDPOINT`, `RUN_LOG_S3_REGION` (default `us-east-1`), `RUN_LOG_S3_PREFIX` (default `run-logs`), `RUN_LOG_S3_FORCE_PATH_STYLE` (default `true`); credentials via the standard AWS env chain; works with any S3-compatible endpoint - Reuses the existing `createS3StorageProvider`; deliberately independent from `PAPERCLIP_STORAGE_PROVIDER` so enabling durable logs does not redirect workspace/file storage - `server/src/services/run-log-store.test.ts` (new): 7 tests with an in-memory `StorageProvider` mock ## Verification - `npx vitest run src/services/run-log-store.test.ts` in `server/` — 7/7 pass locally: - store id stays `local_file` - live read served from the local file (no S3 round-trip) - `finalize` uploads the completed log to the mirror - read falls back to S3 after a simulated pod roll (local file deleted) - ranged S3 read returns correct slice + `nextOffset` - not-found when neither local nor mirror has the log - local-only safe degrade when no bucket is configured - `npx tsc --noEmit -p server` — clean for the touched files - Manual: set `RUN_LOG_S3_*` against any S3-compatible endpoint (e.g. MinIO), complete a run, delete the local `.ndjson` file, and re-request the log via the run-log API — it is served from the mirror ## Risks - Low risk: with `RUN_LOG_S3_BUCKET` unset (the default), behaviour is byte-for-byte today's local-only store - Mirror upload is best-effort by design — a misconfigured bucket loses durability (not correctness) for affected runs; failures are now surfaced via a `console.warn` per failed upload - No DB migration, no API shape change, no change to the persisted `store`/`logRef` handle format ## Model Used - Claude (Anthropic), model ID `claude-fable-5` (Fable 5), via Claude Code with extended thinking and tool use (code execution, file editing). Original implementation TDD-authored with the same tooling. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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: Claude Fable 5 <noreply@anthropic.com>
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import { createReadStream, promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
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";
|
|
|
|
export type RunLogStoreType = "local_file";
|
|
|
|
export interface RunLogHandle {
|
|
store: RunLogStoreType;
|
|
logRef: string;
|
|
}
|
|
|
|
export interface RunLogReadOptions {
|
|
offset?: number;
|
|
limitBytes?: number;
|
|
}
|
|
|
|
export interface RunLogReadResult {
|
|
content: string;
|
|
nextOffset?: number;
|
|
}
|
|
|
|
export interface RunLogFinalizeSummary {
|
|
bytes: number;
|
|
sha256?: string;
|
|
compressed: boolean;
|
|
}
|
|
|
|
export interface RunLogStore {
|
|
begin(input: { companyId: string; agentId: string; runId: string }): Promise<RunLogHandle>;
|
|
append(
|
|
handle: RunLogHandle,
|
|
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string; seq?: number },
|
|
): Promise<number>;
|
|
finalize(handle: RunLogHandle): Promise<RunLogFinalizeSummary>;
|
|
read(handle: RunLogHandle, opts?: RunLogReadOptions): Promise<RunLogReadResult>;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
// 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).
|
|
export function createDurableRunLogStore(options: DurableRunLogStoreOptions): RunLogStore {
|
|
const { basePath } = options;
|
|
const s3 = options.s3;
|
|
const s3Prefix = normalizeKeyPrefix(s3?.keyPrefix);
|
|
|
|
function s3Key(logRef: string): string {
|
|
return s3Prefix ? `${s3Prefix}/${logRef}` : logRef;
|
|
}
|
|
|
|
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,
|
|
): Promise<RunLogReadResult | null> {
|
|
const stat = await fs.stat(filePath).catch(() => null);
|
|
if (!stat) return null;
|
|
const start = Math.max(0, Math.min(offset, stat.size));
|
|
const end = Math.max(start, Math.min(start + limitBytes - 1, stat.size - 1));
|
|
if (start > end) return { content: "", nextOffset: start };
|
|
|
|
const chunks: Buffer[] = [];
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const stream = createReadStream(filePath, { start, end });
|
|
stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
stream.on("error", reject);
|
|
stream.on("end", () => resolve());
|
|
});
|
|
} catch (err) {
|
|
// File deleted between stat() and open (pod-roll cleanup racing a read):
|
|
// treat as missing so the caller falls through to the S3 mirror instead
|
|
// of surfacing the very "Run log not found" this store exists to prevent.
|
|
if ((err as NodeJS.ErrnoException | null)?.code === "ENOENT") return null;
|
|
throw err;
|
|
}
|
|
const content = Buffer.concat(chunks).toString("utf8");
|
|
const nextOffset = end + 1 < stat.size ? end + 1 : undefined;
|
|
return { content, nextOffset };
|
|
}
|
|
|
|
async function readS3Range(
|
|
logRef: string,
|
|
offset: number,
|
|
limitBytes: number,
|
|
): Promise<RunLogReadResult> {
|
|
if (!s3) throw notFound("Run log not found");
|
|
const key = s3Key(logRef);
|
|
const head = await s3.provider.headObject({ objectKey: key });
|
|
if (!head.exists) throw notFound("Run log not found");
|
|
const total = head.contentLength ?? 0;
|
|
const start = Math.max(0, Math.min(offset, total));
|
|
const end = Math.max(start, Math.min(start + limitBytes - 1, total - 1));
|
|
if (start > end || total === 0) return { content: "", nextOffset: start < total ? start : undefined };
|
|
|
|
const result = await s3.provider.getObject({ objectKey: key, range: { start, end } });
|
|
const chunks: Buffer[] = [];
|
|
await new Promise<void>((resolve, reject) => {
|
|
result.stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
result.stream.on("error", reject);
|
|
result.stream.on("end", () => resolve());
|
|
});
|
|
const content = Buffer.concat(chunks).toString("utf8");
|
|
const nextOffset = end + 1 < total ? end + 1 : undefined;
|
|
return { content, 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 fs.writeFile(absPath, "", "utf8");
|
|
return { store: "local_file", logRef: relPath };
|
|
},
|
|
|
|
async append(handle, event) {
|
|
if (handle.store !== "local_file") return 0;
|
|
const absPath = resolveWithin(basePath, handle.logRef);
|
|
const line = JSON.stringify({
|
|
ts: event.ts,
|
|
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`;
|
|
await fs.appendFile(absPath, persisted, "utf8");
|
|
return Buffer.byteLength(persisted, "utf8");
|
|
},
|
|
|
|
async finalize(handle) {
|
|
if (handle.store !== "local_file") return { bytes: 0, compressed: false };
|
|
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) {
|
|
if (handle.store !== "local_file") throw notFound("Run log not found");
|
|
const absPath = resolveWithin(basePath, handle.logRef);
|
|
const offset = opts?.offset ?? 0;
|
|
const limitBytes = opts?.limitBytes ?? 256_000;
|
|
const local = await readLocalRange(absPath, offset, limitBytes);
|
|
if (local) return local;
|
|
// Local file gone (pod rolled) -> serve from the S3 mirror if configured.
|
|
return readS3Range(handle.logRef, offset, limitBytes);
|
|
},
|
|
};
|
|
}
|
|
|
|
// 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.
|
|
function resolveRunLogS3(): { provider: StorageProvider; keyPrefix?: string } | undefined {
|
|
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
|
|
});
|
|
return { provider, keyPrefix: process.env.RUN_LOG_S3_PREFIX?.trim() || "run-logs" };
|
|
}
|
|
|
|
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");
|
|
cachedStore = createDurableRunLogStore({ basePath, s3: resolveRunLogS3() });
|
|
return cachedStore;
|
|
}
|