diff --git a/src/main/database/migrations/051-purge-runtime-snapshot-bloat.ts b/src/main/database/migrations/051-purge-runtime-snapshot-bloat.ts new file mode 100644 index 00000000..5a951b6f --- /dev/null +++ b/src/main/database/migrations/051-purge-runtime-snapshot-bloat.ts @@ -0,0 +1,10 @@ +// Rows written before the compiled-prompt cap carried the entire skill +// catalogue (~1.1MB each; 144MB observed in a real profile). Nothing reads a +// finished run's compiled prompt back, and an interrupted run simply +// recompiles under the cap on recovery, so the table drains cleanly. +export const purgeRuntimeSnapshotBloatSchema = ` + delete from skill_runtime_snapshots; + + insert into schema_migrations(version, name, applied_at) + values (51, 'purge_runtime_snapshot_bloat', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index ca9265d9..39c56542 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -49,6 +49,7 @@ import { authConfigPurgeSchema } from "./migrations/047-auth-config-purge"; import { uiPreferencesSchema } from "./migrations/048-ui-preferences"; import { avatarsSchema } from "./migrations/049-avatars"; import { financeSalesDepartmentsSchema } from "./migrations/050-finance-sales-departments"; +import { purgeRuntimeSnapshotBloatSchema } from "./migrations/051-purge-runtime-snapshot-bloat"; interface Migration { version: number; @@ -107,6 +108,7 @@ const MIGRATIONS: Migration[] = [ { version: 48, name: "ui_preferences", sql: uiPreferencesSchema }, { version: 49, name: "avatars", sql: avatarsSchema }, { version: 50, name: "finance_sales_departments", sql: financeSalesDepartmentsSchema }, + { version: 51, name: "purge_runtime_snapshot_bloat", sql: purgeRuntimeSnapshotBloatSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index 026e6850..53442370 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -178,9 +178,12 @@ export class SkillService { * compiled prompt instead of silently overflowing the model's context. */ resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string { + // Idempotent per run: a run interrupted after resolving (crash, quit) is + // re-queued by startup recovery and must reuse its frozen skill set, not + // die on a uniqueness error that burns real retry budget. const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?") .get(input.agentId, input.runId) as { id: string } | undefined; - if (existingSnapshot) throw new Error("Skill runtime snapshot already exists for this run."); + if (existingSnapshot) return existingSnapshot.id; const forcedRows = this.db.prepare(` select sd.id as skill_id, diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 74db2c13..774cb6b8 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -62,8 +62,6 @@ export class SnapshotService { ); const providerUsageWindows = list(this.db, "provider_usage_windows", "window_start desc"); const providerRateLimits = buildProviderRateLimits(providers, providerUsageWindows); - const skillBindings = list(this.db, "skill_bindings", "priority asc") - .map((binding) => ({ ...binding, enabled: toBool(binding.enabled) })); 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") @@ -115,14 +113,18 @@ export class SnapshotService { providerAuthProfiles, providerAuthSessions, providerCatalog: PROVIDER_CATALOG, - providerRequests: recentList(this.db, "provider_requests", "created_at desc", 100), + // Explicit projection: raw_response_json alone weighs megabytes across + // 100 rows and no renderer surface reads it. + providerRequests: all(this.db.prepare(` + select id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256, + response_text, state, error_message, input_char_count, output_char_count, + execution_mode, policy_decision, external_response_id, stream_event_count, + cancelled_at, created_at, completed_at + from provider_requests order by created_at desc limit 100 + `)).map((row) => camel(row) as unknown as AppSnapshot["providerRequests"][number]), providerRateLimits, providerRateLimitObservations, skillDefinitions: list(this.db, "skill_definitions", "created_at asc"), - skillRevisions: list(this.db, "skill_revisions", "published_at desc"), - skillBindings, - skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"), - skillTests: list(this.db, "skill_tests", "last_run_at desc, id asc"), socialAccounts: list(this.db, "social_accounts", "created_at asc"), socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"), browserSessionContainers: list(this.db, "browser_session_containers", "created_at asc") diff --git a/src/shared/provider-request-domain.ts b/src/shared/provider-request-domain.ts index 041d35d1..86b57740 100644 --- a/src/shared/provider-request-domain.ts +++ b/src/shared/provider-request-domain.ts @@ -10,6 +10,11 @@ export const PROVIDER_REQUEST_STATE = { export type ProviderRequestState = typeof PROVIDER_REQUEST_STATE[keyof typeof PROVIDER_REQUEST_STATE]; +/** + * Snapshot projection of a provider request. The raw provider response body + * stays in the database (task_runs diagnostics read it there) — shipping it + * to the renderer ballooned every snapshot by megabytes for nothing. + */ export interface ProviderRequestRecord { id: string; orgId: string; @@ -19,7 +24,6 @@ export interface ProviderRequestRecord { model: string; promptSha256: string; responseText: string | null; - rawResponseJson: string | null; state: ProviderRequestState; errorMessage: string | null; inputCharCount: number; diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index 3a0caff3..2579866c 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -34,11 +34,7 @@ import type { ProviderCatalogEntry, ProviderRecord, ScheduledPostRecord, - SkillBindingRecord, SkillDefinitionRecord, - SkillRevisionRecord, - SkillRuntimeSnapshotRecord, - SkillTestRecord, SprintRecord, TaskRecord, TaskRunRecord, @@ -103,11 +99,11 @@ export interface AppSnapshot { providerRequests: ProviderRequestRecord[]; providerRateLimits: ProviderRateLimitStateRecord[]; 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 + // of it; only the lightweight catalogue listing ships. skillDefinitions: SkillDefinitionRecord[]; - skillRevisions: SkillRevisionRecord[]; - skillBindings: SkillBindingRecord[]; - skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[]; - skillTests: SkillTestRecord[]; socialAccounts: SocialAccountRecord[]; socialAccountGrants: SocialAccountGrantRecord[]; browserSessionContainers: BrowserSessionContainerRecord[]; diff --git a/tests/integration/collaboration-flow.test.ts b/tests/integration/collaboration-flow.test.ts index 4f1157c4..19e332ac 100644 --- a/tests/integration/collaboration-flow.test.ts +++ b/tests/integration/collaboration-flow.test.ts @@ -106,12 +106,15 @@ describe("collaboration integration flow", () => { const state = snapshot.load(); const evalRun = state.evalRuns.find((run) => run.id === evalRunId); - const snapshotRecord = state.skillRuntimeSnapshots[0]; + // Compiled runtime snapshots live in the database only - the renderer + // snapshot must not carry per-run compiled prompts. + const snapshotRecord = connection.db.prepare("select compiled_prompt from skill_runtime_snapshots limit 1") + .get() as { compiled_prompt: string } | undefined; if (!evalRun) throw new Error("Integration eval run was not created."); 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(snapshotRecord.compiledPrompt).toContain("Release Gate"); + expect(snapshotRecord.compiled_prompt).toContain("Release Gate"); expect(evalRun).toMatchObject({ state: SDLC_RUN_STATE.PASSED, failedCount: 0 }); expect(state.operationalEvents.some((event) => event.signalKey === "skill_resolve_success")).toBe(true); connection.close(); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index c40cbc17..7d05c030 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,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, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50]); + 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, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51]); connection.close(); }); @@ -189,15 +189,24 @@ describe("domain services", () => { skills.resolveRuntimeSnapshot({ orgId, agentId, runId: "run-review-1" }); }); + // Catalogue metadata ships in the snapshot; skill internals stay in the + // database (the snapshot rides IPC on every refresh). const state = snapshot.load(); expect(state.skillDefinitions[0]?.slug).toBe("review-routes"); - expect(state.skillRevisions[0]?.versionSemver).toBe("1.0.0"); - expect(state.skillRevisions[0]?.immutableChecksum).toHaveLength(64); - expect(state.skillRevisions[0]?.manifestJson).toContain("architecture-review"); - expect(state.skillTests[0]?.lastResult).toContain("\"status\":\"passed\""); - expect(state.skillTests[0]?.lastRunAt).toBeTruthy(); - expect(state.skillRuntimeSnapshots[0]?.sourceRevisionIds).toContain(revisionId); - expect(state.skillRuntimeSnapshots[0]?.compiledPrompt).toContain("# Review Routes"); + expect(state).not.toHaveProperty("skillRuntimeSnapshots"); + const revision = connection.db.prepare("select version_semver, immutable_checksum, manifest_json from skill_revisions where id = ?") + .get(revisionId) as { version_semver: string; immutable_checksum: string; manifest_json: string }; + expect(revision.version_semver).toBe("1.0.0"); + expect(revision.immutable_checksum).toHaveLength(64); + expect(revision.manifest_json).toContain("architecture-review"); + const skillTest = connection.db.prepare("select last_result, last_run_at from skill_tests where revision_id = ?") + .get(revisionId) as { last_result: string; last_run_at: string }; + expect(skillTest.last_result).toContain("\"status\":\"passed\""); + expect(skillTest.last_run_at).toBeTruthy(); + const runtime = connection.db.prepare("select source_revision_ids, compiled_prompt from skill_runtime_snapshots where run_id = 'run-review-1'") + .get() as { source_revision_ids: string; compiled_prompt: string }; + expect(runtime.source_revision_ids).toContain(revisionId); + expect(runtime.compiled_prompt).toContain("# Review Routes"); expect(state.auditEvents.some((event) => event.entityType === "skill_revision" && event.action === "tests_run")).toBe(true); connection.close(); }); @@ -238,9 +247,12 @@ describe("domain services", () => { .get(orgA, "review-routes") as { id: string }).id; expect(() => skills.bind({ orgId: orgA, agentId: agentB, skillId, revisionId })).toThrow("Agent was not found."); skills.bind({ orgId: orgA, agentId: agentA, skillId, revisionId }); - skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1" }); - expect(() => skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1" })) - .toThrow("Skill runtime snapshot already exists for this run."); + const firstResolve = skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1" }); + // Idempotent per run: a recovered run reuses its frozen snapshot + // instead of failing on the uniqueness guard. + expect(skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1" })).toBe(firstResolve); + expect(connection.db.prepare("select count(*) as n from skill_runtime_snapshots where run_id = 'run-review-1'").get()) + .toEqual({ n: 1 }); }); expect(agentA).not.toBe(agentB); diff --git a/tests/unit/snapshot-payload.test.ts b/tests/unit/snapshot-payload.test.ts new file mode 100644 index 00000000..604d1c8b --- /dev/null +++ b/tests/unit/snapshot-payload.test.ts @@ -0,0 +1,49 @@ +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 { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; + +/** + * The snapshot is serialized over IPC on every refresh, so its size is a + * user-facing performance contract. A real profile once accumulated 144MB of + * per-run compiled skill prompts that rode along on every load and froze the + * whole app - these tests pin that regression class shut. + */ +describe("snapshot payload", () => { + test("per-run compiled prompts and raw provider bodies never ride the snapshot", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-snapshot-payload-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + const agentId = organization.createAgent({ + orgId, name: "Ada", role: "Engineer", department: "Engineering", + modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, + }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? limit 1").get(orgId) as { id: string }).id; + const megabyte = "x".repeat(1_048_576); + connection.db.prepare(` + insert into skill_runtime_snapshots(id, agent_id, run_id, compiled_prompt, tools_manifest, source_revision_ids, created_at) + values ('srs_fat', ?, 'run_fat', ?, '{}', '[]', ?) + `).run(agentId, megabyte, new Date().toISOString()); + connection.db.prepare(` + insert into provider_requests(id, org_id, provider_id, provider_key, model, prompt_sha256, response_text, raw_response_json, state, error_message, created_at, completed_at) + values ('prq_fat', ?, ?, 'openai', 'gpt-5.4', 'sha', 'short answer', ?, 'succeeded', null, ?, ?) + `).run(orgId, providerId, megabyte, new Date().toISOString(), new Date().toISOString()); + }); + + const state = snapshot.load(); + expect(state).not.toHaveProperty("skillRuntimeSnapshots"); + expect(state).not.toHaveProperty("skillRevisions"); + const request = state.providerRequests.find((row) => row.id === "prq_fat"); + expect(request?.responseText).toBe("short answer"); + expect(request).not.toHaveProperty("rawResponseJson"); + // Two megabytes of database bloat must not translate into snapshot bytes. + expect(JSON.stringify(state).length).toBeLessThan(400_000); + connection.close(); + }); +}); diff --git a/tests/unit/task-execution-service.test.ts b/tests/unit/task-execution-service.test.ts index 3acd91fb..6cd1c86c 100644 --- a/tests/unit/task-execution-service.test.ts +++ b/tests/unit/task-execution-service.test.ts @@ -114,17 +114,19 @@ describe("task execution service", () => { const state = snapshot.load(); const run = state.taskRuns.find((item) => item.id === runId); const request = state.providerRequests.find((item) => item.id === run?.providerRequestId); - const skillRuntimeSnapshot = state.skillRuntimeSnapshots.find((item) => item.runId === runId); + const skillRuntimeSnapshot = connection.db.prepare( + "select agent_id, run_id, compiled_prompt, source_revision_ids from skill_runtime_snapshots where run_id = ?", + ).get(runId) as { agent_id: string; run_id: string; compiled_prompt: string; source_revision_ids: string } | undefined; expect(run).toMatchObject({ taskId, agentId: expect.any(String), providerId, state: "completed", errorMessage: null }); expect(request).toMatchObject({ providerId, workspaceId, state: "succeeded", responseText: "executed:gpt-5.4" }); - expect(skillRuntimeSnapshot).toMatchObject({ agentId, runId }); + expect(skillRuntimeSnapshot).toMatchObject({ agent_id: agentId, run_id: runId }); expect(run?.executionContextId).toMatch(/^ctx_/); expect(run?.serviceIdentityId).toMatch(/^sid_/); expect(JSON.parse(run?.workspaceMountJson ?? "[]")).toEqual([expect.objectContaining({ workspaceId, mode: "read_only" })]); expect(JSON.parse(run?.secretScopeJson ?? "{}")).toMatchObject({ providerIds: [providerId], credentialAccess: "provider_runtime_only" }); - expect(skillRuntimeSnapshot?.compiledPrompt).toContain("Verify release readiness"); - expect(skillRuntimeSnapshot?.sourceRevisionIds).toContain(revisionId); + expect(skillRuntimeSnapshot?.compiled_prompt).toContain("Verify release readiness"); + expect(skillRuntimeSnapshot?.source_revision_ids).toContain(revisionId); expect(capturedPrompt).toContain("Task: Draft launch checklist"); expect(capturedPrompt).toContain("Assignee: Ada (Execution lead)"); expect(capturedPrompt).toContain("Workspace: Task Workspace");