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:
Mohamed Boudra
2026-04-05 13:05:54 +07:00
parent fcfb9c99da
commit 2dd597f5f5
8 changed files with 402 additions and 43 deletions
-23
View File
@@ -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;
}
}
+3
View File
@@ -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,
};