diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index b6f5a5b4..d09de26d 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -1,3 +1,4 @@ +import { bumpDataVersion, currentDataVersion } from "../services/data-version"; import { createIpcGate } from "./ipc-gate"; import { app, dialog, shell, type BrowserWindow } from "electron"; import type { DatabaseConnection } from "../database/connection"; @@ -69,13 +70,18 @@ export function registerIpcHandlers(deps: IpcDeps): void { const integrationKeys = new IntegrationKeyStore(deps.connection.db); const providerOAuth = new ProviderOAuthService(integrationKeys); const skills = new SkillService(deps.connection.db, deps.artifactRoot); - const notifyDataChanged = coalesce(() => { + const announceChange = coalesce(() => { try { if (!deps.mainWindow.isDestroyed() && !deps.mainWindow.webContents.isDestroyed()) { - deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed", version: currentDataVersion() }); } } catch { /* renderer may have been disposed during agent run */ } }, 200); + // The version moves at once, so the snapshot an action returns already carries the change its own event announces. + const notifyDataChanged = () => { + bumpDataVersion(); + announceChange(); + }; const taskExecution = new TaskExecutionService(deps.connection.db, providers, skills, notifyDataChanged); const social = new SocialService(deps.connection.db); const files = new FileService(deps.connection.db, deps.artifactRoot); @@ -456,11 +462,12 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("disconnectComposioToolkit", async (input) => { try { await composioConnections.disconnectToolkit(input.orgId, input.toolkitSlug, input.label); + notifyDataChanged(); return ok(snapshot.load()); } catch (error) { - return fail("composio_disconnect_failed", error instanceof Error ? error.message : String(error)); - } finally { + // A disconnect that failed partway may still have written; the page reloads either way. notifyDataChanged(); + return fail("composio_disconnect_failed", error instanceof Error ? error.message : String(error)); } }); handle("grantConnector", (input) => mutate(() => new ConnectorGrantsService(deps.connection.db).grantFromBoard(input))); @@ -478,11 +485,11 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("refreshComposioAccounts", async (input) => { try { await composioConnections.refreshAccounts(input.orgId); + notifyDataChanged(); return ok(snapshot.load()); } catch (error) { - return fail("composio_refresh_failed", error instanceof Error ? error.message : String(error)); - } finally { notifyDataChanged(); + return fail("composio_refresh_failed", error instanceof Error ? error.message : String(error)); } }); handle("windowMinimize", () => deps.mainWindow.minimize()); diff --git a/src/main/app/provider-background-worker.ts b/src/main/app/provider-background-worker.ts index fa454bfc..cac69175 100644 --- a/src/main/app/provider-background-worker.ts +++ b/src/main/app/provider-background-worker.ts @@ -1,3 +1,4 @@ +import { bumpDataVersion } from "../services/data-version"; import type { BrowserWindow } from "electron"; import type { DatabaseSync } from "node:sqlite"; import { IPC_CHANNELS } from "../../shared/ipc"; @@ -22,7 +23,7 @@ export function startProviderBackgroundWorker(input: ProviderBackgroundWorkerInp try { const changedCount = await service.recoverBackgroundResponses(); if (changedCount > 0 && !input.mainWindow.isDestroyed()) { - input.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + input.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed", version: bumpDataVersion() }); } } finally { running = false; diff --git a/src/main/app/task-queue-worker.ts b/src/main/app/task-queue-worker.ts index c5d9b6e8..45c47115 100644 --- a/src/main/app/task-queue-worker.ts +++ b/src/main/app/task-queue-worker.ts @@ -1,3 +1,4 @@ +import { bumpDataVersion } from "../services/data-version"; import type { BrowserWindow } from "electron"; import type { DatabaseConnection } from "../database/connection"; import { IPC_CHANNELS } from "../../shared/ipc"; @@ -80,7 +81,7 @@ export function claimNextQueuedRun(connection: DatabaseConnection, excludedProvi export function startTaskQueueWorker(input: TaskQueueWorkerInput): () => void { const notifyDataChanged = () => { if (!input.mainWindow.isDestroyed()) { - input.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + input.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed", version: bumpDataVersion() }); } }; const taskExecution = input.taskExecution ?? new TaskExecutionService(input.connection.db, undefined, undefined, notifyDataChanged); diff --git a/src/main/services/data-version.ts b/src/main/services/data-version.ts new file mode 100644 index 00000000..95bb04e8 --- /dev/null +++ b/src/main/services/data-version.ts @@ -0,0 +1,11 @@ +let version = 0; + +/** Marks a change the window should show; snapshots and change events carry the count so the window never reloads what it already has. */ +export function bumpDataVersion(): number { + version += 1; + return version; +} + +export function currentDataVersion(): number { + return version; +} diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 628641c7..4572b37b 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -1,3 +1,4 @@ +import { currentDataVersion } from "./data-version"; import { loadViewState } from "./view-state"; import { loadOnboardingDraft } from "./onboarding-progress"; import type { DatabaseSync, StatementSync, SQLInputValue } from "node:sqlite"; @@ -71,32 +72,17 @@ export class SnapshotService { .map((provider) => ({ ...provider, enabled: toBool(provider.enabled), backgroundEnabled: toBool(provider.backgroundEnabled) })); const providerAuthProfiles = list(this.db, "provider_auth_profiles", "created_at desc") .map((profile) => ({ ...profile, active: toBool(profile.active) })); - const providerAuthSessions = list(this.db, "provider_auth_sessions", "created_at desc"); - const providerRateLimitObservations = list( - this.db, - "provider_rate_limit_observations", - "observed_at desc", - ); const providerUsageWindows = list(this.db, "provider_usage_windows", "window_start desc"); const providerRateLimits = buildProviderRateLimits(providers, providerUsageWindows); const files = list(this.db, "file_objects", "created_at desc") .map((file) => ({ ...file, pinned: toBool(file.pinned) })); - const legalHolds = list(this.db, "legal_holds", "created_at desc") - .map((hold) => ({ ...hold, active: toBool(hold.active) })); - const retentionFreezes = list(this.db, "retention_freezes", "created_at desc") - .map((freeze) => ({ ...freeze, active: toBool(freeze.active) })); - const employeeIdentities = list(this.db, "employee_identities", "created_at desc") - .map((identity) => ({ ...identity, active: toBool(identity.active) })); - const featureFlags = list(this.db, "feature_flags", "key asc") - .map((flag) => ({ ...flag, enabled: toBool(flag.enabled) })); - const operationalEvents = list(this.db, "operational_events", "created_at desc") - .map((event) => ({ ...event, success: toBool(event.success) })); return { activeOrgId, uiPreferences: this.resolveUiPreferences(), onboardingDraft: loadOnboardingDraft(this.db), viewState: loadViewState(this.db), + dataVersion: currentDataVersion(), autonomousPaused: this.resolveAutonomousPaused(), integrationKeys: new IntegrationKeyStore(this.db).statuses(), organizations, @@ -127,7 +113,6 @@ export class SnapshotService { chatAttachments: forMessages(this.db, "chat_attachments", "message_id desc", loadedMessageIds), chatMentions: forMessages(this.db, "chat_mentions", "message_id desc, offset_start asc", loadedMessageIds), chatSlashInvocations: forMessages(this.db, "chat_slash_invocations", "created_at desc", loadedMessageIds), - boardInterventions: list(this.db, "board_interventions", "created_at asc"), chatPresence: list(this.db, "chat_presence", "last_seen_at desc"), chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"), files, @@ -136,7 +121,6 @@ export class SnapshotService { scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"), providers, providerAuthProfiles, - providerAuthSessions, providerCatalog: PROVIDER_CATALOG, // Explicit projection: raw_response_json alone weighs megabytes across // 100 rows and no renderer surface reads it. @@ -150,7 +134,6 @@ export class SnapshotService { `), activeOrgId ?? "").map((row) => camel(row) as unknown as AppSnapshot["providerRequests"][number]), providerRateLimits, usageStats: buildUsageStats(this.db, new Date()), - providerRateLimitObservations, skillDefinitions: list(this.db, "skill_definitions", "created_at asc"), socialAccounts: list(this.db, "social_accounts", "created_at asc"), socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"), @@ -164,19 +147,7 @@ export class SnapshotService { policyRules: list(this.db, "policy_rules", "created_at asc"), migrationReports: list(this.db, "migration_reports", "created_at desc"), complianceExports: list(this.db, "compliance_exports", "created_at desc"), - legalHolds, - retentionFreezes, - employeeIdentities, - serviceIdentities: list(this.db, "service_identities", "created_at desc"), - roleGrants: list(this.db, "role_grants", "created_at desc"), - accessDecisions: recentInOrg(this.db, "access_decisions", "created_at desc", 200, activeOrgId), - orgDepartments: list(this.db, "org_departments", "name asc"), hiringPolicies: list(this.db, "hiring_policies", "department_name asc"), - approvalRoutingRules: list(this.db, "approval_routing_rules", "created_at asc"), - approvalAssignments: list(this.db, "approval_assignments", "created_at desc"), - featureFlags, - sloTargets: list(this.db, "slo_targets", "signal_key asc"), - operationalEvents, auditEvents: recentInOrg(this.db, "audit_events", "created_at desc", 200, activeOrgId), documents: list(this.db, "documents", "updated_at desc"), knowledgeEntries: list(this.db, "knowledge_entries", "updated_at desc"), diff --git a/src/renderer/src/app/snapshot-order.ts b/src/renderer/src/app/snapshot-order.ts new file mode 100644 index 00000000..36ec9f20 --- /dev/null +++ b/src/renderer/src/app/snapshot-order.ts @@ -0,0 +1,4 @@ +/** A snapshot read at an older data version than the one on screen is a late answer; showing it would undo newer data. */ +export function supersedes(shownVersion: number, incomingVersion: number): boolean { + return incomingVersion >= shownVersion; +} diff --git a/src/renderer/src/app/useSnapshot.ts b/src/renderer/src/app/useSnapshot.ts index 632a3388..27cf85e5 100644 --- a/src/renderer/src/app/useSnapshot.ts +++ b/src/renderer/src/app/useSnapshot.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { supersedes } from "./snapshot-order"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AppSnapshot } from "../../../shared/domain"; import type { DesktopResult } from "../../../shared/result"; @@ -23,13 +24,17 @@ export function useSnapshot(): { const [snapshot, setSnapshot] = useState(null); const [syncFailure, setSyncFailure] = useState(null); const [syncedAt, setSyncedAt] = useState(null); + // The newest data version on screen; a change event at or below it needs no reload. + const shownVersion = useRef(-1); const load = useCallback(async () => { try { const result = await window.agentCompany.loadSnapshot(); if (!result.ok) throw new Error(result.error.message); - setSnapshot(result.data); setSyncFailure(null); + if (!supersedes(shownVersion.current, result.data.dataVersion)) return; + shownVersion.current = result.data.dataVersion; + setSnapshot(result.data); setSyncedAt(new Date().toISOString()); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -43,7 +48,8 @@ export function useSnapshot(): { ): Promise => { const result = await operation(); if (!result.ok) throw new Error(result.error.message); - if (result.data) { + if (result.data && supersedes(shownVersion.current, (result.data as AppSnapshot).dataVersion)) { + shownVersion.current = (result.data as AppSnapshot).dataVersion; setSnapshot(result.data as AppSnapshot); setSyncFailure(null); setSyncedAt(new Date().toISOString()); @@ -84,7 +90,7 @@ export function useSnapshot(): { guardedLoad(); }); const unsubscribe = window.agentCompany.subscribe((event) => { - if (event.type === "snapshot-changed") scheduleReload(); + if (event.type === "snapshot-changed" && event.version > shownVersion.current) scheduleReload(); }); return () => { disposed = true; diff --git a/src/shared/board-domain.ts b/src/shared/board-domain.ts index ad4a6fd2..4eade45e 100644 --- a/src/shared/board-domain.ts +++ b/src/shared/board-domain.ts @@ -24,18 +24,3 @@ export type ChatThreadBoardState = ValueOf; export type BoardInterventionMode = ValueOf; export type BoardInterventionAction = ValueOf; -export interface BoardInterventionRecord { - id: string; - orgId: string; - threadId: string; - messageId: string; - mode: BoardInterventionMode; - action: BoardInterventionAction; - details: string; - targetType: "employee" | "agent" | "board" | null; - targetId: string | null; - approvalId: string | null; - createdByType: "employee" | "agent" | "board" | "system"; - createdById: string | null; - createdAt: string; -} diff --git a/src/shared/compliance-domain.ts b/src/shared/compliance-domain.ts index b3ba2a1b..e7d90a86 100644 --- a/src/shared/compliance-domain.ts +++ b/src/shared/compliance-domain.ts @@ -28,26 +28,3 @@ export interface ComplianceExportRecord { completedAt: string | null; } -export interface LegalHoldRecord { - id: string; - orgId: string; - entityType: LegalHoldEntityType; - entityId: string; - reason: string; - createdByEmployeeId: string | null; - active: boolean; - createdAt: string; - releasedAt: string | null; -} - -export interface RetentionFreezeRecord { - id: string; - orgId: string; - scopeType: RetentionFreezeScopeType; - scopeId: string; - reason: string; - createdByEmployeeId: string | null; - active: boolean; - createdAt: string; - releasedAt: string | null; -} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index de8acf90..89d318a5 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -113,7 +113,8 @@ export interface ProviderOAuthEvent { } export type RendererEvent = - | { type: "snapshot-changed" } + /** `version` is the data version the change produced; a snapshot at that version or later already shows it. */ + | { type: "snapshot-changed"; version: number } | { type: "provider-response-stream"; event: ProviderStreamRendererEvent } | ProviderOAuthEvent; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 1387372f..2d46f85f 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -1,14 +1,14 @@ import type { ProviderDataPolicy } from "./provider-policy-domain"; -export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; +export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type ChatThreadBoardState } from "./board-domain"; export type { ChatReplyStatus, ChatSpaceAnchor, ChatSpaceRecord, ChatMessageRecord, ChatMembershipRecord, ChatThreadRecord, ChatAttachmentRecord, ChatMentionRecord, ChatSlashInvocationRecord, ChatPresenceRecord, ChatReadStateRecord } from "./chat-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"; -export { FEATURE_FLAG_KEY, SLO_SIGNAL_KEY, type FeatureFlagKey, type FeatureFlagRecord, type OperationalEventRecord, type SloSignalKey, type SloTargetRecord } from "./operations-domain"; -export type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./provider-auth-domain"; +export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type RetentionFreezeScopeType } from "./compliance-domain"; +export { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type IdentitySource, type PrincipalType, type RoleKey } from "./identity-domain"; +export { ROUTE_TO_TYPE, type HiringPolicyRecord, type RouteToType } from "./org-governance-domain"; +export { FEATURE_FLAG_KEY, SLO_SIGNAL_KEY, type FeatureFlagKey, type SloSignalKey } from "./operations-domain"; +export type { ProviderAuthProfileRecord } from "./provider-auth-domain"; export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain"; export { PROVIDER_REQUEST_STATE, type ProviderRequestRecord, type ProviderRequestState, type ProviderStreamRendererEvent } from "./provider-request-domain"; -export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain"; +export type { ProviderRateLimitStateRecord } from "./provider-runtime-domain"; export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchPage, type SearchResultRecord } from "./search-domain"; export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain"; export type { AppSnapshot, UiLanguage, UiPreferences, UiTheme } from "./snapshot-domain"; diff --git a/src/shared/identity-domain.ts b/src/shared/identity-domain.ts index 0da7d00f..3fd9d82b 100644 --- a/src/shared/identity-domain.ts +++ b/src/shared/identity-domain.ts @@ -14,21 +14,3 @@ export type RoleKey = ValueOf; export type AccessDecision = ValueOf; export type PrincipalType = "employee" | "agent" | "service_identity"; -export interface EmployeeIdentityRecord { - id: string; orgId: string; employeeId: string; source: IdentitySource; externalSubject: string; - email: string | null; active: boolean; createdAt: string; lastSeenAt: string | null; -} - -export interface ServiceIdentityRecord { - id: string; orgId: string; agentId: string; name: string; scopeJson: string; createdAt: string; -} - -export interface RoleGrantRecord { - id: string; orgId: string; principalType: PrincipalType; principalId: string; roleKey: RoleKey; - scopeType: string; scopeId: string; grantedByEmployeeId: string | null; createdAt: string; -} - -export interface AccessDecisionRecord { - id: string; orgId: string; principalType: PrincipalType; principalId: string; action: string; - resourceType: string; resourceId: string; decision: AccessDecision; reason: string; createdAt: string; -} diff --git a/src/shared/operations-domain.ts b/src/shared/operations-domain.ts index 6c9ad279..5b2b8b08 100644 --- a/src/shared/operations-domain.ts +++ b/src/shared/operations-domain.ts @@ -16,14 +16,3 @@ export const SLO_SIGNAL_KEY = { export type FeatureFlagKey = ValueOf; export type SloSignalKey = ValueOf; -export interface FeatureFlagRecord { - id: string; orgId: string; key: FeatureFlagKey; label: string; enabled: boolean; createdAt: string; updatedAt: string; -} - -export interface SloTargetRecord { - id: string; orgId: string; signalKey: SloSignalKey; label: string; targetBasisPoints: number; windowMinutes: number; createdAt: string; updatedAt: string; -} - -export interface OperationalEventRecord { - id: string; orgId: string; signalKey: SloSignalKey; source: string; success: boolean; durationMs: number | null; detailJson: string; createdAt: string; -} diff --git a/src/shared/org-governance-domain.ts b/src/shared/org-governance-domain.ts index c3a36e34..14327cc3 100644 --- a/src/shared/org-governance-domain.ts +++ b/src/shared/org-governance-domain.ts @@ -3,22 +3,8 @@ type ValueOf = T[keyof T]; export const ROUTE_TO_TYPE = { EMPLOYEE: "employee", AGENT: "agent", BOARD: "board" } as const; export type RouteToType = ValueOf; -export interface OrgDepartmentRecord { - id: string; orgId: string; name: string; leadAgentId: string | null; - headcountLimit: number; monthlyBudgetLimitCents: number; createdAt: string; updatedAt: string; -} - export interface HiringPolicyRecord { id: string; orgId: string; departmentName: string; maxHeadcount: number; monthlyBudgetLimitCents: number; requireApprovalAboveCents: number; createdAt: string; updatedAt: string; } -export interface ApprovalRoutingRuleRecord { - id: string; orgId: string; kind: string; minimumRisk: string; - routeToType: RouteToType; routeToId: string; createdAt: string; -} - -export interface ApprovalAssignmentRecord { - id: string; orgId: string; approvalId: string; assigneeType: RouteToType; - assigneeId: string; routingRuleId: string | null; createdAt: string; -} diff --git a/src/shared/provider-auth-domain.ts b/src/shared/provider-auth-domain.ts index bd17bcfe..5c7f4cd4 100644 --- a/src/shared/provider-auth-domain.ts +++ b/src/shared/provider-auth-domain.ts @@ -16,19 +16,3 @@ export interface ProviderAuthProfileRecord { createdAt: string; } -export interface ProviderAuthSessionRecord { - id: string; - orgId: string; - providerId: string; - providerKey: string; - employeeId: string; - state: string; - nonce: string; - codeVerifierSha256: string; - redirectUri: string; - signInUrl: string; - partition: string; - createdAt: string; - expiresAt: string; - completedAt: string | null; -} diff --git a/src/shared/provider-runtime-domain.ts b/src/shared/provider-runtime-domain.ts index dd3fc414..45b22dda 100644 --- a/src/shared/provider-runtime-domain.ts +++ b/src/shared/provider-runtime-domain.ts @@ -26,15 +26,6 @@ export interface ProviderObservedRateLimitState { rawStateJson: string; } -export interface ProviderRateLimitObservationRecord extends ProviderObservedRateLimitState { - id: string; - orgId: string; - providerId: string; - providerKey: string; - model: string | null; - observedAt: string; -} - /** The message of the typed error a run gets when its provider's monthly request allowance is used up. */ export const PROVIDER_MONTHLY_LIMIT_MESSAGE = "Provider monthly request limit has been reached."; diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index 41312bcf..aea52d42 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -1,7 +1,6 @@ import type { OnboardingDraft } from "./commands"; import type { UsageStatsRecord } from "./usage-stats"; import type { IntegrationKeyStatus } from "./commands/integration-keys"; -import type { BoardInterventionRecord as BoardInterventionRecordModel } from "./board-domain"; import type { ChatAttachmentRecord, ChatMembershipRecord, @@ -15,7 +14,7 @@ import type { ChatReplyStatus, ChatSpaceAnchor, } from "./chat-domain"; -import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain"; +import type { ComplianceExportRecord } from "./compliance-domain"; import type { AgentRecord, ApprovalRecord, @@ -44,12 +43,10 @@ import type { TaskRunRecord, WorkspaceRecord, } from "./domain"; -import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-domain"; -import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain"; -import type { FeatureFlagRecord, OperationalEventRecord, SloTargetRecord } from "./operations-domain"; -import type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./provider-auth-domain"; +import type { HiringPolicyRecord } from "./org-governance-domain"; +import type { ProviderAuthProfileRecord } from "./provider-auth-domain"; import type { ProviderRequestRecord } from "./provider-request-domain"; -import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain"; +import type { ProviderRateLimitStateRecord } from "./provider-runtime-domain"; import type { BrowserActionPolicyRecord, BrowserActionRecord, @@ -75,6 +72,8 @@ export interface AppSnapshot { onboardingDraft: OnboardingDraft | null; /** Where the operator was: page, sections, filters and selected records, by key. */ viewState: Record; + /** The data version this snapshot was read at. */ + dataVersion: number; /** True while the operator has paused the autonomous run engine. */ autonomousPaused: boolean; integrationKeys: IntegrationKeyStatus[]; @@ -96,7 +95,6 @@ export interface AppSnapshot { chatAttachments: ChatAttachmentRecord[]; chatMentions: ChatMentionRecord[]; chatSlashInvocations: ChatSlashInvocationRecord[]; - boardInterventions: BoardInterventionRecordModel[]; chatPresence: ChatPresenceRecord[]; chatReadStates: ChatReadStateRecord[]; files: FileObjectRecord[]; @@ -105,13 +103,11 @@ export interface AppSnapshot { scheduledPosts: ScheduledPostRecord[]; providers: ProviderRecord[]; providerAuthProfiles: ProviderAuthProfileRecord[]; - providerAuthSessions: ProviderAuthSessionRecord[]; providerCatalog: ProviderCatalogEntry[]; providerRequests: ProviderRequestRecord[]; providerRateLimits: ProviderRateLimitStateRecord[]; /** Period statistics per org over all records, for charts and billing. */ usageStats: UsageStatsRecord[]; - providerRateLimitObservations: ProviderRateLimitObservationRecord[]; // The snapshot is the renderer's working set and rides IPC on every // refresh. Skill internals (revisions, bindings, per-run runtime // snapshots with compiled prompts) are main-process concerns and stay out @@ -127,19 +123,7 @@ export interface AppSnapshot { policyRules: PolicyRuleRecord[]; migrationReports: MigrationReportRecord[]; complianceExports: ComplianceExportRecord[]; - legalHolds: LegalHoldRecord[]; - retentionFreezes: RetentionFreezeRecord[]; - employeeIdentities: EmployeeIdentityRecord[]; - serviceIdentities: ServiceIdentityRecord[]; - roleGrants: RoleGrantRecord[]; - accessDecisions: AccessDecisionRecord[]; - orgDepartments: OrgDepartmentRecord[]; hiringPolicies: HiringPolicyRecord[]; - approvalRoutingRules: ApprovalRoutingRuleRecord[]; - approvalAssignments: ApprovalAssignmentRecord[]; - featureFlags: FeatureFlagRecord[]; - sloTargets: SloTargetRecord[]; - operationalEvents: OperationalEventRecord[]; auditEvents: AuditEventRecord[]; documents: DocumentRecord[]; knowledgeEntries: KnowledgeEntryRecord[]; diff --git a/tests/e2e/render-recovery.spec.ts b/tests/e2e/render-recovery.spec.ts index 8ee180c5..6822fe77 100644 --- a/tests/e2e/render-recovery.spec.ts +++ b/tests/e2e/render-recovery.spec.ts @@ -25,6 +25,10 @@ test("a record that breaks one page leaves the rest of the app working", async ( db.prepare("insert into approvals(id, org_id, kind, title, details, risk, requested_by_type, state, created_at) values ('app_bad', ?, 'task_risk', 'Broken request', 'd', 'unknown', 'system', 'pending', ?)") .run(orgId, new Date().toISOString()); db.close(); + // Written behind the app's back, so the test announces it as the app's own changes are. + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]!.webContents.send("agentcompany:renderer-event", { type: "snapshot-changed", version: Number.MAX_SAFE_INTEGER }); + }); await page.getByRole("button", { name: "Approvals" }).click(); const failure = page.getByRole("alert").filter({ hasText: "This page could not be shown." }); diff --git a/tests/e2e/startup-failure.spec.ts b/tests/e2e/startup-failure.spec.ts index 65cc4c79..370b96bd 100644 --- a/tests/e2e/startup-failure.spec.ts +++ b/tests/e2e/startup-failure.spec.ts @@ -54,7 +54,7 @@ test("data that can no longer be updated says so, from when it is, and recovers" db.close(); }; const announceChange = () => app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]!.webContents.send("agentcompany:renderer-event", { type: "snapshot-changed" }); + BrowserWindow.getAllWindows()[0]!.webContents.send("agentcompany:renderer-event", { type: "snapshot-changed", version: Number.MAX_SAFE_INTEGER }); }); try { const page = await app.firstWindow(); diff --git a/tests/helpers/table-rows.ts b/tests/helpers/table-rows.ts new file mode 100644 index 00000000..20787bdb --- /dev/null +++ b/tests/helpers/table-rows.ts @@ -0,0 +1,11 @@ +import type { DatabaseSync } from "node:sqlite"; +import { camel } from "../../src/main/services/snapshot-service"; + +/** A table's rows with camel-cased keys, for asserting backend records the app no longer ships to the window. */ +export function tableRows(db: DatabaseSync, table: string, orderBy: string, booleans: string[] = []): Array> { + return (db.prepare(`select * from ${table} order by ${orderBy}`).all() as Array>).map((row) => { + const next = camel(row as Record); + for (const key of booleans) next[key] = next[key] === 1; + return next; + }); +} diff --git a/tests/integration/collaboration-flow.test.ts b/tests/integration/collaboration-flow.test.ts index ca5c5365..b2177349 100644 --- a/tests/integration/collaboration-flow.test.ts +++ b/tests/integration/collaboration-flow.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { mkdirSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; @@ -104,9 +105,9 @@ describe("collaboration integration flow", () => { .get() as { compiled_prompt: string } | undefined; if (!snapshotRecord) throw new Error("Skill runtime snapshot was not created."); expect(state.approvals.find((approval) => approval.id === approvalId)?.state).toBe(APPROVAL_STATE.APPROVED); - expect(state.approvalAssignments.some((assignment) => assignment.approvalId === approvalId)).toBe(true); + expect(tableRows(connection.db, "approval_assignments", "created_at desc").some((assignment) => assignment.approvalId === approvalId)).toBe(true); expect(snapshotRecord.compiled_prompt).toContain("Release Gate"); - expect(state.operationalEvents.some((event) => event.signalKey === "skill_resolve_success")).toBe(true); + expect(tableRows(connection.db, "operational_events", "created_at desc", ["success"]).some((event) => event.signalKey === "skill_resolve_success")).toBe(true); connection.close(); }); }); diff --git a/tests/unit/chat-governance.test.ts b/tests/unit/chat-governance.test.ts index e1240c8b..a64822c5 100644 --- a/tests/unit/chat-governance.test.ts +++ b/tests/unit/chat-governance.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { writeFileSync } from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; @@ -567,10 +568,10 @@ describe("chat governance", () => { const state = snapshot.load(); const thread = state.chatThreads.find((item) => item.id === threadId); expect(thread).toMatchObject({ boardState: "takeover", controlledByType: "agent", controlledById: agentId }); - expect(state.boardInterventions.map((item) => item.action)).toEqual(["freeze", "rollback", "takeover", "force_approval"]); + expect(tableRows(connection.db, "board_interventions", "created_at asc").map((item) => item.action)).toEqual(["freeze", "rollback", "takeover", "force_approval"]); const forceApproval = state.approvals.find((item) => item.kind === "board_force_approval"); if (!forceApproval) throw new Error("Board force approval was not created."); - const forceIntervention = state.boardInterventions.find((item) => item.action === "force_approval"); + const forceIntervention = tableRows(connection.db, "board_interventions", "created_at asc").find((item) => item.action === "force_approval"); if (!forceIntervention) throw new Error("Board force approval intervention was not created."); expect(forceIntervention.approvalId).toBe(forceApproval.id); expect(state.chatSlashInvocations.some((item) => item.resultRef === forceIntervention.id)).toBe(true); diff --git a/tests/unit/compliance-service.test.ts b/tests/unit/compliance-service.test.ts index d58c0448..0eb5d216 100644 --- a/tests/unit/compliance-service.test.ts +++ b/tests/unit/compliance-service.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -167,7 +168,7 @@ describe("compliance service", () => { expect(redactedApproval?.decidedByEmployeeId).toBeNull(); expect(redactedSocialAccount?.authorizedEmployeeId).toBeNull(); expect(state.socialAccountGrants).toHaveLength(0); - expect(state.providerAuthSessions).toHaveLength(0); + expect(tableRows(connection.db, "provider_auth_sessions", "created_at desc")).toHaveLength(0); expect(state.complianceExports[0]?.exportType).toBe("gdpr_delete"); expect(state.complianceExports[0]?.requestedByEmployeeId).toBeNull(); expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "gdpr_deleted")).toBe(true); diff --git a/tests/unit/data-version.test.ts b/tests/unit/data-version.test.ts new file mode 100644 index 00000000..f26bced5 --- /dev/null +++ b/tests/unit/data-version.test.ts @@ -0,0 +1,35 @@ +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 { SnapshotService } from "../../src/main/services/snapshot-service"; +import { bumpDataVersion, currentDataVersion } from "../../src/main/services/data-version"; +import { supersedes } from "../../src/renderer/src/app/snapshot-order"; + +describe("data version", () => { + test("a snapshot read after a change carries that change's version, so its own change event needs no reload", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-data-version-${randomUUID()}.db`)); + const snapshot = new SnapshotService(connection.db); + const before = snapshot.load().dataVersion; + const announced = bumpDataVersion(); + const after = snapshot.load(); + expect(after.dataVersion).toBe(announced); + expect(announced).toBeGreaterThan(before); + // A later change is newer than what is shown, so it is reloaded. + expect(bumpDataVersion()).toBeGreaterThan(after.dataVersion); + expect(currentDataVersion()).toBe(after.dataVersion + 1); + // Records the window never reads are not shipped with every reload. + expect(Object.keys(after)).not.toEqual(expect.arrayContaining(["operationalEvents", "featureFlags", "approvalAssignments", "roleGrants"])); + connection.close(); + }); +}); + +describe("snapshot order", () => { + test("a late answer read at an older version never replaces newer data on screen", () => { + expect(supersedes(2, 1)).toBe(false); + expect(supersedes(2, 2)).toBe(true); + expect(supersedes(2, 3)).toBe(true); + expect(supersedes(-1, 0)).toBe(true); + }); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 9ce5a3f4..cbe76a8d 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -55,13 +56,13 @@ describe("domain services", () => { expect(state.organizations).toHaveLength(1); expect(state.employees[0]?.displayName).toBe("Dana Lee"); expect(state.providers.length).toBeGreaterThanOrEqual(6); - expect(state.employeeIdentities).toHaveLength(1); - expect(state.roleGrants.map((grant) => grant.roleKey).sort()).toEqual(["approval_decider", "hiring_manager", "org_admin"]); - expect(state.orgDepartments.map((department) => department.name)).toContain("Engineering"); + expect(tableRows(connection.db, "employee_identities", "created_at desc", ["active"])).toHaveLength(1); + expect(tableRows(connection.db, "role_grants", "created_at desc").map((grant) => grant.roleKey).sort()).toEqual(["approval_decider", "hiring_manager", "org_admin"]); + expect(tableRows(connection.db, "org_departments", "name asc").map((department) => department.name)).toContain("Engineering"); expect(state.hiringPolicies.some((policy) => policy.departmentName === "Engineering")).toBe(true); - expect(state.approvalRoutingRules[0]).toMatchObject({ kind: "*", minimumRisk: "low", routeToType: "employee" }); - expect(state.featureFlags.map((flag) => flag.key)).toContain("board_intervention"); - expect(state.sloTargets.map((target) => target.signalKey)).toContain("provider_request_success"); + expect(tableRows(connection.db, "approval_routing_rules", "created_at asc")[0]).toMatchObject({ kind: "*", minimumRisk: "low", routeToType: "employee" }); + expect(tableRows(connection.db, "feature_flags", "key asc", ["enabled"]).map((flag) => flag.key)).toContain("board_intervention"); + expect(tableRows(connection.db, "slo_targets", "signal_key asc").map((target) => target.signalKey)).toContain("provider_request_success"); expect(state.chatSpaces.map((space) => space.name)).toContain("board"); expect(state.policyRules[0]?.action).toBe("task_create"); expect(state.auditEvents[0]?.action).toBe("created"); @@ -100,7 +101,7 @@ describe("domain services", () => { const state = snapshot.load(); expect(state.tasks).toHaveLength(1); expect(state.approvals).toHaveLength(1); - expect(state.approvalAssignments).toHaveLength(1); + expect(tableRows(connection.db, "approval_assignments", "created_at desc")).toHaveLength(1); expect(state.approvals[0]?.state).toBe("pending"); expect(state.approvals[0]).toMatchObject({ sourceEntityType: "task", sourceEntityId: state.tasks[0]?.id }); connection.close(); @@ -140,7 +141,7 @@ describe("domain services", () => { expect(state.approvals).toHaveLength(1); expect(state.approvals[0]?.state).toBe("approved"); expect(state.approvals[0]?.decidedByEmployeeId).toBe(employeeId); - expect(state.approvalAssignments).toHaveLength(0); + expect(tableRows(connection.db, "approval_assignments", "created_at desc")).toHaveLength(0); connection.close(); }); diff --git a/tests/unit/identity-access-service.test.ts b/tests/unit/identity-access-service.test.ts index 5258aed5..27fc0906 100644 --- a/tests/unit/identity-access-service.test.ts +++ b/tests/unit/identity-access-service.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -58,8 +59,8 @@ describe("identity access service", () => { }); const state = snapshot.load(); - expect(state.employeeIdentities.some((identity) => identity.source === "sso" && identity.email === "dana@example.com")).toBe(true); - expect(state.roleGrants.some((grant) => grant.principalId === employeeId && grant.roleKey === ROLE_KEY.APPROVAL_DECIDER)).toBe(true); + expect(tableRows(connection.db, "employee_identities", "created_at desc", ["active"]).some((identity) => identity.source === "sso" && identity.email === "dana@example.com")).toBe(true); + expect(tableRows(connection.db, "role_grants", "created_at desc").some((grant) => grant.principalId === employeeId && grant.roleKey === ROLE_KEY.APPROVAL_DECIDER)).toBe(true); expect(state.auditEvents.some((event) => event.entityType === "employee_identity" && event.action === "synced")).toBe(true); connection.close(); }); diff --git a/tests/unit/operations-service.test.ts b/tests/unit/operations-service.test.ts index 150756ed..87b44ab1 100644 --- a/tests/unit/operations-service.test.ts +++ b/tests/unit/operations-service.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,7 +8,6 @@ import { OperationsService } from "../../src/main/services/operations-service"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ProviderService } from "../../src/main/services/provider-service"; import { providerAdapters, type ProviderAdapterRegistry } from "../../src/main/services/provider-adapters"; -import { SnapshotService } from "../../src/main/services/snapshot-service"; import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE } from "../../src/shared/domain"; function createConnection(): DatabaseConnection { @@ -37,20 +37,18 @@ describe("operations service", () => { test("seeds feature flags and SLO targets, and no drill that scores configuration as fault handling", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); - const snapshot = new SnapshotService(connection.db); connection.transaction(() => { createOrganization(organization); }); - const state = snapshot.load(); - expect(state.featureFlags.map((flag) => flag.key).sort()).toEqual([ + expect(tableRows(connection.db, "feature_flags", "key asc", ["enabled"]).map((flag) => flag.key).sort()).toEqual([ "board_intervention", "provider_background_execution", "social_publish_approval", ]); // Every SLO target is fed by a runtime source; none waits on a signal only a drill would write. - expect(state.sloTargets.map((target) => target.signalKey).sort()).toEqual([ + expect(tableRows(connection.db, "slo_targets", "signal_key asc").map((target) => target.signalKey).sort()).toEqual([ "approval_route_coverage", "migration_validation_success", "provider_request_success", "skill_resolve_success", ]); expect(connection.db.prepare("select name from sqlite_master where type = 'table' and name like 'chaos_drill%'").all()).toEqual([]); diff --git a/tests/unit/org-governance-service.test.ts b/tests/unit/org-governance-service.test.ts index 0e3172e2..5e7c7979 100644 --- a/tests/unit/org-governance-service.test.ts +++ b/tests/unit/org-governance-service.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -72,8 +73,8 @@ describe("org governance service", () => { }); const state = snapshot.load(); - expect(state.approvalAssignments).toHaveLength(1); - expect(state.approvalAssignments[0]).toMatchObject({ approvalId, assigneeType: "employee" }); + expect(tableRows(connection.db, "approval_assignments", "created_at desc")).toHaveLength(1); + expect(tableRows(connection.db, "approval_assignments", "created_at desc")[0]).toMatchObject({ approvalId, assigneeType: "employee" }); expect(state.auditEvents.some((event) => event.entityType === "approval" && event.action === "routed")).toBe(true); connection.close(); }); @@ -107,8 +108,8 @@ describe("org governance service", () => { const state = snapshot.load(); const approval = state.approvals.find((item) => item.kind === "agent_hire"); expect(approval).toMatchObject({ orgId, title: "Review hire: Ada", risk: RISK_LEVEL.MEDIUM }); - expect(state.approvalAssignments).toHaveLength(1); - expect(state.approvalAssignments[0]).toMatchObject({ approvalId: approval?.id, assigneeType: "employee" }); + expect(tableRows(connection.db, "approval_assignments", "created_at desc")).toHaveLength(1); + expect(tableRows(connection.db, "approval_assignments", "created_at desc")[0]).toMatchObject({ approvalId: approval?.id, assigneeType: "employee" }); connection.close(); }); }); diff --git a/tests/unit/provider-rate-limit-registry.test.ts b/tests/unit/provider-rate-limit-registry.test.ts index c2b5471c..c736403b 100644 --- a/tests/unit/provider-rate-limit-registry.test.ts +++ b/tests/unit/provider-rate-limit-registry.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -5,7 +6,6 @@ import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; import { parseProviderRateLimitHeaders, ProviderRateLimitRegistry } from "../../src/main/services/provider-rate-limit-registry"; -import { SnapshotService } from "../../src/main/services/snapshot-service"; function createConnection(): DatabaseConnection { return new DatabaseConnection(join(tmpdir(), `agentcompany-provider-rate-limits-${randomUUID()}.db`)); @@ -52,7 +52,6 @@ describe("provider rate limit registry", () => { test("persists observed quota state into snapshots", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); - const snapshot = new SnapshotService(connection.db); const registry = new ProviderRateLimitRegistry(connection.db); let orgId = ""; let openAiProviderId = ""; @@ -75,7 +74,7 @@ describe("provider rate limit registry", () => { }); }); - expect(snapshot.load().providerRateLimitObservations[0]).toMatchObject({ + expect(tableRows(connection.db, "provider_rate_limit_observations", "observed_at desc")[0]).toMatchObject({ orgId, providerId: openAiProviderId, providerKey: "openai", diff --git a/tests/unit/provider-service.test.ts b/tests/unit/provider-service.test.ts index 417382fb..ca9ccb63 100644 --- a/tests/unit/provider-service.test.ts +++ b/tests/unit/provider-service.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -238,7 +239,7 @@ describe("provider service", () => { responseText: "api_key:gpt-5.4:Draft board memo", state: "succeeded", }); - expect(state.operationalEvents[0]).toMatchObject({ signalKey: "provider_request_success", source: "provider_gateway", success: true }); + expect(tableRows(connection.db, "operational_events", "created_at desc", ["success"])[0]).toMatchObject({ signalKey: "provider_request_success", source: "provider_gateway", success: true }); expect(state.providers.find((provider) => provider.id === openAiProviderId)?.healthState).toBe("healthy"); connection.close(); }); @@ -246,7 +247,6 @@ describe("provider service", () => { test("records provider response rate limit observations", async () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); - const snapshot = new SnapshotService(connection.db); const adapters: ProviderAdapterRegistry = { ...providerAdapters, openai: { @@ -281,7 +281,7 @@ describe("provider service", () => { instructions: null, }); - expect(snapshot.load().providerRateLimitObservations[0]).toMatchObject({ + expect(tableRows(connection.db, "provider_rate_limit_observations", "observed_at desc")[0]).toMatchObject({ orgId, providerId: openAiProviderId, providerKey: "openai", @@ -337,7 +337,7 @@ describe("provider service", () => { state: "failed", errorMessage: "rate limited", }); - expect(state.providerRateLimitObservations[0]).toMatchObject({ + expect(tableRows(connection.db, "provider_rate_limit_observations", "observed_at desc")[0]).toMatchObject({ orgId, providerId: openAiProviderId, providerKey: "openai", diff --git a/tests/unit/social-service.test.ts b/tests/unit/social-service.test.ts index 7e224ce2..55776d34 100644 --- a/tests/unit/social-service.test.ts +++ b/tests/unit/social-service.test.ts @@ -1,3 +1,4 @@ +import { tableRows } from "../helpers/table-rows"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -274,7 +275,6 @@ describe("social service", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); const social = new SocialService(connection.db); - const snapshot = new SnapshotService(connection.db); let orgId = ""; let authorizerId = ""; let approvalId = ""; @@ -303,8 +303,7 @@ describe("social service", () => { .get(postId) as { approval_id: string }).approval_id; }); - const state = snapshot.load(); - expect(state.approvalAssignments).toContainEqual(expect.objectContaining({ + expect(tableRows(connection.db, "approval_assignments", "created_at desc")).toContainEqual(expect.objectContaining({ approvalId, assigneeType: ROUTE_TO_TYPE.EMPLOYEE, assigneeId: authorizerId,