Harden SQLite migration and apply post-merge fixes
Migration hardening: - Back up JSON to $PASEO_HOME/backup/pre-migration/ before import - Deduplicate projects/workspaces by path before insert - Log per-batch progress during agent snapshot import - Clear error messages for corrupt JSON files - 5 new test cases for backup, dedup, progress, and error clarity Post-merge fixes: - Add worktreeRoot to session checkout result (type error fix) - Port reload agent tab action from main - Remove deleted useDelayedHistoryRefreshToast hook usage
This commit is contained in:
@@ -20,7 +20,6 @@ import {
|
||||
type AgentScreenMissingState,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -808,28 +807,6 @@ function ChatAgentContent({
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
}, [agentId, serverId]);
|
||||
|
||||
const isHistoryRefreshCatchingUp =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "toast";
|
||||
const shouldEmitSyncErrorToast =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "sync_error" &&
|
||||
viewState.sync.shouldEmitSyncErrorToast;
|
||||
|
||||
useDelayedHistoryRefreshToast({
|
||||
isCatchingUp: isHistoryRefreshCatchingUp,
|
||||
indicatorColor: theme.colors.primary,
|
||||
showToast: panelToast.api.show,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEmitSyncErrorToast) {
|
||||
return;
|
||||
}
|
||||
panelToast.api.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [panelToast.api, shouldEmitSyncErrorToast]);
|
||||
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-not-found">
|
||||
|
||||
@@ -66,6 +66,7 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -332,6 +333,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -453,6 +455,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -583,6 +586,7 @@ function ResolvedDesktopTabChip({
|
||||
normalizedWorkspaceId,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -606,6 +610,7 @@ function ResolvedDesktopTabChip({
|
||||
normalizedWorkspaceId: string;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -628,6 +633,7 @@ function ResolvedDesktopTabChip({
|
||||
tabCount,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
@@ -642,6 +648,7 @@ function ResolvedDesktopTabChip({
|
||||
onCloseTabsToRight,
|
||||
onCopyAgentId,
|
||||
onCopyResumeCommand,
|
||||
onReloadAgent,
|
||||
tabCount,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
PanelRight,
|
||||
RotateCw,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
X,
|
||||
@@ -186,6 +187,7 @@ type MobileWorkspaceTabSwitcherProps = {
|
||||
onSelectSwitcherTab: (key: string) => void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
@@ -273,6 +275,7 @@ function MobileWorkspaceTabOption({
|
||||
onPress,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -288,6 +291,7 @@ function MobileWorkspaceTabOption({
|
||||
onPress: () => void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
@@ -303,6 +307,7 @@ function MobileWorkspaceTabOption({
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsAbove,
|
||||
onCloseTabsAfter: onCloseTabsBelow,
|
||||
@@ -346,11 +351,14 @@ function MobileWorkspaceTabOption({
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
tooltip={entry.tooltip}
|
||||
leading={(() => {
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
case "rotate-cw":
|
||||
return <RotateCw size={16} color={iconColor} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
case "arrow-right-to-line":
|
||||
@@ -393,6 +401,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onSelectSwitcherTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -464,6 +473,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onPress={onPress}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onCloseTab={onCloseTab}
|
||||
onCloseTabsAbove={onCloseTabsAbove}
|
||||
onCloseTabsBelow={onCloseTabsBelow}
|
||||
@@ -1308,6 +1318,23 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[normalizedServerId, toast],
|
||||
);
|
||||
|
||||
const handleReloadAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (!client || !isConnected) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
toast.show("Reloaded agent", { variant: "success" });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to reload agent");
|
||||
}
|
||||
},
|
||||
[client, isConnected, toast],
|
||||
);
|
||||
|
||||
const handleCopyWorkspacePath = useCallback(async () => {
|
||||
if (!workspaceDirectory) {
|
||||
toast.error("Workspace path not available");
|
||||
@@ -2135,6 +2162,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
@@ -2155,6 +2183,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
@@ -2190,6 +2219,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
|
||||
@@ -192,6 +192,78 @@ describe("importLegacyAgentSnapshots", () => {
|
||||
expect(rows).toHaveLength(150);
|
||||
expect(rows.map((row) => row.agentId)).toContain("agent-149");
|
||||
});
|
||||
|
||||
test("creates backup of agent directory before import", async () => {
|
||||
await seedWorkspace("/tmp/project");
|
||||
writeLegacyAgentJson({
|
||||
paseoHome,
|
||||
relativePath: "agents/project-a/agent-1.json",
|
||||
payload: createLegacyAgentJson(),
|
||||
});
|
||||
|
||||
await importLegacyAgentSnapshots({
|
||||
db: database.db,
|
||||
paseoHome,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const backupPath = path.join(
|
||||
paseoHome,
|
||||
"backup",
|
||||
"pre-migration",
|
||||
"agents",
|
||||
"project-a",
|
||||
"agent-1.json",
|
||||
);
|
||||
expect(existsSync(backupPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(backupPath, "utf8"))).toMatchObject({
|
||||
id: "agent-1",
|
||||
cwd: "/tmp/project",
|
||||
});
|
||||
});
|
||||
|
||||
test("logs batch progress for large imports", async () => {
|
||||
await seedWorkspace("/tmp/large-project");
|
||||
const logger = createTestLogger();
|
||||
const infoSpy = vi.spyOn(logger, "info");
|
||||
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
writeLegacyAgentJson({
|
||||
paseoHome,
|
||||
relativePath: `agents/large-project/agent-${index}.json`,
|
||||
payload: createLegacyAgentJson({
|
||||
id: `agent-${index}`,
|
||||
cwd: "/tmp/large-project",
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: `session-${index}`,
|
||||
model: "gpt-5.1-codex-mini",
|
||||
modeId: "plan",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await importLegacyAgentSnapshots({
|
||||
db: database.db,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
const batchLogs = infoSpy.mock.calls.filter(
|
||||
([context, message]) => message === "Importing agent snapshot batch" && typeof context === "object",
|
||||
);
|
||||
expect(batchLogs.length).toBeGreaterThan(1);
|
||||
expect(batchLogs[0]?.[0]).toMatchObject({
|
||||
batch: 1,
|
||||
totalBatches: batchLogs.length,
|
||||
});
|
||||
expect(batchLogs.at(-1)?.[0]).toMatchObject({
|
||||
batch: batchLogs.length,
|
||||
totalBatches: batchLogs.length,
|
||||
rowsProcessed: 150,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createLegacyAgentJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
|
||||
@@ -40,7 +40,25 @@ export async function importLegacyAgentSnapshots(options: {
|
||||
};
|
||||
}
|
||||
|
||||
const records = await readLegacyAgentRecords(path.join(options.paseoHome, "agents"), options.logger);
|
||||
const agentsDir = path.join(options.paseoHome, "agents");
|
||||
if (!(await pathExists(agentsDir))) {
|
||||
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "no-legacy-files",
|
||||
};
|
||||
}
|
||||
|
||||
await backupLegacyAgentDirectory({
|
||||
sourceDir: agentsDir,
|
||||
paseoHome: options.paseoHome,
|
||||
logger: options.logger,
|
||||
});
|
||||
|
||||
const { records, skippedCount } = await readLegacyAgentRecords(agentsDir, options.logger);
|
||||
if (skippedCount > 0) {
|
||||
options.logger.warn({ skippedCount }, "Skipped invalid agent JSON files during migration");
|
||||
}
|
||||
if (records.length === 0) {
|
||||
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
|
||||
return {
|
||||
@@ -109,8 +127,15 @@ export async function importLegacyAgentSnapshots(options: {
|
||||
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(record.cwd));
|
||||
return workspaceId === undefined ? [] : [toAgentSnapshotRowValues({ record, workspaceId })];
|
||||
});
|
||||
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) {
|
||||
const batch = rows.slice(startIndex, startIndex + MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
|
||||
const batchNum = Math.floor(startIndex / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) + 1;
|
||||
const rowsProcessed = startIndex + batch.length;
|
||||
options.logger.info(
|
||||
{ batch: batchNum, totalBatches, rowsProcessed },
|
||||
"Importing agent snapshot batch",
|
||||
);
|
||||
tx.insert(agentSnapshots).values(batch).run();
|
||||
}
|
||||
});
|
||||
@@ -126,23 +151,29 @@ export async function importLegacyAgentSnapshots(options: {
|
||||
};
|
||||
}
|
||||
|
||||
async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<StoredAgentRecord[]> {
|
||||
async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<{
|
||||
records: StoredAgentRecord[];
|
||||
skippedCount: number;
|
||||
}> {
|
||||
let entries: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
return { records: [], skippedCount: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recordsById = new Map<string, StoredAgentRecord>();
|
||||
let skippedCount = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith(".json")) {
|
||||
const record = await readRecordFile(path.join(baseDir, entry.name), logger);
|
||||
if (record) {
|
||||
recordsById.set(record.id, record);
|
||||
} else {
|
||||
skippedCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -165,11 +196,16 @@ async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<
|
||||
const record = await readRecordFile(path.join(baseDir, entry.name, childEntry.name), logger);
|
||||
if (record) {
|
||||
recordsById.set(record.id, record);
|
||||
} else {
|
||||
skippedCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(recordsById.values());
|
||||
return {
|
||||
records: Array.from(recordsById.values()),
|
||||
skippedCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function readRecordFile(filePath: string, logger: Logger): Promise<StoredAgentRecord | null> {
|
||||
@@ -186,3 +222,26 @@ async function hasAnyAgentSnapshotRows(db: PaseoDatabaseHandle["db"]): Promise<b
|
||||
const rows = await db.select({ count: count() }).from(agentSnapshots);
|
||||
return (rows[0]?.count ?? 0) > 0;
|
||||
}
|
||||
|
||||
async function backupLegacyAgentDirectory(options: {
|
||||
sourceDir: string;
|
||||
paseoHome: string;
|
||||
logger: Logger;
|
||||
}): Promise<void> {
|
||||
const backupPath = path.join(options.paseoHome, "backup", "pre-migration", "agents");
|
||||
await fs.mkdir(path.dirname(backupPath), { recursive: true });
|
||||
await fs.cp(options.sourceDir, backupPath, { recursive: true });
|
||||
options.logger.info({ backupPath }, "Backed up legacy agent snapshots before migration");
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
@@ -173,6 +173,136 @@ describe("importLegacyProjectWorkspaceJson", () => {
|
||||
expect(await database.db.select().from(projects)).toEqual([]);
|
||||
expect(await database.db.select().from(workspaces)).toEqual([]);
|
||||
});
|
||||
|
||||
test("deduplicates projects with the same rootPath", async () => {
|
||||
writeLegacyJson({
|
||||
paseoHome,
|
||||
projectsJson: [
|
||||
{
|
||||
projectId: "project-1",
|
||||
rootPath: "/tmp/project-1",
|
||||
kind: "git",
|
||||
displayName: "First Project",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
{
|
||||
projectId: "project-2",
|
||||
rootPath: "/tmp/project-1",
|
||||
kind: "git",
|
||||
displayName: "Replacement Project",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
workspacesJson: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
projectId: "project-2",
|
||||
cwd: "/tmp/project-1",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importLegacyProjectWorkspaceJson({
|
||||
db: database.db,
|
||||
paseoHome,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "imported",
|
||||
importedProjects: 1,
|
||||
importedWorkspaces: 1,
|
||||
});
|
||||
const projectRows = await database.db.select().from(projects);
|
||||
expect(projectRows).toHaveLength(1);
|
||||
expect(projectRows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
directory: "/tmp/project-1",
|
||||
displayName: "Replacement Project",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("creates backup of JSON files before import", 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await importLegacyProjectWorkspaceJson({
|
||||
db: database.db,
|
||||
paseoHome,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const backupDir = path.join(paseoHome, "backup", "pre-migration");
|
||||
const projectsBackupPath = path.join(backupDir, "projects.json");
|
||||
const workspacesBackupPath = path.join(backupDir, "workspaces.json");
|
||||
expect(existsSync(projectsBackupPath)).toBe(true);
|
||||
expect(existsSync(workspacesBackupPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(projectsBackupPath, "utf8"))).toHaveLength(1);
|
||||
expect(JSON.parse(readFileSync(workspacesBackupPath, "utf8"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("produces clear error message for corrupt project JSON", async () => {
|
||||
writeLegacyJson({
|
||||
paseoHome,
|
||||
projectsJson: [
|
||||
{
|
||||
projectId: "project-1",
|
||||
rootPath: 123,
|
||||
kind: "git",
|
||||
displayName: "Project One",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
workspacesJson: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
importLegacyProjectWorkspaceJson({
|
||||
db: database.db,
|
||||
paseoHome,
|
||||
logger: createTestLogger(),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Failed to parse ${path.join(paseoHome, "projects", "projects.json")}. ` +
|
||||
"The file may be corrupted.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function writeLegacyJson(input: {
|
||||
|
||||
@@ -49,11 +49,7 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
||||
}): 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),
|
||||
]);
|
||||
const databaseHasRows = await hasAnyProjectWorkspaceRows(options.db);
|
||||
|
||||
if (databaseHasRows) {
|
||||
options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty");
|
||||
@@ -63,6 +59,30 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
||||
};
|
||||
}
|
||||
|
||||
const [projectsExists, workspacesExists] = await Promise.all([
|
||||
pathExists(projectsPath),
|
||||
pathExists(workspacesPath),
|
||||
]);
|
||||
if (!projectsExists && !workspacesExists) {
|
||||
options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist");
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "no-legacy-files",
|
||||
};
|
||||
}
|
||||
|
||||
await backupLegacyProjectWorkspaceJson({
|
||||
projectsPath,
|
||||
workspacesPath,
|
||||
paseoHome: options.paseoHome,
|
||||
logger: options.logger,
|
||||
});
|
||||
|
||||
const [projectRows, workspaceRows] = await Promise.all([
|
||||
readLegacyProjects(projectsPath),
|
||||
readLegacyWorkspaces(workspacesPath),
|
||||
]);
|
||||
|
||||
if (projectRows.length === 0 && workspaceRows.length === 0) {
|
||||
options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist");
|
||||
return {
|
||||
@@ -71,10 +91,18 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
||||
};
|
||||
}
|
||||
|
||||
const dedupedProjects = [...new Map(projectRows.map((project) => [project.rootPath, project])).values()];
|
||||
const dedupedWorkspaces = [...new Map(workspaceRows.map((workspace) => [workspace.cwd, workspace])).values()];
|
||||
|
||||
options.logger.info(
|
||||
{ projects: dedupedProjects.length, workspaces: dedupedWorkspaces.length },
|
||||
"Starting legacy project/workspace import",
|
||||
);
|
||||
|
||||
options.db.transaction((tx) => {
|
||||
// Insert projects, mapping old format to new schema
|
||||
const projectDirectoryToId = new Map<string, number>();
|
||||
for (const legacy of projectRows) {
|
||||
for (const legacy of dedupedProjects) {
|
||||
const row = tx
|
||||
.insert(projects)
|
||||
.values({
|
||||
@@ -100,7 +128,7 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
||||
}
|
||||
|
||||
// Insert workspaces, resolving project FK
|
||||
for (const legacy of workspaceRows) {
|
||||
for (const legacy of dedupedWorkspaces) {
|
||||
const projectId = legacyProjectIdToNewId.get(legacy.projectId);
|
||||
if (projectId === undefined) {
|
||||
throw new Error(`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`);
|
||||
@@ -125,27 +153,49 @@ export async function importLegacyProjectWorkspaceJson(options: {
|
||||
|
||||
options.logger.info(
|
||||
{
|
||||
importedProjects: projectRows.length,
|
||||
importedWorkspaces: workspaceRows.length,
|
||||
importedProjects: dedupedProjects.length,
|
||||
importedWorkspaces: dedupedWorkspaces.length,
|
||||
},
|
||||
"Imported legacy project/workspace JSON into the database",
|
||||
);
|
||||
|
||||
return {
|
||||
status: "imported",
|
||||
importedProjects: projectRows.length,
|
||||
importedWorkspaces: workspaceRows.length,
|
||||
importedProjects: dedupedProjects.length,
|
||||
importedWorkspaces: dedupedWorkspaces.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLegacyProjects(filePath: string) {
|
||||
const raw = await readOptionalJsonFile(filePath);
|
||||
return raw ? z.array(LegacyProjectSchema).parse(raw) : [];
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return z.array(LegacyProjectSchema).parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse ${filePath}. The file may be corrupted. ` +
|
||||
`Check the file and fix or remove invalid entries. ` +
|
||||
`Original error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLegacyWorkspaces(filePath: string) {
|
||||
const raw = await readOptionalJsonFile(filePath);
|
||||
return raw ? z.array(LegacyWorkspaceSchema).parse(raw) : [];
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return z.array(LegacyWorkspaceSchema).parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse ${filePath}. The file may be corrupted. ` +
|
||||
`Check the file and fix or remove invalid entries. ` +
|
||||
`Original error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalJsonFile(filePath: string): Promise<unknown | null> {
|
||||
@@ -170,3 +220,34 @@ async function hasAnyProjectWorkspaceRows(db: PaseoDatabaseHandle["db"]): Promis
|
||||
const workspaceCount = workspaceCountRows[0]?.count ?? 0;
|
||||
return projectCount > 0 || workspaceCount > 0;
|
||||
}
|
||||
|
||||
async function backupLegacyProjectWorkspaceJson(options: {
|
||||
projectsPath: string;
|
||||
workspacesPath: string;
|
||||
paseoHome: string;
|
||||
logger: Logger;
|
||||
}): Promise<void> {
|
||||
const backupDir = path.join(options.paseoHome, "backup", "pre-migration");
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
if (await pathExists(options.projectsPath)) {
|
||||
await fs.copyFile(options.projectsPath, path.join(backupDir, "projects.json"));
|
||||
}
|
||||
if (await pathExists(options.workspacesPath)) {
|
||||
await fs.copyFile(options.workspacesPath, path.join(backupDir, "workspaces.json"));
|
||||
}
|
||||
|
||||
options.logger.info({ backupPath: backupDir }, "Backed up legacy project/workspace JSON before migration");
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1290,6 +1290,7 @@ export class Session {
|
||||
isGit: false as const,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
}
|
||||
@@ -1299,6 +1300,7 @@ export class Session {
|
||||
isGit: true as const,
|
||||
currentBranch: workspace.displayName,
|
||||
remoteUrl: project.gitRemote,
|
||||
worktreeRoot: workspace.directory,
|
||||
isPaseoOwnedWorktree: true as const,
|
||||
mainRepoRoot: project.directory,
|
||||
}
|
||||
@@ -1307,6 +1309,7 @@ export class Session {
|
||||
isGit: true as const,
|
||||
currentBranch: workspace.displayName,
|
||||
remoteUrl: project.gitRemote,
|
||||
worktreeRoot: workspace.directory,
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user