blob: e23b8282ba119c2ee9f69d3c32517b046cadbb05 [file]
import { stableHash } from './request-shape.js';
import { compareAgentGraphIdentity } from './stream-graph-identity.js';
import {
replayAgentGraphRecords,
type AgentGraphOperatorBinding,
type AgentGraphOperatorState,
type AgentGraphRecord,
} from './stream-graph-projection.js';
export const AGENT_GRAPH_TRACE_SCHEMA_VERSION = 1 as const;
/**
* One directed record path between two existing operator bindings.
*
* Edges deliberately do not own readiness policy. Every committed source
* record is visible on every direct outgoing edge; a later operator adapter
* can decide locally which inputs make one activation runnable.
*/
export interface AgentGraphTraceEdge {
edgeId: string;
fromOperatorId: string;
toOperatorId: string;
}
/**
* Read-only DAG topology for a trace snapshot.
*
* The topology identifies existing Session-backed operators. It does not
* create, start, stop, or otherwise own those Sessions.
*/
export interface AgentGraphTraceTopology {
graphId: string;
operators: readonly AgentGraphOperatorBinding[];
edges: readonly AgentGraphTraceEdge[];
}
/**
* Reference-only observation that one committed graph record is visible to a
* direct downstream operator.
*/
export interface AgentGraphTraceRoute {
schemaVersion: typeof AGENT_GRAPH_TRACE_SCHEMA_VERSION;
routeId: string;
graphId: string;
edgeId: string;
sourceOperatorId: string;
targetOperatorId: string;
sourceActivationId: string;
sourceRecordId: string;
eventTime: number;
}
export interface AgentGraphTraceEdgeState extends AgentGraphTraceEdge {
routeIds: string[];
sourceRecordIds: string[];
}
export interface AgentGraphTraceOperatorState {
operatorId: string;
sessionId: string;
topologicalIndex: number;
upstreamOperatorIds: string[];
downstreamOperatorIds: string[];
emittedRecordIds: string[];
receivedRouteIds: string[];
runtimeState?: AgentGraphOperatorState;
}
/**
* Deterministic, trace-only materialization of topology plus committed facts.
*
* This snapshot has no graph-wide completion or runnable state. Those require
* explicit admission/closure and operator-readiness protocols in later slices.
*/
export interface AgentGraphTraceSnapshot {
schemaVersion: typeof AGENT_GRAPH_TRACE_SCHEMA_VERSION;
graphId: string;
topologyFingerprint: string;
topologicalOrder: string[];
rootOperatorIds: string[];
sinkOperatorIds: string[];
recordIds: string[];
operators: Record<string, AgentGraphTraceOperatorState>;
edges: Record<string, AgentGraphTraceEdgeState>;
routes: AgentGraphTraceRoute[];
}
export interface BuildAgentGraphTraceSnapshotInput {
topology: AgentGraphTraceTopology;
records: readonly AgentGraphRecord[];
}
interface ValidatedTopology {
operatorsById: Map<string, AgentGraphOperatorBinding>;
edges: AgentGraphTraceEdge[];
incoming: Map<string, AgentGraphTraceEdge[]>;
outgoing: Map<string, AgentGraphTraceEdge[]>;
topologicalOrder: string[];
}
export function buildAgentGraphTraceSnapshot(
input: BuildAgentGraphTraceSnapshotInput,
): AgentGraphTraceSnapshot {
const validated = validateTopology(input.topology);
const topologyFingerprint = fingerprintTopology(input.topology.graphId, validated);
const replay =
input.records.length > 0
? replayAgentGraphRecords(input.records)
: {
graphId: input.topology.graphId,
appliedRecordIds: [],
operators: {},
};
if (replay.graphId !== input.topology.graphId) {
throw new Error(
`Trace topology ${input.topology.graphId} cannot observe records from graph ${replay.graphId}`,
);
}
const recordsById = new Map(input.records.map((record) => [record.recordId, record]));
const orderedRecords = replay.appliedRecordIds.map((recordId) => recordsById.get(recordId)!);
for (const record of orderedRecords) {
const binding = validated.operatorsById.get(record.operatorId);
if (!binding) {
throw new Error(
`Graph record ${record.recordId} references unknown topology operator ${record.operatorId}`,
);
}
if (binding.sessionId !== record.sessionId) {
throw new Error(
`Topology operator ${record.operatorId} is bound to ${binding.sessionId}, record uses ${record.sessionId}`,
);
}
}
const topologicalIndex = new Map(
validated.topologicalOrder.map((operatorId, index) => [operatorId, index]),
);
const compareOperators = (a: string, b: string): number =>
topologicalIndex.get(a)! - topologicalIndex.get(b)! || compareAgentGraphIdentity(a, b);
const replayOperators = new Map(Object.entries(replay.operators));
const operators = new Map<string, AgentGraphTraceOperatorState>();
for (const operatorId of validated.topologicalOrder) {
const binding = validated.operatorsById.get(operatorId)!;
const runtimeState = replayOperators.get(operatorId);
operators.set(operatorId, {
operatorId,
sessionId: binding.sessionId,
topologicalIndex: topologicalIndex.get(operatorId)!,
upstreamOperatorIds: uniqueOperatorIds(
(validated.incoming.get(operatorId) ?? []).map((edge) => edge.fromOperatorId),
compareOperators,
),
downstreamOperatorIds: uniqueOperatorIds(
(validated.outgoing.get(operatorId) ?? []).map((edge) => edge.toOperatorId),
compareOperators,
),
emittedRecordIds: [],
receivedRouteIds: [],
...(runtimeState ? { runtimeState: cloneOperatorState(runtimeState) } : {}),
});
}
const edges = new Map<string, AgentGraphTraceEdgeState>();
for (const edge of validated.edges) {
edges.set(edge.edgeId, {
...edge,
routeIds: [],
sourceRecordIds: [],
});
}
const routes: AgentGraphTraceRoute[] = [];
for (const record of orderedRecords) {
operators.get(record.operatorId)!.emittedRecordIds.push(record.recordId);
const outgoing = [...(validated.outgoing.get(record.operatorId) ?? [])].sort(
(a, b) =>
compareOperators(a.toOperatorId, b.toOperatorId) ||
compareAgentGraphIdentity(a.edgeId, b.edgeId),
);
for (const edge of outgoing) {
const route: AgentGraphTraceRoute = {
schemaVersion: AGENT_GRAPH_TRACE_SCHEMA_VERSION,
routeId: traceRouteId(input.topology.graphId, edge, record.recordId),
graphId: input.topology.graphId,
edgeId: edge.edgeId,
sourceOperatorId: edge.fromOperatorId,
targetOperatorId: edge.toOperatorId,
sourceActivationId: record.activationId,
sourceRecordId: record.recordId,
eventTime: record.eventTime,
};
routes.push(route);
edges.get(edge.edgeId)!.routeIds.push(route.routeId);
edges.get(edge.edgeId)!.sourceRecordIds.push(record.recordId);
operators.get(edge.toOperatorId)!.receivedRouteIds.push(route.routeId);
}
}
return {
schemaVersion: AGENT_GRAPH_TRACE_SCHEMA_VERSION,
graphId: input.topology.graphId,
topologyFingerprint,
topologicalOrder: [...validated.topologicalOrder],
rootOperatorIds: validated.topologicalOrder.filter(
(operatorId) => (validated.incoming.get(operatorId) ?? []).length === 0,
),
sinkOperatorIds: validated.topologicalOrder.filter(
(operatorId) => (validated.outgoing.get(operatorId) ?? []).length === 0,
),
recordIds: replay.appliedRecordIds,
operators: Object.fromEntries(operators),
edges: Object.fromEntries(edges),
routes,
};
}
function fingerprintTopology(graphId: string, topology: ValidatedTopology): string {
return stableHash({
schemaVersion: AGENT_GRAPH_TRACE_SCHEMA_VERSION,
graphId,
operators: [...topology.operatorsById.values()]
.map(({ operatorId, sessionId }) => ({ operatorId, sessionId }))
.sort((a, b) => compareAgentGraphIdentity(a.operatorId, b.operatorId)),
edges: topology.edges
.map(({ edgeId, fromOperatorId, toOperatorId }) => ({
edgeId,
fromOperatorId,
toOperatorId,
}))
.sort(compareEdges),
});
}
function validateTopology(topology: AgentGraphTraceTopology): ValidatedTopology {
if (!topology.graphId.trim()) throw new Error('Trace graph id must not be empty');
const operatorsById = new Map<string, AgentGraphOperatorBinding>();
const operatorBySession = new Map<string, string>();
for (const operator of topology.operators) {
if (!operator.operatorId.trim()) throw new Error('Trace operator id must not be empty');
if (!operator.sessionId.trim()) throw new Error('Trace operator session id must not be empty');
if (operatorsById.has(operator.operatorId)) {
throw new Error(`Duplicate trace operator ${operator.operatorId}`);
}
const sessionOwner = operatorBySession.get(operator.sessionId);
if (sessionOwner) {
throw new Error(
`Session ${operator.sessionId} is bound to trace operators ${sessionOwner} and ${operator.operatorId}`,
);
}
operatorsById.set(operator.operatorId, {
operatorId: operator.operatorId,
sessionId: operator.sessionId,
});
operatorBySession.set(operator.sessionId, operator.operatorId);
}
const edgeIds = new Set<string>();
const endpointPairs = new Set<string>();
const incoming = new Map<string, AgentGraphTraceEdge[]>();
const outgoing = new Map<string, AgentGraphTraceEdge[]>();
const edges = topology.edges
.map(({ edgeId, fromOperatorId, toOperatorId }) => ({
edgeId,
fromOperatorId,
toOperatorId,
}))
.sort(compareEdges);
for (const edge of edges) {
if (!edge.edgeId.trim()) throw new Error('Trace edge id must not be empty');
if (edgeIds.has(edge.edgeId)) throw new Error(`Duplicate trace edge ${edge.edgeId}`);
edgeIds.add(edge.edgeId);
if (!operatorsById.has(edge.fromOperatorId)) {
throw new Error(`Trace edge ${edge.edgeId} has unknown source ${edge.fromOperatorId}`);
}
if (!operatorsById.has(edge.toOperatorId)) {
throw new Error(`Trace edge ${edge.edgeId} has unknown target ${edge.toOperatorId}`);
}
if (edge.fromOperatorId === edge.toOperatorId) {
throw new Error(`Trace edge ${edge.edgeId} cannot be a self-loop`);
}
const endpointPair = `${edge.fromOperatorId}\0${edge.toOperatorId}`;
if (endpointPairs.has(endpointPair)) {
throw new Error(
`Trace graph has multiple edges from ${edge.fromOperatorId} to ${edge.toOperatorId}`,
);
}
endpointPairs.add(endpointPair);
addEdge(outgoing, edge.fromOperatorId, edge);
addEdge(incoming, edge.toOperatorId, edge);
}
const topologicalOrder = topologicalSort(operatorsById, outgoing, incoming);
return { operatorsById, edges, incoming, outgoing, topologicalOrder };
}
function topologicalSort(
operatorsById: ReadonlyMap<string, AgentGraphOperatorBinding>,
outgoing: ReadonlyMap<string, readonly AgentGraphTraceEdge[]>,
incoming: ReadonlyMap<string, readonly AgentGraphTraceEdge[]>,
): string[] {
const remainingIncoming = new Map(
[...operatorsById.keys()].map((operatorId) => [
operatorId,
(incoming.get(operatorId) ?? []).length,
]),
);
const ready = [...operatorsById.keys()]
.filter((operatorId) => remainingIncoming.get(operatorId) === 0)
.sort(compareAgentGraphIdentity);
const ordered: string[] = [];
while (ready.length > 0) {
const operatorId = ready.shift()!;
ordered.push(operatorId);
for (const edge of [...(outgoing.get(operatorId) ?? [])].sort(compareEdges)) {
const remaining = remainingIncoming.get(edge.toOperatorId)! - 1;
remainingIncoming.set(edge.toOperatorId, remaining);
if (remaining === 0) {
insertSorted(ready, edge.toOperatorId);
}
}
}
if (ordered.length !== operatorsById.size) {
const cyclic = [...operatorsById.keys()]
.filter((operatorId) => !ordered.includes(operatorId))
.sort(compareAgentGraphIdentity);
throw new Error(`Trace graph contains a cycle involving: ${cyclic.join(', ')}`);
}
return ordered;
}
function addEdge(
index: Map<string, AgentGraphTraceEdge[]>,
operatorId: string,
edge: AgentGraphTraceEdge,
): void {
const edges = index.get(operatorId) ?? [];
edges.push(edge);
index.set(operatorId, edges);
}
function uniqueOperatorIds(
operatorIds: readonly string[],
compare: (a: string, b: string) => number,
): string[] {
return [...new Set(operatorIds)].sort(compare);
}
function compareEdges(a: AgentGraphTraceEdge, b: AgentGraphTraceEdge): number {
return (
compareAgentGraphIdentity(a.fromOperatorId, b.fromOperatorId) ||
compareAgentGraphIdentity(a.toOperatorId, b.toOperatorId) ||
compareAgentGraphIdentity(a.edgeId, b.edgeId)
);
}
function insertSorted(values: string[], value: string): void {
const index = values.findIndex((candidate) => compareAgentGraphIdentity(value, candidate) < 0);
if (index === -1) values.push(value);
else values.splice(index, 0, value);
}
function traceRouteId(graphId: string, edge: AgentGraphTraceEdge, sourceRecordId: string): string {
const hash = stableHash({
schemaVersion: AGENT_GRAPH_TRACE_SCHEMA_VERSION,
graphId,
edgeId: edge.edgeId,
fromOperatorId: edge.fromOperatorId,
toOperatorId: edge.toOperatorId,
sourceRecordId,
});
return `graph_route_${hash.slice('sha256:'.length, 'sha256:'.length + 32)}`;
}
function cloneOperatorState(state: AgentGraphOperatorState): AgentGraphOperatorState {
return {
...state,
activations: Object.fromEntries(
Object.entries(state.activations).map(([activationId, activation]) => [
activationId,
{ ...activation },
]),
),
};
}