feat(settings): import existing project setup

This commit is contained in:
Mohamed Boudra
2026-07-17 11:15:10 +02:00
parent a8ebd390fa
commit 8fe11e3bb0
43 changed files with 3464 additions and 48 deletions

View File

@@ -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.<id>.command` becomes `scripts.<id>.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 -- <path> &&` 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.

2
package-lock.json generated
View File

@@ -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",

View File

@@ -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.

View File

@@ -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;

View File

@@ -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<string> {
async function readProjectConfigFile(
project: Pick<ProjectsSettingsProject, "path">,
): Promise<string> {
return readFile(path.join(project.path, "paseo.json"), "utf8");
}
async function writeConductorSharedToml(
project: Pick<ProjectsSettingsProject, "path">,
contents: string,
): Promise<void> {
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<void> {
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<void> {
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<string> {
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,

View File

@@ -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 <SettingsScreen view={view} />;
}

View File

@@ -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",
});
});
});

View File

@@ -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") {

View File

@@ -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<typeof buildWorktreeSetupCalloutPolicy>["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,
});

View File

@@ -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: "خدمات طويلة الأمد وأوامر لمرة واحدة يمكنك إطلاقها من أي وكيل في هذا المشروع",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: "このプロジェクトのどのエージェントからでも起動できる、長時間実行サービスと単発コマンド",

View File

@@ -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",

View File

@@ -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: "Долгоработающие службы и одноразовые команды, которые можно запускать из любого агента в этом проекте.",

View File

@@ -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 启动的长期服务和一次性命令",

View File

@@ -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 (
<Svg width={size} height={size} viewBox="0 0 115 174" fill="none">
{PATHS.map((path) => (
<Path key={path} d={path} fill={color} stroke={color} />
))}
</Svg>
);
}

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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,
});
}

View File

@@ -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<SheetHeader>(
() => ({
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 (
<AdaptiveModalSheet
visible={visible}
header={header}
onClose={onClose}
testID="project-config-import-sheet"
desktopMaxWidth={640}
>
<View style={styles.content}>
{isLoading ? (
<View style={styles.loading}>
<LoadingSpinner color={styles.spinnerColor.color} />
</View>
) : null}
{visibleError ? (
<Alert
testID="project-config-import-error"
variant="error"
title={t("settings.project.import.errorTitle")}
description={projectConfigImportErrorText(visibleError, t)}
>
{needsRefresh ? (
<Button
testID="project-config-import-refresh"
onPress={onRefresh}
variant="outline"
size="sm"
>
{t("settings.project.import.refreshPreview")}
</Button>
) : (
<Button
testID="project-config-import-retry"
onPress={handleRetry}
variant="outline"
size="sm"
>
{t("settings.project.actions.tryAgain")}
</Button>
)}
<Button
testID="project-config-import-cancel-error"
onPress={onClose}
variant="ghost"
size="sm"
>
{t("settings.project.actions.cancel")}
</Button>
</Alert>
) : null}
{preview ? <PreviewBody preview={preview} /> : null}
<View style={styles.footer}>
<Button testID="project-config-import-cancel" onPress={onClose} variant="ghost" size="md">
{t("settings.project.actions.cancel")}
</Button>
<Button
testID="project-config-import-apply"
onPress={onApply}
variant="default"
size="md"
disabled={!canImport || isApplying}
loading={isApplying}
>
{isApplying
? t("settings.project.import.importing")
: t("settings.project.import.import")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
function PreviewBody({ preview }: { preview: ProjectConfigImportPreview }) {
const { t } = useTranslation();
const grouped = groupPreviewItems(preview);
return (
<View style={styles.preview}>
<View style={styles.sourceRow}>
<ConductorIcon size={18} color={styles.iconColor.color} />
<Text style={settingsStyles.rowTitle}>{t("settings.project.import.sources")}</Text>
</View>
<View style={settingsStyles.card}>
{preview.inputs.map((input, index) => (
<View
key={`${input.role}:${input.relativePath}`}
style={index === 0 ? settingsStyles.row : styles.rowWithBorder}
>
<Text style={settingsStyles.rowTitle}>{input.relativePath}</Text>
<Text style={settingsStyles.rowHint}>{input.role}</Text>
</View>
))}
</View>
<ItemGroup title={t("settings.project.import.willImport")} items={grouped.imports} />
<ItemGroup title={t("settings.project.import.needsAttention")} items={grouped.attention} />
<ItemGroup title={t("settings.project.import.notSupported")} items={grouped.unsupported} />
</View>
);
}
function ItemGroup({
title,
items,
}: {
title: string;
items: ProjectConfigImportPreview["items"];
}) {
if (items.length === 0) {
return null;
}
return (
<View style={styles.group}>
<Text style={styles.groupTitle}>{title}</Text>
<View style={settingsStyles.card}>
{items.map((item, index) => (
<View
key={`${item.key}:${item.outcome}:${item.detail ?? ""}`}
style={index === 0 ? settingsStyles.row : styles.rowWithBorder}
>
<Text style={settingsStyles.rowTitle}>{item.label}</Text>
{item.detail ? <Text style={styles.commandText}>{item.detail}</Text> : null}
</View>
))}
</View>
</View>
);
}
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,
},
}));

View File

@@ -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<ProjectConfigImportVisibleError | null>(null);
const [applyErrorRetryAction, setApplyErrorRetryAction] =
useState<ProjectConfigImportRetryAction>("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 };
}

View File

@@ -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<ReturnType<DaemonClient["readProjectConfig"
export interface ProjectSettingsScreenProps {
projectKey: string;
importIntent?: ProjectConfigImportIntent | null;
}
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
export default function ProjectSettingsScreen({
projectKey,
importIntent = null,
}: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
() => 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<string>(
() => 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<ProjectConfigImportIntent | null>(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 ? (
<ProjectConfigImportSheet
visible
state={importModel.state}
onClose={handleCloseImport}
onRefresh={handleRefreshImport}
onApply={importModel.apply}
/>
) : null}
</View>
);
}
@@ -273,6 +354,9 @@ interface RenderContentInput {
onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean;
importPreviewQuery: ReturnType<typeof useProjectConfigImportPreview>;
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<typeof useProjectConfigImportPreview>;
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"
>
<ConductorImportRow
previewQuery={importPreviewQuery}
serverId={serverId}
onOpenImport={onOpenImport}
importIntent={importIntent}
/>
<SettingsSection
title={t("settings.project.worktree.setup")}
testID="worktree-setup-section"
@@ -788,6 +894,62 @@ function ResolveSpinnerColor(): string {
return styles.spinnerColor.color;
}
interface ConductorImportRowProps {
previewQuery: ReturnType<typeof useProjectConfigImportPreview>;
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 (
<SettingsSection
title={t("settings.project.import.rowTitle")}
testID="conductor-import-section"
>
<Pressable
testID="conductor-import-row"
accessibilityRole="button"
accessibilityLabel={t("settings.project.import.rowTitle")}
onPress={handlePress}
style={settingsStyles.card}
>
<View style={styles.importRow}>
<View style={styles.importIconWrap}>
<ConductorIcon size={16} color={styles.iconColor.color} />
</View>
<View style={styles.importText}>
<Text style={settingsStyles.rowTitle}>{t("settings.project.import.rowTitle")}</Text>
<Text style={settingsStyles.rowHint} numberOfLines={2}>
{t("settings.project.import.rowDescription")}
</Text>
</View>
</View>
</Pressable>
</SettingsSection>
);
}
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",

View File

@@ -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 <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
return (
<ProjectSettingsScreen projectKey={view.projectKey} importIntent={view.importIntent} />
);
}
if (view.kind === "section") {
switch (view.section) {

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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<GetProjectConfigImportPayload> {
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<ApplyProjectConfigImportPayload> {
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[];

View File

@@ -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([

View File

@@ -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,

View File

@@ -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<typeof PaseoMetadataGenerationSche
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
export type ProjectConfigImportSource = z.infer<typeof ProjectConfigImportSourceSchema>;
export type ProjectConfigImportInput = z.infer<typeof ProjectConfigImportInputSchema>;
export type ProjectConfigImportItem = z.infer<typeof ProjectConfigImportItemSchema>;
export type ProjectConfigImportPreview = z.infer<typeof ProjectConfigImportPreviewSchema>;
export type ProjectConfigRpcError = z.infer<typeof ProjectConfigRpcErrorSchema>;

View File

@@ -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", [

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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<ReturnType<typeof readPaseoConfigForEdit>, { 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.",
}),
]),
);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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<ProjectConfigImportInput>((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<string, unknown>, 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<string>();
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<string, string>([
["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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}

View File

@@ -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<SessionOutboundMessage, { type: "project.config.get_import.response" }>["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" },
},
});
});
});

View File

@@ -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<SessionInboundMessage, { type: "project.config.get_import.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "project.config.apply_import.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "read_project_config_request" }>,
error: ProjectConfigRpcError,
@@ -150,6 +233,38 @@ export class ProjectConfigSession {
});
}
private emitProjectConfigImportGetFailure(
msg: Extract<SessionInboundMessage, { type: "project.config.get_import.request" }>,
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<SessionInboundMessage, { type: "project.config.apply_import.request" }>,
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<string | null> {
const requestedRoot = canonicalizeConfigRoot(repoRoot);
const projects = await this.projectRegistry.list();

View File

@@ -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,
},