fix: harden migration/reconciliation and fix legacy import project grouping
- DbProjectRegistry/DbWorkspaceRegistry: use ON CONFLICT(directory) instead of ON CONFLICT(id) so inserts and upserts handle duplicate directories gracefully instead of crashing - Bootstrap: wrap legacy imports in try/catch (non-fatal), move reconciliation start after imports so first pass cleans up stale data - Legacy project/workspace import: deduplicate by rootPath (prefer git over non_git), derive gitRemote from legacy projectId field - Legacy agent snapshot import: detect git metadata and group agents by remote/toplevel instead of creating one project per cwd, clamp running/initializing statuses to closed - Timeline hydration: set historyPrimed based on whether durable store has rows (not just whether it exists), remove hard gate so imported agents get their timelines populated lazily from provider history - Durable timeline append: use bulkInsert with pre-assigned seq instead of appendCommitted which recalculates seq, fixing UNIQUE constraint failures during concurrent hydration writes
This commit is contained in:
@@ -575,6 +575,10 @@ export class AgentManager {
|
|||||||
options?: AgentTimelineFetchOptions,
|
options?: AgentTimelineFetchOptions,
|
||||||
): Promise<AgentTimelineFetchResult> {
|
): Promise<AgentTimelineFetchResult> {
|
||||||
this.requireAgent(id);
|
this.requireAgent(id);
|
||||||
|
const agent = this.agents.get(id);
|
||||||
|
if (agent && !agent.historyPrimed) {
|
||||||
|
await this.hydrateTimelineFromLegacyProviderHistory(agent);
|
||||||
|
}
|
||||||
if (this.durableTimelineStore) {
|
if (this.durableTimelineStore) {
|
||||||
return await this.durableTimelineStore.fetchCommitted(id, options);
|
return await this.durableTimelineStore.fetchCommitted(id, options);
|
||||||
}
|
}
|
||||||
@@ -1509,15 +1513,12 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only compatibility hook for managers constructed without a durable
|
* Hydrates the timeline from provider history if the agent's durable
|
||||||
* timeline store. Production loads committed history from the durable store
|
* timeline is empty (e.g., imported agents that have provider history
|
||||||
* during session registration instead of replaying provider history here.
|
* on disk but no persisted timeline rows). No-ops if already hydrated.
|
||||||
*/
|
*/
|
||||||
async hydrateTimelineFromProvider(agentId: string): Promise<void> {
|
async hydrateTimelineFromProvider(agentId: string): Promise<void> {
|
||||||
const agent = this.requireSessionAgent(agentId);
|
const agent = this.requireSessionAgent(agentId);
|
||||||
if (this.durableTimelineStore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.hydrateTimelineFromLegacyProviderHistory(agent);
|
await this.hydrateTimelineFromLegacyProviderHistory(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1820,6 +1821,7 @@ export class AgentManager {
|
|||||||
const durableTimelineSeed = shouldSeedFromDurable
|
const durableTimelineSeed = shouldSeedFromDurable
|
||||||
? await this.loadCommittedTimelineSeed(resolvedAgentId, now)
|
? await this.loadCommittedTimelineSeed(resolvedAgentId, now)
|
||||||
: null;
|
: null;
|
||||||
|
const durableTimelineHasRows = durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1;
|
||||||
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
|
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
|
||||||
if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) {
|
if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) {
|
||||||
this.timelineStore.initialize(resolvedAgentId, timelineSeed ?? { timestamp: now.toISOString() });
|
this.timelineStore.initialize(resolvedAgentId, timelineSeed ?? { timestamp: now.toISOString() });
|
||||||
@@ -1848,7 +1850,7 @@ export class AgentManager {
|
|||||||
unsubscribeSession: null,
|
unsubscribeSession: null,
|
||||||
provisionalAssistantText: options?.provisionalAssistantText ?? null,
|
provisionalAssistantText: options?.provisionalAssistantText ?? null,
|
||||||
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd),
|
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd),
|
||||||
historyPrimed: options?.historyPrimed ?? shouldSeedFromDurable,
|
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
|
||||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||||
lastUsage: options?.lastUsage,
|
lastUsage: options?.lastUsage,
|
||||||
lastError: options?.lastError,
|
lastError: options?.lastError,
|
||||||
@@ -2576,7 +2578,7 @@ export class AgentManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const task = this.durableTimelineStore
|
const task = this.durableTimelineStore
|
||||||
.appendCommitted(agentId, row.item, { timestamp: row.timestamp })
|
.bulkInsert(agentId, [row])
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
|
|||||||
@@ -403,6 +403,27 @@ export async function createPaseoDaemon(
|
|||||||
const projectRegistry = new DbProjectRegistry(database.db);
|
const projectRegistry = new DbProjectRegistry(database.db);
|
||||||
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
|
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await importLegacyProjectWorkspaceJson({
|
||||||
|
db: database.db,
|
||||||
|
paseoHome: config.paseoHome,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Legacy project/workspace import failed (non-fatal)");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await importLegacyAgentSnapshots({
|
||||||
|
db: database.db,
|
||||||
|
paseoHome: config.paseoHome,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Legacy agent snapshot import failed (non-fatal)");
|
||||||
|
}
|
||||||
|
|
||||||
const reconciliationService = new WorkspaceReconciliationService({
|
const reconciliationService = new WorkspaceReconciliationService({
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
@@ -410,19 +431,6 @@ export async function createPaseoDaemon(
|
|||||||
});
|
});
|
||||||
reconciliationService.start();
|
reconciliationService.start();
|
||||||
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
|
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
|
||||||
|
|
||||||
await importLegacyProjectWorkspaceJson({
|
|
||||||
db: database.db,
|
|
||||||
paseoHome: config.paseoHome,
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
|
|
||||||
await importLegacyAgentSnapshots({
|
|
||||||
db: database.db,
|
|
||||||
paseoHome: config.paseoHome,
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked");
|
|
||||||
await chatService.initialize();
|
await chatService.initialize();
|
||||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||||
const checkoutDiffManager = new CheckoutDiffManager({
|
const checkoutDiffManager = new CheckoutDiffManager({
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export class DbProjectRegistry implements ProjectRegistry {
|
|||||||
const [row] = await this.db
|
const [row] = await this.db
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values(record)
|
.values(record)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: projects.directory,
|
||||||
|
set: {
|
||||||
|
kind: record.kind,
|
||||||
|
displayName: record.displayName,
|
||||||
|
gitRemote: record.gitRemote,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
archivedAt: record.archivedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
.returning({ id: projects.id });
|
.returning({ id: projects.id });
|
||||||
return row!.id;
|
return row!.id;
|
||||||
}
|
}
|
||||||
@@ -52,13 +62,11 @@ export class DbProjectRegistry implements ProjectRegistry {
|
|||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values(nextRecord)
|
.values(nextRecord)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: projects.id,
|
target: projects.directory,
|
||||||
set: {
|
set: {
|
||||||
directory: nextRecord.directory,
|
|
||||||
kind: nextRecord.kind,
|
kind: nextRecord.kind,
|
||||||
displayName: nextRecord.displayName,
|
displayName: nextRecord.displayName,
|
||||||
gitRemote: nextRecord.gitRemote,
|
gitRemote: nextRecord.gitRemote,
|
||||||
createdAt: nextRecord.createdAt,
|
|
||||||
updatedAt: nextRecord.updatedAt,
|
updatedAt: nextRecord.updatedAt,
|
||||||
archivedAt: nextRecord.archivedAt,
|
archivedAt: nextRecord.archivedAt,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ export class DbWorkspaceRegistry implements WorkspaceRegistry {
|
|||||||
const [row] = await this.db
|
const [row] = await this.db
|
||||||
.insert(workspaces)
|
.insert(workspaces)
|
||||||
.values(record)
|
.values(record)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: workspaces.directory,
|
||||||
|
set: {
|
||||||
|
projectId: record.projectId,
|
||||||
|
kind: record.kind,
|
||||||
|
displayName: record.displayName,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
archivedAt: record.archivedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
.returning({ id: workspaces.id });
|
.returning({ id: workspaces.id });
|
||||||
return row!.id;
|
return row!.id;
|
||||||
}
|
}
|
||||||
@@ -56,13 +66,11 @@ export class DbWorkspaceRegistry implements WorkspaceRegistry {
|
|||||||
.insert(workspaces)
|
.insert(workspaces)
|
||||||
.values(nextRecord)
|
.values(nextRecord)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: workspaces.id,
|
target: workspaces.directory,
|
||||||
set: {
|
set: {
|
||||||
projectId: nextRecord.projectId,
|
projectId: nextRecord.projectId,
|
||||||
directory: nextRecord.directory,
|
|
||||||
kind: nextRecord.kind,
|
kind: nextRecord.kind,
|
||||||
displayName: nextRecord.displayName,
|
displayName: nextRecord.displayName,
|
||||||
createdAt: nextRecord.createdAt,
|
|
||||||
updatedAt: nextRecord.updatedAt,
|
updatedAt: nextRecord.updatedAt,
|
||||||
archivedAt: nextRecord.archivedAt,
|
archivedAt: nextRecord.archivedAt,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
|
|
||||||
import { count } from "drizzle-orm";
|
import { count } from "drizzle-orm";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js";
|
import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js";
|
||||||
|
import { detectWorkspaceGitMetadata } from "../workspace-git-metadata.js";
|
||||||
|
import { READ_ONLY_GIT_ENV } from "../checkout-git-utils.js";
|
||||||
import { normalizeWorkspaceId } from "../workspace-registry-model.js";
|
import { normalizeWorkspaceId } from "../workspace-registry-model.js";
|
||||||
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
|
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
|
||||||
import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js";
|
import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js";
|
||||||
@@ -76,10 +79,15 @@ export async function importLegacyAgentSnapshots(options: {
|
|||||||
workspaceRows.map((row) => [row.directory, row.id] as const),
|
workspaceRows.map((row) => [row.directory, row.id] as const),
|
||||||
);
|
);
|
||||||
const projectRows = tx
|
const projectRows = tx
|
||||||
.select({ id: projects.id, directory: projects.directory })
|
.select({ id: projects.id, directory: projects.directory, gitRemote: projects.gitRemote })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.all();
|
.all();
|
||||||
const projectIdsByDirectory = new Map(projectRows.map((row) => [row.directory, row.id] as const));
|
const projectIdsByDirectory = new Map(projectRows.map((row) => [row.directory, row.id] as const));
|
||||||
|
const projectIdsByRemote = new Map(
|
||||||
|
projectRows
|
||||||
|
.filter((row): row is typeof row & { gitRemote: string } => row.gitRemote !== null)
|
||||||
|
.map((row) => [row.gitRemote, row.id] as const),
|
||||||
|
);
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const normalizedDirectory = normalizeWorkspaceId(record.cwd);
|
const normalizedDirectory = normalizeWorkspaceId(record.cwd);
|
||||||
if (workspaceIdsByDirectory.has(normalizedDirectory)) {
|
if (workspaceIdsByDirectory.has(normalizedDirectory)) {
|
||||||
@@ -87,17 +95,30 @@ export async function importLegacyAgentSnapshots(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = record.updatedAt ?? record.createdAt;
|
const timestamp = record.updatedAt ?? record.createdAt;
|
||||||
const displayName =
|
const gitInfo = detectGitInfoForCwd(record.cwd);
|
||||||
normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedDirectory;
|
const resolvedDirectory = gitInfo?.toplevel
|
||||||
let projectId = projectIdsByDirectory.get(normalizedDirectory);
|
? normalizeWorkspaceId(gitInfo.toplevel)
|
||||||
|
: normalizedDirectory;
|
||||||
|
const projectDisplayName = gitInfo?.metadata.projectDisplayName
|
||||||
|
?? resolvedDirectory.split(/[\\/]/).filter(Boolean).at(-1)
|
||||||
|
?? resolvedDirectory;
|
||||||
|
const projectKind = gitInfo?.metadata.projectKind ?? "directory";
|
||||||
|
const gitRemote = gitInfo?.metadata.gitRemote ?? null;
|
||||||
|
const workspaceKind = gitInfo?.metadata.isWorktree ? "worktree" : "checkout";
|
||||||
|
const workspaceDisplayName = gitInfo?.metadata.workspaceDisplayName
|
||||||
|
?? normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1)
|
||||||
|
?? normalizedDirectory;
|
||||||
|
|
||||||
|
let projectId = projectIdsByDirectory.get(resolvedDirectory)
|
||||||
|
?? (gitRemote !== null ? projectIdsByRemote.get(gitRemote) : undefined);
|
||||||
if (projectId === undefined) {
|
if (projectId === undefined) {
|
||||||
const projectRow = tx
|
const projectRow = tx
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({
|
.values({
|
||||||
directory: normalizedDirectory,
|
directory: resolvedDirectory,
|
||||||
displayName,
|
displayName: projectDisplayName,
|
||||||
kind: "directory",
|
kind: projectKind,
|
||||||
gitRemote: null,
|
gitRemote,
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@@ -105,7 +126,10 @@ export async function importLegacyAgentSnapshots(options: {
|
|||||||
.returning({ id: projects.id })
|
.returning({ id: projects.id })
|
||||||
.get();
|
.get();
|
||||||
projectId = projectRow!.id;
|
projectId = projectRow!.id;
|
||||||
projectIdsByDirectory.set(normalizedDirectory, projectId);
|
projectIdsByDirectory.set(resolvedDirectory, projectId);
|
||||||
|
if (gitRemote !== null) {
|
||||||
|
projectIdsByRemote.set(gitRemote, projectId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceRow = tx
|
const workspaceRow = tx
|
||||||
@@ -113,8 +137,8 @@ export async function importLegacyAgentSnapshots(options: {
|
|||||||
.values({
|
.values({
|
||||||
projectId,
|
projectId,
|
||||||
directory: normalizedDirectory,
|
directory: normalizedDirectory,
|
||||||
displayName,
|
displayName: workspaceDisplayName,
|
||||||
kind: "checkout",
|
kind: workspaceKind,
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@@ -125,7 +149,13 @@ export async function importLegacyAgentSnapshots(options: {
|
|||||||
}
|
}
|
||||||
const rows = records.flatMap((record) => {
|
const rows = records.flatMap((record) => {
|
||||||
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(record.cwd));
|
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(record.cwd));
|
||||||
return workspaceId === undefined ? [] : [toAgentSnapshotRowValues({ record, workspaceId })];
|
if (workspaceId === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const clampedRecord = (record.lastStatus === "running" || record.lastStatus === "initializing")
|
||||||
|
? { ...record, lastStatus: "closed" as const }
|
||||||
|
: record;
|
||||||
|
return [toAgentSnapshotRowValues({ record: clampedRecord, workspaceId })];
|
||||||
});
|
});
|
||||||
const totalBatches = Math.ceil(rows.length / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
|
const totalBatches = Math.ceil(rows.length / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
|
||||||
for (let startIndex = 0; startIndex < rows.length; startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) {
|
for (let startIndex = 0; startIndex < rows.length; startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) {
|
||||||
@@ -245,3 +275,24 @@ async function pathExists(targetPath: string): Promise<boolean> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectGitInfoForCwd(
|
||||||
|
cwd: string,
|
||||||
|
): { toplevel: string; metadata: ReturnType<typeof detectWorkspaceGitMetadata> } | null {
|
||||||
|
try {
|
||||||
|
const toplevel = execSync("git rev-parse --show-toplevel", {
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
if (!toplevel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const directoryName = toplevel.split(/[\\/]/).filter(Boolean).at(-1) ?? toplevel;
|
||||||
|
const metadata = detectWorkspaceGitMetadata(cwd, directoryName);
|
||||||
|
return { toplevel, metadata };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Adhoc test: imports real legacy data from ~/.paseo into a fresh SQLite DB
|
||||||
|
* and asserts that projects/workspaces are properly grouped.
|
||||||
|
*
|
||||||
|
* Run with: npx vitest run packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts
|
||||||
|
*/
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
|
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
|
||||||
|
import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js";
|
||||||
|
import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js";
|
||||||
|
import { projects, workspaces, agentSnapshots } from "./schema.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const REAL_PASEO_HOME = path.join(os.homedir(), ".paseo");
|
||||||
|
|
||||||
|
describe("legacy import from real ~/.paseo data", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let dbDir: string;
|
||||||
|
let database: PaseoDatabaseHandle;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-real-import-"));
|
||||||
|
dbDir = path.join(tmpDir, "db");
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
database = await openPaseoDatabase(dbDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await database?.close();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports real data and groups projects correctly", async () => {
|
||||||
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
// Phase 1: Import legacy project/workspace JSON (has proper grouping)
|
||||||
|
const pwResult = await importLegacyProjectWorkspaceJson({
|
||||||
|
db: database.db,
|
||||||
|
paseoHome: REAL_PASEO_HOME,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
console.log("Project/workspace import result:", pwResult);
|
||||||
|
|
||||||
|
// Phase 2: Import legacy agent snapshots
|
||||||
|
const agentResult = await importLegacyAgentSnapshots({
|
||||||
|
db: database.db,
|
||||||
|
paseoHome: REAL_PASEO_HOME,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
console.log("Agent snapshot import result:", agentResult);
|
||||||
|
|
||||||
|
// --- Assertions ---
|
||||||
|
|
||||||
|
const allProjects = await database.db.select().from(projects);
|
||||||
|
const allWorkspaces = await database.db.select().from(workspaces);
|
||||||
|
const allAgents = await database.db.select().from(agentSnapshots);
|
||||||
|
|
||||||
|
console.log(`Total projects: ${allProjects.length}`);
|
||||||
|
console.log(`Total workspaces: ${allWorkspaces.length}`);
|
||||||
|
console.log(`Total agents: ${allAgents.length}`);
|
||||||
|
|
||||||
|
// 1. There should be fewer projects than workspaces (workspaces group under projects)
|
||||||
|
expect(allProjects.length).toBeLessThan(allWorkspaces.length);
|
||||||
|
|
||||||
|
// 2. There should be exactly ONE project per unique git remote
|
||||||
|
const projectsByRemote = new Map<string, typeof allProjects>();
|
||||||
|
for (const project of allProjects) {
|
||||||
|
if (project.gitRemote) {
|
||||||
|
const existing = projectsByRemote.get(project.gitRemote) ?? [];
|
||||||
|
existing.push(project);
|
||||||
|
projectsByRemote.set(project.gitRemote, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateRemotes: string[] = [];
|
||||||
|
for (const [remote, projectList] of projectsByRemote) {
|
||||||
|
if (projectList.length > 1) {
|
||||||
|
duplicateRemotes.push(remote);
|
||||||
|
console.log(`DUPLICATE: ${remote} has ${projectList.length} projects:`);
|
||||||
|
for (const p of projectList) {
|
||||||
|
console.log(` id=${p.id} directory=${p.directory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(duplicateRemotes).toEqual([]);
|
||||||
|
|
||||||
|
// 3. Specifically: getpaseo/paseo should be ONE project
|
||||||
|
const paseoProjects = allProjects.filter(
|
||||||
|
(p) => p.gitRemote === "git@github.com:getpaseo/paseo.git",
|
||||||
|
);
|
||||||
|
expect(paseoProjects).toHaveLength(1);
|
||||||
|
const paseoProject = paseoProjects[0]!;
|
||||||
|
|
||||||
|
// 4. All paseo workspaces (worktrees + main checkout + subdirs) should be under that one project
|
||||||
|
const paseoWorkspaces = allWorkspaces.filter(
|
||||||
|
(w) => w.projectId === paseoProject.id,
|
||||||
|
);
|
||||||
|
console.log(`Paseo project id=${paseoProject.id}, directory=${paseoProject.directory}`);
|
||||||
|
console.log(`Paseo workspaces: ${paseoWorkspaces.length}`);
|
||||||
|
|
||||||
|
// The old data had ~51 paseo workspaces
|
||||||
|
expect(paseoWorkspaces.length).toBeGreaterThanOrEqual(10);
|
||||||
|
|
||||||
|
// 5. Subdirectory workspaces (packages/server, packages/app) should be under the same project
|
||||||
|
const subdirWorkspaces = paseoWorkspaces.filter((w) =>
|
||||||
|
w.directory.includes("/packages/"),
|
||||||
|
);
|
||||||
|
console.log(`Paseo subdirectory workspaces: ${subdirWorkspaces.length}`);
|
||||||
|
for (const w of subdirWorkspaces) {
|
||||||
|
console.log(` ${w.directory} (projectId=${w.projectId})`);
|
||||||
|
expect(w.projectId).toBe(paseoProject.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Worktree workspaces should be under the same project
|
||||||
|
const worktreeWorkspaces = paseoWorkspaces.filter((w) =>
|
||||||
|
w.directory.includes("/.paseo/worktrees/"),
|
||||||
|
);
|
||||||
|
console.log(`Paseo worktree workspaces: ${worktreeWorkspaces.length}`);
|
||||||
|
expect(worktreeWorkspaces.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 7. No git project should be a subdirectory of another project with the SAME git remote
|
||||||
|
// (e.g., /dev/paseo/packages/server should not be its own project if /dev/paseo exists)
|
||||||
|
const activeGitProjects = allProjects.filter((p) => !p.archivedAt && p.gitRemote);
|
||||||
|
const subdirProjects: string[] = [];
|
||||||
|
for (const project of activeGitProjects) {
|
||||||
|
for (const other of activeGitProjects) {
|
||||||
|
if (
|
||||||
|
project.id !== other.id &&
|
||||||
|
project.gitRemote === other.gitRemote &&
|
||||||
|
project.directory.startsWith(other.directory + "/")
|
||||||
|
) {
|
||||||
|
subdirProjects.push(
|
||||||
|
`${project.directory} (id=${project.id}) is under ${other.directory} (id=${other.id}), both remote=${project.gitRemote}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subdirProjects.length > 0) {
|
||||||
|
console.log("Subdirectory git projects with same remote (should be empty):");
|
||||||
|
for (const s of subdirProjects) {
|
||||||
|
console.log(` ${s}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(subdirProjects).toEqual([]);
|
||||||
|
|
||||||
|
// 8. All agent snapshots should reference valid workspaces
|
||||||
|
for (const agent of allAgents) {
|
||||||
|
const workspace = allWorkspaces.find((w) => w.id === agent.workspaceId);
|
||||||
|
expect(workspace).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,16 @@ import { z } from "zod";
|
|||||||
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
|
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
|
||||||
import { projects, workspaces } from "./schema.js";
|
import { projects, workspaces } from "./schema.js";
|
||||||
|
|
||||||
|
const LEGACY_REMOTE_PREFIX = "remote:";
|
||||||
|
|
||||||
|
function deriveGitRemoteFromLegacyProjectId(projectId: string): string | null {
|
||||||
|
if (!projectId.startsWith(LEGACY_REMOTE_PREFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hostAndPath = projectId.slice(LEGACY_REMOTE_PREFIX.length);
|
||||||
|
return `git@${hostAndPath.replace("/", ":")}.git`;
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy JSON schemas — these match the old pre-migration format
|
// Legacy JSON schemas — these match the old pre-migration format
|
||||||
const LegacyProjectSchema = z.object({
|
const LegacyProjectSchema = z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
@@ -91,24 +101,26 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const dedupedProjects = [...new Map(projectRows.map((project) => [project.rootPath, project])).values()];
|
// Deduplicate legacy projects by rootPath — prefer git over non_git
|
||||||
const dedupedWorkspaces = [...new Map(workspaceRows.map((workspace) => [workspace.cwd, workspace])).values()];
|
const deduplicatedProjects = new Map<string, (typeof projectRows)[number]>();
|
||||||
|
for (const legacy of projectRows) {
|
||||||
options.logger.info(
|
const existing = deduplicatedProjects.get(legacy.rootPath);
|
||||||
{ projects: dedupedProjects.length, workspaces: dedupedWorkspaces.length },
|
if (!existing || (legacy.kind === "git" && existing.kind !== "git")) {
|
||||||
"Starting legacy project/workspace import",
|
deduplicatedProjects.set(legacy.rootPath, legacy);
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
options.db.transaction((tx) => {
|
options.db.transaction((tx) => {
|
||||||
// Insert projects, mapping old format to new schema
|
// Insert projects, mapping old format to new schema
|
||||||
const projectDirectoryToId = new Map<string, number>();
|
const projectDirectoryToId = new Map<string, number>();
|
||||||
for (const legacy of dedupedProjects) {
|
for (const legacy of deduplicatedProjects.values()) {
|
||||||
const row = tx
|
const row = tx
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({
|
.values({
|
||||||
directory: legacy.rootPath,
|
directory: legacy.rootPath,
|
||||||
displayName: legacy.displayName,
|
displayName: legacy.displayName,
|
||||||
kind: legacy.kind === "non_git" ? "directory" : legacy.kind,
|
kind: legacy.kind === "non_git" ? "directory" : legacy.kind,
|
||||||
|
gitRemote: deriveGitRemoteFromLegacyProjectId(legacy.projectId),
|
||||||
createdAt: legacy.createdAt,
|
createdAt: legacy.createdAt,
|
||||||
updatedAt: legacy.updatedAt,
|
updatedAt: legacy.updatedAt,
|
||||||
archivedAt: legacy.archivedAt,
|
archivedAt: legacy.archivedAt,
|
||||||
@@ -119,6 +131,7 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build a map from legacy projectId -> new integer id
|
// Build a map from legacy projectId -> new integer id
|
||||||
|
// Uses original projectRows so all duplicate projectIds resolve to the same new id
|
||||||
const legacyProjectIdToNewId = new Map<string, number>();
|
const legacyProjectIdToNewId = new Map<string, number>();
|
||||||
for (const legacy of projectRows) {
|
for (const legacy of projectRows) {
|
||||||
const newId = projectDirectoryToId.get(legacy.rootPath);
|
const newId = projectDirectoryToId.get(legacy.rootPath);
|
||||||
@@ -128,7 +141,7 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert workspaces, resolving project FK
|
// Insert workspaces, resolving project FK
|
||||||
for (const legacy of dedupedWorkspaces) {
|
for (const legacy of workspaceRows) {
|
||||||
const projectId = legacyProjectIdToNewId.get(legacy.projectId);
|
const projectId = legacyProjectIdToNewId.get(legacy.projectId);
|
||||||
if (projectId === undefined) {
|
if (projectId === undefined) {
|
||||||
throw new Error(`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`);
|
throw new Error(`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`);
|
||||||
@@ -151,18 +164,20 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importedProjects = deduplicatedProjects.size;
|
||||||
|
|
||||||
options.logger.info(
|
options.logger.info(
|
||||||
{
|
{
|
||||||
importedProjects: dedupedProjects.length,
|
importedProjects,
|
||||||
importedWorkspaces: dedupedWorkspaces.length,
|
importedWorkspaces: workspaceRows.length,
|
||||||
},
|
},
|
||||||
"Imported legacy project/workspace JSON into the database",
|
"Imported legacy project/workspace JSON into the database",
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "imported",
|
status: "imported",
|
||||||
importedProjects: dedupedProjects.length,
|
importedProjects,
|
||||||
importedWorkspaces: dedupedWorkspaces.length,
|
importedWorkspaces: workspaceRows.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user