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:
236
apps/server/src/services/audit/session-manager.ts
Normal file
236
apps/server/src/services/audit/session-manager.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
ensureRunsDir,
|
||||
readCurrentSession,
|
||||
writeCurrentSession,
|
||||
clearCurrentSession,
|
||||
updateIndexEntry,
|
||||
findInProgressSessions,
|
||||
readIndex,
|
||||
type IndexEntry,
|
||||
} from './runs-dir.js';
|
||||
|
||||
export interface SessionJson {
|
||||
session_id: string;
|
||||
task: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
status: 'in_progress' | 'completed';
|
||||
expected_record_types?: string[];
|
||||
total_records?: number;
|
||||
}
|
||||
|
||||
export function generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const min = String(now.getMinutes()).padStart(2, '0');
|
||||
return `adhoc_${y}${m}${d}_${h}${min}`;
|
||||
}
|
||||
|
||||
export function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
task: string,
|
||||
sessionId?: string,
|
||||
projectRoot?: string,
|
||||
): string {
|
||||
const sid = sessionId || generateSessionId();
|
||||
const runsDir = ensureRunsDir(projectRoot);
|
||||
const sessionDir = join(runsDir, sid);
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const session: SessionJson = {
|
||||
session_id: sid,
|
||||
task,
|
||||
start_time: isoNow(),
|
||||
status: 'in_progress',
|
||||
expected_record_types: ['data', 'change', 'conversation'],
|
||||
};
|
||||
|
||||
writeFileSync(join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), 'utf-8');
|
||||
writeCurrentSession(sid, projectRoot);
|
||||
|
||||
updateIndexEntry({
|
||||
id: sid,
|
||||
type: 'adhoc',
|
||||
status: 'in_progress',
|
||||
task,
|
||||
created: session.start_time,
|
||||
last_updated: session.start_time,
|
||||
}, projectRoot);
|
||||
|
||||
return sid;
|
||||
}
|
||||
|
||||
export function getSessionDir(sessionId: string, projectRoot?: string): string {
|
||||
return join(ensureRunsDir(projectRoot), sessionId);
|
||||
}
|
||||
|
||||
export function getActiveSession(projectRoot?: string): SessionJson | null {
|
||||
const sid = readCurrentSession(projectRoot);
|
||||
if (!sid) return null;
|
||||
return readSession(sid, projectRoot);
|
||||
}
|
||||
|
||||
export function readSession(sessionId: string, projectRoot?: string): SessionJson | null {
|
||||
const path = join(getSessionDir(sessionId, projectRoot), 'session.json');
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as SessionJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSession(
|
||||
sessionId: string,
|
||||
updates: Partial<SessionJson>,
|
||||
projectRoot?: string,
|
||||
): void {
|
||||
const session = readSession(sessionId, projectRoot) || { session_id: sessionId, task: '', start_time: isoNow(), status: 'in_progress' as const };
|
||||
Object.assign(session, updates);
|
||||
writeFileSync(
|
||||
join(getSessionDir(sessionId, projectRoot), 'session.json'),
|
||||
JSON.stringify(session, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
export function endSession(sessionId: string, projectRoot?: string): void {
|
||||
updateSession(sessionId, { status: 'completed', end_time: isoNow() }, projectRoot);
|
||||
updateIndexEntry({ id: sessionId, type: 'adhoc', status: 'completed', last_updated: isoNow() }, projectRoot);
|
||||
clearCurrentSession(projectRoot);
|
||||
}
|
||||
|
||||
export function appendToTrail(sessionId: string, records: Record<string, unknown>[], projectRoot?: string): void {
|
||||
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
||||
appendFileSync(trailPath, lines, 'utf-8');
|
||||
}
|
||||
|
||||
export function readTrail(sessionId: string, projectRoot?: string): Record<string, unknown>[] {
|
||||
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||
try {
|
||||
const content = readFileSync(trailPath, 'utf-8').trim();
|
||||
if (!content) return [];
|
||||
return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecoverySummary {
|
||||
sessionId: string;
|
||||
task: string;
|
||||
recentActivity: IndexEntry[];
|
||||
userCorrections: Record<string, unknown>[];
|
||||
unresolvedIssues: string[];
|
||||
recommendedPriorities: string[];
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function recoverContext(
|
||||
sessionId: string,
|
||||
level: number,
|
||||
projectRoot?: string,
|
||||
): RecoverySummary {
|
||||
const session = readSession(sessionId, projectRoot);
|
||||
const idx = readIndex(projectRoot);
|
||||
const recentActivity = idx.entries.slice(-5);
|
||||
const trail = readTrail(sessionId, projectRoot);
|
||||
const userCorrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||
|
||||
const summary: RecoverySummary = {
|
||||
sessionId,
|
||||
task: session?.task || '(unknown)',
|
||||
recentActivity,
|
||||
userCorrections,
|
||||
unresolvedIssues: [],
|
||||
recommendedPriorities: [],
|
||||
level,
|
||||
};
|
||||
|
||||
if (level >= 1) {
|
||||
const last = trail.slice(-3);
|
||||
if (last.length > 0) {
|
||||
summary.recommendedPriorities.push(`Last action: ${JSON.stringify(last[last.length - 1]?.['action'] || 'none')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= 3) {
|
||||
summary.recommendedPriorities.push(`Full trail: ${trail.length} records`);
|
||||
}
|
||||
|
||||
let checkCount = 0;
|
||||
for (const entry of recentActivity) {
|
||||
if (entry.status === 'in_progress' && entry.id !== sessionId) {
|
||||
summary.unresolvedIssues.push(`Unfinished session: ${entry.id} (${entry.task || 'no task'})`);
|
||||
checkCount++;
|
||||
if (checkCount >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function checkUnfinishedSessions(projectRoot?: string): IndexEntry[] {
|
||||
return findInProgressSessions(projectRoot);
|
||||
}
|
||||
|
||||
export function generateSessionSummary(sessionId: string, projectRoot?: string): string {
|
||||
const session = readSession(sessionId, projectRoot);
|
||||
const trail = readTrail(sessionId, projectRoot);
|
||||
const corrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||
const changes = trail.filter(r => r['action'] === 'edit_file' || r['action'] === 'create_file' || r['action'] === 'delete_file');
|
||||
|
||||
const lines: string[] = [
|
||||
`# Session Summary | ${sessionId}`,
|
||||
'',
|
||||
`## Task: ${session?.task || '(unknown)'}`,
|
||||
`## Time: ${session?.start_time || '?'} → ${session?.end_time || 'in_progress'}`,
|
||||
`## Status: ${session?.status || 'unknown'}`,
|
||||
'',
|
||||
'## Completed Work',
|
||||
];
|
||||
|
||||
for (const r of trail) {
|
||||
if (r['action']) {
|
||||
lines.push(`- ${r['action']}: ${r['detail'] || r['reason'] || '(no detail)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (corrections.length > 0) {
|
||||
lines.push('', '## User Corrections');
|
||||
for (const c of corrections) {
|
||||
lines.push(`- Original: ${c['original_claim']}`);
|
||||
lines.push(` Correction: ${c['correction']}`);
|
||||
if (c['principle_extracted']) {
|
||||
lines.push(` Principle: ${c['principle_extracted']}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
lines.push('', '## Files Changed');
|
||||
const fileSet = new Set<string>();
|
||||
for (const c of changes) {
|
||||
const files = c['files'];
|
||||
if (Array.isArray(files)) {
|
||||
for (const f of files) fileSet.add(String(f));
|
||||
}
|
||||
}
|
||||
for (const f of fileSet) lines.push(`- ${f}`);
|
||||
}
|
||||
|
||||
lines.push('', '## Stats');
|
||||
lines.push(`- Total records: ${trail.length}`);
|
||||
lines.push(`- Corrections: ${corrections.length}`);
|
||||
lines.push(`- File changes: ${changes.length}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user