UNPKG

arela

Version:

AI-powered CTO with multi-agent orchestration, code summarization, visual testing (web + mobile) for blazing fast development.

221 lines 7.73 kB
import path from "node:path"; import fs from "fs-extra"; import Database from "better-sqlite3"; import { AuditMemory } from "./audit.js"; /** * GovernanceMemory (Hexi-006) * * Thin wrapper over the existing audit log database (`.arela/memory/audit.db`) * that exposes a higher-level query surface for governance / historical queries. * * NOTE: This does not modify the underlying audit schema; it simply reads from * the existing `audit_log` table created by `AuditMemory`. */ export class GovernanceMemory { cwd; dbPath; constructor(cwd = process.cwd()) { this.cwd = cwd; } /** * Initialize governance memory for a project path. * Ensures the underlying audit database exists and schema is ready. */ async init(projectPath) { const base = projectPath || this.cwd; this.dbPath = path.join(base, ".arela", "memory", "audit.db"); // Ensure directory exists await fs.ensureDir(path.dirname(this.dbPath)); // Reuse AuditMemory to initialize schema without changing its behaviour. const audit = new AuditMemory(base); await audit.init(); } /** * Get raw events from the audit log with optional filtering. * Filtering is done in memory for simplicity and to keep schema-agnostic. */ async getEvents(filters) { const db = this.openDb(); try { const rows = db .prepare(` SELECT id, timestamp, agent, action, result, metadata, commit_hash, ticket_id, policy_violations FROM audit_log ORDER BY timestamp DESC `) .all(); let events = rows.map((row) => this.rowToEvent(row)); if (filters?.type) { events = events.filter((e) => e.type === filters.type); } if (filters?.agent) { events = events.filter((e) => e.agent === filters.agent); } if (typeof filters?.startDate === "number") { events = events.filter((e) => e.timestamp >= filters.startDate); } if (typeof filters?.endDate === "number") { events = events.filter((e) => e.timestamp <= filters.endDate); } if (typeof filters?.limit === "number" && filters.limit > 0) { events = events.slice(0, filters.limit); } return events; } finally { db.close(); } } async getEventsByType(type) { return this.getEvents({ type }); } async getEventsByAgent(agent) { return this.getEvents({ agent }); } async getRecentEvents(limit) { return this.getEvents({ limit }); } /** * Governance decisions derived from audit events with type === "decision". * The event's metadata is expected to carry decision-specific fields. */ async getDecisions() { const events = await this.getEvents({ type: "decision" }); return events.map((event) => { const data = event.data ?? {}; return { id: String(data.id ?? event.id), title: String(data.title ?? data.action ?? "Unknown decision"), description: String(data.description ?? ""), rationale: String(data.rationale ?? ""), timestamp: event.timestamp, tags: Array.isArray(data.tags) ? data.tags : [], }; }); } async getDecisionsByTag(tag) { const decisions = await this.getDecisions(); return decisions.filter((d) => d.tags.includes(tag)); } /** * Change events derived from audit events with type === "change". */ async getChanges(filePath) { const events = await this.getEvents({ type: "change" }); const changes = events.map((event) => { const data = event.data ?? {}; return { id: String(data.id ?? event.id), file: String(data.file ?? ""), author: String(data.author ?? event.agent), timestamp: event.timestamp, description: String(data.description ?? ""), linesAdded: Number(data.linesAdded ?? 0), linesRemoved: Number(data.linesRemoved ?? 0), }; }); if (filePath) { const normalized = this.normalizePath(filePath); return changes.filter((c) => c.file === normalized); } return changes; } async getChangesByAuthor(author) { const changes = await this.getChanges(); return changes.filter((c) => c.author === author); } /** * Aggregate governance statistics from the audit log. */ async getStats() { const events = await this.getEvents(); const eventsByType = {}; let totalDecisions = 0; let totalChanges = 0; let lastUpdated = 0; for (const event of events) { eventsByType[event.type] = (eventsByType[event.type] ?? 0) + 1; if (event.type === "decision") { totalDecisions += 1; } else if (event.type === "change") { totalChanges += 1; } if (event.timestamp > lastUpdated) { lastUpdated = event.timestamp; } } return { totalEvents: events.length, totalDecisions, totalChanges, eventsByType, lastUpdated, }; } openDb() { if (!this.dbPath) { // Fallback if init was not called explicitly – mirror AuditMemory behaviour. this.dbPath = path.join(this.cwd, ".arela", "memory", "audit.db"); } fs.ensureDirSync(path.dirname(this.dbPath)); const db = new Database(this.dbPath); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); return db; } rowToEvent(row) { const metadata = this.safeParseJson(row.metadata) ?? {}; const policyViolations = this.safeParseJson(row.policy_violations) ?? undefined; const timestampMs = this.parseTimestamp(row.timestamp); // Prefer explicit metadata.type if present; fall back to action/result. const type = typeof metadata.type === "string" ? metadata.type : row.action ? String(row.action) : "event"; const data = { ...metadata, result: row.result, commitHash: row.commit_hash, ticketId: row.ticket_id, policyViolations, }; return { id: String(row.id), timestamp: timestampMs, type, agent: String(row.agent), data, }; } parseTimestamp(value) { if (typeof value === "number") { return value; } if (typeof value === "string") { const parsed = Date.parse(value); if (!Number.isNaN(parsed)) { return parsed; } } return Date.now(); } safeParseJson(value) { if (!value || typeof value !== "string") { return undefined; } try { return JSON.parse(value); } catch { return undefined; } } normalizePath(filePath) { const absolute = path.isAbsolute(filePath) ? filePath : path.join(this.cwd, filePath); const relative = path.relative(this.cwd, absolute); return relative.split(path.sep).join(path.posix.sep); } } //# sourceMappingURL=governance.js.map