diff --git a/apps/extension/src/debug/__tests__/evidence-model.test.ts b/apps/extension/src/debug/__tests__/evidence-model.test.ts index c7923b8..482339e 100644 --- a/apps/extension/src/debug/__tests__/evidence-model.test.ts +++ b/apps/extension/src/debug/__tests__/evidence-model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { consoleSource, operationEvidence, requestKind } from "../evidence-model"; +import { consoleSource, operationContext, operationEvidence, requestKind } from "../evidence-model"; import { sanitizeFields } from "../observer"; import { redactBody } from "../redact"; import type { DebugOperation, DebugPage, DebugRecording, DebugRequest } from "../types"; @@ -86,6 +86,34 @@ const fixture = (): DebugRecording => ({ }); describe("operation evidence", () => { + it("uses one window for request and console links, including running and delayed evidence", () => { + const record = fixture(); + record.requests.push( + { ...request, id: "late", started_at: 2500 }, + { ...request, id: "next", started_at: 3000 }, + ); + record.console = [320, 2500, 3000].map((at, index) => ({ + id: `c${index}`, + at, + last_at: at, + level: "log", + text: "log", + count: 1, + })); + record.operations.push({ ...save, id: "d:a3", started_at: 3000 }); + const context = operationContext(record, save); + expect(context.operation.request_ids).toEqual([request.id, "late"]); + expect(context.operation.console_ids).toEqual(["c0", "c1"]); + expect(context.console.map((entry) => entry.relation)).toEqual(["window", "delayed"]); + expect(context.operation.truncated).toBe(false); + record.run.dropped_requests = 1; + expect(operationContext(record, save).operation.truncated).toBe(true); + record.operations = [{ ...input, state: "running", finished_at: undefined }]; + record.saved_at = 2000; + expect(operationContext(record, record.operations[0]).operation.request_ids).toEqual([ + request.id, + ]); + }); it("does not retain credentials embedded in server-rendered input values", () => { for (const html of [ '', diff --git a/apps/extension/src/debug/__tests__/journal.test.ts b/apps/extension/src/debug/__tests__/journal.test.ts index 185f938..6832a8d 100644 --- a/apps/extension/src/debug/__tests__/journal.test.ts +++ b/apps/extension/src/debug/__tests__/journal.test.ts @@ -31,7 +31,7 @@ describe("bounded evidence journal", () => { expect.objectContaining({ response_body: { state: "available", text: "saved" } }), ]); expect(failed).not.toHaveBeenCalled(); - await journal.stop(); + await journal.flush(); }); it("reports storage failures and bounds the queue while a transaction is stalled", async () => { let release: () => void = () => {}; @@ -50,7 +50,7 @@ describe("bounded evidence journal", () => { throw new Error("disk full"); }); release(); - await journal.stop(); + await journal.flush(); expect(failed).toHaveBeenCalledWith("evidence_write_failed"); }); it("preserves pins and complete stored bodies when merging a metadata-only read", () => { diff --git a/apps/extension/src/debug/__tests__/manager.test.ts b/apps/extension/src/debug/__tests__/manager.test.ts index 4a7eb54..ca2f978 100644 --- a/apps/extension/src/debug/__tests__/manager.test.ts +++ b/apps/extension/src/debug/__tests__/manager.test.ts @@ -145,6 +145,42 @@ describe("task-scoped debug lifecycle", () => { expect(expressions.some((expression) => expression.includes(".agent(false,"))).toBe(true); }); + it("updates the operation cursor for delayed evidence only within its observation window", async () => { + const f = await fixture(); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + const ticket = await f.manager.before({ + id: "click", + method: "tool.click", + params: { session_id: "s1", tab_id: 7 }, + }); + f.manager.after(ticket); + const before = await f.manager.read({ session_id: "s1", action: "operations" }); + f.advance(2000); + f.request("delayed"); + const updated = await f.manager.read({ + session_id: "s1", + action: "operations", + since: before.next_since, + }); + expect(updated.operations).toHaveLength(1); + expect(updated.operations![0].request_ids).toEqual([`${run.id}:n1`]); + const detail = await f.manager.read({ + session_id: "s1", + action: "operation", + id: ticket!.operation.id, + }); + expect(detail.evidence!.links).toEqual([{ request_id: `${run.id}:n1`, relation: "delayed" }]); + f.advance(14000); + f.request("outside"); + const unchanged = await f.manager.read({ + session_id: "s1", + action: "operations", + since: updated.next_since, + }); + expect(unchanged.operations).toEqual([]); + }); + it("adds no CDP listeners, reads or page observations before explicit start", async () => { const f = await fixture(); active.push(f.manager); @@ -490,6 +526,12 @@ describe("persistent debugging records", () => { const f = await fixture(journalArchive); active.push(f.manager); const run = await f.manager.start("s1", 7); + const ticket = await f.manager.before({ + id: "save", + method: "tool.click", + params: { session_id: "s1", tab_id: 7 }, + }); + expect(ticket).toBeDefined(); for (let i = 0; i < 230; i++) { f.event("Network.requestWillBeSent", { requestId: `post-${i}`, @@ -505,6 +547,7 @@ describe("persistent debugging records", () => { f.event("Network.loadingFailed", { requestId: `post-${i}`, errorText: "test failure" }); if (i % 20 === 0) await Promise.resolve(); } + f.manager.after(ticket); const first = `${run.id}:n1`; const body = await f.manager.read({ session_id: "s1", @@ -518,6 +561,27 @@ describe("persistent debugging records", () => { await f.manager.read({ session_id: "s1", action: "stop" }); const saved = await f.manager.read({ session_id: "s1", action: "export" }); expect(saved.recording!.requests).toHaveLength(230); + expect(saved.recording!.operations[0].request_ids).toContain(first); + expect(saved.recording!.operations[0].truncated).toBe(false); + for (const reader of ["read", "readHistory"] as const) { + const detail = await f.manager[reader]({ + session_id: "s1", + run_id: run.id, + action: "operation", + id: ticket!.operation.id, + }); + expect(detail.operation!.request_ids).toEqual( + detail.evidence!.links.map((link) => link.request_id), + ); + expect(detail.operation!.truncated).toBe(false); + const operations = await f.manager[reader]({ + session_id: "s1", + run_id: run.id, + action: "operations", + }); + expect(operations.operations![0].request_ids).toContain(first); + expect(operations.operations![0].truncated).toBe(false); + } expect(saved.run!.coverage).not.toContain("evidence_write_backlog"); await f.sessions.start("s2"); await expect( @@ -534,6 +598,66 @@ describe("persistent debugging records", () => { expect(restored.request!.request_body.text).toHaveLength(4096); }); + it.each([ + "read", + "readHistory", + ] as const)("%s reports a failed detail read in the current response", async (reader) => { + const archive = new MemoryArchive(); + const f = await fixture({ + list: () => archive.list(), + put: (record) => archive.put(record), + delete: (id) => archive.delete(id), + get: (id) => archive.get(id), + retain: async () => {}, + request: async () => { + throw new Error("read failed"); + }, + }); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.request("live"); + const result = await f.manager[reader]({ + action: "request", + session_id: "s1", + run_id: run.id, + id: `${run.id}:n1`, + }); + expect(result.request!.url).toBe("https://site.test/live"); + expect(result.run!.coverage).toContain("evidence_read_failed"); + }); + + it("rechecks task ownership when a retained detail read finishes", async () => { + const archive = new MemoryArchive(); + let finish: (entry: DebugRequest | undefined) => void = () => {}; + const requested = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const f = await fixture({ + list: () => archive.list(), + put: (record) => archive.put(record), + delete: (id) => archive.delete(id), + get: (id) => archive.get(id), + request: requested, + }); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.request("owned"); + const reading = f.manager.read({ + session_id: "s1", + run_id: run.id, + action: "request", + id: `${run.id}:n1`, + }); + const rejected = expect(reading).rejects.toThrow("session not found"); + await vi.waitFor(() => expect(requested).toHaveBeenCalled()); + f.context.agentCreatedTabs.delete(7); + finish(undefined); + await rejected; + }); + it("reports failed persistent reads and still exposes live evidence", async () => { const archive = new MemoryArchive(); const failure = async (): Promise => { diff --git a/apps/extension/src/debug/archive.ts b/apps/extension/src/debug/archive.ts index 15015d6..32c221b 100644 --- a/apps/extension/src/debug/archive.ts +++ b/apps/extension/src/debug/archive.ts @@ -4,6 +4,7 @@ import { JOURNAL_REQUESTS, jsonBytes, mergeRequest, + requestMetadata, retentionPriority, } from "./journal"; import { interruptPerformance } from "./performance"; @@ -16,17 +17,6 @@ interface StoredRequest { bytes: number; priority: number; } -function metadata(entry: DebugRequest): DebugRequest { - const { text: _request, ...request_body } = entry.request_body; - const { text: _response, ...response_body } = entry.response_body; - const { - request_headers: _headers, - response_headers: _responseHeaders, - timing: _timing, - ...rest - } = entry; - return { ...rest, request_body, response_body }; -} export const HISTORY_LIMIT = 50; export const HISTORY_BYTES = 50 * 1024 * 1024; @@ -193,7 +183,7 @@ export class LocalDebugArchive implements DebugArchive { requests: [saved.entry], }).requests[0]; item.update({ ...saved, entry: recovered }); - tx.objectStore("request_index").put(metadata(recovered)); + tx.objectStore("request_index").put(requestMetadata(recovered)); item.continue(); }; } @@ -270,7 +260,7 @@ export class LocalDebugArchive implements DebugArchive { ), ]); const merged = new Map( - recording.requests.map((entry) => [entry.id, bodies ? entry : metadata(entry)]), + recording.requests.map((entry) => [entry.id, bodies ? entry : requestMetadata(entry)]), ); for (const value of requests) { const entry = bodies ? (value as StoredRequest).entry : (value as DebugRequest); @@ -470,7 +460,7 @@ export class LocalDebugArchive implements DebugArchive { bytes, priority: retentionPriority(entry), } satisfies StoredRequest); - index.put(metadata(entry)); + index.put(requestMetadata(entry)); if (--remaining === 0) finish(); }; } @@ -519,7 +509,7 @@ export class LocalDebugArchive implements DebugArchive { saved.bytes = bytes; saved.priority = retentionPriority(saved.entry); tx.objectStore("requests").put(saved); - tx.objectStore("request_index").put(metadata(saved.entry)); + tx.objectStore("request_index").put(requestMetadata(saved.entry)); tx.objectStore("runs").put(run); }; await done; diff --git a/apps/extension/src/debug/evidence-model.ts b/apps/extension/src/debug/evidence-model.ts index cd435f8..96f14b0 100644 --- a/apps/extension/src/debug/evidence-model.ts +++ b/apps/extension/src/debug/evidence-model.ts @@ -38,22 +38,29 @@ export function consoleSource( return "unknown"; } -export function relatedRequests( - recording: DebugRecording, - operation: DebugOperation, -): DebugEvidence["links"] { +/** Derive all associations from the same retained evidence, never the hot cache. */ +export function operationContext(recording: DebugRecording, operation: DebugOperation) { const { immediate, end, next_start } = operationWindow(recording, operation); - return recording.requests - .filter( - (request) => - request.started_at >= operation.started_at && - request.started_at <= end && - request.started_at < next_start, - ) - .map((request) => ({ + const contains = (at: number) => at >= operation.started_at && at <= end && at < next_start; + const relation = (at: number): "window" | "delayed" => (at <= immediate ? "window" : "delayed"); + const requests = recording.requests.filter((request) => contains(request.started_at)); + const messages = recording.console + .filter((entry) => contains(entry.at)) + .map((entry) => ({ ...entry, relation: relation(entry.at) })); + return { + requests, + console: messages, + links: requests.map((request) => ({ request_id: request.id, - relation: request.started_at <= immediate ? "window" : "delayed", - })); + relation: relation(request.started_at), + })), + operation: { + ...operation, + request_ids: requests.map((request) => request.id), + console_ids: messages.map((entry) => entry.id), + truncated: recording.run.dropped_requests > 0 || recording.run.dropped_console > 0, + }, + }; } export function operationWindow( recording: DebugRecording, @@ -61,7 +68,10 @@ export function operationWindow( ): { immediate: number; end: number; next_start: number } { const index = recording.operations.findIndex((item) => item.id === operation.id); const next = index >= 0 ? recording.operations[index + 1] : undefined; - const immediate = operation.window_end ?? operation.finished_at ?? operation.started_at; + const immediate = + operation.window_end ?? + operation.finished_at ?? + (operation.state === "running" ? recording.saved_at : operation.started_at); const end = Math.min( operation.observation_end ?? immediate, next?.started_at ?? Infinity, @@ -131,11 +141,9 @@ function payload(request: DebugRequest, part: "request" | "response"): Leaf[] { export function operationEvidence( record: DebugRecording, operation: DebugOperation, + context = operationContext(record, operation), ): DebugEvidence { - const links = relatedRequests(record, operation); - const requests = record.requests.filter((request) => - links.some((link) => link.request_id === request.id), - ); + const { links, requests } = context; const gaps = new Set( record.run.coverage.filter((value) => value.startsWith("evidence_")), ); diff --git a/apps/extension/src/debug/journal.ts b/apps/extension/src/debug/journal.ts index 902713f..803d7df 100644 --- a/apps/extension/src/debug/journal.ts +++ b/apps/extension/src/debug/journal.ts @@ -9,6 +9,19 @@ const PENDING_COUNT = 256; export const jsonBytes = (value: unknown): number => new TextEncoder().encode(JSON.stringify(value)).byteLength; +/** Shared by persisted indexes and detail projections; no browser dependencies. */ +export function requestMetadata(entry: DebugRequest): DebugRequest { + const { text: _request, ...request_body } = entry.request_body; + const { text: _response, ...response_body } = entry.response_body; + const { + request_headers: _headers, + response_headers: _responseHeaders, + timing: _timing, + ...rest + } = entry; + return { ...rest, request_body, response_body }; +} + /** Memory eviction must not erase evidence already captured or saved. */ export function mergeRequest( previous: DebugRequest | undefined, @@ -48,14 +61,12 @@ export class DebugJournal { private bytes = 0; private timer?: ReturnType; private writing?: Promise; - private stopped = false; constructor( private readonly archive: DebugArchive, private readonly run: () => DebugRun, private readonly failed: (reason: string) => void, ) {} retain = (value: DebugRequest): void => { - if (this.stopped) return; const entry = structuredClone(mergeRequest(this.pending.get(value.id)?.entry, value)); const bytes = jsonBytes(entry); const previous = this.pending.get(entry.id); @@ -95,8 +106,4 @@ export class DebugJournal { if (this.writing === writing) this.writing = undefined; if (this.pending.size) await this.flush(); } - async stop(): Promise { - this.stopped = true; - await this.flush(); - } } diff --git a/apps/extension/src/debug/manager.ts b/apps/extension/src/debug/manager.ts index 712a3aa..778810a 100644 --- a/apps/extension/src/debug/manager.ts +++ b/apps/extension/src/debug/manager.ts @@ -147,7 +147,10 @@ export class DebugManager { } private change(state: RunState): number { const sequence = ++state.run.next_since; - if (state.current && this.now() <= (state.current.window_end ?? this.now())) + if ( + state.current && + this.now() <= (state.current.observation_end ?? state.current.window_end ?? this.now()) + ) state.current.sequence = sequence; this.scheduleSave(state); return sequence; @@ -950,31 +953,6 @@ export class DebugManager { ); } - private operation(state: RunState, operation: DebugOperation, details = true): DebugOperation { - const end = Math.min(operation.window_end ?? this.now(), this.now()); - const nextStart = - state.operations[state.operations.indexOf(operation) + 1]?.started_at ?? Infinity; - const requests = state.network - .list() - .filter( - (entry) => - entry.started_at >= operation.started_at && - entry.started_at <= end && - entry.started_at < nextStart, - ); - const messages = state.console.filter( - (entry) => entry.at >= operation.started_at && entry.at <= end && entry.at < nextStart, - ); - const { before, after, observations, ...rest } = operation; - return { - ...rest, - ...(details ? { before, after, observations } : {}), - request_ids: requests.map((entry) => entry.id), - console_ids: messages.map((entry) => entry.id), - truncated: state.network.dropped > 0 || state.run.dropped_console > 0, - }; - } - private async capturePage(state: RunState, loaded = false): Promise { if (state.pagePending || state.run.state !== "capturing") return; state.pagePending = true; @@ -1040,7 +1018,7 @@ export class DebugManager { saved_at: this.now(), run: this.summary(state), requests: state.network.list(), - operations: state.operations.map((operation) => this.operation(state, operation)), + operations: state.operations, console: state.console, pages: state.pages, performance: state.performance, @@ -1135,10 +1113,15 @@ export class DebugManager { async readHistory(params: DebugParams): Promise { this.sync(); if (!params.run_id) throw new Error("recording ID is required"); - const live = this.runs.get(params.run_id); + return this.readEvidence(params, this.runs.get(params.run_id)); + } + + /** Internal reader; callers retain their own task or extension-page authorization. */ + private async readEvidence(params: DebugParams, live?: RunState): Promise { + const runId = params.run_id!; if (params.action === "requests" && (!live || live.journal)) { await live?.journal?.flush(); - const indexed = await this.archive?.query?.(params.run_id, params).catch((error) => { + const indexed = await this.archive?.query?.(runId, params).catch((error) => { if (!live) throw error; this.coverage(live, "evidence_read_failed"); return undefined; @@ -1165,18 +1148,18 @@ export class DebugManager { const bodies = ["operation", "export", "duplicates"].includes(params.action); const recording = live ? await this.evidenceRecording(live, bodies) - : await this.archive?.get(params.run_id, bodies); + : await this.archive?.get(runId, bodies); if (recording && params.action === "request" && params.id) { - const stored = await this.archive?.request?.(params.run_id, params.id).catch((error) => { + const stored = await this.archive?.request?.(runId, params.id).catch((error) => { if (!live) throw error; this.coverage(live, "evidence_read_failed"); - recording.run.coverage = [...live.run.coverage]; + recording.run.coverage = [...new Set([...recording.run.coverage, ...live.run.coverage])]; return undefined; }); - if (stored) { - const current = recording.requests.find((entry) => entry.id === params.id); - recording.requests = [current ? mergeRequest(stored, current) : stored]; - } + const current = + live?.network.get(params.id) ?? recording.requests.find((entry) => entry.id === params.id); + if (stored || current) + recording.requests = [current ? mergeRequest(stored, current) : stored!]; } if (!recording) throw new Error("debug recording not found or expired"); return readRecording(recording, params); @@ -1258,19 +1241,23 @@ export class DebugManager { if (!this.archive?.pin) throw new Error("persistent evidence unavailable"); await this.archive.pin(runId, params.id!, params.action === "pin"); } - const saved = await this.readHistory({ - ...params, - run_id: runId, - action: - params.action === "stop" - ? "rules" - : ["pin", "unpin"].includes(params.action) - ? "request" - : params.action, - }); + const saved = await this.readEvidence( + { + ...params, + run_id: runId, + action: + params.action === "stop" + ? "rules" + : ["pin", "unpin"].includes(params.action) + ? "request" + : params.action, + }, + this.runs.get(runId), + ); if ( this.sessions.get(params.session_id) !== context || - !this.ownedRuns.get(context)?.has(runId) + !this.ownedRuns.get(context)?.has(runId) || + !isAgentControlledTab(context, owned?.get(runId) ?? -1) ) throw new Error("session not found"); return params.action === "stop" ? { ...result, run: saved.run } : saved; @@ -1323,56 +1310,27 @@ export class DebugManager { ); return { ...result, run: this.summary(state), rules: state.controls.list() }; } - if (params.action === "requests" && state.journal) { - await state.journal.flush(); - const indexed = await this.archive?.query?.(state.run.id, params).catch(() => { - this.coverage(state, "evidence_read_failed"); - return undefined; - }); - if (indexed) { - state.run.storage = indexed.run?.storage; - return { - ...indexed, - run: { - ...this.summary(state), - requests: indexed.run!.requests, - dropped_requests: indexed.run!.dropped_requests, - }, - }; + if (params.action === "operation") { + const operation = state.operations.find((entry) => entry.id === params.id); + if (!operation) throw new Error("operation not found or evicted"); + // During the observation window a caller may request an early post-state. + if ( + state.current === operation && + state.run.state === "capturing" && + operation.state !== "running" && + this.now() <= (operation.window_end ?? 0) + ) { + operation.after = await this.page(state); + operation.sequence = this.change(state); } } - if (params.action !== "operation") { - if (params.action === "performance") { - await this.refreshPerformance(state); - return readRecording(this.recording(state), params); - } - const recording = await this.evidenceRecording( - state, - ["export", "duplicates"].includes(params.action), - ); - if (params.action === "request" && params.id) { - const saved = await this.archive?.request?.(state.run.id, params.id).catch(() => { - this.coverage(state, "evidence_read_failed"); - return undefined; - }); - const current = state.network.get(params.id); - if (saved || current) - recording.requests = [current ? mergeRequest(saved, current) : saved!]; - } - return readRecording(recording, params); - } - const operation = state.operations.find((entry) => entry.id === params.id); - if (!operation) throw new Error("operation not found or evicted"); - // During the observation window a caller may request an early post-state. + const evidence = await this.readEvidence({ ...params, run_id: state.run.id }, state); if ( - state.current === operation && - state.run.state === "capturing" && - operation.state !== "running" && - this.now() <= (operation.window_end ?? 0) - ) { - operation.after = await this.page(state); - operation.sequence = this.change(state); - } - return readRecording(await this.evidenceRecording(state), params); + this.sessions.get(params.session_id) !== context || + state.released || + !isAgentControlledTab(context, state.run.tab_id) + ) + throw new Error("session not found"); + return evidence; } } diff --git a/apps/extension/src/debug/network-store.ts b/apps/extension/src/debug/network-store.ts index c727968..9a381cb 100644 --- a/apps/extension/src/debug/network-store.ts +++ b/apps/extension/src/debug/network-store.ts @@ -1,6 +1,7 @@ import type { CdpDebuggee } from "@/browser-driver/chromium-cdp"; import type { CdpRunner } from "@/tools/shared"; import { sendToCdpTarget } from "@/tools/shared"; +import { requestMetadata } from "./journal"; import { BODY_CHARS, redactBody, redactHeaders, redactText, redactUrl } from "./redact"; import type { DebugBody, DebugIntervention, DebugRequest } from "./types"; @@ -670,23 +671,17 @@ export function requestProjection( maxChars = 4096, pointer?: string, ): DebugRequest { - const { request_headers, response_headers, timing, request_body, response_body, ...metadata } = - entry; - const summary = (body: DebugBody): DebugBody => { - const { text: _text, ...rest } = body; - return rest; - }; return { - ...metadata, - request_body: - part === "request" - ? bodySlice(request_body, offset, maxChars, pointer) - : summary(request_body), - response_body: - part === "response" - ? bodySlice(response_body, offset, maxChars, pointer) - : summary(response_body), - ...(part === "headers" ? { request_headers, response_headers } : {}), - ...(part === "timing" ? { timing } : {}), + ...requestMetadata(entry), + ...(part === "request" + ? { request_body: bodySlice(entry.request_body, offset, maxChars, pointer) } + : {}), + ...(part === "response" + ? { response_body: bodySlice(entry.response_body, offset, maxChars, pointer) } + : {}), + ...(part === "headers" + ? { request_headers: entry.request_headers, response_headers: entry.response_headers } + : {}), + ...(part === "timing" ? { timing: entry.timing } : {}), }; } diff --git a/apps/extension/src/debug/recording.ts b/apps/extension/src/debug/recording.ts index 7d76bdc..026c6e4 100644 --- a/apps/extension/src/debug/recording.ts +++ b/apps/extension/src/debug/recording.ts @@ -1,5 +1,5 @@ import { analyzeRecording } from "./analysis"; -import { operationEvidence, operationWindow } from "./evidence-model"; +import { operationContext, operationEvidence } from "./evidence-model"; import { requestProjection } from "./network-store"; import { matchesRequest, projectFields } from "./query"; import type { DebugParams, DebugRecording, DebugResult } from "./types"; @@ -21,7 +21,16 @@ export function readRecording(recording: DebugRecording, params: DebugParams): D ...(end < all.length ? { next_offset: end, truncated: true } : {}), }; } - if (params.action === "export") return { ...result, recording }; + if (params.action === "export") + return { + ...result, + recording: { + ...recording, + operations: recording.operations.map( + (entry) => operationContext(recording, entry).operation, + ), + }, + }; if (params.action === "rules") return { ...result, rules: recording.rules ?? [], replays: recording.replays ?? [] }; if (params.action === "pages" || params.action === "console") { @@ -69,9 +78,15 @@ export function readRecording(recording: DebugRecording, params: DebugParams): D operations: recording.operations .filter((item) => page.includes(item)) .sort((a, b) => a.sequence - b.sequence) - .map( - ({ before: _before, after: _after, observations: _observations, ...item }) => item, - ), + .map((entry) => { + const { + before: _before, + after: _after, + observations: _observations, + ...item + } = operationContext(recording, entry).operation; + return item; + }), }; return { ...result, @@ -85,26 +100,13 @@ export function readRecording(recording: DebugRecording, params: DebugParams): D if (params.action === "operation") { const operation = recording.operations.find((entry) => entry.id === params.id); if (!operation) throw new Error("operation not found or evicted"); - const evidence = operationEvidence(recording, operation); - const window = operationWindow(recording, operation); + const context = operationContext(recording, operation); return { ...result, - operation, - evidence, - requests: recording.requests - .filter((entry) => evidence.links.some((link) => link.request_id === entry.id)) - .map((entry) => requestProjection(entry)), - console: recording.console - .filter( - (entry) => - entry.at >= operation.started_at && - entry.at <= window.end && - entry.at < window.next_start, - ) - .map((entry) => ({ - ...entry, - relation: entry.at <= window.immediate ? "window" : "delayed", - })), + operation: context.operation, + evidence: operationEvidence(recording, operation, context), + requests: context.requests.map((entry) => requestProjection(entry)), + console: context.console, }; } throw new Error("unsupported history action"); diff --git a/apps/extension/src/entrypoints/debug/App.test.tsx b/apps/extension/src/entrypoints/debug/App.test.tsx index d59d274..c11e6be 100644 --- a/apps/extension/src/entrypoints/debug/App.test.tsx +++ b/apps/extension/src/entrypoints/debug/App.test.tsx @@ -1,5 +1,5 @@ import { i18n } from "@browser-skill/i18n"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { debugHistory, @@ -10,6 +10,7 @@ import { } from "@/debug/client"; import type { DebugOperation, DebugRequest, DebugRun } from "@/debug/types"; import { DebugApp } from "./App"; +import { useRequests } from "./use-requests"; vi.mock("@/debug/client", () => ({ debugHistory: vi.fn(), @@ -220,3 +221,180 @@ describe("website evidence workspace", () => { expect(debugRequest).not.toHaveBeenCalled(); }); }); + +describe("incremental request list", () => { + it("merges updates, resyncs on retention, and pauses when its panel is hidden", async () => { + let current = { ...run, next_since: 101 }; + let retained = Array.from({ length: 101 }, (_, i) => ({ + ...request, + id: `d1:n${i + 1}`, + sequence: i + 1, + started_at: 1000 + i, + })); + vi.mocked(recordingRequest).mockImplementation(async (params) => { + const page = retained + .filter((entry) => entry.sequence > (params.since ?? 0)) + .sort((a, b) => a.sequence - b.sequence) + .slice(0, params.limit); + return { + session_id: "s1", + run: current, + requests: page, + next_since: page.at(-1)?.sequence ?? current.next_since, + }; + }); + const { result, rerender } = renderHook(({ run, enabled }) => useRequests(run, enabled), { + initialProps: { run: current, enabled: true }, + }); + await waitFor(() => expect(result.current.requests).toHaveLength(101)); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 100]); + retained[0] = { ...retained[0], status: 500, sequence: 102 }; + retained.push({ ...request, id: "d1:new", sequence: 103 }); + current = { ...current, next_since: 103 }; + rerender({ run: current, enabled: true }); + await waitFor(() => expect(result.current.requests).toHaveLength(102)); + expect(recordingRequest).toHaveBeenLastCalledWith(expect.objectContaining({ since: 101 })); + expect(result.current.requests.find((entry) => entry.id === "d1:n1")!.status).toBe(500); + retained = retained.filter((entry) => entry.id !== "d1:n2"); + current = { ...current, next_since: 104, dropped_requests: 1 }; + const before = vi.mocked(recordingRequest).mock.calls.length; + rerender({ run: current, enabled: true }); + await waitFor(() => expect(result.current.requests).toHaveLength(101)); + expect(vi.mocked(recordingRequest).mock.calls[before][0].since).toBe(0); + expect(result.current.requests.some((entry) => entry.id === "d1:n2")).toBe(false); + const calls = vi.mocked(recordingRequest).mock.calls.length; + current = { ...current, next_since: 105 }; + rerender({ run: current, enabled: false }); + expect(recordingRequest).toHaveBeenCalledTimes(calls); + rerender({ run: current, enabled: true }); + await waitFor(() => expect(recordingRequest).toHaveBeenCalledTimes(calls + 1)); + expect(recordingRequest).toHaveBeenLastCalledWith(expect.objectContaining({ since: 103 })); + }); + + it("serializes overlapping refreshes and keeps progress made by a superseded page", async () => { + let finish: (value: Awaited>) => void = () => {}; + vi.mocked(recordingRequest) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue({ + session_id: "s1", + requests: [{ ...request, id: "d1:n2", sequence: 3 }], + next_since: 3, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(recordingRequest).toHaveBeenCalledTimes(1)); + rerender({ ...run, next_since: 3 }); + expect(recordingRequest).toHaveBeenCalledTimes(1); + finish({ session_id: "s1", requests: [request], next_since: 2 }); + await waitFor(() => expect(result.current.requests).toHaveLength(2)); + expect(recordingRequest).toHaveBeenLastCalledWith(expect.objectContaining({ since: 2 })); + }); + + it("restarts if retention changes during a read, without keeping removed rows", async () => { + vi.mocked(recordingRequest) + .mockResolvedValueOnce({ session_id: "s1", requests: [request], next_since: 2 }) + .mockResolvedValueOnce({ + session_id: "s1", + run: { ...run, dropped_requests: 1 }, + requests: [], + next_since: 3, + }) + .mockResolvedValueOnce({ + session_id: "s1", + run: { ...run, dropped_requests: 1 }, + requests: [{ ...request, id: "d1:n2", sequence: 3 }], + next_since: 3, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(result.current.requests[0]?.id).toBe(request.id)); + rerender({ ...run, next_since: 3 }); + await waitFor(() => expect(result.current.requests[0]?.id).toBe("d1:n2")); + expect(result.current.requests).toHaveLength(1); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 2, 0]); + }); + + it("rechecks older evidence after an incomplete storage fallback", async () => { + const fallbackRun = { ...run, coverage: ["evidence_read_failed"] }; + vi.mocked(recordingRequest) + .mockResolvedValueOnce({ + session_id: "s1", + run: fallbackRun, + requests: [{ ...request, id: "d1:new", sequence: 50 }], + next_since: 50, + }) + .mockResolvedValueOnce({ + session_id: "s1", + run: fallbackRun, + requests: [request, { ...request, id: "d1:new", sequence: 50 }], + next_since: 51, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(result.current.requests).toHaveLength(1)); + rerender({ ...run, next_since: 51 }); + await waitFor(() => expect(result.current.requests).toHaveLength(2)); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 0]); + }); + + it("resumes after a failed page instead of discarding earlier progress", async () => { + const page = Array.from({ length: 100 }, (_, i) => ({ + ...request, + id: `d1:n${i + 1}`, + sequence: i + 1, + })); + vi.mocked(recordingRequest) + .mockResolvedValueOnce({ session_id: "s1", requests: page, next_since: 100 }) + .mockRejectedValueOnce(new Error("storage unavailable")) + .mockResolvedValueOnce({ + session_id: "s1", + requests: [{ ...request, id: "d1:last", sequence: 101 }], + next_since: 101, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(result.current.error).toBe("storage unavailable")); + expect(result.current.requests).toHaveLength(100); + rerender({ ...run, next_since: 101 }); + await waitFor(() => expect(result.current.requests).toHaveLength(101)); + expect(result.current.error).toBe(""); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 100, 100]); + }); + + it("ignores a previous run's late response after the selection changes", async () => { + let finish: (value: Awaited>) => void = () => {}; + vi.mocked(recordingRequest) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue({ + session_id: "s2", + requests: [{ ...request, id: "d2:n1", run_id: "d2" }], + next_since: 2, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(recordingRequest).toHaveBeenCalledTimes(1)); + rerender({ ...run, id: "d2", session_id: "s2" }); + await waitFor(() => expect(result.current.requests[0]?.id).toBe("d2:n1")); + finish({ session_id: "s1", requests: [request], next_since: 2 }); + await Promise.resolve(); + expect(result.current.requests.map((entry) => entry.id)).toEqual(["d2:n1"]); + expect(recordingRequest).toHaveBeenLastCalledWith( + expect.objectContaining({ session_id: "s2", run_id: "d2", since: 0 }), + ); + }); +}); diff --git a/apps/extension/src/entrypoints/debug/App.tsx b/apps/extension/src/entrypoints/debug/App.tsx index ea8acbb..340a833 100644 --- a/apps/extension/src/entrypoints/debug/App.tsx +++ b/apps/extension/src/entrypoints/debug/App.tsx @@ -34,6 +34,7 @@ import { } from "./evidence"; import { ReplayEditor, RuleEditor, RulesPanel } from "./network-controls"; import { OperationEvidence } from "./operation-evidence"; +import { useRequests } from "./use-requests"; export function DebugApp() { const { t } = useTranslation("extension"); @@ -51,7 +52,6 @@ export function DebugApp() { const [filter, setFilter] = useState(""); const [revision, setRevision] = useState(0); const [operations, setOperations] = useState([]); - const [requests, setRequests] = useState([]); const [messages, setMessages] = useState([]); const [pages, setPages] = useState([]); const [operationId, setOperationId] = useState(""); @@ -75,6 +75,10 @@ export function DebugApp() { ); const isHistory = !selection.session && !selection.run; const pulse = run?.next_since ?? 0; + const { requests, error: requestError } = useRequests( + run, + mode === "requests" && !operationId && !selectedRequest, + ); const errorCounts = messages .filter((entry) => entry.level === "error") .reduce>((counts, entry) => { @@ -138,7 +142,6 @@ export function DebugApp() { useEffect(() => { initialOperation.current = ""; setOperations([]); - setRequests([]); setMessages([]); setPages([]); setOperationId(""); @@ -158,26 +161,12 @@ export function DebugApp() { recordingRequest({ ...base, action: "console" }), recordingRequest({ ...base, action: "pages" }), ]); - const entries: DebugRequest[] = []; - let since = 0; - for (let page = 0; page < 20; page++) { - if (cancelled) return; - const batch = await recordingRequest({ ...base, action: "requests", since, limit: 100 }); - entries.push(...(batch.requests ?? [])); - since = batch.next_since ?? since; - if ((batch.requests?.length ?? 0) < 100) break; - } if (cancelled) return; setOperations((actions.operations ?? []).sort((a, b) => a.started_at - b.started_at)); if (initialOperation.current !== runId && actions.operations?.length) { initialOperation.current = runId; setOperationId(actions.operations.at(-1)!.id); } - setRequests( - [...new Map(entries.map((item) => [item.id, item])).values()].sort( - (a, b) => a.started_at - b.started_at, - ), - ); setMessages(consoleResult.console ?? []); setPages(pageResult.pages ?? []); })().catch((reason) => { @@ -362,7 +351,7 @@ export function DebugApp() { )} - {(error || historyError || taskError || run?.storage_error) && ( + {(error || requestError || historyError || taskError || run?.storage_error) && (

)} {confirmDelete && ( diff --git a/apps/extension/src/entrypoints/debug/evidence.tsx b/apps/extension/src/entrypoints/debug/evidence.tsx index 3435112..9cfff84 100644 --- a/apps/extension/src/entrypoints/debug/evidence.tsx +++ b/apps/extension/src/entrypoints/debug/evidence.tsx @@ -5,7 +5,6 @@ import { RiArrowRightLine, RiArrowRightUpLine, RiCheckLine, - RiCodeSSlashLine, } from "@remixicon/react"; import { useEffect, useState } from "react"; import { debugRequest, recordingRequest } from "@/debug/client"; diff --git a/apps/extension/src/entrypoints/debug/use-requests.ts b/apps/extension/src/entrypoints/debug/use-requests.ts new file mode 100644 index 0000000..a1ff269 --- /dev/null +++ b/apps/extension/src/entrypoints/debug/use-requests.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import { recordingRequest } from "@/debug/client"; +import type { DebugRequest, DebugRun } from "@/debug/types"; + +interface RequestCache { + key: string; + since: number; + dropped: number; + entries: Map; + busy: boolean; + resync?: boolean; + refresh?: () => Promise; +} + +/** Keep one selected run, serialize reads, and reuse successfully read pages across refreshes. */ +export function useRequests(run: DebugRun | undefined, enabled: boolean) { + const cache = useRef(undefined); + const [view, setView] = useState({ key: "", requests: [] as DebugRequest[], error: "" }); + const session = run?.session_id ?? ""; + const id = run?.id ?? ""; + const key = `${session}/${id}`; + const pulse = run?.next_since ?? 0; + const dropped = run?.dropped_requests ?? 0; + + useEffect(() => { + if (cache.current?.key !== key) + cache.current = { key, since: 0, dropped, entries: new Map(), busy: false }; + const state = cache.current; + if (!id || !enabled) return; + let cancelled = false; + const publish = (error = "") => { + if (!cancelled) + setView({ + key, + requests: [...state.entries.values()].sort( + (a, b) => a.started_at - b.started_at || a.sequence - b.sequence, + ), + error, + }); + }; + const reset = (removed: number) => { + state.entries.clear(); + state.since = 0; + state.dropped = removed; + state.resync = false; + publish(); + }; + const update = async () => { + if (cancelled) return; + // The protocol has no deletion cursor: resync when retention removed requests. + if (state.resync || dropped > state.dropped) reset(Math.max(dropped, state.dropped)); + for (let page = 0; page < 21; page++) { + if (cancelled) return; + const since = state.since; + const batch = await recordingRequest({ + session_id: session, + run_id: id, + action: "requests", + since, + limit: 100, + }); + if (cache.current !== state) return; + const removed = batch.run?.dropped_requests ?? state.dropped; + if (removed > state.dropped) { + reset(removed); + if (since > 0) continue; + } + // A fallback read may omit older stored rows; never advance past them permanently. + if (batch.run?.coverage.includes("evidence_read_failed")) state.resync = true; + const entries = batch.requests ?? []; + for (const entry of entries) state.entries.set(entry.id, entry); + state.since = Math.max(since, batch.next_since ?? since); + // Commit progress even when a newer pulse queued another read. + if (entries.length < 100 || state.since === since) break; + } + publish(); + }; + state.refresh = () => + update().catch((reason) => + publish(reason instanceof Error ? reason.message : String(reason)), + ); + // Coalesce pulses to one pending refresh even if a browser read stalls. + if (!state.busy) { + state.busy = true; + void (async () => { + try { + while (state.refresh) { + const refresh = state.refresh; + state.refresh = undefined; + await refresh(); + } + } finally { + state.busy = false; + } + })(); + } + return () => { + cancelled = true; + }; + }, [key, session, id, pulse, dropped, enabled]); + + return view.key === key ? view : { requests: [], error: "" }; +} diff --git a/packages/i18n/src/locales/en-US/extension.json b/packages/i18n/src/locales/en-US/extension.json index 66ac976..cae056f 100644 --- a/packages/i18n/src/locales/en-US/extension.json +++ b/packages/i18n/src/locales/en-US/extension.json @@ -326,10 +326,8 @@ "timeline": "Action timeline", "noOperations": "No recorded actions.", "noSession": "This task has ended. Open history to review saved debugging records.", - "chooseOperation": "Select an action to inspect its evidence.", "duringRequests": "Requests in this window", "console": "Console", - "pageChanges": "Page changes", "noRequests": "No retained requests.", "noConsole": "No retained console messages.", "correlation": "Grouped by time. Temporal proximity does not establish causality.", @@ -359,10 +357,7 @@ "missingBody": "Body was not captured, was evicted, or is unavailable from the browser.", "redacted": "Common credentials redacted", "run": "Capture run", - "selectRun": "Choose a capture run", "noData": "No data yet", - "elapsed": "{{ms}} ms", - "repeat": "Repeated {{count}} times", "state_running": "Running", "state_completed": "Completed", "state_error": "Failed", @@ -378,7 +373,6 @@ "method_hover": "Hover", "method_evaluate": "Evaluate script", "coverage": "Capture coverage", - "retry": "Retry", "method_wheel": "Scroll", "method_scroll_to": "Scroll to element", "method_focus": "Focus", @@ -515,7 +509,6 @@ "controlFailed": "Failed", "controlCancelled": "Cancelled", "originalRequest": "Original request", - "appliedChanges": "Changed fields", "captureRequired": "Start a new capture to add rules or replay requests.", "controlDetails": "View rule definition", "bodyIncomplete": "Original body is incomplete. Supply the complete body before sending.", diff --git a/packages/i18n/src/locales/ko-KR/extension.json b/packages/i18n/src/locales/ko-KR/extension.json index d6c6433..d0ac274 100644 --- a/packages/i18n/src/locales/ko-KR/extension.json +++ b/packages/i18n/src/locales/ko-KR/extension.json @@ -326,10 +326,8 @@ "timeline": "동작 타임라인", "noOperations": "기록된 동작이 없습니다.", "noSession": "작업이 종료되었습니다. 기록에서 저장된 디버깅 결과를 확인하세요.", - "chooseOperation": "동작을 선택해 관련 기록을 확인하세요.", "duringRequests": "이 시간대의 요청", "console": "콘솔", - "pageChanges": "페이지 변경", "noRequests": "보관된 요청이 없습니다.", "noConsole": "보관된 Console 메시지가 없습니다.", "correlation": "시간순으로 묶었습니다. 시간적 인접성이 인과관계를 의미하지는 않습니다.", @@ -359,10 +357,7 @@ "missingBody": "본문이 수집되지 않았거나 삭제되었거나 브라우저에서 제공하지 않습니다.", "redacted": "일반적인 인증 정보 가림", "run": "수집 실행", - "selectRun": "수집 실행 선택", "noData": "아직 데이터 없음", - "elapsed": "{{ms}} ms", - "repeat": "{{count}}번 반복", "state_running": "실행 중", "state_completed": "완료", "state_error": "실패", @@ -378,7 +373,6 @@ "method_hover": "마우스 올리기", "method_evaluate": "스크립트 실행", "coverage": "수집 범위", - "retry": "다시 시도", "method_wheel": "스크롤", "method_scroll_to": "요소로 스크롤", "method_focus": "포커스", @@ -515,7 +509,6 @@ "controlFailed": "실패", "controlCancelled": "취소됨", "originalRequest": "원본 요청", - "appliedChanges": "변경된 필드", "captureRequired": "규칙 추가 또는 재전송을 위해 새 캡처를 시작하세요.", "controlDetails": "규칙 정의 보기", "bodyIncomplete": "원본 본문이 불완전합니다. 전송 전에 전체 본문을 입력하세요.", diff --git a/packages/i18n/src/locales/zh-CN/extension.json b/packages/i18n/src/locales/zh-CN/extension.json index 4c602ed..249f948 100644 --- a/packages/i18n/src/locales/zh-CN/extension.json +++ b/packages/i18n/src/locales/zh-CN/extension.json @@ -326,10 +326,8 @@ "timeline": "操作时间线", "noOperations": "暂无已记录的操作。", "noSession": "当前任务已结束。请从历史记录查看已保存的调试结果。", - "chooseOperation": "选择一次操作,查看相关证据。", "duringRequests": "期间请求", "console": "Console", - "pageChanges": "页面变化", "noRequests": "暂无已保留的请求。", "noConsole": "暂无已保留的 Console 消息。", "correlation": "按发生时间归组;时间相邻不代表存在因果关系。", @@ -359,10 +357,7 @@ "missingBody": "正文未采集、已淘汰或浏览器未能提供。", "redacted": "常见凭据已脱敏", "run": "采集轮次", - "selectRun": "选择采集轮次", "noData": "暂无数据", - "elapsed": "{{ms}} ms", - "repeat": "重复 {{count}} 次", "state_running": "执行中", "state_completed": "已执行", "state_error": "执行失败", @@ -378,7 +373,6 @@ "method_hover": "悬停", "method_evaluate": "执行脚本", "coverage": "采集范围与限制", - "retry": "重试", "method_wheel": "滚动", "method_scroll_to": "滚动到元素", "method_focus": "聚焦", @@ -515,7 +509,6 @@ "controlFailed": "执行失败", "controlCancelled": "已取消", "originalRequest": "原请求", - "appliedChanges": "修改项", "captureRequired": "开始新的调试记录后,可以添加规则或重放请求。", "controlDetails": "查看规则定义", "bodyIncomplete": "原正文不完整;发送前请提供完整正文。",