mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
## Thinking Path > - Paperclip manages agent work and shows run progress to operators. > - Run lists, live events, transcripts, and workspace details must remain responsive as usage grows. > - Run-list redaction rereads the full context for every run. Hidden tabs can still trigger requests through live events and manual timers. > - Workspace detail reads repeat Git inspection even when concurrent callers request the same state. > - This pull request batches registry reads, pauses hidden-tab refreshes, and caches Git inspection for display. > - Cleanup keeps fresh Git checks, and redaction keeps company and run boundaries. ## Linked Issues **What happened?** Run-list responses perform one extra database read per run and parse full context JSON to obtain small secret registries. Hidden tabs continue transcript reads and event-triggered refetches. Workspace detail requests repeat Git scans. **Expected behavior** A run list reads registries once. Hidden tabs stop recurring run reads and reconcile when visible. Concurrent workspace detail reads share a short-lived Git result. **Steps to reproduce** 1. Open run lists and task transcripts in several tabs while agents run. 2. Hide some tabs and observe transcript and event-triggered requests. 3. Request a 200-run list and count redaction database queries. 4. Request the same workspace detail concurrently and count Git inspections. Related: #5255 adjusts polling cadence. This change addresses hidden-tab lifecycle, batched registry reads, and workspace inspection reuse. No duplicate with this scope was found. ## What Changed - Batch heartbeat and live-run redaction into one company-scoped registry query. Select only registry JSON for run and issue redaction. - Resolve duplicate secret values once per request. Preserve each run's registry and remove registry material from responses. - Suspend company event sockets and transcript reads while hidden. Refresh active queries and resume transcript offsets on return. - Prevent queued event invalidations and developer health polling from fetching in hidden tabs. Gate legacy run-log readers in both UI variants. - Exclude legacy plugin placeholder connections from remote health probes. Select only due connection IDs in SQL before the sweep limit. Preserve existing plugin records. - Cache concurrent Git display inspections for five seconds, with at most 256 entries. Leave close-readiness and cleanup checks uncached. - Add regression coverage and document the performance behavior. - Stabilize the existing Rust descendant-lineage fixture: allow a bounded 30 seconds for 300 durable notifications under concurrent test load, retaining every correctness assertion and adding timeout diagnostics. ## Verification - Regression coverage verifies one registry query for 200 runs, per-run isolation, request-local secret resolution, decryption failures, Git cache expiry/bounds, hidden-tab pause, and visibility recovery. - Real PostgreSQL redaction/run-route suites passed all 57 tests; workspace-service coverage passed. The health-sweep regression verifies plugin placeholders and chat connections remain untouched and do not consume the sweep limit. - Both legacy transcript viewers retain history and resume their byte offset after visibility changes. The related visibility/progress/chunk suites passed all 29 tests. Other focused UI suites and token gates passed. - Full `pnpm -r typecheck` and `pnpm build` passed. Affected-package typechecks/builds passed after review fixes. The concurrent Rust provider suite passed 84 tests (two ignored), and Rust formatting passed. - Full local `pnpm test:run` stopped after the general-server group: 10,538 passed, 65 skipped, four failed. Fresh chat-delivery and health-sweep reruns passed; building the debug runner fixture cleared the native-event test. One unchanged native-session recovery assertion still fails locally with a semantic-digest error instead of the expected settled-session message. The full local command is therefore not green. CI runs the later groups separately and skips the two native-session tests requiring a prebuilt runner binary (confirmed in its 37-test native-session suite). - All CI gates pass on final head `ee610e737`: typechecking, general and serialized tests, browser tests, runner verification, build, and canary dry run. One server shard passed on its single retry after exposure fixtures encountered port 42001 where they assumed 42000; that suite also passed locally (25 passed, three platform-specific skips). - Greptile reviewed the final head at 5/5 with no actionable findings. ## Risks - Workspace delivery display can lag local Git changes by five seconds. Destructive operations still inspect current state. - Hidden tabs do not receive company live-event notifications until visible. Active queries refresh on return. - This change preserves legacy plugin records and does not repair instance-specific workspace rows. There is no database migration. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution, and browser inspection. The exact 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 (targeted regressions; full-suite limitation 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>
190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
import { usePageVisibility } from "@/lib/page-visibility";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { LiveEvent, SummarySlotIssueRef } from "@paperclipai/shared";
|
|
|
|
import type { RunLogChunk } from "@/adapters";
|
|
import { heartbeatsApi } from "@/api/heartbeats";
|
|
import { useCompanyLiveEvent } from "@/context/LiveUpdatesProvider";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import {
|
|
mergeRunLogChunks,
|
|
parsePersistedLogContent,
|
|
readChunkSeq,
|
|
type ChunkMergeRefs,
|
|
type IncomingRunLogChunk,
|
|
} from "@/lib/run-log-chunks";
|
|
import {
|
|
closeDanglingCodeFence,
|
|
extractAssistantOutputText,
|
|
parseSummaryDraftStream,
|
|
} from "@/lib/summary-draft-stream";
|
|
|
|
const LOG_POLL_INTERVAL_MS = 1500;
|
|
const LOG_READ_LIMIT_BYTES = 256_000;
|
|
const MAX_CHUNKS = 400;
|
|
|
|
export interface SummaryDraftStream {
|
|
/** The generation run id, once learned from a live event or the fallback. */
|
|
runId: string | null;
|
|
/** Latest `STATUS:` line streamed by the Summarizer (prefix stripped). */
|
|
statusLine: string | null;
|
|
/** Accumulating draft Markdown (fence-guarded while still streaming). */
|
|
draft: string | null;
|
|
/** True once the closing draft sentinel has arrived. */
|
|
draftClosed: boolean;
|
|
/** Whether any protocol output (status line or draft) has streamed yet. */
|
|
hasStream: boolean;
|
|
}
|
|
|
|
function freshMergeRefs(): ChunkMergeRefs {
|
|
return { seenChunkKeys: new Set<string>(), trimmedSeqFloorByRun: new Map<string, number>() };
|
|
}
|
|
|
|
function readPayloadString(value: unknown): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
/**
|
|
* Token-streamed draft for a generating summary slot.
|
|
*
|
|
* Learns the generation run's id from `heartbeat.run.progress` /
|
|
* `heartbeat.run.queued` events (matched by `issueId`), falling back to the
|
|
* issue's active-run endpoint after a page refresh. It then merges the run's
|
|
* assistant `acpx.text_delta` output from both the persisted run-log poller and
|
|
* the live company-events socket (reusing the shared chunk-merge/seq-dedupe
|
|
* util) and parses the STATUS lines + sentinel-wrapped draft out of that stream.
|
|
*
|
|
* Degrades gracefully: no run id / no deltas (WS down, non-ACP adapter, model
|
|
* skipped the protocol) simply yields an empty stream and the card keeps its
|
|
* spinner. State resets whenever the tracked generation changes.
|
|
*/
|
|
export function useSummaryDraftStream(
|
|
companyId: string | null | undefined,
|
|
generatingIssue: SummarySlotIssueRef | null,
|
|
): SummaryDraftStream {
|
|
const { visible } = usePageVisibility();
|
|
const issueId = generatingIssue?.id ?? null;
|
|
const [runId, setRunId] = useState<string | null>(null);
|
|
const [chunks, setChunks] = useState<RunLogChunk[]>([]);
|
|
|
|
const mergeRefs = useRef<ChunkMergeRefs>(freshMergeRefs());
|
|
const pendingLogRowsRef = useRef(new Map<string, string>());
|
|
const logOffsetRef = useRef(0);
|
|
|
|
// Reset all stream state whenever the tracked generation changes — including
|
|
// a superseded generation (new issue id) or generation ending (null).
|
|
useEffect(() => {
|
|
setRunId(null);
|
|
setChunks([]);
|
|
mergeRefs.current = freshMergeRefs();
|
|
pendingLogRowsRef.current = new Map();
|
|
logOffsetRef.current = 0;
|
|
}, [issueId]);
|
|
|
|
const appendChunks = useCallback((incoming: IncomingRunLogChunk[]) => {
|
|
if (incoming.length === 0) return;
|
|
setChunks((prev) => {
|
|
const { chunks: merged, changed } = mergeRunLogChunks(
|
|
"summary-draft",
|
|
prev,
|
|
incoming,
|
|
mergeRefs.current,
|
|
MAX_CHUNKS,
|
|
);
|
|
return changed ? merged : prev;
|
|
});
|
|
}, []);
|
|
|
|
// Learn the generation run id from live progress/queued events for the issue.
|
|
useCompanyLiveEvent((event: LiveEvent) => {
|
|
if (!issueId) return;
|
|
if (event.type !== "heartbeat.run.progress" && event.type !== "heartbeat.run.queued") return;
|
|
const payload = event.payload ?? {};
|
|
if (payload.issueId !== issueId) return;
|
|
const nextRunId = readPayloadString(payload.runId);
|
|
if (nextRunId) setRunId((current) => (current === nextRunId ? current : nextRunId));
|
|
});
|
|
|
|
// Fallback: resolve the active run for the generation issue when no live event
|
|
// has surfaced the run id yet (e.g. a page refresh mid-generation).
|
|
const activeRunQuery = useQuery({
|
|
queryKey: queryKeys.issues.activeRun(issueId ?? "__none__"),
|
|
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
|
|
enabled: Boolean(companyId) && Boolean(issueId) && !runId,
|
|
retry: false,
|
|
refetchInterval: runId ? false : 4000,
|
|
});
|
|
const fallbackRunId = activeRunQuery.data?.id ?? null;
|
|
useEffect(() => {
|
|
if (fallbackRunId) setRunId((current) => current ?? fallbackRunId);
|
|
}, [fallbackRunId]);
|
|
|
|
useEffect(() => {
|
|
logOffsetRef.current = 0;
|
|
pendingLogRowsRef.current = new Map();
|
|
}, [runId]);
|
|
|
|
// Live token deltas over the shared company-events socket.
|
|
useCompanyLiveEvent((event: LiveEvent) => {
|
|
if (!visible || !runId) return;
|
|
if (event.type !== "heartbeat.run.log") return;
|
|
const payload = event.payload ?? {};
|
|
if (payload.runId !== runId) return;
|
|
const chunk = readPayloadString(payload.chunk);
|
|
if (!chunk) return;
|
|
const ts = readPayloadString(payload.ts) ?? event.createdAt;
|
|
const stream =
|
|
payload.stream === "stderr" ? "stderr" : payload.stream === "system" ? "system" : "stdout";
|
|
appendChunks([
|
|
{ ts, stream, chunk, seq: readChunkSeq(payload.seq), dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}` },
|
|
]);
|
|
});
|
|
|
|
// Hydrate already-emitted output and fill any gaps from the persisted run log.
|
|
useEffect(() => {
|
|
if (!visible || !runId) return;
|
|
let cancelled = false;
|
|
let reading = false;
|
|
const read = async () => {
|
|
if (reading || cancelled) return;
|
|
reading = true;
|
|
try {
|
|
const result = await heartbeatsApi.log(runId, logOffsetRef.current, LOG_READ_LIMIT_BYTES);
|
|
if (cancelled) return;
|
|
appendChunks(parsePersistedLogContent(runId, result.content, pendingLogRowsRef.current));
|
|
if (result.nextOffset !== undefined) {
|
|
logOffsetRef.current = result.nextOffset;
|
|
} else if (result.content.length > 0) {
|
|
logOffsetRef.current += result.content.length;
|
|
}
|
|
} catch {
|
|
// Ignore transient/404 reads (log not yet flushed, run just started).
|
|
} finally {
|
|
reading = false;
|
|
}
|
|
};
|
|
|
|
void read();
|
|
const interval = window.setInterval(() => void read(), LOG_POLL_INTERVAL_MS);
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(interval);
|
|
};
|
|
}, [visible, runId, appendChunks]);
|
|
|
|
const parse = useMemo(() => parseSummaryDraftStream(extractAssistantOutputText(chunks)), [chunks]);
|
|
|
|
const draft = parse.draft !== null && !parse.draftClosed
|
|
? closeDanglingCodeFence(parse.draft)
|
|
: parse.draft;
|
|
|
|
return {
|
|
runId,
|
|
statusLine: parse.statusLine,
|
|
draft,
|
|
draftClosed: parse.draftClosed,
|
|
hasStream: parse.draft !== null || parse.statusLine !== null,
|
|
};
|
|
}
|