diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c60eee592..e8e35f812 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ( diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 18a4a4e5c..919bc41e6 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -66,6 +66,7 @@ type WorkspaceDesktopTabsRowProps = { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | 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; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | 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, ], ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 42b948a15..92e17a585 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | 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; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | 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 ; + case "rotate-cw": + return ; case "arrow-left-to-line": return ; 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} diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts index 494e96fe3..48590a573 100644 --- a/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts +++ b/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts @@ -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 = {}): Record { diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.ts index a07a01fa3..986715ec6 100644 --- a/packages/server/src/server/db/legacy-agent-snapshot-import.ts +++ b/packages/server/src/server/db/legacy-agent-snapshot-import.ts @@ -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 { +async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<{ + records: StoredAgentRecord[]; + skippedCount: number; +}> { let entries: Array = []; 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(); + 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 { @@ -186,3 +222,26 @@ async function hasAnyAgentSnapshotRows(db: PaseoDatabaseHandle["db"]): Promise 0; } + +async function backupLegacyAgentDirectory(options: { + sourceDir: string; + paseoHome: string; + logger: Logger; +}): Promise { + 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 { + try { + await fs.access(targetPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} diff --git a/packages/server/src/server/db/legacy-project-workspace-import.test.ts b/packages/server/src/server/db/legacy-project-workspace-import.test.ts index 695148a0a..6a3941c65 100644 --- a/packages/server/src/server/db/legacy-project-workspace-import.test.ts +++ b/packages/server/src/server/db/legacy-project-workspace-import.test.ts @@ -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: { diff --git a/packages/server/src/server/db/legacy-project-workspace-import.ts b/packages/server/src/server/db/legacy-project-workspace-import.ts index ecd8f6d18..4010b63b3 100644 --- a/packages/server/src/server/db/legacy-project-workspace-import.ts +++ b/packages/server/src/server/db/legacy-project-workspace-import.ts @@ -49,11 +49,7 @@ export async function importLegacyProjectWorkspaceJson(options: { }): Promise { const projectsPath = path.join(options.paseoHome, "projects", "projects.json"); const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json"); - const [projectRows, workspaceRows, databaseHasRows] = await Promise.all([ - readLegacyProjects(projectsPath), - readLegacyWorkspaces(workspacesPath), - hasAnyProjectWorkspaceRows(options.db), - ]); + 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(); - 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 { @@ -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 { + 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 { + try { + await fs.access(targetPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index fdbcb303e..95cfd1261 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, };