mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task page must show the instruction and current conversation
quickly.
> - The current page waits for related task data and historical run
transcripts.
> - These reads grow with task history, even when the reader only needs
the latest answer.
> - This pull request gives cold opens a small initial view and loads
older history on demand.
> - Messages and runs observed while the page stays open remain visible.
> - The benefit is a readable task before historical diagnostics finish
loading.
## Linked Issues or Issue Description
Related: #14667 and #16229. The first changes the reveal gate and scroll
anchors. The second adds general history pagination. This PR adds a core
task read and a folded recent-conversation view. It preserves the
default full-read API contracts. The shared files will need coordination
if either related PR lands first.
**What existing behavior does this improve?**
Cold opening an ordinary task with a long conversation or large run
logs.
**Current behavior**
Task enrichment and historical transcripts delay the readable
conversation. A cold open can fetch several megabytes of tool activity
before revealing saved messages.
**Proposed behavior**
Show the task instruction, recent saved replies, latest human input, and
the latest run's conversation data first. Fetch older messages and full
run activity when the reader chooses **More messages**. Retain new
messages while the page stays open.
## What Changed
- Add an authorized core issue view that skips related enrichment. Keep
enrichment on the existing detail read. Share task mutation, rollback
and invalidation updates with the core cache.
- Add a bounded recent-comment read. Preserve the first human
instruction when no description exists, including legacy rows with a
NULL author type. Keep company and task access checks and comment
redaction.
- Add optional recent activity and run-summary views. Fetch original
input receipts for visible comments separately so delivered messages
stay out of the queue. Reconcile watched unfinished runs after a
reconnect beyond the preview window, removing deleted runs after
reconciliation.
- Filter the latest native run's initial events to assistant messages,
request lifecycles and terminal results. Full event reads remain
available on expansion. Legacy fallback reads use a small tail extended
to the first complete record. HTTP cancellation aborts the storage read.
- Retain observed messages across cursor catch-up and new runs. Apply
local cancellation and deletion to both caches and their task aliases.
Refetch locally seeded history pages on expansion. Preserve expansion
across identifier and UUID links. Keep deep links, classic tasks and
Agent Chat on their existing full-history path.
- Update API documentation, design guidance, capability maps and
regression coverage.
## Verification
- Final acceptance on `9ea0b15ae44d9f6d2c9a9f824b4835f299f92979`: all CI
gates passed, including server/UI tests, all eight browser shards,
runner checks, full typecheck and build. [Final CI
run](https://github.com/paperclipai/paperclip/actions/runs/40326990328).
GitHub reports 55 successful checks, two intentionally skipped checks,
and one neutral Paperclip Review check described below. No checks are
pending or failed; Greptile is 5/5 and no review threads remain open.
- October 26 merge update: merged current `master`. The only conflict
was in task-page imports; both the compact-history hook and the new
live-update hooks remain. All CI tests and build jobs passed on
`044fd6733d`. Fresh review found canceled-comment cache drift, legacy
human-comment filtering, send-then-expand truncation, and partial legacy
log records. These defects were fixed in `af3d66a5bd`.
- Review-fix verification: 470 tests pass across ten affected suites,
including all six PostgreSQL cold-open tests without a helper override.
Cancellation/delete and send-then-expand regression tests failed before
the fixes and pass afterward. Coverage includes task identifier/UUID
reopen, stale in-flight reads, legacy NULL author types, local-file and
object-storage tails cut inside UTF-8 text, route forwarding, and
request cancellation. Full workspace typecheck and production build,
token gates, map validation and diff checks pass.
- Follow-up review found that a failed expanded legacy read lost its
record-alignment state on retry. `9ea0b15ae4` commits the expanded
window only after a successful read. The new regression fails before the
fix and passes afterward; all 19 transcript tests, UI typecheck, UI
build and token gates pass. Greptile reviewed this commit at 5/5 with no
actionable findings, and all review threads are resolved.
- The final Paperclip Review check ended neutral because its execution
did not complete; no passing Paperclip assessment is claimed. Its
earlier four concrete findings were fixed and regression-tested.
Greptile and the security scans completed successfully on the final
commit.
- The preceding `af3d66a5bd` CI run was canceled when superseded by the
retry fix. Its server shard 11 check briefly reported failure, but the
job API records cancellation and its test step passed all 1,750 tests.
This superseded run is not used as final CI acceptance.
- 549 focused tests pass on the final implementation across nine task
UI, activity-route, OpenAPI and PostgreSQL suites. These cover pending
history, desktop/mobile visibility, expansion, retained runs, message
bursts, cancellation recovery and queued-comment removal in both views.
- Legacy expansion keeps the latest answer visible while fetching and
deduplicating older output.
- Five PostgreSQL tests pass for core access denial, recent comments,
missing-description instructions, event paging and bounded activity/run
reads. macOS could not start another PostgreSQL cluster because it
exhausted its semaphore limit. These tests used a separate disposable
database on an existing local server through a temporary test setup
override. The helper was restored afterward.
- Full workspace typecheck and build pass with the final state fixes, as
do token gates and capability-map validation.
- Token gates, capability-map validation and `git diff --check` pass.
- Browser proof used a disposable task with 80 comments and six large
run histories. The cold view showed the instruction and recent exchange.
**More messages** revealed older comments. Navigation away and back
preserved expansion.
- Local HTTP sample: core 50 ms, recent comments 24 ms, recent run
summaries 17 ms. The latest native conversation response was 839 JSON
characters / 8 ms; its full event response was 1,099,005 / 86 ms.
Browser content paint was 1.34 seconds with development assets cached.
These are local observations, not staging latency claims.
- The first CI run passed every job except four board-send browser cases
and the review threshold. Those browser fixtures only mocked the old
comments endpoint; they now serve both recent and full reads, retaining
all message and attachment assertions. Both initial review findings were
fixed with regression tests. The next CI run passed every job. The
deeper review identified core-cache mutation, older delivery receipt and
retained run-status gaps; those are now fixed with regression coverage.
Final review is 5/5 with zero unresolved findings. Final-head CI
initially failed one unchanged runner shutdown test
(`runnerd-codex-transport.test.ts`, timeout-attachment case) with
`NativeSessionCloseUnrecoverableError`; all other CI jobs passed. The
same runner code passed on 971a762f80. A targeted local reproduction
also failed during provider drain. The failure is outside the changed
task loading paths; its underlying cause remains unresolved. The single
failed-job retry passed on the same head; this demonstrates recovery,
not a root-cause fix. All CI gates passed on the previous implementation
head `9004f94f28`: [CI run and
retry](https://github.com/paperclipai/paperclip/actions/runs/40192334629).
- `pnpm test:run` was attempted and stopped after repeated embedded
PostgreSQL startup failures. The native session suite recorded 15
database-related failures; other database suites could not start. No
complete local suite pass is claimed.
## Risks
- The compact view intentionally omits older diagnostics until
expansion. Existing open questions and approvals still use the current
interaction and run paths.
- Previously observed comment and activity rows remain cached while
open. The newest preview and incoming cursor pages refresh; this does
not add a general historical-edit synchronization protocol.
- Recent views are optional. Existing full API callers retain their
response shape and behavior. No migration is required.
- A legacy tail can exceed the requested byte limit by the prefix of its
first log record. Backward reads stop at the preceding record boundary
and honor cancellation.
- Staging has not been deployed or qualified with this change. Instance
startup latency is separate from task history loading.
- The related open PRs touch the same task-history files.
## Model Used
OpenAI Codex, based on GPT-6, with reasoning, repository editing, shell
tools and browser testing. The exact serving model identifier and
context-window size 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 — 549 affected task tests;
the full local suite and unrelated runner failure are documented above
- [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>
552 lines
24 KiB
TypeScript
552 lines
24 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";
|
|
|
|
export type RunLogStoreType = "local_file";
|
|
|
|
export interface RunLogHandle {
|
|
store: RunLogStoreType;
|
|
logRef: string;
|
|
attemptId?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export interface RunLogStore {
|
|
begin(input: RunLogBeginInput): 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>;
|
|
// 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.
|
|
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");
|
|
cachedStore = createDurableRunLogStore({ basePath, s3: resolveRunLogS3() });
|
|
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?.();
|
|
}
|