Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion actions/setup/js/scripts/session_schemas.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe("Generated session schemas", () => {
});

it("checks timestamp ordering, the untimed tail and execution singleton semantics", () => {
const timed = time => ({ ...unified({ type: "vendor.progress", data: {} }), provenance: { ...unified({}).provenance, timestampMs: time } });
const timed = time => ({ ...unified({ type: "vendor.progress", data: {} }), provenance: { ...unified({}).provenance, path: `source-${time}`, timestampMs: time } });
const untimed = unified({ type: "vendor.progress", data: {} });
expect(validateSession(jsonl([header, timed(0), timed(0), timed(1), untimed]))).toBe(5);
expect(() => validateSession(jsonl([header, timed(1), timed(0)]))).toThrow("timestamp ordered");
Expand All @@ -221,6 +221,24 @@ describe("Generated session schemas", () => {
expect(() => validateSession(jsonl([header, { ...execution, data: { ...execution.data, exitCode: 256 } }]))).toThrow("SessionExitCode");
});

it("uses persisted millisecond provenance when v1 source metadata omits timestampUnit", () => {
const secondsSource = { component: "agent", phase: "agent", path: "seconds.jsonl", events: 1 };
const millisecondsSource = { component: "mcp", phase: "agent", path: "milliseconds.jsonl", events: 1 };
const collection = {
type: "session.collection",
data: { sources: [secondsSource, millisecondsSource], warnings: 0, untimedEvents: 0, absentComponents: [] },
provenance: { component: "collector", phase: "conclusion", path: "usage/aw_session.jsonl", index: 3 },
};
const event = (component, sourcePath, timestamp, timestampMs) => ({
type: "vendor.progress",
timestamp,
data: {},
provenance: { component, phase: "agent", path: sourcePath, index: 0, timestampMs },
});

expect(validateSession(jsonl([header, event("mcp", "milliseconds.jsonl", 1500, 1500), event("agent", "seconds.jsonl", 2, 2000), collection]))).toBe(4);
});

it("checks exact JSONL framing without requiring canonical JSON number or escape spellings", () => {
const valid = jsonl([header]);
expect(validateSession(valid.replace('"version":1', '"version":1.0'))).toBe(1);
Expand Down
37 changes: 32 additions & 5 deletions actions/setup/js/scripts/validate_session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { Ajv } = require("ajv");
const { validateSessionFileHeader } = require("../unified_session_render.cjs");
const { sessionOrderingEntries, sessionTimestampNs } = require("../unified_session_order.cjs");

const SCHEMA_DIRECTORY = path.resolve(__dirname, "../../../../docs/public/schemas");

Expand Down Expand Up @@ -58,16 +59,42 @@ function validateSession(content, kind = "unified", format = "jsonl") {
if (!validators.array(events)) throw new Error(`Invalid ${kind} session: ${schemaDiagnostic(validators.array.errors)}`);
if (kind === "unified") {
validateSessionFileHeader(events);
let previousTime = -Infinity;
let untimed = false;
const sourceKey = source => JSON.stringify([source.component, source.phase, source.path]);
const sources = new Map();
const units = new Map(events.find(event => event.type === "session.collection" && event.provenance.component === "collector")?.data.sources?.map(source => [sourceKey(source), source.timestampUnit]) ?? []);
for (const [index, event] of events.entries()) {
const sourcePath = event.provenance.path;
if (!sourcePath || /^(?:[/\\]|[a-z]:)/i.test(sourcePath) || sourcePath.split(/[/\\]/).includes("..")) throw new Error(`Invalid relative provenance path at record ${index + 1}`);
if (index === 0) continue;
const time = event.provenance.timestampMs;
if (time === undefined) untimed = true;
const key = sourceKey(event.provenance);
let source = sources.get(key);
if (!source) {
const timestampUnit = units.get(key);
const component = event.provenance.component;
source = { component, timestampUnit: timestampUnit ?? (component === "otel" ? "nanoseconds" : "milliseconds"), useProvenanceTimestamp: timestampUnit === undefined && component !== "otel", events: [] };
sources.set(key, source);
}
if (source.component !== "otel" && source.events.at(-1)?.provenance.index > event.provenance.index) throw new Error(`Unified session is not source ordered at record ${index + 1}`);
source.events.push(event);
}
const ordered = sessionOrderingEntries(
[...sources.values()].map(source => ({
...source,
events: source.events.map(event => ({
timestamp: source.useProvenanceTimestamp && !(typeof event.timestamp === "string" && sessionTimestampNs(event) !== undefined) ? event.provenance.timestampMs : (event.timestamp ?? event.provenance.timestampMs),
event,
})),
}))
);
const keys = new Map(ordered.map(({ event, time }) => [event.event, time]));
let previousTime;
let unanchored = false;
for (const [index, event] of events.entries()) {
if (index === 0) continue;
const time = keys.get(event);
if (time === undefined) unanchored = true;
else {
if (untimed || time < previousTime) throw new Error(`Unified session is not timestamp ordered at record ${index + 1}`);
if (unanchored || (previousTime !== undefined && time < previousTime)) throw new Error(`Unified session is not timestamp ordered at record ${index + 1}`);
previousTime = time;
}
}
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/types/unified_session.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ export interface CollectionData {
phase: string;
path: string;
events: SessionCount;
timestampUnit?: "seconds" | "milliseconds";
timestampUnit?: "seconds" | "milliseconds" | "nanoseconds";
}[];
warnings: SessionCount;
untimedEvents: SessionCount;
Expand Down
115 changes: 53 additions & 62 deletions actions/setup/js/unified_session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const { normalizeUnifiedSessionEvent } = require("./unified_session_payload.cjs"
const { ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs");
const { collectAgentExecution, parseAgentExitCode, validateAgentExitCode, isAgentExecutionEvent } = require("./agent_execution.cjs");
const { normalizeEngineLogEntries, normalizeEngineSessionEvents, parseEngineLog } = require("./engine_log_parser.cjs");
const { sessionTimestamp, orderSessionSources } = require("./unified_session_order.cjs");
const { normalizeOtelEvents } = require("./unified_session_otel.cjs");

const SESSION_FILE_FORMAT_VERSION = 1;

Expand All @@ -29,77 +31,56 @@ function isModelEndpointMismatchData(data) {
}

/** @typedef {import("./types/agent_session").SessionEvent} SessionEvent */
/** @typedef {{component: string, phase: string, path: string, events: SessionEvent[], timestampUnit?: "seconds" | "milliseconds"}} SessionSource */
/** @typedef {{component: string, phase: string, path: string, events: SessionEvent[], timestampUnit?: import("./unified_session_order.cjs").TimestampUnit}} SessionSource */

/** @param {unknown} value @returns {boolean} */
const isNativePath = value => typeof value === "string" && /^sandbox\/agent\/logs\/copilot-session-state\/[A-Za-z0-9_-]+\/events\.jsonl$/.test(value);

/**
* Timestamp units come from the source schema, never from the value's magnitude.
* Native timestamps stay unchanged; this value is only an ordering key.
* @param {any} record
* @param {"seconds" | "milliseconds"} [unit]
* @returns {number | undefined}
*/
function sessionTimestamp(record, unit = "milliseconds") {
const value = record.timestamp ?? record.ts ?? record.time ?? record.created_at ?? record.message?.timestamp;
if (typeof value === "number") {
const ms = unit === "seconds" ? value * 1000 : value;
return Number.isFinite(ms) && Math.abs(ms) <= 8640000000000000 ? ms : undefined;
}
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) return undefined;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : undefined;
}

/**
* Source-local positions disambiguate repeated native IDs and expanded records.
* Equal timestamps and untimed observations retain deterministic source order.
* @param {SessionSource[]} sources
* @returns {import("./types/unified_session").UnifiedSession}
*/
function mergeSessionSources(sources) {
const events = sources.flatMap(source =>
source.events.map((event, index) => {
const timestampMs = sessionTimestamp(event, source.timestampUnit);
const normalized = normalizeUnifiedSessionEvent(event, source.phase);
const native = event.provenance;
const sourceIndex =
source.component === "agent" &&
isNativePath(source.path) &&
native &&
typeof native === "object" &&
"path" in native &&
native.path === source.path &&
(!("phase" in native) || native.phase === source.phase) &&
"index" in native &&
typeof native.index === "number" &&
Number.isSafeInteger(native.index) &&
native.index >= 0
? native.index
: index;
if (event.type === "detection.result" && source.path !== "usage/detection/detection_result.json") {
delete normalized.data.reason;
}
return {
...normalized,
provenance: {
component: source.component,
phase: source.phase,
path: source.path,
index: sourceIndex,
...(timestampMs !== undefined ? { timestampMs } : {}),
...(Object.hasOwn(event, "provenance") ? { native: structuredClone(event.provenance) } : {}),
},
};
})
return orderSessionSources(
sources.map(source => ({
...source,
events: source.events.map((event, index) => {
const timestampMs = sessionTimestamp(event, source.timestampUnit);
const normalized = normalizeUnifiedSessionEvent(event, source.phase);
const native = event.provenance;
const sourceIndex =
source.component === "agent" &&
isNativePath(source.path) &&
native &&
typeof native === "object" &&
"path" in native &&
native.path === source.path &&
(!("phase" in native) || native.phase === source.phase) &&
"index" in native &&
typeof native.index === "number" &&
Number.isSafeInteger(native.index) &&
native.index >= 0
? native.index
: index;
if (event.type === "detection.result" && source.path !== "usage/detection/detection_result.json") {
delete normalized.data.reason;
}
return {
...normalized,
provenance: {
component: source.component,
phase: source.phase,
path: source.path,
index: sourceIndex,
...(timestampMs !== undefined ? { timestampMs } : {}),
...(Object.hasOwn(event, "provenance") ? { native: structuredClone(event.provenance) } : {}),
},
};
}),
}))
);
events.sort((left, right) => {
const leftTime = left.provenance.timestampMs ?? Infinity;
const rightTime = right.provenance.timestampMs ?? Infinity;
return leftTime === rightTime ? 0 : leftTime < rightTime ? -1 : 1;
});
return events;
}

/** @param {"mcp" | "firewall"} component @param {any} record @returns {SessionEvent} */
Expand Down Expand Up @@ -133,6 +114,7 @@ function normalizeRuntimeEvent(component, record) {
...(record.timestamp !== undefined ? { timestamp: record.timestamp } : {}),
...(record.ts !== undefined ? { ts: record.ts } : {}),
...(record.time !== undefined ? { time: record.time } : {}),
...(record.created_at !== undefined ? { created_at: record.created_at } : {}),
};
}

Expand Down Expand Up @@ -223,7 +205,7 @@ function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message
* @param {string} component
* @param {string} phase
* @param {`${string}.${string}` | undefined} type
* @param {"seconds" | "milliseconds"} [timestampUnit]
* @param {import("./unified_session_order.cjs").TimestampUnit} [timestampUnit]
*/
const add = (file, component, phase, type, timestampUnit = "milliseconds") => {
if (!exists(file)) return 0;
Expand All @@ -237,14 +219,21 @@ function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message
report(file, "non_object_record", undefined);
continue;
}
if (component === "agent") {
if (component === "otel") {
events.push(
...normalizeOtelEvents(record, code => {
incompleteSources.add(file);
report(file, code, undefined);
})
);
} else if (component === "agent") {
if (isSessionEvent(record)) events.push(record);
else if (!hasLegacyContent || normalizeEngineLogEntries([record], engine ?? "custom").length === 0) {
incompleteSources.add(file);
report(file, "non_canonical_agent_event", undefined);
}
} else if (type) {
events.push({ type, data: record, ...(record.timestamp !== undefined ? { timestamp: record.timestamp } : {}), ...(record.created_at !== undefined ? { created_at: record.created_at } : {}) });
events.push({ type, data: record, ...Object.fromEntries(["timestamp", "ts", "time", "created_at"].filter(key => record[key] !== undefined).map(key => [key, record[key]])) });
} else if (component === "mcp" || component === "firewall") events.push(normalizeRuntimeEvent(component, record));
else throw new Error(`${ERR_VALIDATION}: Missing event mapping for ${component}`);
}
Expand Down Expand Up @@ -434,6 +423,8 @@ function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message
["", "agent"],
["threat-detection/", "detection"],
]) {
const otel = choose([`${prefix}otel.jsonl`, `usage/${prefix}otel.jsonl`]);
if (otel) add(otel, "otel", phase, undefined, "nanoseconds");
for (const file of walk(path.join(rootDir, prefix, "mcp-logs"))) add(file, "mcp", phase, undefined);
const seen = new Set();
for (const layout of ["sandbox/firewall/logs", "sandbox/firewall/audit", "sandbox/firewall-audit-logs", "firewall-logs", "firewall-audit-logs"]) {
Expand Down
12 changes: 6 additions & 6 deletions actions/setup/js/unified_session.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -604,10 +604,10 @@ describe("Unified conclusion session", () => {
];
const original = structuredClone(sources);
const events = mergeSessionSources(sources);
expect(events.map(event => event.data.content)).toEqual(["", "second", undefined, undefined]);
expect(events.at(-1).provenance).toMatchObject({ native: { vendor: 1 }, index: 0 });
expect(events.at(-1).timestamp).toBe("invalid");
expect(events.at(-1).provenance).not.toHaveProperty("timestampMs");
expect(events.map(event => event.data.content)).toEqual([undefined, "", "second", undefined]);
expect(events[0].provenance).toMatchObject({ native: { vendor: 1 }, index: 0 });
expect(events[0].timestamp).toBe("invalid");
expect(events[0].provenance).not.toHaveProperty("timestampMs");
expect(sources).toEqual(original);
expect(mergeSessionSources(sources)).toEqual(events);
});
Expand Down Expand Up @@ -1058,9 +1058,9 @@ describe("Unified conclusion session", () => {
{ type: "assistant.message", timestamp: "2026-10-02T00:00:01Z", data: { content: "first timed event" } },
]);
const events = writeUnifiedSession({ rootDir: root });
expect(events.map(event => event.type)).toEqual(["session.format", "assistant.message", "session.format", "session.collection"]);
expect(events.map(event => event.type)).toEqual(["session.format", "session.format", "assistant.message", "session.collection"]);
expect(events[0].data.version).toBe(1);
expect(events[2]).toMatchObject({ data: { version: "native-engine-format" }, provenance: { component: "agent", index: 0 } });
expect(events[1]).toMatchObject({ data: { version: "native-engine-format" }, provenance: { component: "agent", index: 0 } });
});

it("fails explicitly on read errors and removes a stale output rather than uploading it", () => {
Expand Down
74 changes: 74 additions & 0 deletions actions/setup/js/unified_session_order.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @ts-check

/** @typedef {"seconds" | "milliseconds" | "nanoseconds"} TimestampUnit */

/**
* Source schemas, not timestamp magnitude, determine numeric units.
* Keep nanoseconds as integers until producing the display-only millisecond key.
* @param {any} record
* @param {TimestampUnit} [unit]
* @returns {bigint | undefined}
*/
function sessionTimestampNs(record, unit = "milliseconds") {
const value = record.timestamp ?? record.ts ?? record.time ?? record.created_at ?? record.message?.timestamp;
if (unit === "nanoseconds") {
if (typeof value !== "string" || !/^\d{1,22}$/.test(value)) return undefined;
const ns = BigInt(value);
return ns <= 8640000000000000000000n ? ns : undefined;
}
if (typeof value === "number") {
const ms = unit === "seconds" ? value * 1000 : value;
if (!Number.isFinite(ms) || Math.abs(ms) > 8640000000000000) return undefined;
const whole = Math.trunc(ms);
return BigInt(whole) * 1000000n + BigInt(Math.round((ms - whole) * 1000000));
}
if (typeof value !== "string") return undefined;
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/i.exec(value);
if (!match) return undefined;
const ms = Date.parse(`${match[1]}${match[3]}`);
return Number.isFinite(ms) ? BigInt(ms) * 1000000n + BigInt((match[2] ?? "").padEnd(9, "0").slice(0, 9)) : undefined;
}

/** @param {any} record @param {TimestampUnit} [unit] @returns {number | undefined} */
function sessionTimestamp(record, unit = "milliseconds") {
const ns = sessionTimestampNs(record, unit);
return ns === undefined ? undefined : Number(ns / 1000000n) + Number(ns % 1000000n) / 1000000;
}

/**
* Missing clocks inherit an ordering anchor, never an observed timestamp.
* Clock regressions cannot reverse a sequential source. OTLP exports are
* unordered batches of spans/logs, rather than an execution sequence.
* @template T
* @param {Array<{component: string, events: T[], timestampUnit?: TimestampUnit}>} sources
* @returns {Array<{event: T, time: bigint | undefined}>}
*/
function sessionOrderingEntries(sources) {
return sources.flatMap(source => {
const times = source.events.map(event => sessionTimestampNs(event, source.timestampUnit));
let anchor = times.find(time => time !== undefined);
return source.events.map((event, index) => {
const time = times[index];
if (time !== undefined && (anchor === undefined || time > anchor)) anchor = time;
return { event, time: source.component === "otel" ? time : anchor };
});
});
}

/**
* @template T
* @param {Array<{component: string, events: T[], timestampUnit?: TimestampUnit}>} sources
* @returns {T[]}
*/
function orderSessionSources(sources) {
const entries = sessionOrderingEntries(sources);
entries.sort((left, right) => {
if (left.time === right.time) return 0;
if (left.time === undefined) return 1;
if (right.time === undefined) return -1;
return left.time < right.time ? -1 : 1;
});
return entries.map(entry => entry.event);
}

module.exports = { sessionTimestamp, sessionTimestampNs, sessionOrderingEntries, orderSessionSources };
Loading
Loading