feat(audit): automate gateway phase0 live-window capture
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AuditEvent } from './types.js';
|
||||
import { findLatestGatewayCancelWindow } from './phase0GatewayWindow.js';
|
||||
|
||||
function event(
|
||||
timestamp: number,
|
||||
eventType: AuditEvent['event_type'],
|
||||
payload: Record<string, unknown>,
|
||||
): AuditEvent {
|
||||
return {
|
||||
timestamp,
|
||||
level: 'info',
|
||||
event_type: eventType,
|
||||
event: payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe('findLatestGatewayCancelWindow', () => {
|
||||
it('returns the latest gateway session containing run.cancel and cancelled states', () => {
|
||||
const events: AuditEvent[] = [
|
||||
event(100, 'run.state', { session_id: 'old', source: 'gateway', state: 'start' }),
|
||||
event(110, 'run.cancel', { session_id: 'old', source: 'gateway', acknowledged: true }),
|
||||
event(115, 'run.state', { session_id: 'old', source: 'gateway', state: 'cancel_requested' }),
|
||||
event(120, 'run.state', { session_id: 'old', source: 'gateway', state: 'cancelled' }),
|
||||
event(200, 'run.state', { session_id: 'new', source: 'gateway', state: 'start' }),
|
||||
event(210, 'run.cancel', { session_id: 'new', source: 'gateway', acknowledged: true }),
|
||||
event(220, 'run.state', { session_id: 'new', source: 'gateway', state: 'cancelled' }),
|
||||
event(300, 'run.state', { session_id: 'channel', source: 'channel', state: 'cancelled' }),
|
||||
];
|
||||
|
||||
const window = findLatestGatewayCancelWindow(events);
|
||||
expect(window).toEqual({
|
||||
session_id: 'new',
|
||||
start_time_ms: 200,
|
||||
end_time_ms: 220,
|
||||
event_count: 3,
|
||||
run_cancel_count: 1,
|
||||
cancel_requested_count: 0,
|
||||
cancelled_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies padding and ignores malformed/missing payload fields', () => {
|
||||
const events: AuditEvent[] = [
|
||||
event(1000, 'run.state', { session_id: 's1', source: 'gateway', state: 'start' }),
|
||||
event(1010, 'run.cancel', { session_id: 's1', source: 'gateway' }),
|
||||
event(1020, 'run.state', { session_id: 's1', source: 'gateway', state: 'cancel_requested' }),
|
||||
event(1030, 'run.state', { session_id: 's1', source: 'gateway', state: 'cancelled' }),
|
||||
event(1040, 'run.cancel', { source: 'gateway' }),
|
||||
event(1050, 'run.state', { session_id: 's2', state: 'cancelled' }),
|
||||
];
|
||||
|
||||
const window = findLatestGatewayCancelWindow(events, { padding_ms: 25 });
|
||||
expect(window).toEqual({
|
||||
session_id: 's1',
|
||||
start_time_ms: 975,
|
||||
end_time_ms: 1055,
|
||||
event_count: 4,
|
||||
run_cancel_count: 1,
|
||||
cancel_requested_count: 1,
|
||||
cancelled_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no gateway cancel+cancelled window exists', () => {
|
||||
const events: AuditEvent[] = [
|
||||
event(1, 'run.state', { session_id: 's1', source: 'gateway', state: 'start' }),
|
||||
event(2, 'run.state', { session_id: 's1', source: 'gateway', state: 'complete' }),
|
||||
event(3, 'run.cancel', { session_id: 's2', source: 'channel' }),
|
||||
];
|
||||
|
||||
expect(findLatestGatewayCancelWindow(events)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { AuditEvent } from './types.js';
|
||||
|
||||
export interface GatewayCancelWindowSummary {
|
||||
session_id: string;
|
||||
start_time_ms: number;
|
||||
end_time_ms: number;
|
||||
event_count: number;
|
||||
run_cancel_count: number;
|
||||
cancel_requested_count: number;
|
||||
cancelled_count: number;
|
||||
}
|
||||
|
||||
export interface FindGatewayCancelWindowOptions {
|
||||
padding_ms?: number;
|
||||
}
|
||||
|
||||
interface SessionWindowAccumulator {
|
||||
session_id: string;
|
||||
min_ts: number;
|
||||
max_ts: number;
|
||||
event_count: number;
|
||||
run_cancel_count: number;
|
||||
cancel_requested_count: number;
|
||||
cancelled_count: number;
|
||||
}
|
||||
|
||||
function toPayload(value: unknown): Record<string, unknown> {
|
||||
return (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function isGatewayEvent(payload: Record<string, unknown>): boolean {
|
||||
return readString(payload.source) === 'gateway';
|
||||
}
|
||||
|
||||
export function findLatestGatewayCancelWindow(
|
||||
events: AuditEvent[],
|
||||
options: FindGatewayCancelWindowOptions = {},
|
||||
): GatewayCancelWindowSummary | null {
|
||||
const bySession = new Map<string, SessionWindowAccumulator>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.event_type !== 'run.state' && event.event_type !== 'run.cancel') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = toPayload(event.event);
|
||||
if (!isGatewayEvent(payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId = readString(payload.session_id);
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const acc = bySession.get(sessionId) ?? {
|
||||
session_id: sessionId,
|
||||
min_ts: event.timestamp,
|
||||
max_ts: event.timestamp,
|
||||
event_count: 0,
|
||||
run_cancel_count: 0,
|
||||
cancel_requested_count: 0,
|
||||
cancelled_count: 0,
|
||||
};
|
||||
|
||||
acc.event_count += 1;
|
||||
acc.min_ts = Math.min(acc.min_ts, event.timestamp);
|
||||
acc.max_ts = Math.max(acc.max_ts, event.timestamp);
|
||||
|
||||
if (event.event_type === 'run.cancel') {
|
||||
acc.run_cancel_count += 1;
|
||||
} else {
|
||||
const state = readString(payload.state);
|
||||
if (state === 'cancel_requested') {
|
||||
acc.cancel_requested_count += 1;
|
||||
} else if (state === 'cancelled') {
|
||||
acc.cancelled_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
bySession.set(sessionId, acc);
|
||||
}
|
||||
|
||||
const candidates = [...bySession.values()]
|
||||
.filter((row) => row.run_cancel_count > 0 && row.cancelled_count > 0)
|
||||
.sort((a, b) => {
|
||||
const tsDelta = b.max_ts - a.max_ts;
|
||||
if (tsDelta !== 0) {
|
||||
return tsDelta;
|
||||
}
|
||||
const cancelDelta = b.run_cancel_count - a.run_cancel_count;
|
||||
if (cancelDelta !== 0) {
|
||||
return cancelDelta;
|
||||
}
|
||||
return a.session_id.localeCompare(b.session_id);
|
||||
});
|
||||
|
||||
const latest = candidates[0];
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const padRaw = options.padding_ms ?? 0;
|
||||
const paddingMs = Number.isFinite(padRaw) && padRaw > 0
|
||||
? Math.floor(padRaw)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
session_id: latest.session_id,
|
||||
start_time_ms: Math.max(0, latest.min_ts - paddingMs),
|
||||
end_time_ms: latest.max_ts + paddingMs,
|
||||
event_count: latest.event_count,
|
||||
run_cancel_count: latest.run_cancel_count,
|
||||
cancel_requested_count: latest.cancel_requested_count,
|
||||
cancelled_count: latest.cancelled_count,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user