mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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")]),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user