diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index d96e946a..7ec21015 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -18,6 +18,7 @@ import { SearchService } from "../services/search-service"; import { IdentityAccessService } from "../services/identity-access-service"; import { OperationsService } from "../services/operations-service"; import { SdlcService } from "../services/sdlc-service"; +import { AuditService } from "../services/audit-service"; export interface IpcDeps { connection: DatabaseConnection; @@ -40,6 +41,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const identityAccess = new IdentityAccessService(deps.connection.db); const operations = new OperationsService(deps.connection.db); const sdlc = new SdlcService(deps.connection.db); + const audit = new AuditService(deps.connection.db); const onboarding = new OnboardingService({ db: deps.connection.db, saveProviderCredential: (input) => providers.saveCredential(input), @@ -163,6 +165,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.runEvalSuite, (_event, input) => mutate(() => sdlc.runEvalSuite(input))); ipcMain.handle(IPC_CHANNELS.setPrBotGate, (_event, input) => mutate(() => sdlc.setPrBotGate(input))); ipcMain.handle(IPC_CHANNELS.createComplianceExport, (_event, input) => mutate(() => compliance.createExport(input))); + ipcMain.handle(IPC_CHANNELS.createAuditReplay, (_event, input) => mutate(() => audit.createThreadReplay(input))); ipcMain.handle(IPC_CHANNELS.createLegalHold, (_event, input) => mutate(() => compliance.createLegalHold(input))); ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input))); ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input))); diff --git a/AgentCompany-New/src/main/database/migrations/019-audit-replay.ts b/AgentCompany-New/src/main/database/migrations/019-audit-replay.ts new file mode 100644 index 00000000..23018990 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/019-audit-replay.ts @@ -0,0 +1,18 @@ +export const auditReplaySchema = ` + create table if not exists audit_replay_runs ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + thread_id text not null references chat_threads(id) on delete cascade, + event_count integer not null, + event_digest text not null, + replay_json text not null, + created_by_employee_id text references employees(id) on delete set null, + created_at text not null + ); + + create index if not exists idx_audit_replay_runs_org_created on audit_replay_runs(org_id, created_at); + create index if not exists idx_audit_replay_runs_thread on audit_replay_runs(thread_id, created_at); + + insert into schema_migrations(version, name, applied_at) + values (19, 'audit_replay', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index e7c47b6f..d019601a 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -17,6 +17,7 @@ import { identityAccessSchema } from "./migrations/015-identity-access"; import { orgGovernanceSchema } from "./migrations/016-org-governance"; import { operationsControlsSchema } from "./migrations/017-operations-controls"; import { sdlcControlsSchema } from "./migrations/018-sdlc-controls"; +import { auditReplaySchema } from "./migrations/019-audit-replay"; interface Migration { version: number; @@ -43,6 +44,7 @@ const MIGRATIONS: Migration[] = [ { version: 16, name: "org_governance", sql: orgGovernanceSchema }, { version: 17, name: "operations_controls", sql: operationsControlsSchema }, { version: 18, name: "sdlc_controls", sql: sdlcControlsSchema }, + { version: 19, name: "audit_replay", sql: auditReplaySchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/audit-service.ts b/AgentCompany-New/src/main/services/audit-service.ts index 2162ccb8..a8ba5276 100644 --- a/AgentCompany-New/src/main/services/audit-service.ts +++ b/AgentCompany-New/src/main/services/audit-service.ts @@ -1,6 +1,36 @@ +import { createHash } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; +import type { CreateAuditReplayInput } from "../../shared/commands"; import { createId } from "../../shared/ids"; +interface AuditEventRow { + id: string; + org_id: string; + entity_type: string; + entity_id: string; + actor_type: string; + actor_id: string | null; + action: string; + payload_json: string; + created_at: string; +} + +interface ChatThreadRow { + id: string; + root_message_id: string | null; +} + +interface ReplayEvent { + id: string; + entityType: string; + entityId: string; + actorType: string; + actorId: string | null; + action: string; + payload: Record; + createdAt: string; +} + export class AuditService { constructor(private readonly db: DatabaseSync) {} @@ -28,4 +58,98 @@ export class AuditService { new Date().toISOString(), ); } + + createThreadReplay(input: CreateAuditReplayInput): string { + const thread = this.requireThread(input.orgId, input.threadId); + this.requireEmployee(input.orgId, input.createdByEmployeeId); + const messageIds = this.threadMessageIds(input.orgId, input.threadId, thread.root_message_id); + const events = this.threadEvents(input.orgId, input.threadId, messageIds); + if (events.length === 0) throw new Error("Thread replay requires audit events."); + const replay = { + threadId: input.threadId, + rootMessageId: thread.root_message_id, + messageIds: [...messageIds].sort(), + events, + }; + const replayJson = JSON.stringify(replay); + const eventDigest = createHash("sha256").update(replayJson).digest("hex"); + const id = createId("arp"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into audit_replay_runs( + id, org_id, thread_id, event_count, event_digest, replay_json, + created_by_employee_id, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, input.orgId, input.threadId, events.length, eventDigest, replayJson, input.createdByEmployeeId, now); + this.record({ + orgId: input.orgId, + entityType: "audit_replay_run", + entityId: id, + actorType: "employee", + actorId: input.createdByEmployeeId, + action: "created", + payload: { threadId: input.threadId, eventCount: events.length, eventDigest }, + }); + return id; + } + + private requireThread(orgId: string, threadId: string): ChatThreadRow { + const thread = this.db.prepare("select id, root_message_id from chat_threads where id = ? and org_id = ?") + .get(threadId, orgId) as ChatThreadRow | undefined; + if (!thread) throw new Error("Chat thread was not found."); + return thread; + } + + private requireEmployee(orgId: string, employeeId: string): void { + const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId); + if (!employee) throw new Error("Audit replay employee was not found."); + } + + private threadMessageIds(orgId: string, threadId: string, rootMessageId: string | null): Set { + const ids = new Set(); + if (rootMessageId) ids.add(rootMessageId); + const rows = this.db.prepare("select id from chat_messages where org_id = ? and thread_id = ? order by created_at asc") + .all(orgId, threadId) as Array<{ id: string }>; + for (const row of rows) ids.add(row.id); + return ids; + } + + private threadEvents(orgId: string, threadId: string, messageIds: Set): ReplayEvent[] { + const rows = this.db.prepare("select * from audit_events where org_id = ? order by created_at asc, id asc") + .all(orgId) as unknown as AuditEventRow[]; + return rows + .filter((row) => eventMatchesThread(row, threadId, messageIds)) + .map((row) => ({ + id: row.id, + entityType: row.entity_type, + entityId: row.entity_id, + actorType: row.actor_type, + actorId: row.actor_id, + action: row.action, + payload: parsePayload(row.payload_json), + createdAt: row.created_at, + })); + } +} + +function eventMatchesThread(row: AuditEventRow, threadId: string, messageIds: Set): boolean { + if (row.entity_type === "audit_replay_run") return false; + if (row.entity_type === "chat_thread" && row.entity_id === threadId) return true; + if (row.entity_type === "chat_message" && messageIds.has(row.entity_id)) return true; + const payload = parsePayload(row.payload_json); + return stringField(payload, "threadId") === threadId + || stringField(payload, "sourceThreadId") === threadId + || messageIds.has(stringField(payload, "messageId")) + || messageIds.has(stringField(payload, "sourceMessageId")); +} + +function parsePayload(payloadJson: string): Record { + const parsed = JSON.parse(payloadJson) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Audit event payload is invalid."); + return parsed as Record; +} + +function stringField(payload: Record, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value : ""; } diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index c7be8090..a3512976 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -70,6 +70,7 @@ const LEGACY_TABLES = [ "legal_holds", "retention_freezes", "search_documents", + "audit_replay_runs", "audit_events", ]; @@ -222,6 +223,9 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, { table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "retention_freezes", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, + { table: "audit_replay_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "audit_replay_runs", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" }, + { table: "audit_replay_runs", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, { table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, ]; diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index dc43c5a4..51eb0f78 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -245,7 +245,7 @@ export class SdlcService { const schemaVersion = count(this.db, "select coalesce(max(version), 0) as count from schema_migrations"); return [ { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, - { key: "schema_version", passed: schemaVersion >= 18, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 19, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 73e6a3c3..6b3f5090 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -121,6 +121,7 @@ export class SnapshotService { evalCases: list(this.db, "eval_cases", "title asc"), evalRuns: list(this.db, "eval_runs", "created_at desc"), prBotGates, + auditReplayRuns: list(this.db, "audit_replay_runs", "created_at desc"), auditEvents: list(this.db, "audit_events", "created_at desc").slice(0, 200), }; } diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index 8534b9e2..b9018d0a 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -44,6 +44,7 @@ const api: DesktopApi = { runEvalSuite: (input) => invoke(IPC_CHANNELS.runEvalSuite, input), setPrBotGate: (input) => invoke(IPC_CHANNELS.setPrBotGate, input), createComplianceExport: (input) => invoke(IPC_CHANNELS.createComplianceExport, input), + createAuditReplay: (input) => invoke(IPC_CHANNELS.createAuditReplay, input), createLegalHold: (input) => invoke(IPC_CHANNELS.createLegalHold, input), setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input), createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input), diff --git a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx index 0b51a8d9..a3919b37 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -100,6 +100,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { const evalCases = useMemo(() => snapshot.evalCases.filter((item) => item.orgId === orgId), [snapshot.evalCases, orgId]); const evalRuns = useMemo(() => snapshot.evalRuns.filter((item) => item.orgId === orgId), [snapshot.evalRuns, orgId]); const prBotGates = useMemo(() => snapshot.prBotGates.filter((item) => item.orgId === orgId), [snapshot.prBotGates, orgId]); + const auditReplayRuns = useMemo(() => snapshot.auditReplayRuns.filter((item) => item.orgId === orgId), [snapshot.auditReplayRuns, orgId]); return (
@@ -193,6 +194,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { exports={complianceExports} legalHolds={legalHolds} retentionFreezes={retentionFreezes} + auditReplayRuns={auditReplayRuns} actions={actions} /> )} diff --git a/AgentCompany-New/src/renderer/src/pages/settings/CompliancePanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/CompliancePanel.tsx index 8ec948f9..386c5f52 100644 --- a/AgentCompany-New/src/renderer/src/pages/settings/CompliancePanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/settings/CompliancePanel.tsx @@ -2,6 +2,7 @@ import { Children, type FormEvent, type ReactNode } from "react"; import { Button, Icon, Pill } from "../../shared/ui"; import type { AgentRecord, + AuditReplayRunRecord, ChatSpaceRecord, ChatThreadRecord, ComplianceExportRecord, @@ -24,6 +25,7 @@ export function CompliancePanel({ exports, legalHolds, retentionFreezes, + auditReplayRuns, actions, }: { orgId: string; @@ -36,6 +38,7 @@ export function CompliancePanel({ exports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[]; + auditReplayRuns: AuditReplayRunRecord[]; actions: SnapshotActions; }) { const submitExport = (event: FormEvent) => { @@ -79,6 +82,16 @@ export function CompliancePanel({ })).then((result) => { if (result) event.currentTarget.reset(); }); }; + const submitReplay = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void actions.run(() => window.agentCompany.createAuditReplay({ + orgId, + threadId: String(form.get("threadId") ?? ""), + createdByEmployeeId: employeeId, + })); + }; + return (

Compliance

@@ -133,6 +146,18 @@ export function CompliancePanel({ +
+ +
+ + {chatThreads.map((thread) => )} + +
+ +
+
+
+
@@ -147,6 +172,19 @@ export function CompliancePanel({
+
+ + + {auditReplayRuns.map((run) => ( + + ))} + +
+
@@ -176,7 +214,7 @@ export function CompliancePanel({ ); } -function SectionTitle({ icon, title }: { icon: "approve" | "docs" | "inbox" | "pause" | "shield"; title: string }) { +function SectionTitle({ icon, title }: { icon: "approve" | "docs" | "inbox" | "list" | "pause" | "refresh" | "shield"; title: string }) { return (
@@ -238,3 +276,9 @@ function splitPair(value: string): [string, string] { if (index < 1) throw new Error("Compliance selector value is invalid."); return [value.slice(0, index), value.slice(index + 1)]; } + +function threadTitle(threads: ChatThreadRecord[], threadId: string): string { + const thread = threads.find((item) => item.id === threadId); + if (!thread) throw new Error("Audit replay thread is missing from snapshot."); + return thread.title; +} diff --git a/AgentCompany-New/src/shared/audit-domain.ts b/AgentCompany-New/src/shared/audit-domain.ts new file mode 100644 index 00000000..2b90d984 --- /dev/null +++ b/AgentCompany-New/src/shared/audit-domain.ts @@ -0,0 +1,10 @@ +export interface AuditReplayRunRecord { + id: string; + orgId: string; + threadId: string; + eventCount: number; + eventDigest: string; + replayJson: string; + createdByEmployeeId: string | null; + createdAt: string; +} diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 3b938260..f3e40ad2 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -227,6 +227,12 @@ export interface CreateComplianceExportInput { requestedByEmployeeId: string; } +export interface CreateAuditReplayInput { + orgId: string; + threadId: string; + createdByEmployeeId: string; +} + export interface CreateLegalHoldInput { orgId: string; entityType: LegalHoldEntityType; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 959ccc1f..99060408 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -6,6 +6,7 @@ import type { BootstrapOnboardingSummary, CreateAgentInput, CreateApprovalInput, + CreateAuditReplayInput, CreateChatSpaceInput, CreateComplianceExportInput, CreateLegalHoldInput, @@ -86,6 +87,7 @@ export interface DesktopApi { runEvalSuite(input: RunEvalSuiteInput): Promise>; setPrBotGate(input: SetPrBotGateInput): Promise>; createComplianceExport(input: CreateComplianceExportInput): Promise>; + createAuditReplay(input: CreateAuditReplayInput): Promise>; createLegalHold(input: CreateLegalHoldInput): Promise>; setLegalHoldState(input: SetLegalHoldStateInput): Promise>; createRetentionFreeze(input: CreateRetentionFreezeInput): Promise>; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 71a39ca2..8750f3b9 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -1,4 +1,5 @@ import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain"; +import type { AuditReplayRunRecord } from "./audit-domain"; import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain"; import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-domain"; import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain"; @@ -6,6 +7,7 @@ import type { ChaosDrillRecord, ChaosDrillRunRecord, FeatureFlagRecord, Operatio import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain"; import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain"; export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; +export type { AuditReplayRunRecord } from "./audit-domain"; export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain"; export { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type AccessDecisionRecord, type EmployeeIdentityRecord, type IdentitySource, type PrincipalType, type RoleGrantRecord, type RoleKey, type ServiceIdentityRecord } from "./identity-domain"; export { ROUTE_TO_TYPE, type ApprovalAssignmentRecord, type ApprovalRoutingRuleRecord, type HiringPolicyRecord, type OrgDepartmentRecord, type RouteToType } from "./org-governance-domain"; @@ -477,5 +479,6 @@ export interface AppSnapshot { orgDepartments: OrgDepartmentRecord[]; hiringPolicies: HiringPolicyRecord[]; approvalRoutingRules: ApprovalRoutingRuleRecord[]; approvalAssignments: ApprovalAssignmentRecord[]; featureFlags: FeatureFlagRecord[]; sloTargets: SloTargetRecord[]; operationalEvents: OperationalEventRecord[]; chaosDrills: ChaosDrillRecord[]; chaosDrillRuns: ChaosDrillRunRecord[]; sdlcReviewAgents: SdlcReviewAgentRecord[]; sdlcReviewRuns: SdlcReviewRunRecord[]; evalSuites: EvalSuiteRecord[]; evalCases: EvalCaseRecord[]; evalRuns: EvalRunRecord[]; prBotGates: PrBotGateRecord[]; + auditReplayRuns: AuditReplayRunRecord[]; auditEvents: AuditEventRecord[]; } diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index a2eb4f44..dc967062 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -36,6 +36,7 @@ export const IPC_CHANNELS = { runEvalSuite: "agentcompany:run-eval-suite", setPrBotGate: "agentcompany:set-pr-bot-gate", createComplianceExport: "agentcompany:create-compliance-export", + createAuditReplay: "agentcompany:create-audit-replay", createLegalHold: "agentcompany:create-legal-hold", setLegalHoldState: "agentcompany:set-legal-hold-state", createRetentionFreeze: "agentcompany:create-retention-freeze", diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index 311cf82e..7f48ec36 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -128,6 +128,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Compliance" }).click(); await page.getByRole("button", { name: "Create" }).click(); await expect(page.getByText("gdpr export · employee").first()).toBeVisible(); + await expect(page.getByText("Thread replay")).toBeVisible(); + await page.getByRole("button", { name: "Run" }).click(); + await expect(page.getByText("Replay runs", { exact: true })).toBeVisible(); + await expect(page.getByText(/\d+ events ·/).first()).toBeVisible(); await verifyPagesDoNotOverflow(page); } finally { diff --git a/AgentCompany-New/tests/unit/audit-service.test.ts b/AgentCompany-New/tests/unit/audit-service.test.ts new file mode 100644 index 00000000..ea4aaa7d --- /dev/null +++ b/AgentCompany-New/tests/unit/audit-service.test.ts @@ -0,0 +1,67 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { AuditService } from "../../src/main/services/audit-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-audit-${randomUUID()}.db`)); +} + +describe("audit service", () => { + test("creates thread scoped replay runs from immutable audit events", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const audit = new AuditService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + let threadId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const spaceId = chat.createSpace({ + orgId, + spaceType: "project", + name: "release", + topic: "Release coordination", + visibility: "workspace", + }); + const messageId = chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: "/escalate review release plan", + attachmentFileIds: [], + }); + threadId = (connection.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }).thread_id; + audit.createThreadReplay({ orgId, threadId, createdByEmployeeId: employeeId }); + }); + + const state = snapshot.load(); + const run = state.auditReplayRuns[0]; + if (!run) throw new Error("Audit replay run was not created."); + const replay = JSON.parse(run.replayJson) as { events: Array<{ entityType: string; action: string }> }; + expect(run.threadId).toBe(threadId); + expect(run.eventDigest).toHaveLength(64); + expect(run.eventCount).toBe(replay.events.length); + expect(replay.events).toContainEqual(expect.objectContaining({ entityType: "chat_message", action: "sent" })); + expect(replay.events).toContainEqual(expect.objectContaining({ entityType: "approval", action: "requested" })); + expect(replay.events.some((event) => event.entityType === "audit_replay_run")).toBe(false); + expect(state.auditEvents.some((event) => event.entityType === "audit_replay_run" && event.action === "created")).toBe(true); + connection.close(); + }); +}); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 483f5014..3d793222 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index f1e2b9c6..c2058c94 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -152,6 +152,7 @@ describe("migration service", () => { create table legal_holds(id text primary key, org_id text, created_by_employee_id text); create table retention_freezes(id text primary key, org_id text, created_by_employee_id text); create table search_documents(entity_id text); + create table audit_replay_runs(id text primary key, org_id text, thread_id text, created_by_employee_id text); create table audit_events(id text primary key, org_id text); insert into organizations(id) values ('org-1'); insert into employees(id, org_id) values ('emp-1', 'org-1'); @@ -214,6 +215,7 @@ describe("migration service", () => { insert into legal_holds(id, org_id, created_by_employee_id) values ('hold-1', 'org-1', 'emp-1'); insert into retention_freezes(id, org_id, created_by_employee_id) values ('freeze-1', 'org-1', 'emp-1'); insert into search_documents(entity_id) values ('task-1'); + insert into audit_replay_runs(id, org_id, thread_id, created_by_employee_id) values ('replay-1', 'org-1', 'thread-1', 'emp-1'); insert into audit_events(id, org_id) values ('audit-1', 'org-1'); `); let orgId = ""; @@ -241,6 +243,7 @@ describe("migration service", () => { expect(body.tableCounts.provider_requests).toBe(1); expect(body.tableCounts.provider_usage_windows).toBe(1); expect(body.tableCounts.compliance_exports).toBe(1); + expect(body.tableCounts.audit_replay_runs).toBe(1); expect(body.tableCounts.search_documents).toBe(1); expect(body.tableChecksums.audit_events).toHaveLength(64); expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]); @@ -253,6 +256,7 @@ describe("migration service", () => { expect(body.idMaps.eval_runs).toEqual(["eval-run-1"]); expect(body.idMaps.board_interventions).toEqual(["bdi-1"]); expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]); + expect(body.idMaps.audit_replay_runs).toEqual(["replay-1"]); expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]); connection.close(); });