mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Phase 3a: project/workspace DB cutover + legacy JSON import
Bootstrap now opens PGlite and uses DB-backed project/workspace registries as the sole runtime authority. Legacy JSON files are imported transactionally on first startup; restart is idempotent. Key changes: - bootstrap.ts: DB registries replace file-backed, DB lifecycle managed on startup/shutdown/error - workspace-registry-bootstrap.ts: fixed FK write order (project before workspace), uses list() instead of existsOnDisk() - New transactional import module with rollback safety - Bootstrap smoke tests for import, idempotence, and reconciliation Audit-verified: authority cutover (13/13 checks), boundary (10/10).
This commit is contained in:
@@ -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<void>;
|
||||
}> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<void>((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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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<LegacyProjectWorkspaceImportResult> {
|
||||
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<unknown | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,14 +54,14 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: Logger;
|
||||
}): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -31,6 +31,14 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
|
||||
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
|
||||
|
||||
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<void>;
|
||||
existsOnDisk(): Promise<boolean>;
|
||||
|
||||
Reference in New Issue
Block a user