From 05094fe9e1d7716d421a8cbc8e6d5db7f847431c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 25 Mar 2026 12:19:26 +0700 Subject: [PATCH] Phase 5: remove legacy storage paths and dead compatibility code --- ...ty-service.ts => agent-loading-service.ts} | 20 +-- .../server/src/server/agent/agent-manager.ts | 11 +- .../src/server/agent/agent-snapshot-store.ts | 3 - packages/server/src/server/bootstrap.ts | 3 - .../server/db/db-agent-snapshot-store.test.ts | 13 -- .../src/server/db/db-agent-snapshot-store.ts | 10 -- ...der-history-compatibility-boundary.test.ts | 8 +- ...ider-history-compatibility-service.test.ts | 20 +-- ...er-history-compatibility-ownership.test.ts | 24 ++- packages/server/src/server/session.ts | 34 ++-- .../snapshot-mutation-ownership.test.ts | 2 - .../workspace-registry-bootstrap.test.ts | 5 +- .../server/workspace-registry.test-helpers.ts | 162 ++++++++++++++++++ .../src/server/workspace-registry.test.ts | 6 +- .../server/src/server/workspace-registry.ts | 151 ---------------- 15 files changed, 233 insertions(+), 239 deletions(-) rename packages/server/src/server/{provider-history-compatibility-service.ts => agent-loading-service.ts} (83%) create mode 100644 packages/server/src/server/workspace-registry.test-helpers.ts diff --git a/packages/server/src/server/provider-history-compatibility-service.ts b/packages/server/src/server/agent-loading-service.ts similarity index 83% rename from packages/server/src/server/provider-history-compatibility-service.ts rename to packages/server/src/server/agent-loading-service.ts index a57388ed8a..313aef9445 100644 --- a/packages/server/src/server/provider-history-compatibility-service.ts +++ b/packages/server/src/server/agent-loading-service.ts @@ -13,7 +13,7 @@ import { const pendingAgentBootstrapLoads = new Map>(); -export type ProviderHistoryCompatibilityServiceOptions = { +export type AgentLoadingServiceOptions = { agentManager: Pick< AgentManager, | "createAgent" @@ -25,17 +25,16 @@ export type ProviderHistoryCompatibilityServiceOptions = { logger: pino.Logger; }; -// Compatibility-only bridge for runtime paths that still need coordinated cold -// load, explicit resume, or refresh behavior. -export class ProviderHistoryCompatibilityService { - private readonly agentManager: ProviderHistoryCompatibilityServiceOptions["agentManager"]; - private readonly agentStorage: ProviderHistoryCompatibilityServiceOptions["agentStorage"]; +// Coordinates cold loads, explicit resumes, and refreshes for persisted agents. +export class AgentLoadingService { + private readonly agentManager: AgentLoadingServiceOptions["agentManager"]; + private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"]; private readonly logger: pino.Logger; - constructor(options: ProviderHistoryCompatibilityServiceOptions) { + constructor(options: AgentLoadingServiceOptions) { this.agentManager = options.agentManager; this.agentStorage = options.agentStorage; - this.logger = options.logger.child({ component: "provider-history-compatibility" }); + this.logger = options.logger.child({ component: "agent-loading" }); } async ensureAgentLoaded(options: { agentId: string }): Promise { @@ -66,10 +65,7 @@ export class ProviderHistoryCompatibilityService { handle: AgentPersistenceHandle; overrides?: Partial; }): Promise { - return this.agentManager.resumeAgentFromPersistence( - options.handle, - options.overrides, - ); + return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides); } async refreshAgent(options: { agentId: string }): Promise { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 050bf46920..76144dd6e5 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1397,12 +1397,17 @@ export class AgentManager { return iterator.done ? null : iterator.value; } + /** + * Test-only compatibility hook for managers constructed without a durable + * timeline store. Production loads committed history from the durable store + * during session registration instead of replaying provider history here. + */ async hydrateTimelineFromProvider(agentId: string): Promise { const agent = this.requireAgent(agentId); if (this.durableTimelineStore) { return; } - await this.hydrateTimeline(agent); + await this.hydrateTimelineFromLegacyProviderHistory(agent); } async deleteCommittedTimeline(agentId: string): Promise { @@ -1885,7 +1890,9 @@ export class AgentManager { } } - private async hydrateTimeline(agent: ActiveManagedAgent): Promise { + private async hydrateTimelineFromLegacyProviderHistory( + agent: ActiveManagedAgent, + ): Promise { if (agent.historyPrimed) { return; } diff --git a/packages/server/src/server/agent/agent-snapshot-store.ts b/packages/server/src/server/agent/agent-snapshot-store.ts index b307b1cef6..06ffc8fe31 100644 --- a/packages/server/src/server/agent/agent-snapshot-store.ts +++ b/packages/server/src/server/agent/agent-snapshot-store.ts @@ -2,16 +2,13 @@ import type { ManagedAgent } from "./agent-manager.js"; import type { StoredAgentRecord } from "./agent-storage.js"; export interface AgentSnapshotStore { - initialize(): Promise; list(): Promise; get(agentId: string): Promise; upsert(record: StoredAgentRecord): Promise; - beginDelete(agentId: string): void; remove(agentId: string): Promise; applySnapshot( agent: ManagedAgent, options?: { title?: string | null; internal?: boolean }, ): Promise; setTitle(agentId: string, title: string): Promise; - flush(): Promise; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0451ca8e35..3edc69a1d1 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -379,8 +379,6 @@ export async function createPaseoDaemon( }); const terminalManager = createTerminalManager(); - await agentStorage.initialize(); - logger.info({ elapsed: elapsed() }, "Agent storage initialized"); const projectRegistry = new DbProjectRegistry(database.db); const workspaceRegistry = new DbWorkspaceRegistry(database.db); await importLegacyProjectWorkspaceJson({ @@ -719,7 +717,6 @@ export async function createPaseoDaemon( const stop = async () => { await closeAllAgents(logger, agentManager); await agentManager.flush().catch(() => undefined); - await agentStorage.flush().catch(() => undefined); await shutdownProviders(logger, { runtimeSettings: config.agentProviderSettings, }); diff --git a/packages/server/src/server/db/db-agent-snapshot-store.test.ts b/packages/server/src/server/db/db-agent-snapshot-store.test.ts index 67c25c2dfe..1da5ba47e9 100644 --- a/packages/server/src/server/db/db-agent-snapshot-store.test.ts +++ b/packages/server/src/server/db/db-agent-snapshot-store.test.ts @@ -137,7 +137,6 @@ describe("DbAgentSnapshotStore", () => { dataDir = path.join(tmpDir, "db"); database = await openPaseoDatabase(dataDir); store = new DbAgentSnapshotStore(database.db); - await store.initialize(); }); afterEach(async () => { @@ -219,18 +218,6 @@ describe("DbAgentSnapshotStore", () => { ); }); - test("beginDelete and flush are safe no-ops", async () => { - await store.upsert(createStoredAgentRecord({ id: "agent-noop" })); - - expect(() => store.beginDelete("agent-noop")).not.toThrow(); - await expect(store.flush()).resolves.toBeUndefined(); - expect(await store.get("agent-noop")).toEqual( - expect.objectContaining({ - id: "agent-noop", - }), - ); - }); - test("upsert is idempotent for the same agent ID", async () => { await store.upsert(createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" })); await store.upsert( diff --git a/packages/server/src/server/db/db-agent-snapshot-store.ts b/packages/server/src/server/db/db-agent-snapshot-store.ts index 10d0111706..bd26485479 100644 --- a/packages/server/src/server/db/db-agent-snapshot-store.ts +++ b/packages/server/src/server/db/db-agent-snapshot-store.ts @@ -95,10 +95,6 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore { this.db = db; } - async initialize(): Promise { - return Promise.resolve(); - } - async list(): Promise { const rows = await this.db .select() @@ -132,8 +128,6 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore { }); } - beginDelete(_agentId: string): void {} - async remove(agentId: string): Promise { await this.db.delete(agentSnapshots).where(eq(agentSnapshots.agentId, agentId)); } @@ -168,10 +162,6 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore { await this.upsert({ ...record, title }); } - async flush(): Promise { - return Promise.resolve(); - } - private async resolveWorkspaceId(cwd: string): Promise { const workspaceId = normalizeWorkspaceId(cwd); const rows = await this.db diff --git a/packages/server/src/server/provider-history-compatibility-boundary.test.ts b/packages/server/src/server/provider-history-compatibility-boundary.test.ts index ac74d5fb72..df3e50ff97 100644 --- a/packages/server/src/server/provider-history-compatibility-boundary.test.ts +++ b/packages/server/src/server/provider-history-compatibility-boundary.test.ts @@ -1,15 +1,15 @@ import { readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; -describe("provider history compatibility boundary", () => { +describe("agent loading boundary", () => { test("session runtime code does not directly hydrate provider history", () => { const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8"); - const compatibilitySource = readFileSync( - new URL("./provider-history-compatibility-service.ts", import.meta.url), + const agentLoadingSource = readFileSync( + new URL("./agent-loading-service.ts", import.meta.url), "utf8", ); expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/); - expect(compatibilitySource).not.toMatch(/hydrateTimelineFromProvider\s*\(/); + expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/); }); }); diff --git a/packages/server/src/server/provider-history-compatibility-service.test.ts b/packages/server/src/server/provider-history-compatibility-service.test.ts index 16493e6afc..a946770864 100644 --- a/packages/server/src/server/provider-history-compatibility-service.test.ts +++ b/packages/server/src/server/provider-history-compatibility-service.test.ts @@ -4,11 +4,11 @@ import path from "node:path"; import pino from "pino"; import { describe, expect, test, vi } from "vitest"; +import { AgentLoadingService } from "./agent-loading-service.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js"; import { openPaseoDatabase } from "./db/pglite-database.js"; -import { ProviderHistoryCompatibilityService } from "./provider-history-compatibility-service.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; function createStoredAgentRecord(overrides?: Partial): StoredAgentRecord { @@ -56,7 +56,7 @@ function createCompatibilitySnapshot(overrides?: Partial }; } -describe("ProviderHistoryCompatibilityService", () => { +describe("AgentLoadingService", () => { test("ensureAgentLoaded seeds the live timeline from durable rows for an unloaded persisted agent", async () => { const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-load-")); const logger = pino({ level: "silent" }); @@ -71,7 +71,7 @@ describe("ProviderHistoryCompatibilityService", () => { logger, idFactory: () => "00000000-0000-4000-8000-000000000301", }); - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: manager as any, agentStorage: storage as any, logger, @@ -118,7 +118,7 @@ describe("ProviderHistoryCompatibilityService", () => { logger, idFactory: () => "00000000-0000-4000-8000-000000000302", }); - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: manager as any, agentStorage: storage as any, logger, @@ -169,7 +169,7 @@ describe("ProviderHistoryCompatibilityService", () => { info: vi.fn(), warn: vi.fn(), }; - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: agentManager as any, agentStorage: agentStorage as any, logger: logger as any, @@ -200,7 +200,7 @@ describe("ProviderHistoryCompatibilityService", () => { info: vi.fn(), warn: vi.fn(), }; - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: agentManager as any, agentStorage: { get: async () => null, @@ -248,7 +248,7 @@ describe("ProviderHistoryCompatibilityService", () => { info: vi.fn(), warn: vi.fn(), }; - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: agentManager as any, agentStorage: { get: async () => null, @@ -278,7 +278,7 @@ describe("ProviderHistoryCompatibilityService", () => { info: vi.fn(), warn: vi.fn(), }; - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: agentManager as any, agentStorage: { get: async () => null, @@ -313,7 +313,7 @@ describe("ProviderHistoryCompatibilityService", () => { info: vi.fn(), warn: vi.fn(), }; - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: agentManager as any, agentStorage: { get: vi.fn(async () => record), @@ -352,7 +352,7 @@ describe("ProviderHistoryCompatibilityService", () => { }); test("refreshAgent preserves the unloaded no-persistence error", async () => { - const service = new ProviderHistoryCompatibilityService({ + const service = new AgentLoadingService({ agentManager: { getAgent: vi.fn(() => null), resumeAgentFromPersistence: vi.fn(), diff --git a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts index bdfdb29b60..97acd7fa01 100644 --- a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts +++ b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts @@ -39,7 +39,7 @@ function createCompatibilitySnapshot(overrides?: Partial } function createSessionForOwnershipTests(options?: { - providerHistoryCompatibilityService?: { + agentLoadingService?: { ensureAgentLoaded?: (options: { agentId: string }) => Promise; resumeAgent?: (options: { handle: { provider: string; sessionId: string }; @@ -66,17 +66,13 @@ function createSessionForOwnershipTests(options?: { listAgents: () => [], getAgent: vi.fn(() => options?.loadedAgent ?? null), createAgent: vi.fn(async () => { - throw new Error( - "Session should delegate unloaded bootstrap to ProviderHistoryCompatibilityService", - ); + throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService"); }), resumeAgentFromPersistence: vi.fn(async () => { - throw new Error( - "Session should delegate persistence resume to ProviderHistoryCompatibilityService", - ); + throw new Error("Session should delegate persistence resume to AgentLoadingService"); }), reloadAgentSession: vi.fn(async () => { - throw new Error("Session should delegate refresh reload to ProviderHistoryCompatibilityService"); + throw new Error("Session should delegate refresh reload to AgentLoadingService"); }), hydrateTimelineFromProvider: vi.fn(async () => { throw new Error("Session should not call hydrateTimelineFromProvider directly"); @@ -127,7 +123,7 @@ function createSessionForOwnershipTests(options?: { stt: null, tts: null, terminalManager: null, - providerHistoryCompatibilityService: options?.providerHistoryCompatibilityService, + agentLoadingService: options?.agentLoadingService, } as any) as any; return { session, emitted, agentManager }; @@ -145,7 +141,7 @@ describe("provider history compatibility ownership", () => { timestamp: new Date("2026-03-24T00:00:01.000Z"), }, ], - providerHistoryCompatibilityService: { + agentLoadingService: { ensureAgentLoaded, }, }); @@ -178,7 +174,7 @@ describe("provider history compatibility ownership", () => { const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); const { session, agentManager, emitted } = createSessionForOwnershipTests({ storedRecord: createStoredAgentRecord(), - providerHistoryCompatibilityService: { + agentLoadingService: { ensureAgentLoaded, }, }); @@ -220,7 +216,7 @@ describe("provider history compatibility ownership", () => { test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => { const resumeAgent = vi.fn(async () => createCompatibilitySnapshot()); const { session, emitted } = createSessionForOwnershipTests({ - providerHistoryCompatibilityService: { + agentLoadingService: { resumeAgent, }, }); @@ -272,7 +268,7 @@ describe("provider history compatibility ownership", () => { ); const { session, emitted } = createSessionForOwnershipTests({ loadedAgent: createCompatibilitySnapshot(), - providerHistoryCompatibilityService: { + agentLoadingService: { refreshAgent, }, }); @@ -304,7 +300,7 @@ describe("provider history compatibility ownership", () => { const refreshAgent = vi.fn(async () => createCompatibilitySnapshot()); const { session, emitted } = createSessionForOwnershipTests({ storedRecord: createStoredAgentRecord(), - providerHistoryCompatibilityService: { + agentLoadingService: { refreshAgent, }, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1412dd5334..259dd6ec22 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -108,7 +108,7 @@ import type { WorkspaceRegistry, } from "./workspace-registry.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; -import { ProviderHistoryCompatibilityService } from "./provider-history-compatibility-service.js"; +import { AgentLoadingService } from "./agent-loading-service.js"; import { buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, @@ -181,6 +181,16 @@ const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024; const MAX_TERMINAL_STREAM_SLOTS = 256; +type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & { + beginDelete(agentId: string): void; +}; + +function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void { + if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") { + (agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId); + } +} + function deriveInitialAgentTitle(prompt: string): string | null { const firstContentLine = prompt .split(/\r?\n/) @@ -393,7 +403,7 @@ export type SessionOptions = { projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; workspaceReconciliationService?: WorkspaceReconciliationService; - providerHistoryCompatibilityService?: ProviderHistoryCompatibilityService; + agentLoadingService?: AgentLoadingService; createAgentMcpTransport: AgentMcpTransportFactory; stt: Resolvable; tts: Resolvable; @@ -554,7 +564,7 @@ export class Session { private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceReconciliationService: WorkspaceReconciliationService; - private readonly providerHistoryCompatibilityService: ProviderHistoryCompatibilityService; + private readonly agentLoadingService: AgentLoadingService; private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; @@ -616,7 +626,7 @@ export class Session { projectRegistry, workspaceRegistry, workspaceReconciliationService, - providerHistoryCompatibilityService, + agentLoadingService, createAgentMcpTransport, stt, tts, @@ -655,9 +665,9 @@ export class Session { this.syncWorkspaceGitWatchTarget(cwd, options), removeWorkspaceGitWatchTarget: async (cwd) => this.removeWorkspaceGitWatchTarget(cwd), }); - this.providerHistoryCompatibilityService = - providerHistoryCompatibilityService ?? - new ProviderHistoryCompatibilityService({ + this.agentLoadingService = + agentLoadingService ?? + new AgentLoadingService({ agentManager: this.agentManager, agentStorage: this.agentStorage, logger: this.sessionLogger, @@ -1082,7 +1092,7 @@ export class Session { } private async ensureAgentLoaded(agentId: string): Promise { - return this.providerHistoryCompatibilityService.ensureAgentLoaded({ agentId }); + return this.agentLoadingService.ensureAgentLoaded({ agentId }); } private matchesAgentFilter(options: { @@ -1675,8 +1685,8 @@ export class Session { (await this.agentStorage.get(agentId))?.cwd ?? null; - // Prevent the persistence hook from re-creating the record while we close/delete. - this.agentStorage.beginDelete(agentId); + // File-backed storage still needs an early delete fence before closeAgent(). + beginAgentDeleteIfSupported(this.agentStorage, agentId); try { await this.agentManager.closeAgent(agentId); @@ -2482,7 +2492,7 @@ export class Session { ); try { await this.unarchiveAgentByHandle(handle); - const snapshot = await this.providerHistoryCompatibilityService.resumeAgent({ + const snapshot = await this.agentLoadingService.resumeAgent({ handle, overrides, }); @@ -2530,7 +2540,7 @@ export class Session { if (this.agentManager.getAgent(agentId)) { await this.interruptAgentIfRunning(agentId); } - const snapshot = await this.providerHistoryCompatibilityService.refreshAgent({ agentId }); + const snapshot = await this.agentLoadingService.refreshAgent({ agentId }); await this.forwardAgentUpdate(snapshot); const timelineSize = this.agentManager.getTimeline(agentId).length; if (requestId) { diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 5c4fdcbff8..12811214e2 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -22,13 +22,11 @@ describe("snapshot mutation ownership boundary", () => { model: "gpt-5.2-codex", }); await daemonHandle.daemon.agentManager.flush(); - await daemonHandle.daemon.agentStorage.flush(); const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot"); await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4"); await daemonHandle.daemon.agentManager.flush(); - await daemonHandle.daemon.agentStorage.flush(); expect(applySnapshotSpy).toHaveBeenCalledTimes(1); diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 5cbb923424..4675fdb321 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -6,7 +6,10 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { AgentStorage } from "./agent/agent-storage.js"; -import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; +import { + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "./workspace-registry.test-helpers.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { openPaseoDatabase } from "./db/pglite-database.js"; import { DbProjectRegistry } from "./db/db-project-registry.js"; diff --git a/packages/server/src/server/workspace-registry.test-helpers.ts b/packages/server/src/server/workspace-registry.test-helpers.ts new file mode 100644 index 0000000000..e9ff4eaeda --- /dev/null +++ b/packages/server/src/server/workspace-registry.test-helpers.ts @@ -0,0 +1,162 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import type { Logger } from "pino"; + +import { + parsePersistedProjectRecords, + parsePersistedWorkspaceRecords, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, +} from "./workspace-registry.js"; + +type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; + +class FileBackedRegistry { + private readonly filePath: string; + private readonly logger: Logger; + private readonly parseRecord: (record: unknown) => TRecord; + private readonly parseRecords: (input: unknown) => TRecord[]; + private readonly getId: (record: TRecord) => string; + private loaded = false; + private readonly cache = new Map(); + private persistQueue: Promise = Promise.resolve(); + + constructor(options: { + filePath: string; + logger: Logger; + parseRecords: (input: unknown) => TRecord[]; + getId: (record: TRecord) => string; + component: string; + }) { + this.filePath = options.filePath; + this.parseRecords = options.parseRecords; + this.parseRecord = (record) => options.parseRecords([record])[0]!; + this.getId = options.getId; + this.logger = options.logger.child({ + module: "workspace-registry", + component: options.component, + }); + } + + async initialize(): Promise { + await this.load(); + } + + async existsOnDisk(): Promise { + try { + await fs.access(this.filePath); + return true; + } catch { + return false; + } + } + + async list(): Promise { + await this.load(); + return Array.from(this.cache.values()); + } + + async get(id: string): Promise { + await this.load(); + return this.cache.get(id) ?? null; + } + + async upsert(record: TRecord): Promise { + await this.load(); + const parsed = this.parseRecord(record); + this.cache.set(this.getId(parsed), parsed); + await this.enqueuePersist(); + } + + async archive(id: string, archivedAt: string): Promise { + await this.load(); + const existing = this.cache.get(id); + if (!existing) { + return; + } + const next = this.parseRecord({ + ...existing, + updatedAt: archivedAt, + archivedAt, + }); + this.cache.set(id, next); + await this.enqueuePersist(); + } + + async remove(id: string): Promise { + await this.load(); + if (!this.cache.delete(id)) { + return; + } + await this.enqueuePersist(); + } + + private async load(): Promise { + if (this.loaded) { + return; + } + + this.cache.clear(); + try { + const raw = await fs.readFile(this.filePath, "utf8"); + const parsed = this.parseRecords(JSON.parse(raw)); + for (const record of parsed) { + this.cache.set(this.getId(record), record); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file"); + } + } + this.loaded = true; + } + + private async persist(): Promise { + const records = Array.from(this.cache.values()); + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8"); + await fs.rename(tempPath, this.filePath); + } + + private async enqueuePersist(): Promise { + const nextPersist = this.persistQueue.then(() => this.persist()); + this.persistQueue = nextPersist.catch(() => {}); + await nextPersist; + } +} + +export class FileBackedProjectRegistry + extends FileBackedRegistry + implements ProjectRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + parseRecords: parsePersistedProjectRecords, + getId: (record) => record.projectId, + component: "projects", + }); + } +} + +export class FileBackedWorkspaceRegistry + extends FileBackedRegistry + implements WorkspaceRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + parseRecords: parsePersistedWorkspaceRecords, + getId: (record) => record.workspaceId, + component: "workspaces", + }); + } +} diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index b4abe506b5..8f060a21eb 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -6,10 +6,12 @@ import { beforeEach, afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, FileBackedProjectRegistry, FileBackedWorkspaceRegistry, +} from "./workspace-registry.test-helpers.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, } from "./workspace-registry.js"; describe("workspace registries", () => { diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index dff81ee2df..0c5c317031 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -1,8 +1,3 @@ -import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import path from "node:path"; - -import type { Logger } from "pino"; import { z } from "zod"; import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js"; @@ -59,152 +54,6 @@ export interface WorkspaceRegistry { remove(workspaceId: string): Promise; } -type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; - -class FileBackedRegistry { - private readonly filePath: string; - private readonly logger: Logger; - private readonly schema: z.ZodSchema; - private readonly getId: (record: TRecord) => string; - private loaded = false; - private readonly cache = new Map(); - private persistQueue: Promise = Promise.resolve(); - - constructor(options: { - filePath: string; - logger: Logger; - schema: z.ZodSchema; - getId: (record: TRecord) => string; - component: string; - }) { - this.filePath = options.filePath; - this.schema = options.schema; - this.getId = options.getId; - this.logger = options.logger.child({ - module: "workspace-registry", - component: options.component, - }); - } - - async initialize(): Promise { - await this.load(); - } - - async existsOnDisk(): Promise { - try { - await fs.access(this.filePath); - return true; - } catch { - return false; - } - } - - async list(): Promise { - await this.load(); - return Array.from(this.cache.values()); - } - - async get(id: string): Promise { - await this.load(); - return this.cache.get(id) ?? null; - } - - async upsert(record: TRecord): Promise { - await this.load(); - const parsed = this.schema.parse(record); - this.cache.set(this.getId(parsed), parsed); - await this.enqueuePersist(); - } - - async archive(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return; - } - const next = this.schema.parse({ - ...existing, - updatedAt: archivedAt, - archivedAt, - }); - this.cache.set(id, next); - await this.enqueuePersist(); - } - - async remove(id: string): Promise { - await this.load(); - if (!this.cache.delete(id)) { - return; - } - await this.enqueuePersist(); - } - - private async load(): Promise { - if (this.loaded) { - return; - } - - this.cache.clear(); - try { - const raw = await fs.readFile(this.filePath, "utf8"); - const parsed = z.array(this.schema).parse(JSON.parse(raw)); - for (const record of parsed) { - this.cache.set(this.getId(record), record); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file"); - } - } - this.loaded = true; - } - - private async persist(): Promise { - const records = Array.from(this.cache.values()); - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; - await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8"); - await fs.rename(tempPath, this.filePath); - } - - private async enqueuePersist(): Promise { - const nextPersist = this.persistQueue.then(() => this.persist()); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; - } -} - -export class FileBackedProjectRegistry - extends FileBackedRegistry - implements ProjectRegistry -{ - constructor(filePath: string, logger: Logger) { - super({ - filePath, - logger, - schema: PersistedProjectRecordSchema, - getId: (record) => record.projectId, - component: "projects", - }); - } -} - -export class FileBackedWorkspaceRegistry - extends FileBackedRegistry - implements WorkspaceRegistry -{ - constructor(filePath: string, logger: Logger) { - super({ - filePath, - logger, - schema: PersistedWorkspaceRecordSchema, - getId: (record) => record.workspaceId, - component: "workspaces", - }); - } -} - export function createPersistedProjectRecord(input: { projectId: string; rootPath: string;