Add thread audit replay controls
This commit is contained in:
1 parent
bbf90a1a62
commit
6768d19239
19 files changed
+299
-3
No files matched your search
@@ -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)));
|
||||
|
||||
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string> {
|
||||
const ids = new Set<string>();
|
||||
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<string>): 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<string>): 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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
function stringField(payload: Record<string, unknown>, key: string): string {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 (
|
||||
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
|
||||
@@ -193,6 +194,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
exports={complianceExports}
|
||||
legalHolds={legalHolds}
|
||||
retentionFreezes={retentionFreezes}
|
||||
auditReplayRuns={auditReplayRuns}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLFormElement>) => {
|
||||
@@ -79,6 +82,16 @@ export function CompliancePanel({
|
||||
})).then((result) => { if (result) event.currentTarget.reset(); });
|
||||
};
|
||||
|
||||
const submitReplay = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createAuditReplay({
|
||||
orgId,
|
||||
threadId: String(form.get("threadId") ?? ""),
|
||||
createdByEmployeeId: employeeId,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Compliance</h1>
|
||||
@@ -133,6 +146,18 @@ export function CompliancePanel({
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="refresh" title="Thread replay" />
|
||||
<form onSubmit={submitReplay} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Thread" name="threadId">
|
||||
{chatThreads.map((thread) => <option key={thread.id} value={thread.id}>{thread.title}</option>)}
|
||||
</LabeledSelect>
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit" disabled={chatThreads.length === 0}><Icon name="refresh" size={12} /> Run</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="inbox" title="Export history" />
|
||||
<RecordList emptyLabel="0 exports">
|
||||
@@ -147,6 +172,19 @@ export function CompliancePanel({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card" style={{ marginTop: 16 }}>
|
||||
<SectionTitle icon="list" title="Replay runs" />
|
||||
<RecordList emptyLabel="0 replay runs">
|
||||
{auditReplayRuns.map((run) => (
|
||||
<RecordRow
|
||||
key={run.id}
|
||||
title={threadTitle(chatThreads, run.threadId)}
|
||||
meta={`${run.eventCount} events · ${run.eventDigest.slice(0, 12)} · ${formatRelative(run.createdAt)}`}
|
||||
/>
|
||||
))}
|
||||
</RecordList>
|
||||
</section>
|
||||
|
||||
<section className="card" style={{ marginTop: 16 }}>
|
||||
<SectionTitle icon="approve" title="Active controls" />
|
||||
<RecordList emptyLabel="0 active controls">
|
||||
@@ -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 (
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<Icon name={icon} size={14} color="var(--text-3)" />
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface AuditReplayRunRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
threadId: string;
|
||||
eventCount: number;
|
||||
eventDigest: string;
|
||||
replayJson: string;
|
||||
createdByEmployeeId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BootstrapOnboardingSummary,
|
||||
CreateAgentInput,
|
||||
CreateApprovalInput,
|
||||
CreateAuditReplayInput,
|
||||
CreateChatSpaceInput,
|
||||
CreateComplianceExportInput,
|
||||
CreateLegalHoldInput,
|
||||
@@ -86,6 +87,7 @@ export interface DesktopApi {
|
||||
runEvalSuite(input: RunEvalSuiteInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setPrBotGate(input: SetPrBotGateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createComplianceExport(input: CreateComplianceExportInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createAuditReplay(input: CreateAuditReplayInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createLegalHold(input: CreateLegalHoldInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user