From 8fe11e3bb0d3bccdd71ac2a802dbe17dc4ad9e35 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 17 Jul 2026 11:15:10 +0200 Subject: [PATCH] feat(settings): import existing project setup --- docs/conductor-project-import.md | 34 ++ package-lock.json | 2 +- packages/app/e2e/helpers/project-settings.ts | 75 +++ packages/app/e2e/helpers/seed-client.ts | 1 + packages/app/e2e/projects-settings.spec.ts | 280 ++++++++++- .../app/settings/projects/[projectKey].tsx | 14 +- .../worktree-setup-callout-policy.test.ts | 24 + .../worktree-setup-callout-policy.ts | 25 +- .../worktree-setup-callout-source.tsx | 50 +- packages/app/src/i18n/resources/ar.ts | 24 + packages/app/src/i18n/resources/en.ts | 24 + packages/app/src/i18n/resources/es.ts | 24 + packages/app/src/i18n/resources/fr.ts | 24 + packages/app/src/i18n/resources/ja.ts | 24 + packages/app/src/i18n/resources/pt-BR.ts | 24 + packages/app/src/i18n/resources/ru.ts | 24 + packages/app/src/i18n/resources/zh-CN.ts | 24 + .../project-config-import/conductor-icon.tsx | 33 ++ .../project-config-import-model.test.ts | 132 ++++++ .../project-config-import-model.ts | 158 +++++++ .../project-config-import-preview.ts | 36 ++ .../project-config-import-sheet.tsx | 265 +++++++++++ .../use-project-config-import-model.ts | 119 +++++ .../src/screens/project-settings-screen.tsx | 188 +++++++- packages/app/src/screens/settings-screen.tsx | 7 +- packages/app/src/utils/host-routes.test.ts | 14 + packages/app/src/utils/host-routes.ts | 15 + packages/client/src/daemon-client.test.ts | 90 ++++ packages/client/src/daemon-client.ts | 51 ++ .../scripts/generate-validation-aot.mjs | 101 +++- packages/protocol/src/messages.ts | 73 +++ packages/protocol/src/paseo-config-schema.ts | 58 +++ .../tests/validation/ws-outbound.test.ts | 18 + packages/server/package.json | 1 + packages/server/src/server/session.ts | 4 + .../project-config/import/conductor.test.ts | 401 ++++++++++++++++ .../session/project-config/import/index.ts | 127 +++++ .../session/project-config/import/merge.ts | 102 ++++ .../session/project-config/import/model.ts | 60 +++ .../import/sources/conductor.ts | 445 ++++++++++++++++++ .../project-config-session.test.ts | 200 +++++++- .../project-config/project-config-session.ts | 115 +++++ .../server/src/server/websocket-server.ts | 2 + 43 files changed, 3464 insertions(+), 48 deletions(-) create mode 100644 docs/conductor-project-import.md create mode 100644 packages/app/src/project-config-import/conductor-icon.tsx create mode 100644 packages/app/src/project-config-import/project-config-import-model.test.ts create mode 100644 packages/app/src/project-config-import/project-config-import-model.ts create mode 100644 packages/app/src/project-config-import/project-config-import-preview.ts create mode 100644 packages/app/src/project-config-import/project-config-import-sheet.tsx create mode 100644 packages/app/src/project-config-import/use-project-config-import-model.ts create mode 100644 packages/server/src/server/session/project-config/import/conductor.test.ts create mode 100644 packages/server/src/server/session/project-config/import/index.ts create mode 100644 packages/server/src/server/session/project-config/import/merge.ts create mode 100644 packages/server/src/server/session/project-config/import/model.ts create mode 100644 packages/server/src/server/session/project-config/import/sources/conductor.ts diff --git a/docs/conductor-project-import.md b/docs/conductor-project-import.md new file mode 100644 index 000000000..2a01e4d70 --- /dev/null +++ b/docs/conductor-project-import.md @@ -0,0 +1,34 @@ +# Conductor Project Import + +Paseo can import repository-local Conductor project settings into `paseo.json`. + +The importer reads only these files from the selected project root: + +- `.conductor/settings.local.toml` +- `.conductor/settings.toml` +- `conductor.json`, only when `.conductor/settings.toml` is absent + +It does not read `~/.conductor` or managed organization settings, because importing those into a repository-owned `paseo.json` could leak local or policy-controlled values. + +## Imported + +- `scripts.setup` becomes `worktree.setup` when Paseo setup is empty. +- `scripts.archive` becomes `worktree.teardown` when Paseo teardown is empty. +- `scripts.run..command` becomes `scripts..command` unless Paseo already has that script id. +- Legacy `scripts.run` string becomes `scripts.run`. +- Run `args` are shell-quoted and appended to the command. +- Safe relative run `options.cwd` becomes a `cd -- &&` command prefix. +- Run scripts using `CONDUCTOR_PORT` become Paseo service scripts and use `PASEO_PORT`. + +## Reported But Not Imported + +- Existing Paseo setup, teardown, or script ids win and are reported as collisions. +- Absolute or escaping `cwd` values are skipped. +- Cloud-only scripts are skipped. +- Hidden scripts import their command but report the hidden flag as unsupported. +- `scripts.run_mode` and `scripts.auto_run_after_setup` are unsupported. +- `file_include_globs` and `.worktreeinclude` are reported, not converted to shell copy commands. +- Environment variable values are never returned to the client or written into `paseo.json`; only names are shown. +- Spotlight, git, archive, agent, provider, and presentation-only Conductor settings are not migrated. + +Apply re-reads the source files and `paseo.json` before writing. If either changed since preview, the user must refresh the preview before importing. diff --git a/package-lock.json b/package-lock.json index 297bdcbd1..0a401f4cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31726,7 +31726,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -38078,6 +38077,7 @@ "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", "sherpa-onnx-node": "1.12.28", + "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "uuid": "^9.0.1", diff --git a/packages/app/e2e/helpers/project-settings.ts b/packages/app/e2e/helpers/project-settings.ts index eed5bf9b5..ad673d6af 100644 --- a/packages/app/e2e/helpers/project-settings.ts +++ b/packages/app/e2e/helpers/project-settings.ts @@ -251,6 +251,81 @@ export async function installReadTransportFailure( }; } +export async function installImportPreviewTransportFailure( + page: Page, +): Promise<{ allowRecovery: () => void }> { + return installSessionRpcTransportFailure( + page, + "project.config.get_import.request", + "Test import preview transport failure.", + ); +} + +export async function installImportApplyTransportFailure( + page: Page, +): Promise<{ allowRecovery: () => void }> { + return installSessionRpcTransportFailure( + page, + "project.config.apply_import.request", + "Test import apply transport failure.", + ); +} + +async function installSessionRpcTransportFailure( + page: Page, + requestType: string, + error: string, +): Promise<{ allowRecovery: () => void }> { + let shouldFail = true; + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if (shouldFail && sessionMessage?.type === requestType) { + const requestId = sessionMessage.requestId; + if (typeof requestId === "string") { + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId, + requestType, + error, + code: "transport", + }, + }, + }), + ); + } + return; + } + try { + server.send(message); + } catch { + // server socket already closed + } + }); + + server.onMessage((message) => { + try { + ws.send(message); + } catch { + // client socket already closed + } + }); + }); + + return { + allowRecovery() { + shouldFail = false; + }, + }; +} + // Installs a transparent WS proxy that can later drop all active daemon connections // and block new ones. Code 1001 (Going Away) without reason triggers "error" state // in DaemonClient due to describeTransportClose returning a non-empty string. diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 6a04bbd51..a85a5dff5 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -137,6 +137,7 @@ export interface SeedDaemonClient { agentId: string; }): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; getLastServerInfoMessage(): { + serverId: string; features?: { projectAdd?: boolean; workspaceRecovery?: boolean; diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index 1669586b5..e8a55fe69 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -1,4 +1,4 @@ -import { chmod, readFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test as base, type Page } from "./fixtures"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; @@ -22,6 +22,8 @@ import { expectScriptRowCount, expectWriteFailedCalloutActions, installDaemonConnectionGate, + installImportApplyTransportFailure, + installImportPreviewTransportFailure, installReadTransportFailure, navigateToProjectSettings, openProjectSettings, @@ -43,6 +45,7 @@ const updatedSetup = ["npm install", "npm run build"]; interface ProjectsSettingsProject { name: string; path: string; + projectId?: string; } interface ProjectsSettingsFixtures { @@ -134,10 +137,47 @@ async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promi expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`); } -async function readProjectConfigFile(project: ProjectsSettingsProject): Promise { +async function readProjectConfigFile( + project: Pick, +): Promise { return readFile(path.join(project.path, "paseo.json"), "utf8"); } +async function writeConductorSharedToml( + project: Pick, + contents: string, +): Promise { + const conductorDir = path.join(project.path, ".conductor"); + await mkdir(conductorDir, { recursive: true }); + await writeFile(path.join(conductorDir, "settings.toml"), contents); +} + +async function openConductorImportRoute(input: { + page: Page; + projectId: string; + serverId: string; + intentId: string; +}): Promise { + const query = new URLSearchParams({ + importSource: "conductor", + importServerId: input.serverId, + importIntentId: input.intentId, + }); + await input.page.goto(`/settings/projects/${encodeURIComponent(input.projectId)}?${query}`); +} + +async function expectConductorImportPreview(page: Page): Promise { + const sheet = page.getByTestId("project-config-import-sheet"); + await expect(sheet).toBeVisible({ timeout: 30_000 }); + await expect(sheet.getByText("Will import")).toBeVisible({ timeout: 30_000 }); +} + +function getSeededServerId(serverInfo: { serverId: string } | null): string { + const serverId = serverInfo?.serverId; + expect(serverId).toBeTruthy(); + return serverId!; +} + async function addProjectFromSidebar(page: Page, projectPath: string): Promise { await openAddProjectFlow(page); await chooseAddProjectMethod(page, "directory-search"); @@ -217,6 +257,242 @@ test.describe("Projects settings", () => { }); }); +test.describe("Projects settings — Conductor project import", () => { + test("callout opens the review sheet, discloses collisions, and imports available items", async ({ + page, + }) => { + const workspace = await seedWorkspace({ + repoPrefix: "projects-settings-conductor-", + repo: { + paseoConfig: { + scripts: { + dev: { + command: "pnpm dev", + customScriptField: "preserved", + }, + }, + }, + files: [ + { + path: ".conductor/settings.toml", + content: [ + "[scripts]", + 'setup = "npm ci"', + 'run_mode = "tmux"', + "", + "[scripts.run.dev]", + 'command = "npm run dev -- --port $CONDUCTOR_PORT"', + "", + "[scripts.run.lint]", + 'command = "npm run lint"', + "", + ].join("\n"), + }, + ], + }, + }); + + try { + await gotoAppShell(page); + await page.getByRole("button", { name: "main" }).click(); + + const callout = page.getByTestId(`worktree-setup-callout-${workspace.projectId}`); + await expect(callout.getByText("Conductor setup found")).toBeVisible({ timeout: 30_000 }); + await callout.getByRole("button", { name: "Review migration" }).click(); + + await expectConductorImportPreview(page); + await expect(page.getByText("Worktree setup")).toBeVisible(); + await expect(page.getByText("Script lint")).toBeVisible(); + await expect(page.getByText("Needs attention")).toBeVisible(); + await expect(page.getByText('Paseo already has a "dev" script.')).toBeVisible(); + await expect(page.getByText("Not supported")).toBeVisible(); + await expect(page.getByText("Paseo has no project-wide run mode.")).toBeVisible(); + + await page.getByTestId("project-config-import-apply").click(); + await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible({ + timeout: 30_000, + }); + await expect(callout).not.toBeVisible({ timeout: 30_000 }); + + await expect + .poll(async () => JSON.parse(await readProjectConfigFile({ path: workspace.repoPath }))) + .toMatchObject({ + worktree: { + setup: "npm ci", + }, + scripts: { + dev: { + command: "pnpm dev", + customScriptField: "preserved", + }, + lint: { + command: "npm run lint", + }, + }, + }); + } finally { + await workspace.cleanup(); + } + }); + + test("malformed TOML and preview transport failures stay actionable in the sheet", async ({ + page, + }) => { + const previewFailure = await installImportPreviewTransportFailure(page); + const workspace = await seedWorkspace({ + repoPrefix: "projects-settings-conductor-invalid-", + repo: { + files: [ + { + path: ".conductor/settings.toml", + content: '[scripts\nsetup = "npm ci"\n', + }, + ], + }, + }); + const serverId = getSeededServerId(workspace.client.getLastServerInfoMessage()); + + try { + await openConductorImportRoute({ + page, + projectId: workspace.projectId, + serverId, + intentId: "preview-transport", + }); + + await expect(page.getByTestId("project-config-import-error")).toContainText( + "Test import preview transport failure.", + { timeout: 30_000 }, + ); + await expect(page.getByTestId("project-config-import-retry")).toBeVisible(); + await expect(page.getByTestId("project-config-import-cancel-error")).toBeVisible(); + + previewFailure.allowRecovery(); + await page.getByTestId("project-config-import-retry").click(); + + await expect(page.getByTestId("project-config-import-error")).toContainText( + ".conductor/settings.toml", + { timeout: 30_000 }, + ); + await expect(page.getByTestId("project-config-import-retry")).toBeVisible(); + await page.getByTestId("project-config-import-cancel-error").click(); + await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible(); + } finally { + await workspace.cleanup(); + } + }); + + test("stale source refresh reloads the preview before import", async ({ page }) => { + const workspace = await seedWorkspace({ + repoPrefix: "projects-settings-conductor-stale-", + repo: { + files: [ + { + path: ".conductor/settings.toml", + content: '[scripts]\nsetup = "npm ci"\n', + }, + ], + }, + }); + const serverId = getSeededServerId(workspace.client.getLastServerInfoMessage()); + + try { + await openConductorImportRoute({ + page, + projectId: workspace.projectId, + serverId, + intentId: "stale-source", + }); + await expectConductorImportPreview(page); + + await writeConductorSharedToml( + { path: workspace.repoPath }, + '[scripts]\nsetup = "pnpm install"\n', + ); + await page.getByTestId("project-config-import-apply").click(); + + await expect(page.getByTestId("project-config-import-error")).toContainText( + "The Conductor config changed", + { timeout: 30_000 }, + ); + await page.getByTestId("project-config-import-refresh").click(); + + await expect(page.getByText("pnpm install")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("project-config-import-error")).not.toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("project-config-import-apply")).toBeEnabled({ + timeout: 30_000, + }); + await page.getByTestId("project-config-import-apply").click(); + + await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible({ + timeout: 30_000, + }); + await expect + .poll(async () => JSON.parse(await readProjectConfigFile({ path: workspace.repoPath }))) + .toMatchObject({ + worktree: { + setup: "pnpm install", + }, + }); + } finally { + await workspace.cleanup(); + } + }); + + test("apply transport failure and blocked writes offer retry and cancel", async ({ page }) => { + const applyFailure = await installImportApplyTransportFailure(page); + const workspace = await seedWorkspace({ + repoPrefix: "projects-settings-conductor-apply-fail-", + repo: { + files: [ + { + path: ".conductor/settings.toml", + content: '[scripts]\nsetup = "npm ci"\n', + }, + ], + }, + }); + const serverId = getSeededServerId(workspace.client.getLastServerInfoMessage()); + + try { + await openConductorImportRoute({ + page, + projectId: workspace.projectId, + serverId, + intentId: "apply-transport", + }); + await expectConductorImportPreview(page); + + await page.getByTestId("project-config-import-apply").click(); + await expect(page.getByTestId("project-config-import-error")).toContainText( + "Test import apply transport failure.", + { timeout: 30_000 }, + ); + await expect(page.getByTestId("project-config-import-retry")).toBeVisible(); + + applyFailure.allowRecovery(); + await blockPaseoConfigWrites(workspace.repoPath); + await page.getByTestId("project-config-import-retry").click(); + await expect(page.getByTestId("project-config-import-error")).toContainText( + "Try again, or reload the latest version from disk.", + { timeout: 30_000 }, + ); + await page.getByTestId("project-config-import-retry").click(); + await expect(page.getByTestId("project-config-import-error")).toContainText( + "Try again, or reload the latest version from disk.", + { timeout: 30_000 }, + ); + await page.getByTestId("project-config-import-cancel-error").click(); + await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible(); + } finally { + await unblockPaseoConfigWrites(workspace.repoPath).catch(() => undefined); + await workspace.cleanup(); + } + }); +}); + test.describe("Projects settings — error UX", () => { test("stale-write callout appears on save, disables save, and reload clears it", async ({ page, diff --git a/packages/app/src/app/settings/projects/[projectKey].tsx b/packages/app/src/app/settings/projects/[projectKey].tsx index 5fe6f442a..e89ae1f3c 100644 --- a/packages/app/src/app/settings/projects/[projectKey].tsx +++ b/packages/app/src/app/settings/projects/[projectKey].tsx @@ -1,12 +1,22 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; import SettingsScreen from "@/screens/settings-screen"; +import { parseProjectConfigImportIntent } from "@/project-config-import/project-config-import-model"; export default function SettingsProjectDetailRoute() { - const params = useLocalSearchParams<{ projectKey?: string | string[] }>(); + const params = useLocalSearchParams<{ + projectKey?: string | string[]; + importSource?: string | string[]; + importServerId?: string | string[]; + importIntentId?: string | string[]; + }>(); const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey; const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : ""; - const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]); + const importIntent = useMemo(() => parseProjectConfigImportIntent(params), [params]); + const view = useMemo( + () => ({ kind: "project" as const, projectKey, importIntent }), + [importIntent, projectKey], + ); return ; } diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index 03257be24..df3f3d049 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildConductorMigrationCalloutPolicy, buildWorktreeSetupCalloutPolicy, selectActiveGitWorkspaceProject, shouldShowWorktreeSetupCallout, @@ -100,4 +101,27 @@ describe("buildWorktreeSetupCalloutPolicy", () => { testID: "worktree-setup-callout-project-1", }); }); + + it("builds a host-bound Conductor migration route with a single-use intent", () => { + expect( + buildConductorMigrationCalloutPolicy( + { + serverId: "server-1", + projectKey: "remote:github.com/acme/app", + repoRoot: "/repo/project-1", + }, + "intent-1", + ), + ).toEqual({ + id: "worktree-setup-missing:remote:github.com/acme/app", + dismissalKey: "worktree-setup-missing:remote:github.com/acme/app", + priority: 100, + title: "Conductor setup found", + description: "Import its workspace setup and run scripts into Paseo.", + actionLabel: "Review migration", + projectSettingsRoute: + "/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=conductor&importServerId=server-1&importIntentId=intent-1", + testID: "worktree-setup-callout-remote:github.com/acme/app", + }); + }); }); diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index 371af8f22..9a5e2c37e 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -1,6 +1,6 @@ import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; import { i18n } from "@/i18n/i18next"; -import { buildProjectSettingsRoute } from "@/utils/host-routes"; +import { buildProjectSettingsImportRoute, buildProjectSettingsRoute } from "@/utils/host-routes"; export interface WorktreeSetupWorkspaceInput { projectId: string; @@ -73,6 +73,29 @@ export function buildWorktreeSetupCalloutPolicy( }; } +export function buildConductorMigrationCalloutPolicy( + project: ActiveGitWorkspaceProject, + intentId: string, +): WorktreeSetupCalloutPolicy { + const calloutKey = `worktree-setup-missing:${project.projectKey}`; + + return { + id: calloutKey, + dismissalKey: calloutKey, + priority: 100, + title: i18n.t("sidebar.worktreeSetup.conductorTitle"), + description: i18n.t("sidebar.worktreeSetup.conductorDescription"), + actionLabel: i18n.t("sidebar.worktreeSetup.reviewMigration"), + projectSettingsRoute: buildProjectSettingsImportRoute({ + projectKey: project.projectKey, + source: "conductor", + serverId: project.serverId, + intentId, + }), + testID: `worktree-setup-callout-${project.projectKey}`, + }; +} + function hasSetupCommands(config: PaseoConfigRaw): boolean { const setup = config.worktree?.setup; if (typeof setup === "string") { diff --git a/packages/app/src/components/worktree-setup-callout-source.tsx b/packages/app/src/components/worktree-setup-callout-source.tsx index 2cba8dfe6..55edad648 100644 --- a/packages/app/src/components/worktree-setup-callout-source.tsx +++ b/packages/app/src/components/worktree-setup-callout-source.tsx @@ -3,10 +3,13 @@ import { useRouter } from "expo-router"; import { useEffect, useMemo } from "react"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useProjectConfigImportPreview } from "@/project-config-import/project-config-import-preview"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useHostFeature } from "@/runtime/host-features"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { + buildConductorMigrationCalloutPolicy, buildWorktreeSetupCalloutPolicy, selectActiveGitWorkspaceProject, shouldShowWorktreeSetupCallout, @@ -20,14 +23,17 @@ export function WorktreeSetupCalloutSource() { (workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace), ); const client = useHostRuntimeClient(activeProject?.serverId ?? ""); + const supportsConductorImport = useHostFeature( + activeProject?.serverId, + "projectConfigImportConductor", + ); const callouts = useSidebarCallouts(); const router = useRouter(); - const openProjectSettings = useStableEvent(() => { - if (!activeProject) { - return; - } - router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute); - }); + const openProjectSettings = useStableEvent( + (route: ReturnType["projectSettingsRoute"]) => { + router.navigate(route); + }, + ); const readQuery = useQuery({ queryKey: ["project-config", activeProject?.serverId ?? "", activeProject?.repoRoot ?? ""], @@ -41,13 +47,25 @@ export function WorktreeSetupCalloutSource() { retry: false, }); - const calloutPolicy = useMemo( - () => - activeProject && shouldShowWorktreeSetupCallout(readQuery.data) - ? buildWorktreeSetupCalloutPolicy(activeProject) - : null, - [activeProject, readQuery.data], - ); + const shouldConsiderSetup = activeProject && shouldShowWorktreeSetupCallout(readQuery.data); + const importPreviewQuery = useProjectConfigImportPreview({ + client, + serverId: activeProject?.serverId ?? "", + repoRoot: activeProject?.repoRoot ?? "", + source: { kind: "conductor" }, + enabled: Boolean(shouldConsiderSetup && supportsConductorImport), + }); + + const calloutPolicy = useMemo(() => { + if (!activeProject || !shouldShowWorktreeSetupCallout(readQuery.data)) { + return null; + } + const preview = importPreviewQuery.data; + if (supportsConductorImport && preview?.ok === true && preview.status === "available") { + return buildConductorMigrationCalloutPolicy(activeProject, String(Date.now())); + } + return buildWorktreeSetupCalloutPolicy(activeProject); + }, [activeProject, importPreviewQuery.data, readQuery.data, supportsConductorImport]); useEffect(() => { if (!calloutPolicy) { @@ -61,7 +79,11 @@ export function WorktreeSetupCalloutSource() { title: calloutPolicy.title, description: calloutPolicy.description, actions: [ - { label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" }, + { + label: calloutPolicy.actionLabel, + onPress: () => openProjectSettings(calloutPolicy.projectSettingsRoute), + variant: "primary", + }, ], testID: calloutPolicy.testID, }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 38379dd5a..bcb8f0ed3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -867,6 +867,9 @@ export const ar: TranslationResources = { description: "أضف أوامر الإعداد حتى تتمكن أشجار العمل الجديدة من تثبيت التبعيات وإعداد نفسها تلقائيًا.", openProjectSettings: "افتح إعدادات المشروع", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2064,6 +2067,27 @@ export const ar: TranslationResources = { teardown: "هدم", teardownAccessibility: "أوامر هدم شجرة العمل", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "البرامج النصية", info: "خدمات طويلة الأمد وأوامر لمرة واحدة يمكنك إطلاقها من أي وكيل في هذا المشروع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 641176146..2580056ca 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -878,6 +878,9 @@ export const en = { description: "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", openProjectSettings: "Open project settings", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2078,6 +2081,27 @@ export const en = { teardown: "Teardown", teardownAccessibility: "Worktree teardown commands", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "Scripts", info: "Long-running services and one-off commands you can launch from any agent in this project", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3b1ebb85c..1654fec56 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -898,6 +898,9 @@ export const es: TranslationResources = { description: "Agregue comandos de configuración para que los nuevos árboles de trabajo puedan instalar dependencias y prepararse automáticamente.", openProjectSettings: "Abrir la configuración del proyecto", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2116,6 +2119,27 @@ export const es: TranslationResources = { teardown: "Demoler", teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "Scripts", info: "Servicios de larga duración y comandos únicos que puede iniciar desde cualquier agente en este proyecto", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5525f1be5..61b15d66a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -896,6 +896,9 @@ export const fr: TranslationResources = { description: "Ajoutez des commandes de configuration pour que les nouveaux arbres de travail puissent installer des dépendances et se préparer automatiquement.", openProjectSettings: "Ouvrir les paramètres du projet", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2119,6 +2122,27 @@ export const fr: TranslationResources = { teardown: "Démolir", teardownAccessibility: "Commandes de démontage de Worktree", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "Scripts", info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer à partir de n'importe quel agent de ce projet", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 29000af6a..696748519 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -880,6 +880,9 @@ export const ja: TranslationResources = { description: "新しいワークツリーが依存関係をインストールして自動的に準備できるようにセットアップコマンドを追加してください。", openProjectSettings: "プロジェクト設定を開く", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2089,6 +2092,27 @@ export const ja: TranslationResources = { teardown: "削除時", teardownAccessibility: "ワークツリー削除時のコマンド", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "スクリプト", info: "このプロジェクトのどのエージェントからでも起動できる、長時間実行サービスと単発コマンド", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f3db413ac..05fb2a2ae 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -890,6 +890,9 @@ export const ptBR: TranslationResources = { description: "Adicione comandos de configuração para que novos worktrees instalem dependências e se preparem automaticamente.", openProjectSettings: "Abrir configurações do projeto", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2102,6 +2105,27 @@ export const ptBR: TranslationResources = { teardown: "Desmontagem", teardownAccessibility: "Comandos de desmontagem do worktree", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "Scripts", info: "Serviços contínuos e comandos avulsos que você pode iniciar de qualquer agente neste projeto", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e5cda739e..29e39d7b8 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -889,6 +889,9 @@ export const ru: TranslationResources = { description: "Добавьте команды настройки, чтобы новые рабочие деревья могли автоматически устанавливать зависимости и готовиться.", openProjectSettings: "Открыть настройки проекта", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2107,6 +2110,27 @@ export const ru: TranslationResources = { teardown: "Срывать", teardownAccessibility: "Команды разрушения рабочего дерева", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "Скрипты", info: "Долгоработающие службы и одноразовые команды, которые можно запускать из любого агента в этом проекте.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 65b8c970c..1b0811001 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -860,6 +860,9 @@ export const zhCN: TranslationResources = { title: "设置 worktree scripts", description: "添加 setup 命令,让新的 worktree 自动安装依赖并完成准备。", openProjectSettings: "打开 project 设置", + conductorTitle: "Conductor setup found", + conductorDescription: "Import its workspace setup and run scripts into Paseo.", + reviewMigration: "Review migration", }, project: { actions: { @@ -2040,6 +2043,27 @@ export const zhCN: TranslationResources = { teardown: "Teardown", teardownAccessibility: "Worktree teardown 命令", }, + import: { + rowTitle: "Import from Conductor", + rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + sheetTitle: "Import from {{source}}", + sources: "Source files", + willImport: "Will import", + needsAttention: "Needs attention", + notSupported: "Not supported", + import: "Import", + importing: "Importing...", + refreshPreview: "Refresh preview", + success: "Conductor settings imported", + errorTitle: "Couldn't import settings", + errors: { + notFound: "No Conductor project config was found.", + invalid: "{{path}} couldn't be parsed.", + staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleProject: "paseo.json changed. Refresh the preview before importing.", + nothing: "There is nothing new to import.", + }, + }, scripts: { title: "Scripts", info: "可从此 Project 中任意 Agent 启动的长期服务和一次性命令", diff --git a/packages/app/src/project-config-import/conductor-icon.tsx b/packages/app/src/project-config-import/conductor-icon.tsx new file mode 100644 index 000000000..54fc1d5d3 --- /dev/null +++ b/packages/app/src/project-config-import/conductor-icon.tsx @@ -0,0 +1,33 @@ +import Svg, { Path } from "react-native-svg"; + +const PATHS = [ + "M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z", + "M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z", + "M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z", + "M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z", + "M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z", + "M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z", + "M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z", + "M37.0967 173.493V147.045H58.9707V173.493H37.0967Z", + "M55.3955 173.493V147.045H77.2695V173.493H55.3955Z", + "M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z", + "M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z", + "M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z", + "M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z", + "M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z", + "M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z", + "M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z", + "M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z", + "M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z", + "M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z", +] as const; + +export function ConductorIcon({ size = 18, color = "#282423" }: { size?: number; color?: string }) { + return ( + + {PATHS.map((path) => ( + + ))} + + ); +} diff --git a/packages/app/src/project-config-import/project-config-import-model.test.ts b/packages/app/src/project-config-import/project-config-import-model.test.ts new file mode 100644 index 000000000..462924bef --- /dev/null +++ b/packages/app/src/project-config-import/project-config-import-model.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeProjectConfigImportError, + openProjectConfigImport, + parseProjectConfigImportIntent, + projectConfigImportApplyFailureRetryAction, + projectConfigImportCanApply, + projectConfigImportNeedsRefresh, + projectConfigImportSourceFeature, +} from "./project-config-import-model"; + +describe("project config import intent", () => { + it("parses a Conductor host-bound route intent", () => { + expect( + parseProjectConfigImportIntent({ + importSource: "conductor", + importServerId: "server-1", + importIntentId: "intent-1", + }), + ).toEqual({ + serverId: "server-1", + source: { kind: "conductor" }, + intentId: "intent-1", + }); + }); + + it("rejects missing or unknown sources", () => { + expect( + parseProjectConfigImportIntent({ + importSource: "other", + importServerId: "server-1", + importIntentId: "intent-1", + }), + ).toBeNull(); + expect(parseProjectConfigImportIntent({ importSource: "conductor" })).toBeNull(); + }); + + it("maps Conductor to its source-specific feature flag", () => { + expect(projectConfigImportSourceFeature({ kind: "conductor" })).toBe( + "projectConfigImportConductor", + ); + }); +}); + +describe("project config import state model", () => { + const intent = { + serverId: "server-1", + source: { kind: "conductor" as const }, + intentId: "intent-1", + }; + const preview = { + requestId: "preview-1", + repoRoot: "/repo/app", + source: { kind: "conductor" as const }, + ok: true as const, + status: "available" as const, + sourceRevision: "source-1", + paseoRevision: null, + inputs: [], + items: [], + preview: { worktree: { setup: "npm ci" } }, + }; + + it("projects loading, ready, applying, and error states", () => { + expect( + openProjectConfigImport({ + intent, + preview: null, + isLoading: true, + error: null, + isApplying: false, + }), + ).toEqual({ status: "loading", intent, preview: null, error: null }); + + const ready = openProjectConfigImport({ + intent, + preview, + isLoading: false, + error: null, + isApplying: false, + }); + expect(ready).toEqual({ status: "ready", intent, preview, error: null }); + expect(projectConfigImportCanApply(ready)).toBe(true); + + expect( + openProjectConfigImport({ + intent, + preview, + isLoading: false, + error: null, + isApplying: true, + }), + ).toEqual({ status: "applying", intent, preview, error: null }); + + expect( + openProjectConfigImport({ + intent, + preview, + isLoading: false, + error: { code: "stale_source_config", source: { kind: "conductor" } }, + isApplying: false, + }), + ).toEqual({ + status: "error", + intent, + preview, + error: { code: "stale_source_config", source: { kind: "conductor" } }, + retryAction: "refresh", + }); + }); + + it("normalizes transport errors and identifies refresh-required domain errors", () => { + expect(normalizeProjectConfigImportError(new Error("socket closed"))).toEqual({ + code: "transport", + message: "socket closed", + }); + expect( + projectConfigImportNeedsRefresh({ + code: "stale_project_config", + currentRevision: null, + }), + ).toBe(true); + expect(projectConfigImportNeedsRefresh({ code: "write_failed" })).toBe(false); + expect(projectConfigImportApplyFailureRetryAction({ code: "write_failed" })).toBe("apply"); + expect( + projectConfigImportApplyFailureRetryAction({ + code: "stale_source_config", + source: { kind: "conductor" }, + }), + ).toBe("refresh"); + }); +}); diff --git a/packages/app/src/project-config-import/project-config-import-model.ts b/packages/app/src/project-config-import/project-config-import-model.ts new file mode 100644 index 000000000..276a4b682 --- /dev/null +++ b/packages/app/src/project-config-import/project-config-import-model.ts @@ -0,0 +1,158 @@ +import type { + ProjectConfigImportPreview, + ProjectConfigImportSource, + ProjectConfigRpcError, +} from "@getpaseo/protocol/messages"; + +export interface ProjectConfigImportIntent { + serverId: string; + source: ProjectConfigImportSource; + intentId: string; +} + +export type ProjectConfigImportState = + | { + status: "loading"; + intent: ProjectConfigImportIntent; + preview: ProjectConfigImportPreview | null; + error: null; + } + | { + status: "ready"; + intent: ProjectConfigImportIntent; + preview: ProjectConfigImportPreview; + error: null; + } + | { + status: "applying"; + intent: ProjectConfigImportIntent; + preview: ProjectConfigImportPreview; + error: null; + } + | { + status: "error"; + intent: ProjectConfigImportIntent; + preview: ProjectConfigImportPreview | null; + error: ProjectConfigImportVisibleError; + retryAction: ProjectConfigImportRetryAction; + }; + +export type ProjectConfigImportVisibleError = + | ProjectConfigRpcError + | { code: "transport"; message: string }; +export type ProjectConfigImportRetryAction = "refresh" | "apply"; + +export function openProjectConfigImport(input: { + intent: ProjectConfigImportIntent; + preview: ProjectConfigImportPreview | null; + isLoading: boolean; + error: ProjectConfigImportVisibleError | null; + errorRetryAction?: ProjectConfigImportRetryAction; + isApplying: boolean; +}): ProjectConfigImportState { + if (input.error) { + return { + status: "error", + intent: input.intent, + preview: input.preview, + error: input.error, + retryAction: input.errorRetryAction ?? "refresh", + }; + } + if (input.preview && input.isApplying) { + return { + status: "applying", + intent: input.intent, + preview: input.preview, + error: null, + }; + } + if (input.preview) { + return { + status: "ready", + intent: input.intent, + preview: input.preview, + error: null, + }; + } + return { + status: "loading", + intent: input.intent, + preview: null, + error: null, + }; +} + +export function projectConfigImportCanApply(state: ProjectConfigImportState): boolean { + return ( + state.status === "ready" && + state.preview.status === "available" && + Boolean(state.preview.preview) + ); +} + +export function projectConfigImportNeedsRefresh(error: ProjectConfigImportVisibleError): boolean { + return error.code === "stale_source_config" || error.code === "stale_project_config"; +} + +export function projectConfigImportApplyFailureRetryAction( + error: ProjectConfigImportVisibleError, +): ProjectConfigImportRetryAction { + return projectConfigImportNeedsRefresh(error) ? "refresh" : "apply"; +} + +export function normalizeProjectConfigImportError( + error: ProjectConfigRpcError | Error | null, +): ProjectConfigImportVisibleError | null { + if (!error) { + return null; + } + if (error instanceof Error) { + return { + code: "transport", + message: error.message.length > 0 ? error.message : "The host did not respond.", + }; + } + return error; +} + +export function parseProjectConfigImportIntent(input: { + importSource?: string | string[]; + importServerId?: string | string[]; + importIntentId?: string | string[]; +}): ProjectConfigImportIntent | null { + const source = first(input.importSource); + const serverId = first(input.importServerId); + const intentId = first(input.importIntentId); + if (source !== "conductor" || !serverId || !intentId) { + return null; + } + return { + serverId, + source: { kind: "conductor" }, + intentId, + }; +} + +export function projectConfigImportSourceFeature(source: ProjectConfigImportSource) { + switch (source.kind) { + case "conductor": + return "projectConfigImportConductor" as const; + } +} + +export function sourceLabel(source: ProjectConfigImportSource): string { + switch (source.kind) { + case "conductor": + return "Conductor"; + } +} + +function first(value: string | string[] | undefined): string | null { + const raw = Array.isArray(value) ? value[0] : value; + if (typeof raw !== "string") { + return null; + } + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/packages/app/src/project-config-import/project-config-import-preview.ts b/packages/app/src/project-config-import/project-config-import-preview.ts new file mode 100644 index 000000000..1c6c2ca58 --- /dev/null +++ b/packages/app/src/project-config-import/project-config-import-preview.ts @@ -0,0 +1,36 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages"; +import { useFetchQuery } from "@/data/query"; + +export function projectConfigImportPreviewQueryKey(input: { + serverId: string; + repoRoot: string; + source: ProjectConfigImportSource; +}) { + return ["project-config-import", input.serverId, input.repoRoot, input.source.kind] as const; +} + +export function useProjectConfigImportPreview(input: { + client: DaemonClient | null; + serverId: string; + repoRoot: string; + source: ProjectConfigImportSource; + enabled: boolean; +}) { + return useFetchQuery({ + queryKey: projectConfigImportPreviewQueryKey(input), + queryFn: () => { + if (!input.client) { + throw new Error("Project config import preview requires a daemon client"); + } + return input.client.getProjectConfigImport({ + repoRoot: input.repoRoot, + source: input.source, + }); + }, + dataShape: "value", + enabled: input.enabled && Boolean(input.client && input.serverId && input.repoRoot), + retry: false, + staleTimeMs: 5_000, + }); +} diff --git a/packages/app/src/project-config-import/project-config-import-sheet.tsx b/packages/app/src/project-config-import/project-config-import-sheet.tsx new file mode 100644 index 000000000..cecfcdd00 --- /dev/null +++ b/packages/app/src/project-config-import/project-config-import-sheet.tsx @@ -0,0 +1,265 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import type { ProjectConfigImportPreview } from "@getpaseo/protocol/messages"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { settingsStyles } from "@/styles/settings"; +import { ConductorIcon } from "./conductor-icon"; +import { + projectConfigImportCanApply, + projectConfigImportNeedsRefresh, + sourceLabel, + type ProjectConfigImportState, + type ProjectConfigImportVisibleError, +} from "./project-config-import-model"; + +interface ProjectConfigImportSheetProps { + visible: boolean; + state: ProjectConfigImportState; + onClose: () => void; + onRefresh: () => void; + onApply: () => void; +} + +export function ProjectConfigImportSheet({ + visible, + state, + onClose, + onRefresh, + onApply, +}: ProjectConfigImportSheetProps) { + const { t } = useTranslation(); + const header = useMemo( + () => ({ + title: t("settings.project.import.sheetTitle", { source: sourceLabel(state.intent.source) }), + }), + [state.intent.source, t], + ); + const preview = state.preview; + const visibleError = state.status === "error" ? state.error : null; + const needsRefresh = visibleError ? projectConfigImportNeedsRefresh(visibleError) : false; + const retryAction = state.status === "error" ? state.retryAction : "refresh"; + const handleRetry = retryAction === "apply" ? onApply : onRefresh; + const canImport = projectConfigImportCanApply(state); + const isLoading = state.status === "loading"; + const isApplying = state.status === "applying"; + + return ( + + + {isLoading ? ( + + + + ) : null} + + {visibleError ? ( + + {needsRefresh ? ( + + ) : ( + + )} + + + ) : null} + + {preview ? : null} + + + + + + + + ); +} + +function PreviewBody({ preview }: { preview: ProjectConfigImportPreview }) { + const { t } = useTranslation(); + const grouped = groupPreviewItems(preview); + return ( + + + + {t("settings.project.import.sources")} + + + {preview.inputs.map((input, index) => ( + + {input.relativePath} + {input.role} + + ))} + + + + + + ); +} + +function ItemGroup({ + title, + items, +}: { + title: string; + items: ProjectConfigImportPreview["items"]; +}) { + if (items.length === 0) { + return null; + } + return ( + + {title} + + {items.map((item, index) => ( + + {item.label} + {item.detail ? {item.detail} : null} + + ))} + + + ); +} + +function groupPreviewItems(preview: ProjectConfigImportPreview) { + return { + imports: preview.items.filter((item) => item.outcome === "import"), + attention: preview.items.filter( + (item) => item.outcome === "rewrite" || item.outcome === "collision", + ), + unsupported: preview.items.filter((item) => item.outcome === "unsupported"), + }; +} + +function projectConfigImportErrorText( + error: ProjectConfigImportVisibleError, + t: TFunction, +): string { + switch (error.code) { + case "transport": + return error.message; + case "source_config_not_found": + return t("settings.project.import.errors.notFound"); + case "invalid_source_config": + return t("settings.project.import.errors.invalid", { path: error.relativePath }); + case "stale_source_config": + return t("settings.project.import.errors.staleSource"); + case "stale_project_config": + return t("settings.project.import.errors.staleProject"); + case "nothing_to_import": + return t("settings.project.import.errors.nothing"); + case "write_failed": + return t("settings.project.writeFailures.failedDescription"); + case "invalid_project_config": + return t("settings.project.readFailures.invalidDescription"); + case "project_not_found": + return t("settings.project.readFailures.missingSingleHost"); + } +} + +const styles = StyleSheet.create((theme) => ({ + content: { + gap: theme.spacing[4], + }, + loading: { + minHeight: 120, + alignItems: "center", + justifyContent: "center", + }, + preview: { + gap: theme.spacing[4], + }, + sourceRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + group: { + gap: theme.spacing[2], + }, + groupTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + rowWithBorder: { + ...settingsStyles.row, + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + commandText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontFamily: theme.fontFamily.mono, + }, + footer: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, + iconColor: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/project-config-import/use-project-config-import-model.ts b/packages/app/src/project-config-import/use-project-config-import-model.ts new file mode 100644 index 000000000..1edfff9b8 --- /dev/null +++ b/packages/app/src/project-config-import/use-project-config-import-model.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ProjectConfigImportPreview } from "@getpaseo/protocol/messages"; +import { useToast } from "@/contexts/toast-context"; +import { + normalizeProjectConfigImportError, + openProjectConfigImport, + projectConfigImportApplyFailureRetryAction, + type ProjectConfigImportIntent, + type ProjectConfigImportRetryAction, + type ProjectConfigImportState, + type ProjectConfigImportVisibleError, +} from "./project-config-import-model"; + +export interface ProjectConfigImportModel { + state: ProjectConfigImportState; + apply: () => void; +} + +export function useProjectConfigImportModel(input: { + intent: ProjectConfigImportIntent; + repoRoot: string; + client: DaemonClient; + preview: ProjectConfigImportPreview | null; + isPreviewLoading: boolean; + previewError: ProjectConfigImportVisibleError | null; + projectConfigQueryKey: readonly [string, string, string]; + onClose: () => void; +}): ProjectConfigImportModel { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const toast = useToast(); + const [applyError, setApplyError] = useState(null); + const [applyErrorRetryAction, setApplyErrorRetryAction] = + useState("apply"); + const previewRevisionKey = [ + input.intent.intentId, + input.preview?.sourceRevision ?? "", + input.preview?.paseoRevision?.mtimeMs ?? "", + input.preview?.paseoRevision?.size ?? "", + ].join(":"); + + useEffect(() => { + setApplyError(null); + }, [previewRevisionKey]); + + const applyMutation = useMutation({ + mutationFn: async () => { + if (!input.preview?.sourceRevision) { + throw new Error("Import preview is not available"); + } + return input.client.applyProjectConfigImport({ + repoRoot: input.repoRoot, + source: input.intent.source, + expectedSourceRevision: input.preview.sourceRevision, + expectedPaseoRevision: input.preview.paseoRevision, + }); + }, + onSuccess: (result) => { + if (!result.ok) { + setApplyError(result.error); + setApplyErrorRetryAction(projectConfigImportApplyFailureRetryAction(result.error)); + return; + } + queryClient.setQueryData(input.projectConfigQueryKey, { + ok: true, + config: result.config, + revision: result.revision, + requestId: "import-cache", + repoRoot: input.repoRoot, + }); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.show(t("settings.project.import.success"), { variant: "success" }); + setApplyError(null); + input.onClose(); + }, + onError: (error) => { + setApplyError( + normalizeProjectConfigImportError( + error instanceof Error ? error : new Error(String(error)), + ), + ); + setApplyErrorRetryAction("apply"); + }, + }); + + const apply = useCallback(() => { + if (applyMutation.isPending) { + return; + } + setApplyError(null); + applyMutation.mutate(); + }, [applyMutation]); + + const state = useMemo( + () => + openProjectConfigImport({ + intent: input.intent, + preview: input.preview, + isLoading: input.isPreviewLoading, + error: applyError ?? input.previewError, + errorRetryAction: applyError ? applyErrorRetryAction : "refresh", + isApplying: applyMutation.isPending, + }), + [ + applyError, + applyErrorRetryAction, + applyMutation.isPending, + input.intent, + input.isPreviewLoading, + input.preview, + input.previewError, + ], + ); + + return { state, apply }; +} diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 8a9f5e8bd..2f95dd391 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -26,6 +26,15 @@ import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { ConductorIcon } from "@/project-config-import/conductor-icon"; +import { + normalizeProjectConfigImportError, + type ProjectConfigImportIntent, +} from "@/project-config-import/project-config-import-model"; +import { ProjectConfigImportSheet } from "@/project-config-import/project-config-import-sheet"; +import { useProjectConfigImportPreview } from "@/project-config-import/project-config-import-preview"; +import { useProjectConfigImportModel } from "@/project-config-import/use-project-config-import-model"; +import { useHostFeature } from "@/runtime/host-features"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; @@ -85,9 +94,13 @@ type ReadProjectConfigData = Awaited projects.find((entry) => entry.projectKey === projectKey), @@ -96,15 +109,22 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr const editableHosts = useMemo(() => filterEditableHosts(project), [project]); const [selectedServerId, setSelectedServerId] = useState( - () => editableHosts[0]?.serverId ?? "", + () => + (importIntent && editableHosts.some((host) => host.serverId === importIntent.serverId) + ? importIntent.serverId + : editableHosts[0]?.serverId) ?? "", ); useEffect(() => { + if (importIntent && editableHosts.some((host) => host.serverId === importIntent.serverId)) { + setSelectedServerId(importIntent.serverId); + return; + } const stillValid = editableHosts.some((host) => host.serverId === selectedServerId); if (!stillValid) { setSelectedServerId(editableHosts[0]?.serverId ?? ""); } - }, [editableHosts, selectedServerId]); + }, [editableHosts, importIntent, selectedServerId]); const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId); const isHostGone = @@ -127,6 +147,7 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr onSelectHost={setSelectedServerId} client={client} isHostGone={isHostGone} + importIntent={importIntent} /> ); } @@ -184,6 +205,7 @@ interface ProjectSettingsBodyProps { onSelectHost: (serverId: string) => void; client: DaemonClient; isHostGone: boolean; + importIntent: ProjectConfigImportIntent | null; } function ProjectSettingsBody({ @@ -193,7 +215,13 @@ function ProjectSettingsBody({ onSelectHost, client, isHostGone, + importIntent, }: ProjectSettingsBodyProps) { + const [openImportIntent, setOpenImportIntent] = useState(null); + const supportsConductorImport = useHostFeature( + selectedHost.serverId, + "projectConfigImportConductor", + ); const queryKey = useMemo( () => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const, [selectedHost.serverId, selectedHost.repoRoot], @@ -223,10 +251,51 @@ function ProjectSettingsBody({ const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; + const importPreviewQuery = useProjectConfigImportPreview({ + client, + serverId: selectedHost.serverId, + repoRoot: selectedHost.repoRoot, + source: { kind: "conductor" }, + enabled: supportsConductorImport && readQuery.data?.ok === true, + }); + + useEffect(() => { + if (!importIntent) { + return; + } + if (importIntent.serverId !== selectedHost.serverId) { + return; + } + setOpenImportIntent(importIntent); + }, [importIntent, selectedHost.serverId]); const handleReload = useCallback(() => { void readQuery.refetch(); }, [readQuery]); + const handleCloseImport = useCallback(() => { + setOpenImportIntent(null); + }, []); + const handleRefreshImport = useCallback(() => { + void importPreviewQuery.refetch(); + }, [importPreviewQuery]); + const importModel = useProjectConfigImportModel({ + intent: openImportIntent ?? { + serverId: selectedHost.serverId, + source: { kind: "conductor" }, + intentId: "closed", + }, + repoRoot: selectedHost.repoRoot, + client, + preview: importPreviewQuery.data?.ok ? importPreviewQuery.data : null, + isPreviewLoading: importPreviewQuery.isLoading || importPreviewQuery.isFetching, + previewError: normalizeProjectConfigImportError( + importPreviewQuery.data && !importPreviewQuery.data.ok + ? importPreviewQuery.data.error + : importPreviewQuery.error, + ), + projectConfigQueryKey: queryKey, + onClose: handleCloseImport, + }); const hasMultipleHosts = hosts.length > 1; @@ -257,7 +326,19 @@ function ProjectSettingsBody({ onReload: handleReload, hasMultipleHosts, isHostGone, + importPreviewQuery, + importIntent: openImportIntent, + onOpenImport: setOpenImportIntent, })} + {openImportIntent ? ( + + ) : null} ); } @@ -273,6 +354,9 @@ interface RenderContentInput { onReload: () => void; hasMultipleHosts: boolean; isHostGone: boolean; + importPreviewQuery: ReturnType; + importIntent: ProjectConfigImportIntent | null; + onOpenImport: (intent: ProjectConfigImportIntent | null) => void; } function renderContent({ @@ -286,6 +370,9 @@ function renderContent({ onReload, hasMultipleHosts, isHostGone, + importPreviewQuery, + importIntent, + onOpenImport, }: RenderContentInput) { if (readQuery.isLoading) { return ( @@ -335,10 +422,14 @@ function renderContent({ key={formKey} baseConfig={loadedConfig} revision={loadedRevision} + serverId={selectedHost.serverId} repoRoot={selectedHost.repoRoot} queryKey={queryKey} client={client} onReload={onReload} + importPreviewQuery={importPreviewQuery} + importIntent={importIntent} + onOpenImport={onOpenImport} /> ); } @@ -420,19 +511,27 @@ function errorToDetail(error: unknown): string | null { interface ProjectConfigFormProps { baseConfig: PaseoConfigRaw; revision: PaseoConfigRevision | null; + serverId: string; repoRoot: string; queryKey: readonly [string, string, string]; client: DaemonClient; onReload: () => void; + importPreviewQuery: ReturnType; + importIntent: ProjectConfigImportIntent | null; + onOpenImport: (intent: ProjectConfigImportIntent | null) => void; } function ProjectConfigForm({ baseConfig, revision, + serverId, repoRoot, queryKey, client, onReload, + importPreviewQuery, + importIntent, + onOpenImport, }: ProjectConfigFormProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -637,6 +736,13 @@ function ProjectConfigForm({ info={t("settings.project.worktree.info")} testID="worktree-group" > + + ; + serverId: string; + importIntent: ProjectConfigImportIntent | null; + onOpenImport: (intent: ProjectConfigImportIntent | null) => void; +} + +function ConductorImportRow({ + previewQuery, + serverId, + importIntent, + onOpenImport, +}: ConductorImportRowProps) { + const { t } = useTranslation(); + const preview = previewQuery.data?.ok ? previewQuery.data : null; + const shouldShow = preview?.status === "available" || importIntent?.source.kind === "conductor"; + const handlePress = useCallback(() => { + onOpenImport({ + serverId, + source: { kind: "conductor" }, + intentId: String(Date.now()), + }); + }, [onOpenImport, serverId]); + + if (!shouldShow) { + return null; + } + + return ( + + + + + + + + {t("settings.project.import.rowTitle")} + + {t("settings.project.import.rowDescription")} + + + + + + ); +} + interface ProjectNameEditorProps { project: ProjectSummary; client: DaemonClient; @@ -1321,6 +1483,26 @@ const styles = StyleSheet.create((theme) => ({ iconColor: { color: theme.colors.foregroundMuted, }, + importRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + padding: theme.spacing[3], + }, + importIconWrap: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + importText: { + flex: 1, + minWidth: 0, + }, hostIndicator: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5eefdf6fa..f2f490fc1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -98,6 +98,7 @@ import { } from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; +import type { ProjectConfigImportIntent } from "@/project-config-import/project-config-import-model"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { @@ -123,7 +124,7 @@ export type SettingsView = | { kind: "section"; section: SettingsSectionSlug } | { kind: "host"; serverId: string; section: HostSectionSlug } | { kind: "projects" } - | { kind: "project"; projectKey: string }; + | { kind: "project"; projectKey: string; importIntent?: ProjectConfigImportIntent | null }; interface SidebarSectionItem { id: SettingsSectionSlug; @@ -1385,7 +1386,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti return ; } if (view.kind === "project") { - return ; + return ( + + ); } if (view.kind === "section") { switch (view.section) { diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index fbc8115fc..3047f62f4 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -9,6 +9,7 @@ import { resolveKnownHostRoute, buildSessionsRoute, buildSettingsAddHostRoute, + buildProjectSettingsImportRoute, buildProjectSettingsRoute, buildProjectsSettingsRoute, decodeFilePathFromPathSegment, @@ -208,6 +209,19 @@ describe("projects settings routes", () => { ); }); + it("buildProjectSettingsImportRoute includes the import source, host, and intent", () => { + expect( + buildProjectSettingsImportRoute({ + projectKey: "remote:github.com/acme/app", + source: "conductor", + serverId: "server-1", + intentId: "intent 1", + }), + ).toBe( + "/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=conductor&importServerId=server-1&importIntentId=intent+1", + ); + }); + it("project keys round-trip through decodeURIComponent", () => { const projectKey = "remote:github.com/acme/app"; const route = buildProjectSettingsRoute(projectKey); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 717bc8296..d1254be41 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -568,3 +568,18 @@ export function buildProjectSettingsRoute(projectKey: string) { } return `/settings/projects/${encodeSegment(normalized)}` as const; } + +export function buildProjectSettingsImportRoute(input: { + projectKey: string; + source: "conductor"; + serverId: string; + intentId: string; +}) { + const base = buildProjectSettingsRoute(input.projectKey); + const query = new URLSearchParams({ + importSource: input.source, + importServerId: input.serverId, + importIntentId: input.intentId, + }); + return `${base}?${query.toString()}` as const; +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 3b3183862..b084b1d5c 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3109,6 +3109,96 @@ test("writes project config via correlated RPC and returns inline failures", asy }); }); +test("previews and applies project config import via correlated RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const previewPromise = client.getProjectConfigImport({ + requestId: "get-import-1", + repoRoot: "/repo/app", + source: { kind: "conductor" }, + }); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "project.config.get_import.request", + requestId: "get-import-1", + repoRoot: "/repo/app", + source: { kind: "conductor" }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "project.config.get_import.response", + payload: { + requestId: "get-import-1", + repoRoot: "/repo/app", + source: { kind: "conductor" }, + ok: true, + status: "available", + sourceRevision: "source-1", + paseoRevision: null, + inputs: [{ role: "shared", relativePath: ".conductor/settings.toml" }], + items: [{ key: "worktree.setup", label: "Setup", outcome: "import" }], + preview: { worktree: { setup: "npm ci" } }, + }, + }), + ); + + await expect(previewPromise).resolves.toMatchObject({ + ok: true, + sourceRevision: "source-1", + preview: { worktree: { setup: "npm ci" } }, + }); + + const applyPromise = client.applyProjectConfigImport({ + requestId: "apply-import-1", + repoRoot: "/repo/app", + source: { kind: "conductor" }, + expectedSourceRevision: "source-1", + expectedPaseoRevision: null, + }); + + expect(parseSentFrame(mock.sent[1])).toEqual({ + type: "project.config.apply_import.request", + requestId: "apply-import-1", + repoRoot: "/repo/app", + source: { kind: "conductor" }, + expectedSourceRevision: "source-1", + expectedPaseoRevision: null, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "project.config.apply_import.response", + payload: { + requestId: "apply-import-1", + repoRoot: "/repo/app", + ok: false, + error: { code: "stale_source_config", source: { kind: "conductor" } }, + }, + }), + ); + + await expect(applyPromise).resolves.toEqual({ + requestId: "apply-import-1", + repoRoot: "/repo/app", + ok: false, + error: { code: "stale_source_config", source: { kind: "conductor" } }, + }); +}); + test("requests directory suggestions via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 33a801b35..ccfd7934f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -94,6 +94,7 @@ import type { SendAgentMessageRequest, PaseoConfigRaw, PaseoConfigRevision, + ProjectConfigImportSource, WorkspaceCreateRequest, WorkspaceRecoveryState, } from "@getpaseo/protocol/messages"; @@ -430,6 +431,14 @@ type WriteProjectConfigPayload = Extract< SessionOutboundMessage, { type: "write_project_config_response" } >["payload"]; +type GetProjectConfigImportPayload = Extract< + SessionOutboundMessage, + { type: "project.config.get_import.response" } +>["payload"]; +type ApplyProjectConfigImportPayload = Extract< + SessionOutboundMessage, + { type: "project.config.apply_import.response" } +>["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< @@ -442,6 +451,18 @@ export interface WriteProjectConfigInput { expectedRevision: PaseoConfigRevision | null; requestId?: string; } +export interface GetProjectConfigImportInput { + repoRoot: string; + source: ProjectConfigImportSource; + requestId?: string; +} +export interface ApplyProjectConfigImportInput { + repoRoot: string; + source: ProjectConfigImportSource; + expectedSourceRevision: string; + expectedPaseoRevision: PaseoConfigRevision | null; + requestId?: string; +} interface ListCommandsOptions { agentId: string; requestId?: string; @@ -4266,6 +4287,36 @@ export class DaemonClient { }); } + async getProjectConfigImport( + input: GetProjectConfigImportInput, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "project.config.get_import.request", + repoRoot: input.repoRoot, + source: input.source, + }, + responseType: "project.config.get_import.response", + }); + } + + async applyProjectConfigImport( + input: ApplyProjectConfigImportInput, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "project.config.apply_import.request", + repoRoot: input.repoRoot, + source: input.source, + expectedSourceRevision: input.expectedSourceRevision, + expectedPaseoRevision: input.expectedPaseoRevision, + }, + responseType: "project.config.apply_import.response", + }); + } + async refreshProvidersSnapshot(options?: { cwd?: string; providers?: AgentProvider[]; diff --git a/packages/protocol/scripts/generate-validation-aot.mjs b/packages/protocol/scripts/generate-validation-aot.mjs index c3fd0b72e..aa443224d 100644 --- a/packages/protocol/scripts/generate-validation-aot.mjs +++ b/packages/protocol/scripts/generate-validation-aot.mjs @@ -34,37 +34,94 @@ async function ensureZodAotRuntimeImportExtensionPatch() { async function ensureZodAotDiscriminatedUnionOutputPatch() { let discriminatedUnionEmitter = await readFile(discriminatedUnionPath, "utf8"); + let changed = false; + if ( - discriminatedUnionEmitter.includes( + !discriminatedUnionEmitter.includes( "const needsOutputPropagation = ir.options.some(hasMutation);", ) ) { - return; + const importBefore = 'import { escapeString } from "../context.js";'; + const importAfter = 'import { escapeString, hasMutation } from "../context.js";'; + const outputFlagBefore = + "const discKey = escapeString(ir.discriminator);\n let code = emit `"; + const outputFlagAfter = + "const discKey = escapeString(ir.discriminator);\n const needsOutputPropagation = ir.options.some(hasMutation);\n let code = emit `"; + const propagationBefore = + " ${g.visit(option, { input: objVar, output: objVar })}\n break;`;"; + const propagationAfter = + ' ${g.visit(option, { input: objVar, output: objVar })}\n ${needsOutputPropagation ? `${g.output}=${objVar};` : ""}\n break;`;'; + + if ( + !discriminatedUnionEmitter.includes(importBefore) || + !discriminatedUnionEmitter.includes(outputFlagBefore) || + !discriminatedUnionEmitter.includes(propagationBefore) + ) { + throw new Error("zod-aot discriminated-union emitter shape changed; update the output patch"); + } + + discriminatedUnionEmitter = discriminatedUnionEmitter + .replace(importBefore, importAfter) + .replace(outputFlagBefore, outputFlagAfter) + .replace(propagationBefore, propagationAfter); + changed = true; } - const importBefore = 'import { escapeString } from "../context.js";'; - const importAfter = 'import { escapeString, hasMutation } from "../context.js";'; - const outputFlagBefore = "const discKey = escapeString(ir.discriminator);\n let code = emit `"; - const outputFlagAfter = - "const discKey = escapeString(ir.discriminator);\n const needsOutputPropagation = ir.options.some(hasMutation);\n let code = emit `"; - const propagationBefore = - " ${g.visit(option, { input: objVar, output: objVar })}\n break;`;"; - const propagationAfter = - ' ${g.visit(option, { input: objVar, output: objVar })}\n ${needsOutputPropagation ? `${g.output}=${objVar};` : ""}\n break;`;'; + if (!discriminatedUnionEmitter.includes("function discriminatorValueLiteral(")) { + const helperBefore = 'import { invalidType } from "../emit-issue.js";\n'; + const helperAfter = `${helperBefore}function discriminatorValueLiteral(value, option, discriminator) { + const property = option?.type === "object" ? option.properties?.[discriminator] : undefined; + if (property?.type === "literal" && property.values.length === 1) { + return JSON.stringify(property.values[0]); + } + return escapeString(value); +} +`; + const slowCaseBefore = " const option = ir.options[index];\n code += emit `"; + const slowCaseAfter = + " const option = ir.options[index];\n const caseValue = discriminatorValueLiteral(value, option, ir.discriminator);\n code += emit `"; + const slowCaseValueBefore = " case ${escapeString(value)}:"; + const slowCaseValueAfter = " case ${caseValue}:"; + const validValuesBefore = `const validValues = Object.keys(ir.mapping) + .map((v) => escapeString(v)) + .join(",");`; + const validValuesAfter = `const validValues = Object.entries(ir.mapping) + .map(([v, i]) => discriminatorValueLiteral(v, ir.options[i], ir.discriminator)) + .join(",");`; + const fastCaseBefore = + " const option = ir.options[index];\n const check = g.visit(option, { input: helperParam });"; + const fastCaseAfter = + " const option = ir.options[index];\n const caseValue = discriminatorValueLiteral(value, option, ir.discriminator);\n const check = g.visit(option, { input: helperParam });"; + const fastCaseValueBefore = + " cases.push(`case ${escapeString(value)}:return ${check};`);"; + const fastCaseValueAfter = " cases.push(`case ${caseValue}:return ${check};`);"; - if ( - !discriminatedUnionEmitter.includes(importBefore) || - !discriminatedUnionEmitter.includes(outputFlagBefore) || - !discriminatedUnionEmitter.includes(propagationBefore) - ) { - throw new Error("zod-aot discriminated-union emitter shape changed; update the output patch"); + if ( + !discriminatedUnionEmitter.includes(helperBefore) || + !discriminatedUnionEmitter.includes(slowCaseBefore) || + !discriminatedUnionEmitter.includes(slowCaseValueBefore) || + !discriminatedUnionEmitter.includes(validValuesBefore) || + !discriminatedUnionEmitter.includes(fastCaseBefore) || + !discriminatedUnionEmitter.includes(fastCaseValueBefore) + ) { + throw new Error( + "zod-aot discriminated-union emitter shape changed; update the literal patch", + ); + } + + discriminatedUnionEmitter = discriminatedUnionEmitter + .replace(helperBefore, helperAfter) + .replace(slowCaseBefore, slowCaseAfter) + .replace(slowCaseValueBefore, slowCaseValueAfter) + .replace(validValuesBefore, validValuesAfter) + .replace(fastCaseBefore, fastCaseAfter) + .replace(fastCaseValueBefore, fastCaseValueAfter); + changed = true; } - discriminatedUnionEmitter = discriminatedUnionEmitter - .replace(importBefore, importAfter) - .replace(outputFlagBefore, outputFlagAfter) - .replace(propagationBefore, propagationAfter); - await writeFile(discriminatedUnionPath, discriminatedUnionEmitter); + if (changed) { + await writeFile(discriminatedUnionPath, discriminatedUnionEmitter); + } } await Promise.all([ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index cf01fc15d..dafcb4b51 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -66,12 +66,19 @@ import { PaseoScriptEntryRawSchema, PaseoWorktreeConfigRawSchema, PaseoConfigRevisionSchema, + ProjectConfigImportItemSchema, + ProjectConfigImportPreviewSchema, + ProjectConfigImportSourceSchema, ProjectConfigRpcErrorSchema, type PaseoConfigRaw, type PaseoConfigRevision, type PaseoMetadataGeneration, type PaseoMetadataGenerationEntry, type PaseoScriptEntryRaw, + type ProjectConfigImportInput, + type ProjectConfigImportItem, + type ProjectConfigImportPreview, + type ProjectConfigImportSource, type ProjectConfigRpcError, } from "./paseo-config-schema.js"; export { @@ -81,11 +88,18 @@ export { PaseoMetadataGenerationSchema, PaseoScriptEntryRawSchema, PaseoWorktreeConfigRawSchema, + ProjectConfigImportItemSchema, + ProjectConfigImportPreviewSchema, + ProjectConfigImportSourceSchema, type PaseoConfigRaw, type PaseoConfigRevision, type PaseoMetadataGeneration, type PaseoMetadataGenerationEntry, type PaseoScriptEntryRaw, + type ProjectConfigImportInput, + type ProjectConfigImportItem, + type ProjectConfigImportPreview, + type ProjectConfigImportSource, type ProjectConfigRpcError, }; // --------------------------------------------------------------------------- @@ -1165,6 +1179,22 @@ export const WriteProjectConfigRequestMessageSchema = z.object({ expectedRevision: PaseoConfigRevisionSchema.nullable(), }); +export const GetProjectConfigImportRequestMessageSchema = z.object({ + type: z.literal("project.config.get_import.request"), + requestId: z.string(), + repoRoot: z.string(), + source: ProjectConfigImportSourceSchema, +}); + +export const ApplyProjectConfigImportRequestMessageSchema = z.object({ + type: z.literal("project.config.apply_import.request"), + requestId: z.string(), + repoRoot: z.string(), + source: ProjectConfigImportSourceSchema, + expectedSourceRevision: z.string(), + expectedPaseoRevision: PaseoConfigRevisionSchema.nullable(), +}); + // ============================================================================ // Dictation Streaming (lossless, resumable) // ============================================================================ @@ -2336,6 +2366,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, WriteProjectConfigRequestMessageSchema, + GetProjectConfigImportRequestMessageSchema, + ApplyProjectConfigImportRequestMessageSchema, DictationStreamStartMessageSchema, DictationStreamChunkMessageSchema, DictationStreamFinishMessageSchema, @@ -2674,6 +2706,8 @@ export const ServerInfoStatusPayloadSchema = z providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), + // COMPAT(projectConfigImportConductor): added in v0.1.X, drop the gate when floor >= v0.1.X. + projectConfigImportConductor: z.boolean().optional(), // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. // Daemon advertises pluggable non-GitHub forge support (the forge registry); // the client gates non-GitHub setup UI on it. @@ -3624,6 +3658,43 @@ export const WriteProjectConfigResponseMessageSchema = z.object({ ]), }); +export const GetProjectConfigImportResponseMessageSchema = z.object({ + type: z.literal("project.config.get_import.response"), + payload: z.discriminatedUnion("ok", [ + ProjectConfigImportPreviewSchema.extend({ + requestId: z.string(), + ok: z.literal(true), + }), + z.object({ + requestId: z.string(), + repoRoot: z.string(), + ok: z.literal(false), + error: ProjectConfigRpcErrorSchema, + }), + ]), +}); + +export const ApplyProjectConfigImportResponseMessageSchema = z.object({ + type: z.literal("project.config.apply_import.response"), + payload: z.discriminatedUnion("ok", [ + z.object({ + requestId: z.string(), + repoRoot: z.string(), + source: ProjectConfigImportSourceSchema, + ok: z.literal(true), + config: PaseoConfigRawSchema, + revision: PaseoConfigRevisionSchema, + items: z.array(ProjectConfigImportItemSchema), + }), + z.object({ + requestId: z.string(), + repoRoot: z.string(), + ok: z.literal(false), + error: ProjectConfigRpcErrorSchema, + }), + ]), +}); + export const AgentPermissionRequestMessageSchema = z.object({ type: z.literal("agent_permission_request"), payload: z.object({ @@ -4846,6 +4917,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetDaemonConfigResponseMessageSchema, ReadProjectConfigResponseMessageSchema, WriteProjectConfigResponseMessageSchema, + GetProjectConfigImportResponseMessageSchema, + ApplyProjectConfigImportResponseMessageSchema, SetAgentModeResponseMessageSchema, SetAgentModelResponseMessageSchema, SetAgentThinkingResponseMessageSchema, diff --git a/packages/protocol/src/paseo-config-schema.ts b/packages/protocol/src/paseo-config-schema.ts index 804e1cdf0..c9a3f2a37 100644 --- a/packages/protocol/src/paseo-config-schema.ts +++ b/packages/protocol/src/paseo-config-schema.ts @@ -79,13 +79,67 @@ export const PaseoConfigRevisionSchema = z.object({ size: z.number(), }); +export const ProjectConfigImportSourceSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("conductor") }), +]); + +export const ProjectConfigImportInputSchema = z.object({ + role: z.string(), + relativePath: z.string(), +}); + +export const ProjectConfigImportItemOutcomeSchema = z.enum([ + "import", + "rewrite", + "collision", + "unsupported", +]); + +export const ProjectConfigImportItemSchema = z.object({ + key: z.string(), + label: z.string(), + outcome: ProjectConfigImportItemOutcomeSchema, + detail: z.string().optional(), +}); + +export const ProjectConfigImportStatusSchema = z.enum([ + "available", + "not_found", + "nothing_to_import", +]); + +export const ProjectConfigImportPreviewSchema = z.object({ + repoRoot: z.string(), + source: ProjectConfigImportSourceSchema, + status: ProjectConfigImportStatusSchema, + sourceRevision: z.string().nullable(), + paseoRevision: PaseoConfigRevisionSchema.nullable(), + inputs: z.array(ProjectConfigImportInputSchema), + items: z.array(ProjectConfigImportItemSchema), + preview: PaseoConfigRawSchema.nullable(), +}); + export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [ z.object({ code: z.literal("project_not_found") }), + z.object({ + code: z.literal("source_config_not_found"), + source: ProjectConfigImportSourceSchema, + }), + z.object({ + code: z.literal("invalid_source_config"), + source: ProjectConfigImportSourceSchema, + relativePath: z.string(), + }), + z.object({ + code: z.literal("stale_source_config"), + source: ProjectConfigImportSourceSchema, + }), z.object({ code: z.literal("invalid_project_config") }), z.object({ code: z.literal("stale_project_config"), currentRevision: PaseoConfigRevisionSchema.nullable(), }), + z.object({ code: z.literal("nothing_to_import") }), z.object({ code: z.literal("write_failed") }), ]); @@ -95,4 +149,8 @@ export type PaseoMetadataGeneration = z.infer; export type PaseoConfig = z.infer; export type PaseoConfigRevision = z.infer; +export type ProjectConfigImportSource = z.infer; +export type ProjectConfigImportInput = z.infer; +export type ProjectConfigImportItem = z.infer; +export type ProjectConfigImportPreview = z.infer; export type ProjectConfigRpcError = z.infer; diff --git a/packages/protocol/tests/validation/ws-outbound.test.ts b/packages/protocol/tests/validation/ws-outbound.test.ts index 63338ac63..c674fbad9 100644 --- a/packages/protocol/tests/validation/ws-outbound.test.ts +++ b/packages/protocol/tests/validation/ws-outbound.test.ts @@ -82,6 +82,24 @@ const SourceSchema = z.discriminatedUnion("type", [ }); }); + it("routes boolean discriminated-union branches with boolean switch cases", async () => { + const schema = await compileInlineSchema(` +const SourceSchema = z.discriminatedUnion("ok", [ + z.object({ ok: z.literal(true), value: z.string() }), + z.object({ ok: z.literal(false), error: z.string() }), +]); +`); + + expect(schema.safeParse({ ok: true, value: "yes" })).toMatchObject({ + success: true, + data: { ok: true, value: "yes" }, + }); + expect(schema.safeParse({ ok: false, error: "no" })).toMatchObject({ + success: true, + data: { ok: false, error: "no" }, + }); + }); + it("routes tool-call-like status unions through the current sequential item union", async () => { const schema = await compileInlineSchema(` const ToolCallItemSchema = z.discriminatedUnion("status", [ diff --git a/packages/server/package.json b/packages/server/package.json index c6f12ebca..e7a4b3f4f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -92,6 +92,7 @@ "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", "sherpa-onnx-node": "1.12.28", + "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "uuid": "^9.0.1", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2416c77e3..4991d9087 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1611,6 +1611,10 @@ export class Session { return this.projectConfigSession.handleReadProjectConfigRequest(msg); case "write_project_config_request": return this.projectConfigSession.handleWriteProjectConfigRequest(msg); + case "project.config.get_import.request": + return this.projectConfigSession.handleGetProjectConfigImportRequest(msg); + case "project.config.apply_import.request": + return this.projectConfigSession.handleApplyProjectConfigImportRequest(msg); default: return undefined; } diff --git a/packages/server/src/server/session/project-config/import/conductor.test.ts b/packages/server/src/server/session/project-config/import/conductor.test.ts new file mode 100644 index 000000000..1ef78edde --- /dev/null +++ b/packages/server/src/server/session/project-config/import/conductor.test.ts @@ -0,0 +1,401 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { readPaseoConfigForEdit } from "../../../../utils/paseo-config-file.js"; +import { applyProjectConfigImport, inspectProjectConfigImport } from "./index.js"; +import { InvalidProjectConfigImportSourceError } from "./model.js"; + +const tempDirs: string[] = []; +const CONDUCTOR_SOURCE = { kind: "conductor" } as const; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "conductor-import-test-")); + tempDirs.push(repo); + return repo; +} + +function writeSharedToml(repo: string, contents: string): void { + mkdirSync(join(repo, ".conductor"), { recursive: true }); + writeFileSync(join(repo, ".conductor", "settings.toml"), contents); +} + +function writeLocalToml(repo: string, contents: string): void { + mkdirSync(join(repo, ".conductor"), { recursive: true }); + writeFileSync(join(repo, ".conductor", "settings.local.toml"), contents); +} + +function inspect(repo: string, paseoConfig = {}) { + return inspectProjectConfigImport({ + repoRoot: repo, + source: CONDUCTOR_SOURCE, + paseoConfig, + paseoRevision: null, + }); +} + +function captureInvalidSourceError(repo: string): InvalidProjectConfigImportSourceError { + try { + inspect(repo); + } catch (error) { + if (error instanceof InvalidProjectConfigImportSourceError) { + return error; + } + } + throw new Error("Expected invalid source config error"); +} + +function readValidPaseoConfig( + repo: string, +): Extract, { ok: true }> { + const result = readPaseoConfigForEdit(repo); + if (!result.ok) { + throw new Error("Expected valid paseo config"); + } + return result; +} + +describe("Conductor project config import", () => { + test("maps shared TOML setup, archive, run service, cwd, args, rewrites, and unsupported settings", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +file_include_globs = ["config/*.local"] + +[scripts] +setup = "echo $CONDUCTOR_WORKSPACE_PATH && echo \${CONDUCTOR_ROOT_PATH}" +archive = "cleanup $CONDUCTOR_PORT" +run_mode = "nonconcurrent" +auto_run_after_setup = true + +[scripts.run.dev] +command = "npm run dev -- --port $CONDUCTOR_PORT" +args = ["--host", "0.0.0.0"] +hide = true + +[scripts.run.dev.options] +cwd = "apps/web" + +[environment_variables] +SECRET_TOKEN = "do-not-return" + +[spotlight_testing] +enabled = true +`, + ); + + const preview = inspect(repo); + + expect(preview.status).toBe("available"); + expect(preview.inputs).toEqual([{ role: "shared", relativePath: ".conductor/settings.toml" }]); + expect(preview.preview).toMatchObject({ + worktree: { + setup: "echo $PASEO_WORKTREE_PATH && echo ${PASEO_SOURCE_CHECKOUT_PATH}", + teardown: "cleanup $PASEO_WORKTREE_PORT", + }, + scripts: { + dev: { + type: "service", + port: "$PASEO_PORT", + command: "cd -- 'apps/web' && npm run dev -- --port $PASEO_PORT '--host' '0.0.0.0'", + }, + }, + }); + expect(preview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "worktree.setup", outcome: "import" }), + expect.objectContaining({ key: "worktree.teardown", outcome: "import" }), + expect.objectContaining({ key: "scripts.dev", outcome: "import" }), + expect.objectContaining({ key: "variables.CONDUCTOR_WORKSPACE_PATH", outcome: "rewrite" }), + expect.objectContaining({ key: "variables.CONDUCTOR_ROOT_PATH", outcome: "rewrite" }), + expect.objectContaining({ key: "variables.CONDUCTOR_PORT", outcome: "rewrite" }), + expect.objectContaining({ key: "scripts.run_mode", outcome: "unsupported" }), + expect.objectContaining({ key: "scripts.auto_run_after_setup", outcome: "unsupported" }), + expect.objectContaining({ key: "file_include_globs", outcome: "unsupported" }), + expect.objectContaining({ + key: "environment_variables", + outcome: "unsupported", + detail: "Environment variable values are not imported. Found: SECRET_TOKEN.", + }), + expect.objectContaining({ key: "spotlight_testing", outcome: "unsupported" }), + expect.objectContaining({ key: "scripts.dev.hide", outcome: "unsupported" }), + ]), + ); + expect(JSON.stringify(preview.items)).not.toContain("do-not-return"); + }); + + test("local TOML overrides shared scripts and legacy JSON is ignored when shared TOML exists", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts] +setup = "shared setup" +[scripts.run.dev] +command = "shared dev" +`, + ); + writeLocalToml( + repo, + ` +[scripts] +setup = "local setup" +[scripts.run.dev] +command = "local dev" +`, + ); + writeFileSync( + join(repo, "conductor.json"), + JSON.stringify({ scripts: { setup: "legacy setup", run: "legacy run" } }), + ); + + const preview = inspect(repo); + + expect(preview.inputs).toEqual([ + { role: "shared", relativePath: ".conductor/settings.toml" }, + { role: "local", relativePath: ".conductor/settings.local.toml" }, + ]); + expect(preview.preview).toMatchObject({ + worktree: { setup: "local setup" }, + scripts: { dev: { command: "local dev" } }, + }); + }); + + test("imports legacy conductor.json when shared TOML is absent", () => { + const repo = makeRepo(); + writeFileSync( + join(repo, "conductor.json"), + JSON.stringify({ scripts: { setup: "legacy setup", run: "npm test" } }), + ); + + const preview = inspect(repo); + + expect(preview.inputs).toEqual([{ role: "legacy", relativePath: "conductor.json" }]); + expect(preview.preview).toMatchObject({ + worktree: { setup: "legacy setup" }, + scripts: { run: { command: "npm test" } }, + }); + }); + + test("reports missing and empty repository-local Conductor configs", () => { + const missingRepo = makeRepo(); + const emptyRepo = makeRepo(); + writeSharedToml(emptyRepo, ""); + + expect(inspect(missingRepo)).toEqual({ + repoRoot: missingRepo, + source: CONDUCTOR_SOURCE, + status: "not_found", + sourceRevision: null, + paseoRevision: null, + inputs: [], + items: [], + preview: null, + }); + expect(inspect(emptyRepo)).toMatchObject({ + repoRoot: emptyRepo, + source: CONDUCTOR_SOURCE, + status: "nothing_to_import", + inputs: [{ role: "shared", relativePath: ".conductor/settings.toml" }], + items: [], + preview: null, + }); + }); + + test("preserves existing Paseo values as collisions and reports nothing to import on retry", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts] +setup = "npm ci" +[scripts.run.dev] +command = "npm run dev" +`, + ); + + const preview = inspect(repo, { + custom: { keep: true }, + worktree: { setup: "pnpm install", extra: "field" }, + scripts: { dev: { command: "pnpm dev", color: "blue" } }, + }); + + expect(preview.status).toBe("nothing_to_import"); + expect(preview.preview).toBeNull(); + expect(preview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "worktree.setup", outcome: "collision" }), + expect.objectContaining({ key: "scripts.dev", outcome: "collision" }), + ]), + ); + }); + + test("does not rewrite variable substrings and warns for unsupported Conductor variables", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts] +setup = "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH" +`, + ); + + const preview = inspect(repo); + + expect(preview.preview).toMatchObject({ + worktree: { setup: "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH" }, + }); + expect(preview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "variables.CONDUCTOR_DEFAULT_BRANCH", + outcome: "unsupported", + }), + ]), + ); + expect(preview.items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ key: "variables.CONDUCTOR_PORT" })]), + ); + }); + + test("source digest changes after a byte change and apply rejects stale source", () => { + const repo = makeRepo(); + writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); + const preview = inspect(repo); + writeSharedToml(repo, '[scripts]\nsetup = "pnpm install"\n'); + + const result = applyProjectConfigImport({ + repoRoot: repo, + source: CONDUCTOR_SOURCE, + expectedSourceRevision: preview.sourceRevision ?? "", + expectedPaseoRevision: null, + }); + + expect(result).toEqual({ + ok: false, + repoRoot: repo, + error: { code: "stale_source_config", source: CONDUCTOR_SOURCE }, + }); + }); + + test("apply rejects a stale paseo.json revision", () => { + const repo = makeRepo(); + writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); + writeFileSync(join(repo, "paseo.json"), '{"custom":true}\n'); + const paseoRevision = readValidPaseoConfig(repo); + const preview = inspectProjectConfigImport({ + repoRoot: repo, + source: CONDUCTOR_SOURCE, + paseoConfig: paseoRevision.config ?? {}, + paseoRevision: paseoRevision.revision, + }); + writeFileSync(join(repo, "paseo.json"), '{"custom":false}\n'); + + expect( + applyProjectConfigImport({ + repoRoot: repo, + source: CONDUCTOR_SOURCE, + expectedSourceRevision: preview.sourceRevision ?? "", + expectedPaseoRevision: paseoRevision.revision, + }), + ).toMatchObject({ + ok: false, + repoRoot: repo, + error: { code: "stale_project_config" }, + }); + }); + + test.skipIf(process.platform === "win32")("apply reports write failure visibly", () => { + const repo = makeRepo(); + writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); + const preview = inspect(repo); + chmodSync(repo, 0o555); + try { + expect( + applyProjectConfigImport({ + repoRoot: repo, + source: CONDUCTOR_SOURCE, + expectedSourceRevision: preview.sourceRevision ?? "", + expectedPaseoRevision: null, + }), + ).toEqual({ + ok: false, + repoRoot: repo, + error: { code: "write_failed" }, + }); + } finally { + chmodSync(repo, 0o755); + } + }); + + test("apply writes formatted paseo.json after recomputing from disk", () => { + const repo = makeRepo(); + writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); + const preview = inspect(repo); + + const result = applyProjectConfigImport({ + repoRoot: repo, + source: CONDUCTOR_SOURCE, + expectedSourceRevision: preview.sourceRevision ?? "", + expectedPaseoRevision: null, + }); + + expect(result).toMatchObject({ + ok: true, + repoRoot: repo, + config: { worktree: { setup: "npm ci" } }, + }); + expect(readFileSync(join(repo, "paseo.json"), "utf8")).toBe( + '{\n "worktree": {\n "setup": "npm ci"\n }\n}\n', + ); + expect(readPaseoConfigForEdit(repo)).toMatchObject({ + ok: true, + config: { worktree: { setup: "npm ci" } }, + }); + }); + + test("malformed TOML identifies the safe relative source path", () => { + const repo = makeRepo(); + writeSharedToml(repo, "[scripts\nsetup = nope"); + + expect(() => inspect(repo)).toThrow(InvalidProjectConfigImportSourceError); + expect(captureInvalidSourceError(repo)).toMatchObject({ + source: CONDUCTOR_SOURCE, + relativePath: ".conductor/settings.toml", + }); + }); + + test("malformed local TOML identifies the local override path", () => { + const repo = makeRepo(); + writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); + writeLocalToml(repo, "[scripts\nsetup = nope"); + + expect(captureInvalidSourceError(repo)).toMatchObject({ + source: CONDUCTOR_SOURCE, + relativePath: ".conductor/settings.local.toml", + }); + }); + + test(".worktreeinclude is reported and not converted to shell commands", () => { + const repo = makeRepo(); + writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); + writeFileSync(join(repo, ".worktreeinclude"), "config/*.local\n"); + + expect(inspect(repo).items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: ".worktreeinclude", + outcome: "unsupported", + detail: "Worktree include patterns are not converted to shell copy commands.", + }), + ]), + ); + }); +}); diff --git a/packages/server/src/server/session/project-config/import/index.ts b/packages/server/src/server/session/project-config/import/index.ts new file mode 100644 index 000000000..73c049492 --- /dev/null +++ b/packages/server/src/server/session/project-config/import/index.ts @@ -0,0 +1,127 @@ +import { + readPaseoConfigForEdit, + writePaseoConfigForEdit, +} from "../../../../utils/paseo-config-file.js"; +import { inspectConductorImport } from "./sources/conductor.js"; +import { mergeProjectConfigImport } from "./merge.js"; +import { + InvalidProjectConfigImportSourceError, + type ApplyProjectConfigImportInput, + type InspectProjectConfigImportInput, + type ProjectConfigImportApplyResult, + type ProjectConfigImportCandidate, + type ProjectConfigImportPreview, + type ProjectConfigImportSource, +} from "./model.js"; + +export type { + ProjectConfigImportApplyResult, + ProjectConfigImportPreview, + ProjectConfigImportSource, +}; + +export function inspectProjectConfigImport( + input: InspectProjectConfigImportInput, +): ProjectConfigImportPreview { + let candidate: ProjectConfigImportCandidate | null; + switch (input.source.kind) { + case "conductor": + candidate = inspectConductorImport({ + repoRoot: input.repoRoot, + source: input.source, + }); + break; + } + + return mergeProjectConfigImport({ + repoRoot: input.repoRoot, + source: input.source, + candidate, + paseoConfig: input.paseoConfig, + paseoRevision: input.paseoRevision, + }); +} + +export function applyProjectConfigImport( + input: ApplyProjectConfigImportInput, +): ProjectConfigImportApplyResult { + const currentConfig = readPaseoConfigForEdit(input.repoRoot); + if (!currentConfig.ok) { + return { ok: false, repoRoot: input.repoRoot, error: currentConfig.error }; + } + + let preview: ProjectConfigImportPreview; + try { + preview = inspectProjectConfigImport({ + repoRoot: input.repoRoot, + source: input.source, + paseoConfig: currentConfig.config ?? {}, + paseoRevision: currentConfig.revision, + }); + } catch (error) { + if (error instanceof InvalidProjectConfigImportSourceError) { + return { + ok: false, + repoRoot: input.repoRoot, + error: { + code: "invalid_source_config", + source: error.source, + relativePath: error.relativePath, + }, + }; + } + throw error; + } + + if (preview.status === "not_found" || !preview.sourceRevision) { + return { + ok: false, + repoRoot: input.repoRoot, + error: { code: "source_config_not_found", source: input.source }, + }; + } + if (preview.sourceRevision !== input.expectedSourceRevision) { + return { + ok: false, + repoRoot: input.repoRoot, + error: { code: "stale_source_config", source: input.source }, + }; + } + if (!paseoConfigRevisionsEqual(currentConfig.revision, input.expectedPaseoRevision)) { + return { + ok: false, + repoRoot: input.repoRoot, + error: { code: "stale_project_config", currentRevision: currentConfig.revision }, + }; + } + if (preview.status === "nothing_to_import" || !preview.preview) { + return { ok: false, repoRoot: input.repoRoot, error: { code: "nothing_to_import" } }; + } + + const written = writePaseoConfigForEdit({ + repoRoot: input.repoRoot, + config: preview.preview, + expectedRevision: input.expectedPaseoRevision, + }); + if (!written.ok) { + return { ok: false, repoRoot: input.repoRoot, error: written.error }; + } + return { + ok: true, + repoRoot: input.repoRoot, + source: input.source, + config: written.config, + revision: written.revision, + items: preview.items, + }; +} + +function paseoConfigRevisionsEqual( + left: ProjectConfigImportPreview["paseoRevision"], + right: ProjectConfigImportPreview["paseoRevision"], +): boolean { + if (left === null || right === null) { + return left === right; + } + return left.mtimeMs === right.mtimeMs && left.size === right.size; +} diff --git a/packages/server/src/server/session/project-config/import/merge.ts b/packages/server/src/server/session/project-config/import/merge.ts new file mode 100644 index 000000000..2db17fd8e --- /dev/null +++ b/packages/server/src/server/session/project-config/import/merge.ts @@ -0,0 +1,102 @@ +import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; +import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./model.js"; + +interface MergeProjectConfigImportInput { + repoRoot: string; + source: ProjectConfigImportPreview["source"]; + candidate: ProjectConfigImportCandidate | null; + paseoConfig: PaseoConfigRaw; + paseoRevision: ProjectConfigImportPreview["paseoRevision"]; +} + +export function mergeProjectConfigImport( + input: MergeProjectConfigImportInput, +): ProjectConfigImportPreview { + if (!input.candidate) { + return { + repoRoot: input.repoRoot, + source: input.source, + status: "not_found", + sourceRevision: null, + paseoRevision: input.paseoRevision, + inputs: [], + items: [], + preview: null, + }; + } + + const base = input.paseoConfig; + const merged: PaseoConfigRaw = { ...base }; + const items = input.candidate.items.map((item) => ({ ...item })); + let importedCount = 0; + + const patchWorktree = input.candidate.patch.worktree ?? {}; + if (Object.hasOwn(patchWorktree, "setup")) { + if (hasLifecycle(base.worktree?.setup)) { + setOutcome(items, "worktree.setup", "collision", "Paseo already has setup commands."); + } else { + merged.worktree = { ...merged.worktree, setup: patchWorktree.setup }; + importedCount += 1; + } + } + if (Object.hasOwn(patchWorktree, "teardown")) { + if (hasLifecycle(base.worktree?.teardown)) { + setOutcome(items, "worktree.teardown", "collision", "Paseo already has teardown commands."); + } else { + merged.worktree = { ...merged.worktree, teardown: patchWorktree.teardown }; + importedCount += 1; + } + } + + const patchScripts = input.candidate.patch.scripts ?? {}; + for (const [scriptId, script] of Object.entries(patchScripts)) { + const key = `scripts.${scriptId}`; + if (base.scripts && Object.hasOwn(base.scripts, scriptId)) { + setOutcome(items, key, "collision", `Paseo already has a "${scriptId}" script.`); + continue; + } + merged.scripts = { ...merged.scripts, [scriptId]: script }; + importedCount += 1; + } + + return { + repoRoot: input.repoRoot, + source: input.candidate.source, + status: importedCount > 0 ? "available" : "nothing_to_import", + sourceRevision: input.candidate.sourceRevision, + paseoRevision: input.paseoRevision, + inputs: input.candidate.inputs, + items, + preview: importedCount > 0 ? merged : null, + }; +} + +function hasLifecycle(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0; + } + if (Array.isArray(value)) { + return value.some((entry) => typeof entry === "string" && entry.trim().length > 0); + } + return false; +} + +function setOutcome( + items: ProjectConfigImportPreview["items"], + key: string, + outcome: "collision", + detail: string, +): void { + const item = items.find((entry) => entry.key === key); + if (!item) { + items.push({ + key, + label: key, + outcome, + detail, + }); + return; + } + item.outcome = outcome; + item.detail = detail; +} diff --git a/packages/server/src/server/session/project-config/import/model.ts b/packages/server/src/server/session/project-config/import/model.ts new file mode 100644 index 000000000..21814cadf --- /dev/null +++ b/packages/server/src/server/session/project-config/import/model.ts @@ -0,0 +1,60 @@ +import type { + PaseoConfigRaw, + PaseoConfigRevision, + ProjectConfigImportInput, + ProjectConfigImportItem, + ProjectConfigImportPreview, + ProjectConfigImportSource, + ProjectConfigRpcError, +} from "@getpaseo/protocol/messages"; + +export type { + ProjectConfigImportInput, + ProjectConfigImportItem, + ProjectConfigImportPreview, + ProjectConfigImportSource, +}; + +export interface ProjectConfigImportCandidate { + source: ProjectConfigImportSource; + sourceRevision: string; + inputs: ProjectConfigImportInput[]; + items: ProjectConfigImportItem[]; + patch: PaseoConfigRaw; +} + +export interface InspectProjectConfigImportInput { + repoRoot: string; + source: ProjectConfigImportSource; + paseoConfig: PaseoConfigRaw; + paseoRevision: PaseoConfigRevision | null; +} + +export interface ApplyProjectConfigImportInput { + repoRoot: string; + source: ProjectConfigImportSource; + expectedSourceRevision: string; + expectedPaseoRevision: PaseoConfigRevision | null; +} + +export type ProjectConfigImportApplyResult = + | { + ok: true; + repoRoot: string; + source: ProjectConfigImportSource; + config: PaseoConfigRaw; + revision: PaseoConfigRevision; + items: ProjectConfigImportItem[]; + } + | { ok: false; repoRoot: string; error: ProjectConfigRpcError }; + +export class InvalidProjectConfigImportSourceError extends Error { + readonly source: ProjectConfigImportSource; + readonly relativePath: string; + + constructor(source: ProjectConfigImportSource, relativePath: string) { + super(`Invalid ${source.kind} config at ${relativePath}`); + this.source = source; + this.relativePath = relativePath; + } +} diff --git a/packages/server/src/server/session/project-config/import/sources/conductor.ts b/packages/server/src/server/session/project-config/import/sources/conductor.ts new file mode 100644 index 000000000..fce23bc7d --- /dev/null +++ b/packages/server/src/server/session/project-config/import/sources/conductor.ts @@ -0,0 +1,445 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@getpaseo/protocol/messages"; +import { + InvalidProjectConfigImportSourceError, + type ProjectConfigImportCandidate, + type ProjectConfigImportInput, + type ProjectConfigImportItem, + type ProjectConfigImportSource, +} from "../model.js"; + +interface SourceFile { + role: string; + relativePath: string; + path: string; + bytes: string; +} + +interface ConductorSettings { + scripts?: { + setup?: unknown; + archive?: unknown; + run?: unknown; + run_mode?: unknown; + auto_run_after_setup?: unknown; + }; + file_include_globs?: unknown; + environment_variables?: unknown; + environment_variables_forward?: unknown; + spotlight_testing?: unknown; + [key: string]: unknown; +} + +interface ConductorRunScript { + command: string; + args?: string[]; + options?: { + cwd?: string; + }; + available_in?: string; + hide?: boolean; + icon?: unknown; + default?: unknown; +} + +type RewriteContext = "lifecycle" | "run"; + +export function inspectConductorImport(input: { + repoRoot: string; + source: ProjectConfigImportSource; +}): ProjectConfigImportCandidate | null { + const sourceFiles = discoverConductorSources(input.repoRoot); + if (sourceFiles.length === 0) { + return null; + } + + const settings = loadConductorSettings(input.source, sourceFiles); + const inputs = sourceFiles.map((file) => ({ + role: file.role, + relativePath: file.relativePath, + })); + const patch: PaseoConfigRaw = {}; + const items: ProjectConfigImportItem[] = []; + + mapLifecycle(settings.scripts?.setup, { + key: "worktree.setup", + label: "Worktree setup", + target: "setup", + patch, + items, + }); + mapLifecycle(settings.scripts?.archive, { + key: "worktree.teardown", + label: "Worktree teardown", + target: "teardown", + patch, + items, + }); + mapRunScripts(settings.scripts?.run, patch, items); + reportUnsupported(input.repoRoot, settings, items); + + return { + source: input.source, + sourceRevision: hashSourceFiles(sourceFiles), + inputs, + items, + patch, + }; +} + +function discoverConductorSources(repoRoot: string): SourceFile[] { + const localPath = join(repoRoot, ".conductor", "settings.local.toml"); + const sharedPath = join(repoRoot, ".conductor", "settings.toml"); + const legacyPath = join(repoRoot, "conductor.json"); + const files: SourceFile[] = []; + + if (existsSync(sharedPath)) { + files.push(readSourceFile(repoRoot, sharedPath, "shared")); + } else if (existsSync(legacyPath)) { + files.push(readSourceFile(repoRoot, legacyPath, "legacy")); + } + if (existsSync(localPath)) { + files.push(readSourceFile(repoRoot, localPath, "local")); + } + return files; +} + +function readSourceFile(repoRoot: string, path: string, role: string): SourceFile { + return { + role, + relativePath: relative(repoRoot, path), + path, + bytes: readFileSync(path, "utf8"), + }; +} + +function loadConductorSettings( + source: ProjectConfigImportSource, + sourceFiles: SourceFile[], +): ConductorSettings { + let merged: ConductorSettings = {}; + for (const file of sourceFiles) { + let parsed: unknown; + try { + parsed = + file.relativePath === "conductor.json" ? JSON.parse(file.bytes) : parseToml(file.bytes); + } catch { + throw new InvalidProjectConfigImportSourceError(source, file.relativePath); + } + if (!isRecord(parsed)) { + throw new InvalidProjectConfigImportSourceError(source, file.relativePath); + } + merged = mergeSettings(merged, parsed as ConductorSettings); + } + return merged; +} + +function mergeSettings(base: ConductorSettings, override: ConductorSettings): ConductorSettings { + return { + ...base, + ...override, + scripts: { + ...(isRecord(base.scripts) ? base.scripts : {}), + ...(isRecord(override.scripts) ? override.scripts : {}), + run: mergeRunScripts(base.scripts?.run, override.scripts?.run), + }, + }; +} + +function mergeRunScripts(base: unknown, override: unknown): unknown { + if (typeof override === "string") { + return override; + } + if (!isRecord(base) || !isRecord(override)) { + return override ?? base; + } + return { ...base, ...override }; +} + +function mapLifecycle( + value: unknown, + input: { + key: string; + label: string; + target: "setup" | "teardown"; + patch: PaseoConfigRaw; + items: ProjectConfigImportItem[]; + }, +): void { + if (typeof value !== "string" || value.trim().length === 0) { + return; + } + const rewritten = rewriteVariables(value, "lifecycle", input.items); + input.patch.worktree = { ...input.patch.worktree, [input.target]: rewritten.command }; + input.items.push({ + key: input.key, + label: input.label, + outcome: "import", + detail: rewritten.command, + }); +} + +function mapRunScripts( + runConfig: unknown, + patch: PaseoConfigRaw, + items: ProjectConfigImportItem[], +): void { + if (typeof runConfig === "string") { + mapRunScript("run", { command: runConfig }, patch, items); + return; + } + if (!isRecord(runConfig)) { + return; + } + for (const scriptId of Object.keys(runConfig).sort()) { + const entry = runConfig[scriptId]; + if (!isRecord(entry)) { + continue; + } + const command = entry.command; + if (typeof command !== "string" || command.trim().length === 0) { + continue; + } + mapRunScript(scriptId, normalizeRunScript(entry, command), patch, items); + } +} + +function normalizeRunScript(entry: Record, command: string): ConductorRunScript { + const args = Array.isArray(entry.args) + ? entry.args.filter((arg): arg is string => typeof arg === "string") + : undefined; + const options = isRecord(entry.options) ? entry.options : undefined; + return { + command, + ...(args ? { args } : {}), + ...(options && typeof options.cwd === "string" ? { options: { cwd: options.cwd } } : {}), + ...(typeof entry.available_in === "string" ? { available_in: entry.available_in } : {}), + ...(typeof entry.hide === "boolean" ? { hide: entry.hide } : {}), + ...(Object.hasOwn(entry, "icon") ? { icon: entry.icon } : {}), + ...(Object.hasOwn(entry, "default") ? { default: entry.default } : {}), + }; +} + +function mapRunScript( + scriptId: string, + script: ConductorRunScript, + patch: PaseoConfigRaw, + items: ProjectConfigImportItem[], +): void { + if (script.available_in === "cloud") { + items.push({ + key: `scripts.${scriptId}`, + label: `Script ${scriptId}`, + outcome: "unsupported", + detail: "Cloud-only scripts are not imported.", + }); + return; + } + + let command = appendArgs(script.command, script.args ?? []); + if (script.options?.cwd) { + const cwdPrefix = safeCwdPrefix(script.options.cwd); + if (!cwdPrefix) { + items.push({ + key: `scripts.${scriptId}.cwd`, + label: `Script ${scriptId} working directory`, + outcome: "unsupported", + detail: "Absolute or escaping cwd values are not imported.", + }); + } else { + command = `${cwdPrefix}${command}`; + } + } + + const isService = containsShellVariable(command, "CONDUCTOR_PORT"); + const rewritten = rewriteVariables(command, isService ? "run" : "lifecycle", items); + const entry: PaseoScriptEntryRaw = { command: rewritten.command }; + if (isService) { + entry.type = "service"; + entry.port = "$PASEO_PORT"; + } + + patch.scripts = { ...patch.scripts, [scriptId]: entry }; + items.push({ + key: `scripts.${scriptId}`, + label: `Script ${scriptId}`, + outcome: "import", + detail: rewritten.command, + }); + + if (script.hide === true) { + items.push({ + key: `scripts.${scriptId}.hide`, + label: `Script ${scriptId} hidden flag`, + outcome: "unsupported", + detail: "Paseo does not hide imported scripts.", + }); + } +} + +function reportUnsupported( + repoRoot: string, + settings: ConductorSettings, + items: ProjectConfigImportItem[], +): void { + const scripts = settings.scripts; + if (scripts?.run_mode !== undefined) { + unsupported(items, "scripts.run_mode", "Paseo has no project-wide run mode."); + } + if (scripts?.auto_run_after_setup !== undefined) { + unsupported( + items, + "scripts.auto_run_after_setup", + "Paseo does not auto-run scripts after setup.", + ); + } + if (settings.file_include_globs !== undefined) { + unsupported( + items, + "file_include_globs", + "File include globs are not converted to shell copy commands.", + ); + } + if (existsSync(join(repoRoot, ".worktreeinclude"))) { + unsupported( + items, + ".worktreeinclude", + "Worktree include patterns are not converted to shell copy commands.", + ); + } + const environmentNames = collectEnvironmentVariableNames(settings); + if (environmentNames.length > 0) { + unsupported( + items, + "environment_variables", + `Environment variable values are not imported. Found: ${environmentNames.join(", ")}.`, + ); + } + if (settings.spotlight_testing !== undefined) { + unsupported( + items, + "spotlight_testing", + "Paseo spotlight is a separate workflow, not project config.", + ); + } +} + +function collectEnvironmentVariableNames(settings: ConductorSettings): string[] { + const names = new Set(); + for (const key of ["environment_variables", "environment_variables_forward"] as const) { + const value = settings[key]; + if (isRecord(value)) { + for (const name of Object.keys(value).sort()) { + names.add(name); + } + } else if (Array.isArray(value)) { + for (const name of value) { + if (typeof name === "string") { + names.add(name); + } + } + } + } + return Array.from(names).sort(); +} + +function unsupported(items: ProjectConfigImportItem[], key: string, detail: string): void { + items.push({ key, label: key, outcome: "unsupported", detail }); +} + +function rewriteVariables( + command: string, + context: RewriteContext, + items: ProjectConfigImportItem[], +): { command: string } { + const replacements = new Map([ + ["CONDUCTOR_WORKSPACE_PATH", "PASEO_WORKTREE_PATH"], + ["CONDUCTOR_ROOT_PATH", "PASEO_SOURCE_CHECKOUT_PATH"], + ["CONDUCTOR_PORT", context === "run" ? "PASEO_PORT" : "PASEO_WORKTREE_PORT"], + ]); + const unsupportedVariables = new Set([ + "CONDUCTOR_DEFAULT_BRANCH", + "CONDUCTOR_WORKSPACE_NAME", + "CONDUCTOR_IS_LOCAL", + ]); + let rewritten = command; + for (const [from, to] of replacements) { + const next = replaceShellVariable(rewritten, from, to); + if (next !== rewritten) { + items.push({ + key: `variables.${from}`, + label: from, + outcome: "rewrite", + detail: `${from} -> ${to}`, + }); + rewritten = next; + } + } + for (const name of unsupportedVariables) { + if (containsShellVariable(rewritten, name)) { + items.push({ + key: `variables.${name}`, + label: name, + outcome: "unsupported", + detail: `${name} has no equivalent Paseo variable.`, + }); + } + } + return { command: rewritten }; +} + +function replaceShellVariable(command: string, from: string, to: string): string { + const pattern = new RegExp(`\\$(?:\\{${from}\\}|${from}(?![A-Za-z0-9_]))`, "g"); + return command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}}` : `$${to}`)); +} + +function containsShellVariable(command: string, name: string): boolean { + const pattern = new RegExp(`\\$(?:\\{${name}\\}|${name}(?![A-Za-z0-9_]))`); + return pattern.test(command); +} + +function appendArgs(command: string, args: string[]): string { + if (args.length === 0) { + return command; + } + return `${command} ${args.map(shellQuote).join(" ")}`; +} + +function safeCwdPrefix(cwd: string): string | null { + if ( + /^(?:\/|[A-Za-z]:[\\/])/.test(cwd) || + cwd === ".." || + cwd.startsWith("../") || + cwd.startsWith("..\\") + ) { + return null; + } + if (cwd.includes("/../") || cwd.includes("\\..\\")) { + return null; + } + return `cd -- ${shellQuote(cwd)} && `; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function hashSourceFiles(sourceFiles: SourceFile[]): string { + const hash = createHash("sha256"); + for (const file of [...sourceFiles].sort((left, right) => + left.relativePath.localeCompare(right.relativePath), + )) { + hash.update(file.relativePath); + hash.update("\0"); + hash.update(file.bytes); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 0faa95404..1763569dc 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -1,4 +1,12 @@ -import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -44,6 +52,23 @@ function makeSubsystem(records: PersistedProjectRecord[]) { return { subsystem, emitted }; } +function expectImportPreview( + emitted: SessionOutboundMessage[], + index: number, +): Extract["payload"] & { + ok: true; +} { + const message = emitted[index]; + expect(message).toMatchObject({ + type: "project.config.get_import.response", + payload: { ok: true }, + }); + if (message.type !== "project.config.get_import.response" || !message.payload.ok) { + throw new Error("Expected import preview response"); + } + return message.payload; +} + describe("ProjectConfigSession", () => { test("read resolves a known root despite a trailing slash and returns the raw config + revision", async () => { const repoRoot = makeRoot(); @@ -227,4 +252,177 @@ describe("ProjectConfigSession", () => { }, ]); }); + + test("import preview rejects archived and unknown roots with project_not_found", async () => { + const archivedRoot = makeRoot(); + const unknownRoot = makeRoot(); + const { subsystem, emitted } = makeSubsystem([ + projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z"), + ]); + + await subsystem.handleGetProjectConfigImportRequest({ + type: "project.config.get_import.request", + requestId: "import-archived-1", + repoRoot: archivedRoot, + source: { kind: "conductor" }, + }); + await subsystem.handleGetProjectConfigImportRequest({ + type: "project.config.get_import.request", + requestId: "import-unknown-1", + repoRoot: unknownRoot, + source: { kind: "conductor" }, + }); + + expect(emitted).toEqual([ + { + type: "project.config.get_import.response", + payload: { + requestId: "import-archived-1", + repoRoot: archivedRoot, + ok: false, + error: { code: "project_not_found" }, + }, + }, + { + type: "project.config.get_import.response", + payload: { + requestId: "import-unknown-1", + repoRoot: unknownRoot, + ok: false, + error: { code: "project_not_found" }, + }, + }, + ]); + }); + + test("import preview reports malformed Conductor config with a safe relative path", async () => { + const repoRoot = makeRoot(); + mkdirSync(join(repoRoot, ".conductor"), { recursive: true }); + writeFileSync(join(repoRoot, ".conductor", "settings.toml"), "[scripts\nsetup = nope"); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleGetProjectConfigImportRequest({ + type: "project.config.get_import.request", + requestId: "import-invalid-1", + repoRoot, + source: { kind: "conductor" }, + }); + + expect(emitted).toEqual([ + { + type: "project.config.get_import.response", + payload: { + requestId: "import-invalid-1", + repoRoot, + ok: false, + error: { + code: "invalid_source_config", + source: { kind: "conductor" }, + relativePath: ".conductor/settings.toml", + }, + }, + }, + ]); + }); + + test("apply import recomputes from disk and writes formatted paseo.json", async () => { + const repoRoot = makeRoot(); + mkdirSync(join(repoRoot, ".conductor"), { recursive: true }); + writeFileSync(join(repoRoot, ".conductor", "settings.toml"), '[scripts]\nsetup = "npm ci"\n'); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleGetProjectConfigImportRequest({ + type: "project.config.get_import.request", + requestId: "import-preview-1", + repoRoot, + source: { kind: "conductor" }, + }); + const preview = expectImportPreview(emitted, 0); + + await subsystem.handleApplyProjectConfigImportRequest({ + type: "project.config.apply_import.request", + requestId: "import-apply-1", + repoRoot, + source: { kind: "conductor" }, + expectedSourceRevision: preview.sourceRevision ?? "", + expectedPaseoRevision: preview.paseoRevision, + }); + + expect(emitted[1]).toMatchObject({ + type: "project.config.apply_import.response", + payload: { + requestId: "import-apply-1", + repoRoot, + ok: true, + config: { worktree: { setup: "npm ci" } }, + }, + }); + expect(readFileSync(join(repoRoot, "paseo.json"), "utf8")).toBe( + '{\n "worktree": {\n "setup": "npm ci"\n }\n}\n', + ); + }); + + test("apply import rejects stale source digest", async () => { + const repoRoot = makeRoot(); + mkdirSync(join(repoRoot, ".conductor"), { recursive: true }); + writeFileSync(join(repoRoot, ".conductor", "settings.toml"), '[scripts]\nsetup = "npm ci"\n'); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleApplyProjectConfigImportRequest({ + type: "project.config.apply_import.request", + requestId: "import-stale-source-1", + repoRoot, + source: { kind: "conductor" }, + expectedSourceRevision: "old", + expectedPaseoRevision: null, + }); + + expect(emitted).toEqual([ + { + type: "project.config.apply_import.response", + payload: { + requestId: "import-stale-source-1", + repoRoot, + ok: false, + error: { code: "stale_source_config", source: { kind: "conductor" } }, + }, + }, + ]); + }); + + test("apply import rejects stale paseo.json revision", async () => { + const repoRoot = makeRoot(); + mkdirSync(join(repoRoot, ".conductor"), { recursive: true }); + writeFileSync(join(repoRoot, ".conductor", "settings.toml"), '[scripts]\nsetup = "npm ci"\n'); + writeFileSync(join(repoRoot, "paseo.json"), '{"custom":true}\n'); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleGetProjectConfigImportRequest({ + type: "project.config.get_import.request", + requestId: "import-preview-stale-project-1", + repoRoot, + source: { kind: "conductor" }, + }); + const preview = expectImportPreview(emitted, 0); + writeFileSync(join(repoRoot, "paseo.json"), '{"custom":false}\n'); + + await subsystem.handleApplyProjectConfigImportRequest({ + type: "project.config.apply_import.request", + requestId: "import-stale-project-1", + repoRoot, + source: { kind: "conductor" }, + expectedSourceRevision: preview.sourceRevision ?? "", + expectedPaseoRevision: preview.paseoRevision, + }); + + expect(emitted[1]).toMatchObject({ + type: "project.config.apply_import.response", + payload: { + requestId: "import-stale-project-1", + repoRoot, + ok: false, + error: { code: "stale_project_config" }, + }, + }); + }); }); diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index ae62bd3d2..697efbd79 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -8,6 +8,8 @@ import { writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../../../utils/paseo-config-file.js"; +import { applyProjectConfigImport, inspectProjectConfigImport } from "./import/index.js"; +import { InvalidProjectConfigImportSourceError } from "./import/model.js"; export interface ProjectConfigSessionHost { emit(msg: SessionOutboundMessage): void; @@ -118,6 +120,87 @@ export class ProjectConfigSession { }); } + async handleGetProjectConfigImportRequest( + msg: Extract, + ): Promise { + const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot); + if (!repoRoot) { + this.emitProjectConfigImportGetFailure(msg, { code: "project_not_found" }); + return; + } + + const config = readPaseoConfigForEdit(repoRoot); + if (!config.ok) { + this.emitProjectConfigImportGetFailure(msg, config.error, repoRoot); + return; + } + + try { + const preview = inspectProjectConfigImport({ + repoRoot, + source: msg.source, + paseoConfig: config.config ?? {}, + paseoRevision: config.revision, + }); + this.host.emit({ + type: "project.config.get_import.response", + payload: { + requestId: msg.requestId, + ok: true, + ...preview, + }, + }); + } catch (error) { + if (error instanceof InvalidProjectConfigImportSourceError) { + this.emitProjectConfigImportGetFailure( + msg, + { + code: "invalid_source_config", + source: error.source, + relativePath: error.relativePath, + }, + repoRoot, + ); + return; + } + throw error; + } + } + + async handleApplyProjectConfigImportRequest( + msg: Extract, + ): Promise { + const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot); + if (!repoRoot) { + this.emitProjectConfigImportApplyFailure(msg, { code: "project_not_found" }); + return; + } + + const result = applyProjectConfigImport({ + repoRoot, + source: msg.source, + expectedSourceRevision: msg.expectedSourceRevision, + expectedPaseoRevision: msg.expectedPaseoRevision, + }); + if (!result.ok) { + this.emitProjectConfigImportApplyFailure(msg, result.error, repoRoot); + return; + } + + this.host.emit({ + type: "project.config.apply_import.response", + payload: { + requestId: msg.requestId, + repoRoot, + source: result.source, + ok: true, + config: result.config, + revision: result.revision, + items: result.items, + }, + }); + } + private emitProjectConfigReadFailure( msg: Extract, error: ProjectConfigRpcError, @@ -150,6 +233,38 @@ export class ProjectConfigSession { }); } + private emitProjectConfigImportGetFailure( + msg: Extract, + error: ProjectConfigRpcError, + repoRoot = msg.repoRoot, + ): void { + this.host.emit({ + type: "project.config.get_import.response", + payload: { + requestId: msg.requestId, + repoRoot, + ok: false, + error, + }, + }); + } + + private emitProjectConfigImportApplyFailure( + msg: Extract, + error: ProjectConfigRpcError, + repoRoot = msg.repoRoot, + ): void { + this.host.emit({ + type: "project.config.apply_import.response", + payload: { + requestId: msg.requestId, + repoRoot, + ok: false, + error, + }, + }); + } + private async resolveKnownProjectRoot(repoRoot: string): Promise { const requestedRoot = canonicalizeConfigRoot(repoRoot); const projects = await this.projectRegistry.list(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cbe2a13af..848cef266 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1279,6 +1279,8 @@ export class VoiceAssistantWebSocketServer { providerRemoval: true, // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, + // COMPAT(projectConfigImportConductor): added in v0.1.X, drop the gate when floor >= v0.1.X. + projectConfigImportConductor: true, // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. forgeProviders: true, },