feat(coder,server): audit engine — session audit, guideline compliance, user correction tracking
Implements audit-harness-inspired session lifecycle: audit session creation/end/recover/report-daily with JSONL buffer and graded context recovery (L0-L4). Guideline service for behavioral compliance rules (condition/action model with criticality). Correction service for persistent user correction tracking across agent sessions. 8 supporting skills: audit-start/end/report-daily/recover + command variants for slash-command integration.
This commit is contained in:
189
apps/server/src/services/audit/journey-projection.ts
Normal file
189
apps/server/src/services/audit/journey-projection.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
Journey,
|
||||
JourneyNode,
|
||||
JourneyEdge,
|
||||
JourneyNodeId,
|
||||
JourneyEdgeId,
|
||||
} from './journey-store.js';
|
||||
import type { Guideline, GuidelineId, Criticality } from './guideline-store.js';
|
||||
|
||||
export interface ProjectedGuideline {
|
||||
id: GuidelineId;
|
||||
content: {
|
||||
condition: string;
|
||||
action: string | null;
|
||||
description: string | null;
|
||||
};
|
||||
criticality: Criticality;
|
||||
creationUtc: string;
|
||||
enabled: boolean;
|
||||
tags: string[];
|
||||
labels: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function formatNodeGuidelineId(nodeId: JourneyNodeId, edgeId?: JourneyEdgeId | null): GuidelineId {
|
||||
return `journey_node:${nodeId}${edgeId ? `:${edgeId}` : ''}` as GuidelineId;
|
||||
}
|
||||
|
||||
export function projectJourneyToGuidelines(
|
||||
journey: Journey,
|
||||
nodes: JourneyNode[],
|
||||
edges: JourneyEdge[],
|
||||
): ProjectedGuideline[] {
|
||||
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
||||
for (const n of nodes) nodeMap.set(n.id, n);
|
||||
|
||||
const edgeMap = new Map<JourneyEdgeId, JourneyEdge>();
|
||||
for (const e of edges) edgeMap.set(e.id, e);
|
||||
|
||||
const nodeEdges = new Map<JourneyNodeId, JourneyEdge[]>();
|
||||
for (const e of edges) {
|
||||
const list = nodeEdges.get(e.source) || [];
|
||||
list.push(e);
|
||||
nodeEdges.set(e.source, list);
|
||||
}
|
||||
|
||||
const guidelines: Map<GuidelineId, ProjectedGuideline> = new Map();
|
||||
const nodeIndexes = new Map<JourneyNodeId, number>();
|
||||
let index = 0;
|
||||
|
||||
const queue: Array<{ edgeId: JourneyEdgeId | null; nodeId: JourneyNodeId }> = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
queue.push({ edgeId: null, nodeId: journey.rootId });
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { edgeId, nodeId } = queue.shift()!;
|
||||
const visitKey = `${edgeId || ''}:${nodeId}`;
|
||||
if (visited.has(visitKey)) continue;
|
||||
visited.add(visitKey);
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
if (!nodeIndexes.has(nodeId)) {
|
||||
index++;
|
||||
nodeIndexes.set(nodeId, index);
|
||||
}
|
||||
|
||||
const edge = edgeId ? edgeMap.get(edgeId) : undefined;
|
||||
|
||||
const baseJourneyNode: Record<string, unknown> = {
|
||||
follow_ups: [],
|
||||
index: String(nodeIndexes.get(nodeId)),
|
||||
journey_id: journey.id,
|
||||
labels: node.labels,
|
||||
tool_ids: node.tools,
|
||||
};
|
||||
|
||||
const edgeJourneyNode = (edge?.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
||||
const nodeJourneyNode = (node.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
||||
|
||||
const mergedJourneyNode = { ...baseJourneyNode, ...nodeJourneyNode, ...edgeJourneyNode };
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
journey_node: mergedJourneyNode,
|
||||
};
|
||||
for (const [k, v] of Object.entries(node.metadata)) {
|
||||
if (k !== 'journey_node') metadata[k] = v;
|
||||
}
|
||||
if (edge) {
|
||||
for (const [k, v] of Object.entries(edge.metadata)) {
|
||||
if (k !== 'journey_node') metadata[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const gid = formatNodeGuidelineId(nodeId, edgeId);
|
||||
const guideline: ProjectedGuideline = {
|
||||
id: gid,
|
||||
content: {
|
||||
condition: (edge?.condition) || '',
|
||||
action: node.action,
|
||||
description: node.description,
|
||||
},
|
||||
criticality: 'high' as Criticality,
|
||||
creationUtc: new Date().toISOString(),
|
||||
enabled: true,
|
||||
tags: journey.tags,
|
||||
labels: [...(node.labels || [])],
|
||||
metadata,
|
||||
};
|
||||
|
||||
guidelines.set(gid, guideline);
|
||||
|
||||
const childEdges = nodeEdges.get(nodeId) || [];
|
||||
for (const childEdge of childEdges) {
|
||||
if (visited.has(`${childEdge.id}:${childEdge.target}`)) continue;
|
||||
queue.push({ edgeId: childEdge.id, nodeId: childEdge.target });
|
||||
|
||||
const childGid = formatNodeGuidelineId(childEdge.target, childEdge.id);
|
||||
const followUps = (guideline.metadata['journey_node'] as Record<string, unknown>)['follow_ups'] as string[];
|
||||
if (!followUps.includes(childGid)) {
|
||||
followUps.push(childGid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...guidelines.values()];
|
||||
}
|
||||
|
||||
export interface BacktrackCheck {
|
||||
journeyId: string;
|
||||
currentNodeId: JourneyNodeId;
|
||||
previousNodeId: JourneyNodeId;
|
||||
isBacktrack: boolean;
|
||||
recommendation: string | null;
|
||||
}
|
||||
|
||||
export function detectJourneyBacktrack(
|
||||
journey: Journey,
|
||||
nodes: JourneyNode[],
|
||||
edges: JourneyEdge[],
|
||||
currentNodeId: JourneyNodeId,
|
||||
previousNodeId: JourneyNodeId,
|
||||
): BacktrackCheck {
|
||||
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
||||
for (const n of nodes) nodeMap.set(n.id, n);
|
||||
|
||||
const adjacency = new Map<JourneyNodeId, JourneyNodeId[]>();
|
||||
for (const e of edges) {
|
||||
const list = adjacency.get(e.source) || [];
|
||||
list.push(e.target);
|
||||
adjacency.set(e.source, list);
|
||||
}
|
||||
|
||||
const isInForwardPath = (from: JourneyNodeId, target: JourneyNodeId): boolean => {
|
||||
const visitedInner = new Set<JourneyNodeId>();
|
||||
const queueInner: JourneyNodeId[] = [from];
|
||||
while (queueInner.length > 0) {
|
||||
const current = queueInner.shift()!;
|
||||
if (current === target) return true;
|
||||
if (visitedInner.has(current)) continue;
|
||||
visitedInner.add(current);
|
||||
for (const next of adjacency.get(current) || []) {
|
||||
if (!visitedInner.has(next)) queueInner.push(next);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fromCurToPrev = isInForwardPath(currentNodeId, previousNodeId);
|
||||
const fromPrevToCur = isInForwardPath(previousNodeId, currentNodeId);
|
||||
|
||||
const isBacktrack = !fromPrevToCur && !fromCurToPrev;
|
||||
|
||||
let recommendation: string | null = null;
|
||||
if (isBacktrack && nodeMap.has(previousNodeId)) {
|
||||
const prevNode = nodeMap.get(previousNodeId)!;
|
||||
recommendation = `Detected potential backtrack from '${currentNodeId}' to '${previousNodeId}' (${prevNode.action || 'no action'}). Consider whether this regression is intentional.`;
|
||||
}
|
||||
|
||||
return {
|
||||
journeyId: journey.id,
|
||||
currentNodeId,
|
||||
previousNodeId,
|
||||
isBacktrack,
|
||||
recommendation,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user