feat(db,inspector): add snapshotSession + restore hookBufferUpdate spec contract (M1+M2)

- Add `snapshotSession(sessionId)` to inspectorSessions.ts per master-plan §3.8:
  returns parsed InterceptedRequest[] in seq order, or null for non-existent sessions.
  Silently skips rows that fail InterceptedRequestSchema validation (defensive).
- Restore canonical no-arg form of `MitmHandlerBase.hookBufferUpdate(intercepted)`
  per master-plan §3.5: when opts is omitted, derive completion fields from
  the intercepted object itself (status/responseHeaders/responseBody/responseSize/
  *LatencyMs) rather than no-op'ing. Extended opts form preserved for legacy callers.
- Update Zod record() calls in InterceptedRequestSchema to current (key,value) signature.
- Add 3 unit tests for snapshotSession (happy path / non-existent / silent skip).
- Add 2 unit tests for hookBufferUpdate (no-arg form + extended opts form).
This commit is contained in:
diegosouzapw
2026-05-28 17:07:01 -03:00
parent f6ed411c62
commit 907075f704
5 changed files with 155 additions and 4 deletions

View File

@@ -6,6 +6,8 @@
import { randomUUID } from "crypto";
import { getDbInstance } from "./core";
import type { InspectorSessionRow } from "./_rowTypes";
import { InterceptedRequestSchema } from "../../mitm/inspector/types";
import type { InterceptedRequest } from "../../mitm/inspector/types";
interface InspectorSessionDbRow {
id: string;
@@ -115,3 +117,41 @@ export function deleteSession(id: string): void {
// Cascade via FK ON DELETE CASCADE for inspector_session_requests
db.prepare("DELETE FROM inspector_sessions WHERE id = ?").run(id);
}
/**
* Return a parsed + validated snapshot of all requests for the given session,
* sorted by ascending seq.
*
* Returns null when the session does not exist.
* Rows whose payload fails InterceptedRequestSchema validation are silently
* skipped (defensive — protects callers from corrupt/partial rows).
*
* Satisfies master-plan §3.8 (F2 spec) named-export contract.
*/
export function snapshotSession(sessionId: string): InterceptedRequest[] | null {
// 1. Verify session exists.
const session = getSession(sessionId);
if (session === null) return null;
// 2. Retrieve raw rows (already ordered by seq ASC).
const rawRows = getSessionRequests(sessionId);
// 3. Parse each payload JSON, validate via Zod schema, skip bad rows.
const results: InterceptedRequest[] = [];
for (const row of rawRows) {
let parsed: unknown;
try {
parsed = JSON.parse(row.payload);
} catch {
// Corrupt JSON — skip.
continue;
}
const result = InterceptedRequestSchema.safeParse(parsed);
if (result.success) {
results.push(result.data as InterceptedRequest);
}
// Invalid rows are silently skipped per defensive contract.
}
return results;
}

View File

@@ -250,6 +250,13 @@ export abstract class MitmHandlerBase {
/**
* Update a previously published Traffic Inspector entry with completion data.
* No-op when the inspector module is not present.
*
* Per master-plan §3.5: the canonical no-arg form `hookBufferUpdate(intercepted)`
* must update the buffer using completion fields already present on `intercepted`
* (status, responseBody, responseHeaders, responseSize, *LatencyMs). When the
* extended `opts` form is used (legacy internal callers), it overrides those
* fields explicitly. Both forms route through `recordRequestComplete` so the
* inspector receives a consistent shape.
*/
protected hookBufferUpdate(
intercepted: InterceptedRequest,
@@ -262,11 +269,18 @@ export abstract class MitmHandlerBase {
upstreamLatencyMs: number;
},
): void {
if (!opts) return;
const finalOpts = opts ?? {
status: typeof intercepted.status === "number" ? intercepted.status : 0,
responseHeaders: intercepted.responseHeaders,
responseBody: intercepted.responseBody,
responseSize: intercepted.responseSize,
proxyLatencyMs: intercepted.proxyLatencyMs ?? 0,
upstreamLatencyMs: intercepted.upstreamLatencyMs ?? 0,
};
void loadAgentBridgeHook().then((hook) => {
if (hook?.recordRequestComplete) {
try {
hook.recordRequestComplete(intercepted, opts);
hook.recordRequestComplete(intercepted, finalOpts);
} catch {
// Hook should never break interception.
}

View File

@@ -39,10 +39,10 @@ export const InterceptedRequestSchema = z.object({
method: z.string(),
host: z.string(),
path: z.string(),
requestHeaders: z.record(z.string()),
requestHeaders: z.record(z.string(), z.string()),
requestBody: z.string().nullable(),
requestSize: z.number().int().nonnegative(),
responseHeaders: z.record(z.string()),
responseHeaders: z.record(z.string(), z.string()),
responseBody: z.string().nullable(),
responseSize: z.number().int().nonnegative(),
status: z.union([z.number().int(), z.literal("in-flight"), z.literal("error")]),

View File

@@ -147,3 +147,54 @@ test("getSessionRequests returns empty array for session with no requests", () =
const requests = mod.getSessionRequests(id);
assert.deepEqual(requests, []);
});
function makeValidInterceptedPayload(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
id: crypto.randomUUID(),
source: "agent-bridge",
timestamp: new Date().toISOString(),
method: "POST",
host: "api.example.com",
path: "/v1/chat/completions",
requestHeaders: { "content-type": "application/json" },
requestBody: null,
requestSize: 0,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: 200,
...overrides,
});
}
test("snapshotSession returns parsed InterceptedRequest[] in seq order", () => {
const { id } = mod.createSession();
mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/req-1" }));
mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/req-2" }));
mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/req-3" }));
const snapshot = mod.snapshotSession(id);
assert.ok(snapshot !== null);
assert.equal(snapshot.length, 3);
assert.equal(snapshot[0].path, "/req-1");
assert.equal(snapshot[1].path, "/req-2");
assert.equal(snapshot[2].path, "/req-3");
});
test("snapshotSession returns null for non-existent session", () => {
const snapshot = mod.snapshotSession("00000000-0000-4000-8000-000000000000");
assert.equal(snapshot, null);
});
test("snapshotSession silently skips rows that fail schema validation", () => {
const { id } = mod.createSession();
mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/good" }));
mod.appendSessionRequest(id, JSON.stringify({ malformed: true }));
mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/good-2" }));
const snapshot = mod.snapshotSession(id);
assert.ok(snapshot !== null);
assert.equal(snapshot.length, 2);
assert.equal(snapshot[0].path, "/good");
assert.equal(snapshot[1].path, "/good-2");
});

View File

@@ -23,6 +23,13 @@ class TestHandler extends MitmHandlerBase {
): Promise<ReturnType<MitmHandlerBase["hookBufferStart"]>> {
return this.hookBufferStart(req, body, mapped);
}
publicHookUpdate(
intercepted: Parameters<MitmHandlerBase["hookBufferUpdate"]>[0],
opts?: Parameters<MitmHandlerBase["hookBufferUpdate"]>[1]
): void {
return this.hookBufferUpdate(intercepted, opts);
}
}
function fakeReq(headers: Record<string, string> = {}): IncomingMessage {
@@ -87,6 +94,45 @@ test("base.hookBufferStart — body is captured (default shouldCaptureBody=true)
assert.ok(typeof r.requestBody === "string");
});
test("base.hookBufferUpdate — no-arg form (per spec §3.5) derives completion data from intercepted", async () => {
const h = new TestHandler();
const req = fakeReq();
const body = Buffer.from(JSON.stringify({ model: "gpt-4o", messages: [] }));
const intercepted = await h.publicHookStart(req, body, "gpt-4o-mapped");
// Mutate the completion fields on `intercepted` to simulate a finished request.
intercepted.status = 200;
intercepted.responseHeaders = { "content-type": "application/json" };
intercepted.responseBody = JSON.stringify({ ok: true });
intercepted.responseSize = 12;
intercepted.proxyLatencyMs = 5;
intercepted.upstreamLatencyMs = 120;
// Per master-plan §3.5, hookBufferUpdate(intercepted) without opts must
// succeed and update the buffer using fields already on `intercepted`.
// The local stub treats hook as absent, so this is effectively a no-op
// but must NOT throw.
assert.doesNotThrow(() => h.publicHookUpdate(intercepted));
});
test("base.hookBufferUpdate — extended opts form still works", async () => {
const h = new TestHandler();
const req = fakeReq();
const body = Buffer.from(JSON.stringify({ messages: [] }));
const intercepted = await h.publicHookStart(req, body, "mapped");
assert.doesNotThrow(() =>
h.publicHookUpdate(intercepted, {
status: 200,
responseHeaders: {},
responseBody: null,
responseSize: 0,
proxyLatencyMs: 1,
upstreamLatencyMs: 2,
})
);
});
test("base.writeError — writes sanitized JSON error body", async () => {
const h = new TestHandler();
let status = 0;