Files
PaperClipAI/server/src/services/heartbeat/run-log.ts
T
DottaandPaperclip 87a7312cfd refactor(server): extract heartbeat run-log formatting (#15573)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Heartbeat orchestration records agent output and run events.
> - The heartbeat service still contains more than 30,000 lines after
the first extraction.
> - Log formatting has fixed limits and does not write run state.
> - These helpers form a small next step toward a more manageable
heartbeat service.
> - This PR moves them into the heartbeat folder without changing their
function bodies.
> - Direct boundary tests and existing caller tests check the output.

## Linked Issues or Issue Description

Refs: #15568.

**What existing behavior does this improve?**

This improves the structure and test coverage of heartbeat run-log
formatting.

**Subsystem affected**

server/ — orchestration services.

**Current behavior**

`heartbeat.ts` contains excerpt handling, payload size limits, and log
chunk formatting beside run orchestration.

**Proposed behavior**

Move these helpers and their constants into
`server/src/services/heartbeat/run-log.ts`.
Keep the same function bodies, output, limits, and public exports.

**Reason and benefit**

Run-log formatting has its own small module and focused tests.
This keeps the second extraction small enough to review on its own.

**Breaking changes**

None. The existing public import path and persisted output stay the
same.

Related run-log PRs: Refs: #6373, Refs: #8841. Those PRs change
redaction behavior. This PR only moves existing formatting code.

## What Changed

- Move 109 lines of helper functions and six constants into
`heartbeat/run-log.ts`.
- Move `appendExcerpt` and retain the existing public exports for
`boundHeartbeatRunEventPayloadForStorage` and `compactRunLogChunk` in
`heartbeat.ts`.
- Add 17 direct regression cases for size and depth limits, cycles,
shared references, immutable input, image omission, redaction order,
excerpt tails, and UTF-8 boundaries.
- Document both heartbeat extractions in `doc/DEVELOPING.md`.

## Verification

- Before extraction, four focused files passed with 55 tests.
- After extraction and the two new excerpt tests, the same four files
passed with 57 tests.
- Run `pnpm exec vitest run
server/src/services/heartbeat/run-log.test.ts
server/src/__tests__/heartbeat-run-log.test.ts
server/src/__tests__/heartbeat-list.test.ts
server/src/__tests__/redaction.test.ts`.
- The original and extracted function blocks match byte for byte after
adding the export keyword to `appendExcerpt`.
- Existing tests still import the public helpers from `heartbeat.ts`.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`
passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- The full suite passed in CI. The duplicate local `pnpm test:run` was
stopped after CI passed; it did not complete locally.
- All CI gates passed on commit
`4e65c666e3b5b46162b3a528b37fba8902a1d200`: 54 passing checks, two
skipped Storybook checks.
- Fresh Greptile review of the same commit: 5/5, no actionable or inline
findings. The PR has no merge conflicts.

## Risks

- Low risk. Moving code can cause an import or build error.
- The same redaction and adapter utility modules remain in use.
- Database writes, current-user redaction, live event delivery, and run
state remain in `heartbeat.ts`.
- The event schema and payload output do not change.
- No database, API, Telemetry, Observability, or UI contract changes are
required.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-6 via Codex. The exact serving model ID and context window
were not exposed in this session. Used reasoning, repository inspection,
shell tools, and code execution.

## 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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-08 09:34:37 -05:00

121 lines
3.8 KiB
TypeScript

import { appendWithByteCap, MAX_EXCERPT_BYTES, parseObject } from "../../adapters/utils.js";
import { redactSensitiveText } from "../../redaction.js";
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50;
const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100;
const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6;
const INLINE_BASE64_IMAGE_DATA_RE =
/("type":"image","source":\{"type":"base64","data":")([A-Za-z0-9+/=]{1024,})(")/g;
export function appendExcerpt(prev: string, chunk: string) {
return appendWithByteCap(prev, chunk, MAX_EXCERPT_BYTES);
}
function truncateRunEventString(value: string) {
if (value.length <= MAX_RUN_EVENT_PAYLOAD_STRING_CHARS) return value;
const omittedChars = value.length - MAX_RUN_EVENT_PAYLOAD_STRING_CHARS;
return `${value.slice(0, MAX_RUN_EVENT_PAYLOAD_STRING_CHARS)}\n[truncated ${omittedChars} chars]`;
}
function boundRunEventValue(
value: unknown,
depth: number,
seen: WeakSet<object>,
): unknown {
if (typeof value === "string") {
return truncateRunEventString(value);
}
if (
value === null ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) {
return {
_truncated: true,
type: "array",
originalLength: value.length,
};
}
const bounded = value
.slice(0, MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS)
.map((entry) => boundRunEventValue(entry, depth + 1, seen));
if (value.length > MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS) {
bounded.push({
_truncated: true,
omittedItems: value.length - MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS,
});
}
return bounded;
}
if (typeof value !== "object" || value === undefined) {
return null;
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
const entries = Object.entries(value as Record<string, unknown>);
if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) {
const bounded = {
_truncated: true,
type: "object",
keys: entries.map(([key]) => key).slice(0, 20),
};
seen.delete(value);
return bounded;
}
const out: Record<string, unknown> = {};
for (const [key, entryValue] of entries.slice(
0,
MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS,
)) {
out[key] = boundRunEventValue(entryValue, depth + 1, seen);
}
if (entries.length > MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS) {
out._truncated = true;
out._omittedKeys = entries.length - MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS;
}
seen.delete(value);
return out;
}
export function boundHeartbeatRunEventPayloadForStorage(
payload: Record<string, unknown>,
): Record<string, unknown> {
const bounded = boundRunEventValue(payload, 0, new WeakSet());
return parseObject(bounded) ?? { _truncated: true };
}
function redactInlineBase64ImageData(chunk: string) {
return chunk.replace(
INLINE_BASE64_IMAGE_DATA_RE,
(_match, prefix: string, data: string, suffix: string) =>
`${prefix}[omitted base64 image data: ${data.length} chars]${suffix}`,
);
}
export function compactRunLogChunk(
chunk: string,
maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS,
) {
const normalized = redactSensitiveText(redactInlineBase64ImageData(chunk));
if (normalized.length <= maxChars) return normalized;
const headChars = Math.max(0, Math.floor(maxChars * 0.6));
const tailChars = Math.max(0, Math.floor(maxChars * 0.25));
const omittedChars = Math.max(0, normalized.length - headChars - tailChars);
const marker = `\n[paperclip truncated run log chunk: omitted ${omittedChars} chars]\n`;
return `${normalized.slice(0, headChars)}${marker}${normalized.slice(normalized.length - tailChars)}`;
}