diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 70c9515204..aa8cedbe06 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,5 +1,6 @@ import os from "node:os"; import path from "node:path"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { Writable } from "node:stream"; import pino from "pino"; @@ -8,6 +9,8 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; +import { openPaseoDatabase } from "./db/pglite-database.js"; +import { projects, workspaces } from "./db/schema.js"; describe("paseo daemon bootstrap", () => { afterEach(() => { @@ -182,4 +185,216 @@ describe("paseo daemon bootstrap", () => { await rm(staticDir, { recursive: true, force: true }); } }); + + test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => { + const { config, cleanup } = await createBootstrapConfig(); + writeLegacyProjectWorkspaceJson(config.paseoHome, { + projects: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspaces: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); + + try { + await daemon.start(); + await daemon.stop(); + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + expect(await database.db.select().from(projects)).toEqual([ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ]); + expect(await database.db.select().from(workspaces)).toEqual([ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ]); + } finally { + await database.close(); + } + } finally { + await cleanup(); + } + }); + + test("does not duplicate imported legacy JSON across daemon restarts", async () => { + const { config, cleanup } = await createBootstrapConfig(); + writeLegacyProjectWorkspaceJson(config.paseoHome, { + projects: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspaces: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + try { + const firstDaemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await firstDaemon.start(); + await firstDaemon.stop(); + + const secondDaemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await secondDaemon.start(); + await secondDaemon.stop(); + + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + expect(await database.db.select().from(projects)).toHaveLength(1); + expect(await database.db.select().from(workspaces)).toHaveLength(1); + } finally { + await database.close(); + } + } finally { + await cleanup(); + } + }); + + test("reconciles workspace records into the DB without recreating legacy JSON registry files", async () => { + const { config, cleanup } = await createBootstrapConfig(); + const agentStorageDir = path.join(config.paseoHome, "agents"); + mkdirSync(agentStorageDir, { recursive: true }); + const storageBucket = path.join(agentStorageDir, "tmp-db-only-project"); + mkdirSync(storageBucket, { recursive: true }); + writeFileSync( + path.join(storageBucket, "agent-1.json"), + JSON.stringify( + { + id: "agent-1", + provider: "codex", + cwd: "/tmp/db-only-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }, + null, + 2, + ), + "utf8", + ); + + try { + const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await daemon.start(); + await daemon.stop(); + + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + expect(await database.db.select().from(projects)).toHaveLength(1); + expect(await database.db.select().from(workspaces)).toHaveLength(1); + } finally { + await database.close(); + } + + expect(existsSync(path.join(config.paseoHome, "projects", "projects.json"))).toBe(false); + expect(existsSync(path.join(config.paseoHome, "projects", "workspaces.json"))).toBe(false); + } finally { + await cleanup(); + } + }); }); + +async function createBootstrapConfig(): Promise<{ + config: PaseoDaemonConfig; + cleanup: () => Promise; +}> { + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-db-")); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + await mkdir(paseoHome, { recursive: true }); + + return { + config: { + listen: "127.0.0.1:0", + paseoHome, + corsAllowedOrigins: [], + allowedHosts: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + relayEnabled: false, + appBaseUrl: "https://app.paseo.sh", + openai: undefined, + speech: undefined, + }, + cleanup: async () => { + await rm(paseoHomeRoot, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + }, + }; +} + +function writeLegacyProjectWorkspaceJson( + paseoHome: string, + input: { + projects: unknown[]; + workspaces: unknown[]; + }, +): void { + const projectsDir = path.join(paseoHome, "projects"); + mkdirSync(projectsDir, { recursive: true }); + writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify(input.projects, null, 2), "utf8"); + writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify(input.workspaces, null, 2), "utf8"); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 92a67bb693..2212048c58 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -88,7 +88,10 @@ import { AgentStorage } from "./agent/agent-storage.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; -import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; +import { DbProjectRegistry } from "./db/db-project-registry.js"; +import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js"; +import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/pglite-database.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; @@ -196,6 +199,7 @@ export async function createPaseoDaemon( const pidLockMode = config.pidLock?.mode ?? "self"; const pidLockOwnerPid = config.pidLock?.ownerPid; const ownsPidLock = pidLockMode === "self"; + let database: PaseoDatabaseHandle | null = null; // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. if (ownsPidLock) { @@ -356,14 +360,6 @@ export async function createPaseoDaemon( const httpServer = createHTTPServer(app); const agentStorage = new AgentStorage(config.agentStoragePath, logger); - const projectRegistry = new FileBackedProjectRegistry( - path.join(config.paseoHome, "projects", "projects.json"), - logger, - ); - const workspaceRegistry = new FileBackedWorkspaceRegistry( - path.join(config.paseoHome, "projects", "workspaces.json"), - logger, - ); const agentManager = new AgentManager({ clients: { ...createAllClients(logger, { @@ -378,6 +374,16 @@ export async function createPaseoDaemon( const terminalManager = createTerminalManager(); await agentStorage.initialize(); logger.info({ elapsed: elapsed() }, "Agent storage initialized"); + database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + logger.info({ elapsed: elapsed() }, "Paseo database opened"); + const projectRegistry = new DbProjectRegistry(database.db); + const workspaceRegistry = new DbWorkspaceRegistry(database.db); + await importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome: config.paseoHome, + logger, + }); + logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked"); await bootstrapWorkspaceRegistries({ paseoHome: config.paseoHome, agentStorage, @@ -717,6 +723,7 @@ export async function createPaseoDaemon( if (voiceMcpBridgeManager) { await voiceMcpBridgeManager.stop().catch(() => undefined); } + await database?.close().catch(() => undefined); await new Promise((resolve) => { httpServer.close(() => resolve()); }); @@ -742,6 +749,7 @@ export async function createPaseoDaemon( getListenTarget: () => boundListenTarget, }; } catch (err) { + await database?.close().catch(() => undefined); if (ownsPidLock) { await releasePidLock(config.paseoHome, { ownerPid: pidLockOwnerPid, diff --git a/packages/server/src/server/db/legacy-project-workspace-import.test.ts b/packages/server/src/server/db/legacy-project-workspace-import.test.ts new file mode 100644 index 0000000000..adae4e84cf --- /dev/null +++ b/packages/server/src/server/db/legacy-project-workspace-import.test.ts @@ -0,0 +1,191 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; + +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js"; +import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js"; +import { projects, workspaces } from "./schema.js"; + +describe("importLegacyProjectWorkspaceJson", () => { + let tmpDir: string; + let paseoHome: string; + let dbDir: string; + let database: PaseoDatabaseHandle; + + beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-import-")); + paseoHome = path.join(tmpDir, ".paseo"); + dbDir = path.join(paseoHome, "db"); + mkdirSync(paseoHome, { recursive: true }); + database = await openPaseoDatabase(dbDir); + }); + + afterEach(async () => { + await database?.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("imports legacy projects and workspaces once when the DB is empty", async () => { + writeLegacyJson({ + paseoHome, + projectsJson: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspacesJson: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + const result = await importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "imported", + importedProjects: 1, + importedWorkspaces: 1, + }); + expect(await database.db.select().from(projects)).toEqual([ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ]); + expect(await database.db.select().from(workspaces)).toEqual([ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ]); + }); + + test("skips import when the DB already has project or workspace data", async () => { + await database.db.insert(projects).values({ + projectId: "existing-project", + rootPath: "/tmp/existing-project", + kind: "git", + displayName: "Existing Project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + writeLegacyJson({ + paseoHome, + projectsJson: [ + { + projectId: "legacy-project", + rootPath: "/tmp/legacy-project", + kind: "git", + displayName: "Legacy Project", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspacesJson: [], + }); + + const result = await importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "skipped", + reason: "database-not-empty", + }); + expect( + await database.db.select().from(projects).where(eq(projects.projectId, "legacy-project")), + ).toEqual([]); + }); + + test("rolls back the whole import when workspace insertion fails", async () => { + writeLegacyJson({ + paseoHome, + projectsJson: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspacesJson: [ + { + workspaceId: "workspace-1", + projectId: "missing-project", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + await expect( + importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }), + ).rejects.toThrow(); + + expect(await database.db.select().from(projects)).toEqual([]); + expect(await database.db.select().from(workspaces)).toEqual([]); + }); +}); + +function writeLegacyJson(input: { + paseoHome: string; + projectsJson: unknown[]; + workspacesJson: unknown[]; +}): void { + const projectsPath = path.join(input.paseoHome, "projects", "projects.json"); + const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json"); + mkdirSync(path.dirname(projectsPath), { recursive: true }); + writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { encoding: "utf8", flag: "w" }); + writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), { + encoding: "utf8", + flag: "w", + }); +} diff --git a/packages/server/src/server/db/legacy-project-workspace-import.ts b/packages/server/src/server/db/legacy-project-workspace-import.ts new file mode 100644 index 0000000000..a13ce04816 --- /dev/null +++ b/packages/server/src/server/db/legacy-project-workspace-import.ts @@ -0,0 +1,109 @@ +import path from "node:path"; +import { promises as fs } from "node:fs"; + +import { count } from "drizzle-orm"; +import type { Logger } from "pino"; + +import { + parsePersistedProjectRecords, + parsePersistedWorkspaceRecords, +} from "../workspace-registry.js"; +import type { PaseoDatabaseHandle } from "./pglite-database.js"; +import { projects, workspaces } from "./schema.js"; + +export type LegacyProjectWorkspaceImportResult = + | { + status: "imported"; + importedProjects: number; + importedWorkspaces: number; + } + | { + status: "skipped"; + reason: "database-not-empty" | "no-legacy-files"; + }; + +export async function importLegacyProjectWorkspaceJson(options: { + db: PaseoDatabaseHandle["db"]; + paseoHome: string; + logger: Logger; +}): Promise { + const projectsPath = path.join(options.paseoHome, "projects", "projects.json"); + const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json"); + const [projectRows, workspaceRows, databaseHasRows] = await Promise.all([ + readLegacyProjects(projectsPath), + readLegacyWorkspaces(workspacesPath), + hasAnyProjectWorkspaceRows(options.db), + ]); + + if (databaseHasRows) { + options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty"); + return { + status: "skipped", + reason: "database-not-empty", + }; + } + + if (projectRows.length === 0 && workspaceRows.length === 0) { + options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist"); + return { + status: "skipped", + reason: "no-legacy-files", + }; + } + + await options.db.transaction(async (tx) => { + if (projectRows.length > 0) { + await tx.insert(projects).values(projectRows); + } + if (workspaceRows.length > 0) { + await tx.insert(workspaces).values(workspaceRows); + } + }); + + options.logger.info( + { + importedProjects: projectRows.length, + importedWorkspaces: workspaceRows.length, + }, + "Imported legacy project/workspace JSON into the database", + ); + + return { + status: "imported", + importedProjects: projectRows.length, + importedWorkspaces: workspaceRows.length, + }; +} + +async function readLegacyProjects(filePath: string) { + const raw = await readOptionalJsonFile(filePath); + return raw ? parsePersistedProjectRecords(raw) : []; +} + +async function readLegacyWorkspaces(filePath: string) { + const raw = await readOptionalJsonFile(filePath); + return raw ? parsePersistedWorkspaceRecords(raw) : []; +} + +async function readOptionalJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return null; + } + throw error; + } +} + +async function hasAnyProjectWorkspaceRows(db: PaseoDatabaseHandle["db"]): Promise { + const [projectCountRows, workspaceCountRows] = await Promise.all([ + db.select({ count: count() }).from(projects), + db.select({ count: count() }).from(workspaces), + ]); + const projectCount = projectCountRows[0]?.count ?? 0; + const workspaceCount = workspaceCountRows[0]?.count ?? 0; + return projectCount > 0 || workspaceCount > 0; +} diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 66fa5d442c..5cbb923424 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; @@ -8,6 +8,9 @@ import { createTestLogger } from "../test-utils/test-logger.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +import { openPaseoDatabase } from "./db/pglite-database.js"; +import { DbProjectRegistry } from "./db/db-project-registry.js"; +import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js"; describe("bootstrapWorkspaceRegistries", () => { let tmpDir: string; @@ -164,4 +167,66 @@ describe("bootstrapWorkspaceRegistries", () => { expect(await workspaceRegistry.list()).toHaveLength(1); expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing"); }); + + test("materializes into DB-backed registries when the database is empty", async () => { + mkdirSync(paseoHome, { recursive: true }); + const database = await openPaseoDatabase(path.join(paseoHome, "db")); + const dbProjectRegistry = new DbProjectRegistry(database.db); + const dbWorkspaceRegistry = new DbWorkspaceRegistry(database.db); + + try { + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-1", + provider: "codex", + cwd: "/tmp/non-git-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry: dbProjectRegistry, + workspaceRegistry: dbWorkspaceRegistry, + logger, + }); + + expect(await dbProjectRegistry.list()).toEqual([ + { + projectId: "/tmp/non-git-project", + rootPath: "/tmp/non-git-project", + kind: "non_git", + displayName: "non-git-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ]); + expect(await dbWorkspaceRegistry.list()).toEqual([ + { + workspaceId: "/tmp/non-git-project", + projectId: "/tmp/non-git-project", + cwd: "/tmp/non-git-project", + kind: "directory", + displayName: "non-git-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ]); + } finally { + await database.close(); + } + }); }); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 9027413397..13ebc85fd4 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -54,14 +54,14 @@ export async function bootstrapWorkspaceRegistries(options: { workspaceRegistry: WorkspaceRegistry; logger: Logger; }): Promise { - const [projectsExists, workspacesExists] = await Promise.all([ - options.projectRegistry.existsOnDisk(), - options.workspaceRegistry.existsOnDisk(), - ]); - await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]); - if (projectsExists && workspacesExists) { + const [existingProjects, existingWorkspaces] = await Promise.all([ + options.projectRegistry.list(), + options.workspaceRegistry.list(), + ]); + + if (existingProjects.length > 0 || existingWorkspaces.length > 0) { return; } @@ -92,21 +92,6 @@ export async function bootstrapWorkspaceRegistries(options: { const createdAt = workspaceCreatedAt ?? new Date().toISOString(); const updatedAt = workspaceUpdatedAt ?? createdAt; - await options.workspaceRegistry.upsert( - createPersistedWorkspaceRecord({ - workspaceId, - projectId: placement.projectKey, - cwd: workspaceId, - kind: deriveWorkspaceKind(placement.checkout), - displayName: deriveWorkspaceDisplayName({ - cwd: workspaceId, - checkout: placement.checkout, - }), - createdAt, - updatedAt, - }), - ); - const existingProjectRange = projectRanges.get(placement.projectKey) ?? { createdAt: null, updatedAt: null, @@ -128,6 +113,21 @@ export async function bootstrapWorkspaceRegistries(options: { updatedAt: existingProjectRange.updatedAt ?? updatedAt, }), ); + + await options.workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId, + projectId: placement.projectKey, + cwd: workspaceId, + kind: deriveWorkspaceKind(placement.checkout), + displayName: deriveWorkspaceDisplayName({ + cwd: workspaceId, + checkout: placement.checkout, + }), + createdAt, + updatedAt, + }), + ); } options.logger.info( diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index dcd7fb4113..dff81ee2df 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -31,6 +31,14 @@ const PersistedWorkspaceRecordSchema = z.object({ export type PersistedProjectRecord = z.infer; export type PersistedWorkspaceRecord = z.infer; +export function parsePersistedProjectRecords(input: unknown): PersistedProjectRecord[] { + return z.array(PersistedProjectRecordSchema).parse(input); +} + +export function parsePersistedWorkspaceRecords(input: unknown): PersistedWorkspaceRecord[] { + return z.array(PersistedWorkspaceRecordSchema).parse(input); +} + export interface ProjectRegistry { initialize(): Promise; existsOnDisk(): Promise;