mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## 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>
121 lines
3.8 KiB
TypeScript
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)}`;
|
|
}
|
|
|