refactor(settings): make project imports pluggable

This commit is contained in:
Mohamed Boudra
2026-07-17 14:42:02 +02:00
parent f7a6548b68
commit f030ddefb1
53 changed files with 2133 additions and 1551 deletions

View File

@@ -1,34 +0,0 @@
# 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.

View File

@@ -251,81 +251,6 @@ 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

@@ -22,8 +22,6 @@ import {
expectScriptRowCount,
expectWriteFailedCalloutActions,
installDaemonConnectionGate,
installImportApplyTransportFailure,
installImportPreviewTransportFailure,
installReadTransportFailure,
navigateToProjectSettings,
openProjectSettings,
@@ -335,53 +333,6 @@ test.describe("Projects settings — Conductor project import", () => {
}
});
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-",
@@ -440,57 +391,6 @@ test.describe("Projects settings — Conductor project import", () => {
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", () => {

View File

@@ -1,9 +1,11 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { parseProjectConfigImportIntent } from "@/project-config-import/project-config-import-model";
import { parseProjectConfigImportIntent } from "@/project-config-import/route";
import { projectConfigImportSourceRegistry } from "@/project-config-import/sources";
export default function SettingsProjectDetailRoute() {
const router = useRouter();
const params = useLocalSearchParams<{
projectKey?: string | string[];
importSource?: string | string[];
@@ -12,10 +14,25 @@ export default function SettingsProjectDetailRoute() {
}>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const importIntent = useMemo(() => parseProjectConfigImportIntent(params), [params]);
const importIntent = useMemo(
() => parseProjectConfigImportIntent(params, projectConfigImportSourceRegistry),
[params],
);
const handleImportIntentConsumed = useCallback(() => {
router.setParams({
importSource: undefined,
importServerId: undefined,
importIntentId: undefined,
});
}, [router]);
const view = useMemo(
() => ({ kind: "project" as const, projectKey, importIntent }),
[importIntent, projectKey],
() => ({
kind: "project" as const,
projectKey,
importIntent,
onImportIntentConsumed: handleImportIntentConsumed,
}),
[handleImportIntentConsumed, importIntent, projectKey],
);
return <SettingsScreen view={view} />;

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildConductorMigrationCalloutPolicy,
buildProjectConfigImportCalloutPolicy,
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
@@ -102,26 +102,48 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
});
});
it("builds a host-bound Conductor migration route with a single-use intent", () => {
it("builds a host-bound import route with a single-use intent", () => {
expect(
buildConductorMigrationCalloutPolicy(
buildProjectConfigImportCalloutPolicy(
{
serverId: "server-1",
projectKey: "remote:github.com/acme/app",
repoRoot: "/repo/project-1",
},
"intent-1",
{
status: "one",
sourceDisplayName: "Fake Source",
sourceRouteValue: "fake",
intentId: "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.",
title: "Fake Source setup found",
description: "Import workspace setup and run scripts from Fake Source.",
actionLabel: "Review migration",
projectSettingsRoute:
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=conductor&importServerId=server-1&importIntentId=intent-1",
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=fake&importServerId=server-1&importIntentId=intent-1",
testID: "worktree-setup-callout-remote:github.com/acme/app",
});
});
it("routes many import sources to project settings without selecting a source", () => {
expect(
buildProjectConfigImportCalloutPolicy(
{
serverId: "server-1",
projectKey: "remote:github.com/acme/app",
repoRoot: "/repo/project-1",
},
{ status: "many" },
),
).toMatchObject({
title: "Project setup imports found",
description: "Review available project setup imports in Project Settings.",
projectSettingsRoute: "/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
});
});
});

View File

@@ -73,25 +73,46 @@ export function buildWorktreeSetupCalloutPolicy(
};
}
export function buildConductorMigrationCalloutPolicy(
export function buildProjectConfigImportCalloutPolicy(
project: ActiveGitWorkspaceProject,
intentId: string,
input:
| {
status: "one";
sourceDisplayName: string;
sourceRouteValue: string;
intentId: string;
}
| { status: "many" },
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
const title =
input.status === "one"
? i18n.t("sidebar.worktreeSetup.importTitle", { source: input.sourceDisplayName })
: i18n.t("sidebar.worktreeSetup.importManyTitle");
const description =
input.status === "one"
? i18n.t("sidebar.worktreeSetup.importDescription", {
source: input.sourceDisplayName,
})
: i18n.t("sidebar.worktreeSetup.importManyDescription");
const projectSettingsRoute =
input.status === "one"
? buildProjectSettingsImportRoute({
projectKey: project.projectKey,
source: input.sourceRouteValue,
serverId: project.serverId,
intentId: input.intentId,
})
: buildProjectSettingsRoute(project.projectKey);
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: i18n.t("sidebar.worktreeSetup.conductorTitle"),
description: i18n.t("sidebar.worktreeSetup.conductorDescription"),
title,
description,
actionLabel: i18n.t("sidebar.worktreeSetup.reviewMigration"),
projectSettingsRoute: buildProjectSettingsImportRoute({
projectKey: project.projectKey,
source: "conductor",
serverId: project.serverId,
intentId,
}),
projectSettingsRoute,
testID: `worktree-setup-callout-${project.projectKey}`,
};
}

View File

@@ -3,13 +3,12 @@ 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 { useProjectConfigImportAvailability } from "@/project-config-import/use-project-config-import-model";
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,
buildProjectConfigImportCalloutPolicy,
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
@@ -23,10 +22,6 @@ 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(
@@ -48,24 +43,29 @@ export function WorktreeSetupCalloutSource() {
});
const shouldConsiderSetup = activeProject && shouldShowWorktreeSetupCallout(readQuery.data);
const importPreviewQuery = useProjectConfigImportPreview({
const importAvailability = useProjectConfigImportAvailability({
client,
serverId: activeProject?.serverId ?? "",
repoRoot: activeProject?.repoRoot ?? "",
source: { kind: "conductor" },
enabled: Boolean(shouldConsiderSetup && supportsConductorImport),
serverId: activeProject?.serverId,
repoRoot: activeProject?.repoRoot,
enabled: Boolean(shouldConsiderSetup),
});
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()));
if (importAvailability.status === "one" && importAvailability.source) {
return buildProjectConfigImportCalloutPolicy(activeProject, {
status: "one",
sourceDisplayName: importAvailability.source.module.displayName,
sourceRouteValue: importAvailability.source.module.routeValue,
intentId: String(Date.now()),
});
}
if (importAvailability.status === "many") {
return buildProjectConfigImportCalloutPolicy(activeProject, { status: "many" });
}
return buildWorktreeSetupCalloutPolicy(activeProject);
}, [activeProject, importPreviewQuery.data, readQuery.data, supportsConductorImport]);
}, [activeProject, importAvailability.source, importAvailability.status, readQuery.data]);
useEffect(() => {
if (!calloutPolicy) {

View File

@@ -24,12 +24,13 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
pushEvent: string;
};
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
export type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
> & {
dataShape: "list" | "value";
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
refetchOnMount?: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>["refetchOnMount"];
staleTimeMs: number;
};
@@ -84,7 +85,7 @@ function replicaQueryOptions<
};
}
function fetchQueryOptions<
export function fetchQueryOptions<
TQueryFnData,
TError = Error,
TData = TQueryFnData,
@@ -96,7 +97,7 @@ function fetchQueryOptions<
throw new Error("Fetch queries must declare a finite staleTimeMs.");
}
const { dataShape, meta, staleTimeMs, ...options } = input;
const { dataShape, meta, refetchOnMount, staleTimeMs, ...options } = input;
return {
...options,
...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}),
@@ -107,7 +108,7 @@ function fetchQueryOptions<
dataShape,
},
},
refetchOnMount: "always",
refetchOnMount: refetchOnMount ?? "always",
staleTime: staleTimeMs,
};
}

View File

@@ -867,8 +867,10 @@ export const ar: TranslationResources = {
description:
"أضف أوامر الإعداد حتى تتمكن أشجار العمل الجديدة من تثبيت التبعيات وإعداد نفسها تلقائيًا.",
openProjectSettings: "افتح إعدادات المشروع",
conductorTitle: "Conductor setup found",
conductorDescription: "Import its workspace setup and run scripts into Paseo.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2068,8 +2070,9 @@ export const ar: TranslationResources = {
teardownAccessibility: "أوامر هدم شجرة العمل",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2078,12 +2081,12 @@ export const ar: TranslationResources = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -878,8 +878,10 @@ 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.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2082,8 +2084,9 @@ export const en = {
teardownAccessibility: "Worktree teardown commands",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2092,12 +2095,12 @@ export const en = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -898,8 +898,10 @@ 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.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2120,8 +2122,9 @@ export const es: TranslationResources = {
teardownAccessibility: "Comandos de desmontaje del árbol de trabajo",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2130,12 +2133,12 @@ export const es: TranslationResources = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -896,8 +896,10 @@ 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.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2123,8 +2125,9 @@ export const fr: TranslationResources = {
teardownAccessibility: "Commandes de démontage de Worktree",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2133,12 +2136,12 @@ export const fr: TranslationResources = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -880,8 +880,10 @@ export const ja: TranslationResources = {
description:
"新しいワークツリーが依存関係をインストールして自動的に準備できるようにセットアップコマンドを追加してください。",
openProjectSettings: "プロジェクト設定を開く",
conductorTitle: "Conductor setup found",
conductorDescription: "Import its workspace setup and run scripts into Paseo.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2093,8 +2095,9 @@ export const ja: TranslationResources = {
teardownAccessibility: "ワークツリー削除時のコマンド",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2103,12 +2106,12 @@ export const ja: TranslationResources = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -890,8 +890,10 @@ 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.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2106,8 +2108,9 @@ export const ptBR: TranslationResources = {
teardownAccessibility: "Comandos de desmontagem do worktree",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2116,12 +2119,12 @@ export const ptBR: TranslationResources = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -889,8 +889,10 @@ export const ru: TranslationResources = {
description:
"Добавьте команды настройки, чтобы новые рабочие деревья могли автоматически устанавливать зависимости и готовиться.",
openProjectSettings: "Открыть настройки проекта",
conductorTitle: "Conductor setup found",
conductorDescription: "Import its workspace setup and run scripts into Paseo.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2111,8 +2113,9 @@ export const ru: TranslationResources = {
teardownAccessibility: "Команды разрушения рабочего дерева",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2121,12 +2124,12 @@ export const ru: TranslationResources = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -860,8 +860,10 @@ 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.",
importTitle: "{{source}} setup found",
importDescription: "Import workspace setup and run scripts from {{source}}.",
importManyTitle: "Project setup imports found",
importManyDescription: "Review available project setup imports in Project Settings.",
reviewMigration: "Review migration",
},
project: {
@@ -2044,8 +2046,9 @@ export const zhCN: TranslationResources = {
teardownAccessibility: "Worktree teardown 命令",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
rowTitle: "Import from {{source}}",
rowDescription:
"Review workspace setup and run scripts from {{source}} before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
@@ -2054,12 +2057,12 @@ export const zhCN: TranslationResources = {
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
success: "{{source}} settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},

View File

@@ -0,0 +1,80 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages";
import type { QueryKey } from "@tanstack/react-query";
import type { FetchQueryInput } from "@/data/query";
import type { ProjectConfigImportSourceDescriptor } from "./sources";
export type ProjectConfigImportPreviewResult = Awaited<
ReturnType<DaemonClient["getProjectConfigImport"]>
>;
type ProjectConfigImportPreviewQueryKey = QueryKey;
const PROJECT_CONFIG_IMPORT_PREVIEW_STALE_MS = 5_000;
export function projectConfigImportPreviewQueryInput(input: {
client: Pick<DaemonClient, "getProjectConfigImport"> | null;
serverId: string;
repoRoot: string;
source: ProjectConfigImportSourceDescriptor | null;
protocolSource: ProjectConfigImportSource | null;
enabled: boolean;
}): FetchQueryInput<
ProjectConfigImportPreviewResult,
Error,
ProjectConfigImportPreviewResult,
ProjectConfigImportPreviewQueryKey
> {
return {
queryKey: projectConfigImportPreviewQueryKey(input.serverId, input.repoRoot, input.source),
queryFn: () => {
if (!input.client || !input.protocolSource) {
throw new Error("Project config import preview requires a daemon client and source");
}
return input.client.getProjectConfigImport({
repoRoot: input.repoRoot,
source: input.protocolSource,
});
},
enabled:
input.enabled &&
Boolean(input.client && input.serverId && input.repoRoot && input.protocolSource),
refetchOnMount: false,
retry: false,
staleTimeMs: PROJECT_CONFIG_IMPORT_PREVIEW_STALE_MS,
dataShape: "value",
};
}
export function projectConfigImportPreviewQueryKey(
serverId: string,
repoRoot: string,
source: ProjectConfigImportSourceDescriptor | null,
): ProjectConfigImportPreviewQueryKey {
return [
"project-config-import",
serverId,
repoRoot,
source ? stableProjectConfigImportSourceKey(source) : "none",
] as const;
}
export function stableProjectConfigImportSourceKey(
source: ProjectConfigImportSourceDescriptor,
): string {
return stableJson(source);
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

View File

@@ -1,136 +1,253 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import {
normalizeProjectConfigImportError,
openProjectConfigImport,
ProjectConfigImportSourceSchema,
type ProjectConfigImportPreview,
type ProjectConfigImportSource,
} from "@getpaseo/protocol/messages";
import { fetchQueryOptions } from "@/data/query";
import {
projectConfigImportPreviewQueryInput,
projectConfigImportPreviewQueryKey,
stableProjectConfigImportSourceKey,
} from "./preview-cache";
import {
createProjectConfigImportIntentFromRegistration,
parseProjectConfigImportIntent,
projectConfigImportApplyFailureRetryAction,
projectConfigImportCanApply,
projectConfigImportNeedsRefresh,
projectConfigImportSourceFeature,
} from "./project-config-import-model";
} from "./route";
import {
createProjectConfigImportSourceRegistry,
type ProjectConfigImportSourceDescriptor,
} from "./sources";
interface FakeImportSource extends ProjectConfigImportSourceDescriptor {
kind: "fake-source";
profile: "alpha";
}
const fakeSource: FakeImportSource = { kind: "fake-source", profile: "alpha" };
const protocolSource: ProjectConfigImportSource = ProjectConfigImportSourceSchema.options[0].parse({
kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value,
});
const registry = createProjectConfigImportSourceRegistry([
{
kind: fakeSource.kind,
source: fakeSource,
displayName: "Test Source",
routeValue: "test-source",
},
]);
function parseFakeSource(source: ProjectConfigImportSourceDescriptor): FakeImportSource | null {
return source.kind === fakeSource.kind ? fakeSource : null;
}
describe("project config import intent", () => {
it("parses a Conductor host-bound route intent", () => {
it("parses a host-bound route intent through the source registry", () => {
expect(
parseProjectConfigImportIntent({
importSource: "conductor",
importServerId: "server-1",
importIntentId: "intent-1",
}),
parseProjectConfigImportIntent(
{
importSource: "test-source",
importServerId: "server-1",
importIntentId: "intent-1",
},
registry,
parseFakeSource,
),
).toEqual({
serverId: "server-1",
source: { kind: "conductor" },
source: fakeSource,
protocolSource: fakeSource,
intentId: "intent-1",
});
});
it("rejects missing or unknown sources", () => {
expect(
parseProjectConfigImportIntent({
importSource: "other",
importServerId: "server-1",
importIntentId: "intent-1",
}),
parseProjectConfigImportIntent(
{
importSource: "other",
importServerId: "server-1",
importIntentId: "intent-1",
},
registry,
parseFakeSource,
),
).toBeNull();
expect(
parseProjectConfigImportIntent({ importSource: "test-source" }, registry, parseFakeSource),
).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);
it("uses the supplied registry instead of a hardcoded route map", () => {
const alternateRegistry = createProjectConfigImportSourceRegistry([
{
kind: fakeSource.kind,
source: fakeSource,
displayName: "Fake Second",
routeValue: "fake",
},
]);
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,
}),
parseProjectConfigImportIntent(
{
importSource: "fake",
importServerId: "server-1",
importIntentId: "intent-1",
},
alternateRegistry,
parseFakeSource,
),
).toEqual({
status: "error",
intent,
preview,
error: { code: "stale_source_config", source: { kind: "conductor" } },
retryAction: "refresh",
serverId: "server-1",
source: fakeSource,
protocolSource: fakeSource,
intentId: "intent-1",
});
});
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: "nothing_to_import" })).toBe(true);
expect(projectConfigImportNeedsRefresh({ code: "write_failed" })).toBe(false);
expect(projectConfigImportApplyFailureRetryAction({ code: "write_failed" })).toBe("apply");
expect(projectConfigImportApplyFailureRetryAction({ code: "nothing_to_import" })).toBe(
"refresh",
);
expect(
projectConfigImportApplyFailureRetryAction({
code: "stale_source_config",
source: { kind: "conductor" },
}),
).toBe("refresh");
});
});
describe("project config import preview cache keys", () => {
it("uses the full source descriptor instead of kind alone", () => {
const alpha = { kind: "variant-source", profile: "alpha" };
const beta = { profile: "beta", kind: "variant-source" };
expect(projectConfigImportPreviewQueryKey("server", "/repo", alpha)).not.toEqual(
projectConfigImportPreviewQueryKey("server", "/repo", beta),
);
});
it("serializes source descriptors deterministically", () => {
expect(stableProjectConfigImportSourceKey({ profile: "alpha", kind: "variant-source" })).toBe(
stableProjectConfigImportSourceKey({ kind: "variant-source", profile: "alpha" }),
);
});
it("does not refetch a current availability preview when the sheet opens", async () => {
const calls: string[] = [];
const rpcSources: ProjectConfigImportSource[] = [];
const client = {
getProjectConfigImport: async (input: {
source: ProjectConfigImportSource;
}): Promise<ProjectConfigImportPreview & { ok: true; requestId: string }> => {
calls.push("preview");
rpcSources.push(input.source);
return {
ok: true,
requestId: "preview-1",
repoRoot: "/repo",
source: protocolSource,
status: "available",
sourceRevision: "source-1",
paseoRevision: null,
inputs: [],
items: [],
preview: {},
};
},
};
const queryClient = new QueryClient();
const input = projectConfigImportPreviewQueryInput({
client,
serverId: "server",
repoRoot: "/repo",
source: { ...protocolSource, profile: "alpha" },
protocolSource,
enabled: true,
});
const options = fetchQueryOptions(input);
await queryClient.fetchQuery(options);
const observer = new QueryObserver(queryClient, queryClient.defaultQueryOptions(options));
const unsubscribe = observer.subscribe(() => {});
observer.getOptimisticResult(queryClient.defaultQueryOptions(options));
unsubscribe();
expect(calls).toEqual(["preview"]);
expect(rpcSources).toEqual([protocolSource]);
expect(input.queryKey).toEqual(
projectConfigImportPreviewQueryKey("server", "/repo", {
...protocolSource,
profile: "alpha",
}),
);
});
it("keeps advertised identity separate from the protocol source after opening", async () => {
const sameKindRegistry = createProjectConfigImportSourceRegistry([
{
kind: protocolSource.kind,
source: protocolSource,
displayName: "Protocol Source",
routeValue: "protocol-source",
},
]);
const [alphaRegistration, betaRegistration] = sameKindRegistry.advertised([
{ kind: protocolSource.kind, profile: "alpha" },
{ kind: protocolSource.kind, profile: "beta" },
]);
const alphaIntent = alphaRegistration
? createProjectConfigImportIntentFromRegistration({
serverId: "server",
registration: alphaRegistration,
intentId: "alpha",
})
: null;
const betaIntent = betaRegistration
? createProjectConfigImportIntentFromRegistration({
serverId: "server",
registration: betaRegistration,
intentId: "beta",
})
: null;
const calls: ProjectConfigImportSource[] = [];
const client = {
getProjectConfigImport: async (input: {
source: ProjectConfigImportSource;
}): Promise<ProjectConfigImportPreview & { ok: true; requestId: string }> => {
calls.push(input.source);
return {
ok: true,
requestId: `preview-${calls.length}`,
repoRoot: "/repo",
source: input.source,
status: "available",
sourceRevision: `source-${calls.length}`,
paseoRevision: null,
inputs: [],
items: [],
preview: {},
};
},
};
const queryClient = new QueryClient();
expect(alphaIntent?.source).toEqual({ kind: protocolSource.kind, profile: "alpha" });
expect(betaIntent?.source).toEqual({ kind: protocolSource.kind, profile: "beta" });
expect(alphaIntent?.protocolSource).toEqual(protocolSource);
expect(betaIntent?.protocolSource).toEqual(protocolSource);
const alphaInput = projectConfigImportPreviewQueryInput({
client,
serverId: "server",
repoRoot: "/repo",
source: alphaIntent?.source ?? null,
protocolSource: alphaIntent?.protocolSource ?? null,
enabled: true,
});
const betaInput = projectConfigImportPreviewQueryInput({
client,
serverId: "server",
repoRoot: "/repo",
source: betaIntent?.source ?? null,
protocolSource: betaIntent?.protocolSource ?? null,
enabled: true,
});
expect(alphaInput.queryKey).not.toEqual(betaInput.queryKey);
await queryClient.fetchQuery(fetchQueryOptions(alphaInput));
await queryClient.fetchQuery(fetchQueryOptions(betaInput));
expect(calls).toEqual([protocolSource, protocolSource]);
});
});

View File

@@ -1,162 +0,0 @@
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" ||
error.code === "nothing_to_import"
);
}
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

@@ -1,36 +0,0 @@
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,114 @@
import { Pressable, Text, View } from "react-native";
import { useCallback } from "react";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { stableProjectConfigImportSourceKey } from "./preview-cache";
import type { ProjectConfigImportIntent } from "./route";
import { ProjectConfigImportSheet } from "./project-config-import-sheet";
import type { ProjectConfigImportSourceRegistration } from "./sources";
import { getProjectConfigImportViewModule } from "./sources/view";
import { useProjectConfigImportModel } from "./use-project-config-import-model";
export function ProjectConfigImportSection(input: {
client: DaemonClient;
serverId: string;
repoRoot: string;
routeIntent: ProjectConfigImportIntent | null;
onRouteIntentConsumed?: () => void;
projectConfigLoaded: boolean;
projectConfigQueryKey: readonly [string, string, string];
}) {
const model = useProjectConfigImportModel(input);
return (
<>
{model.sources.map((source) => (
<ProjectConfigImportRow
key={stableProjectConfigImportSourceKey(source.source)}
source={source}
activeSourceKind={model.intent?.source.kind ?? null}
isAvailable={model.availability.availableSourceKeys.has(
stableProjectConfigImportSourceKey(source.source),
)}
onOpen={model.open}
/>
))}
{model.state ? (
<ProjectConfigImportSheet
visible
state={model.state}
sourceName={model.activeSourceName ?? model.state.intent.source.kind}
onClose={model.close}
onRefresh={model.refresh}
onApply={model.apply}
/>
) : null}
</>
);
}
function ProjectConfigImportRow({
source,
activeSourceKind,
isAvailable,
onOpen,
}: {
source: ProjectConfigImportSourceRegistration;
activeSourceKind: string | null;
isAvailable: boolean;
onOpen: (source: ProjectConfigImportSourceRegistration) => void;
}) {
const { t } = useTranslation();
const shouldShow = isAvailable || activeSourceKind === source.kind;
const handleOpen = useCallback(() => {
onOpen(source);
}, [onOpen, source]);
if (!shouldShow) {
return null;
}
const SourceIcon = getProjectConfigImportViewModule(source.source).Icon;
const rowTitle = t("settings.project.import.rowTitle", { source: source.module.displayName });
return (
<SettingsSection title={rowTitle} testID="project-config-import-section">
<Pressable
testID="project-config-import-row"
accessibilityRole="button"
accessibilityLabel={rowTitle}
onPress={handleOpen}
style={settingsStyles.card}
>
<View style={styles.importRow}>
<SourceIcon size={16} color={styles.iconColor.color} />
<View style={styles.importText}>
<Text style={settingsStyles.rowTitle}>{rowTitle}</Text>
<Text style={settingsStyles.rowHint} numberOfLines={2}>
{t("settings.project.import.rowDescription", { source: source.module.displayName })}
</Text>
</View>
</View>
</Pressable>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
importRow: {
...settingsStyles.row,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
importText: {
flex: 1,
minWidth: 0,
},
iconColor: {
color: theme.colors.foregroundMuted,
},
}));

View File

@@ -1,26 +1,43 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { useMemo } from "react";
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 type {
ProjectConfigImportItem,
ProjectConfigImportPreview,
ProjectConfigRpcError,
} from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet } 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";
import type { ProjectConfigImportIntent } from "./route";
export type ProjectConfigImportVisibleError =
| ProjectConfigRpcError
| { code: "transport"; message: string };
export type ProjectConfigImportState =
| { status: "loading"; intent: ProjectConfigImportIntent; preview: null; error: null }
| {
status: "ready" | "applying";
intent: ProjectConfigImportIntent;
preview: ProjectConfigImportPreview;
error: null;
}
| {
status: "error";
intent: ProjectConfigImportIntent;
preview: ProjectConfigImportPreview | null;
error: ProjectConfigImportVisibleError;
retryAction: "refresh" | "apply";
};
interface ProjectConfigImportSheetProps {
visible: boolean;
state: ProjectConfigImportState;
sourceName: string;
onClose: () => void;
onRefresh: () => void;
onApply: () => void;
@@ -29,23 +46,28 @@ interface ProjectConfigImportSheetProps {
export function ProjectConfigImportSheet({
visible,
state,
sourceName,
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 header = useMemo(
() => ({ title: t("settings.project.import.sheetTitle", { source: sourceName }) }),
[sourceName, t],
);
const preview = state.preview;
const visibleError = state.status === "error" ? state.error : null;
const needsRefresh = visibleError ? projectConfigImportNeedsRefresh(visibleError) : false;
const needsRefresh =
visibleError?.code === "stale_source_config" ||
visibleError?.code === "stale_project_config" ||
visibleError?.code === "nothing_to_import";
const retryAction = state.status === "error" ? state.retryAction : "refresh";
const handleRetry = retryAction === "apply" ? onApply : onRefresh;
const canImport = projectConfigImportCanApply(state);
const canImport =
state.status === "ready" &&
state.preview.status === "available" &&
Boolean(state.preview.preview);
const isLoading = state.status === "loading";
const isApplying = state.status === "applying";
@@ -69,7 +91,7 @@ export function ProjectConfigImportSheet({
testID="project-config-import-error"
variant="error"
title={t("settings.project.import.errorTitle")}
description={projectConfigImportErrorText(visibleError, t)}
description={projectConfigImportErrorText(visibleError, t, sourceName)}
>
{needsRefresh ? (
<Button
@@ -127,38 +149,41 @@ export function ProjectConfigImportSheet({
function PreviewBody({ preview }: { preview: ProjectConfigImportPreview }) {
const { t } = useTranslation();
const grouped = groupPreviewItems(preview);
const sections = [
{
title: t("settings.project.import.sources"),
items: preview.inputs.map((input) => ({
key: `${input.role}:${input.relativePath}`,
label: input.relativePath,
outcome: "import" as const,
detail: input.role,
})),
},
{
title: t("settings.project.import.willImport"),
items: preview.items.filter((item) => item.outcome === "import"),
},
{
title: t("settings.project.import.needsAttention"),
items: preview.items.filter(
(item) => item.outcome === "rewrite" || item.outcome === "collision",
),
},
{
title: t("settings.project.import.notSupported"),
items: preview.items.filter((item) => item.outcome === "unsupported"),
},
];
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} />
{sections.map((section) => (
<PreviewItemGroup key={section.title} title={section.title} items={section.items} />
))}
</View>
);
}
function ItemGroup({
title,
items,
}: {
title: string;
items: ProjectConfigImportPreview["items"];
}) {
function PreviewItemGroup({ title, items }: { title: string; items: ProjectConfigImportItem[] }) {
if (items.length === 0) {
return null;
}
@@ -167,10 +192,7 @@ function ItemGroup({
<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}
>
<View key={`${item.key}:${item.outcome}:${item.detail ?? ""}`} style={rowStyle(index)}>
<Text style={settingsStyles.rowTitle}>{item.label}</Text>
{item.detail ? <Text style={styles.commandText}>{item.detail}</Text> : null}
</View>
@@ -180,29 +202,24 @@ function ItemGroup({
);
}
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 rowStyle(index: number) {
return index === 0 ? settingsStyles.row : styles.rowWithBorder;
}
function projectConfigImportErrorText(
error: ProjectConfigImportVisibleError,
t: TFunction,
t: ReturnType<typeof useTranslation>["t"],
sourceName: string,
): string {
switch (error.code) {
case "transport":
return error.message;
case "source_config_not_found":
return t("settings.project.import.errors.notFound");
return t("settings.project.import.errors.notFound", { source: sourceName });
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");
return t("settings.project.import.errors.staleSource", { source: sourceName });
case "stale_project_config":
return t("settings.project.import.errors.staleProject");
case "nothing_to_import":
@@ -228,11 +245,6 @@ const styles = StyleSheet.create((theme) => ({
preview: {
gap: theme.spacing[4],
},
sourceRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
group: {
gap: theme.spacing[2],
},
@@ -259,7 +271,4 @@ const styles = StyleSheet.create((theme) => ({
spinnerColor: {
color: theme.colors.foregroundMuted,
},
iconColor: {
color: theme.colors.foreground,
},
}));

View File

@@ -0,0 +1,89 @@
import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages";
import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages";
import type {
ProjectConfigImportSourceDescriptor,
ProjectConfigImportSourceRegistry,
} from "./sources";
export interface ProjectConfigImportIntent<
TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor,
TProtocolSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSource,
> {
serverId: string;
source: TSource;
protocolSource: TProtocolSource;
intentId: string;
}
export function parseProjectConfigImportIntent(
input: {
importSource?: string | string[];
importServerId?: string | string[];
importIntentId?: string | string[];
},
registry: ProjectConfigImportSourceRegistry,
): ProjectConfigImportIntent | null;
export function parseProjectConfigImportIntent<TSource extends ProjectConfigImportSourceDescriptor>(
input: {
importSource?: string | string[];
importServerId?: string | string[];
importIntentId?: string | string[];
},
registry: ProjectConfigImportSourceRegistry,
parseSource: (source: ProjectConfigImportSourceDescriptor) => TSource | null,
): ProjectConfigImportIntent<ProjectConfigImportSourceDescriptor, TSource> | null;
export function parseProjectConfigImportIntent(
input: {
importSource?: string | string[];
importServerId?: string | string[];
importIntentId?: string | string[];
},
registry: ProjectConfigImportSourceRegistry,
parseSource?: (
source: ProjectConfigImportSourceDescriptor,
) => ProjectConfigImportSourceDescriptor | null,
): ProjectConfigImportIntent<
ProjectConfigImportSourceDescriptor,
ProjectConfigImportSourceDescriptor
> | null {
const source = first(input.importSource);
const serverId = first(input.importServerId);
const intentId = first(input.importIntentId);
const routeSource = source ? registry.fromRouteValue(source)?.source : null;
const parsedSource = routeSource ? (parseSource ?? parseProtocolSource)(routeSource) : null;
return parsedSource && routeSource && serverId && intentId
? { serverId, source: routeSource, protocolSource: parsedSource, intentId }
: null;
}
export function createProjectConfigImportIntentFromRegistration(input: {
serverId: string;
registration: {
source: ProjectConfigImportSourceDescriptor;
protocolSource: ProjectConfigImportSource | null;
};
intentId: string;
}): ProjectConfigImportIntent | null {
return input.registration.protocolSource
? {
serverId: input.serverId,
source: input.registration.source,
protocolSource: input.registration.protocolSource,
intentId: input.intentId,
}
: null;
}
function parseProtocolSource(
source: ProjectConfigImportSourceDescriptor,
): ProjectConfigImportSource | null {
const parsed = ProjectConfigImportSourceSchema.safeParse(source);
return parsed.success ? parsed.data : null;
}
function first(value: string | string[] | undefined): string | null {
const raw = Array.isArray(value) ? value[0] : value;
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
}

View File

@@ -0,0 +1,8 @@
import type { ProjectConfigImportLogicModule } from ".";
export const conductorProjectConfigImportSource = {
kind: "conductor",
source: { kind: "conductor" },
displayName: "Conductor",
routeValue: "conductor",
} satisfies ProjectConfigImportLogicModule<{ kind: "conductor" }>;

View File

@@ -0,0 +1,12 @@
import { createElement } from "react";
import type { ProjectConfigImportIconProps, ProjectConfigImportViewModule } from "./view-registry";
function ConductorImportIcon(props: ProjectConfigImportIconProps) {
const { ConductorIcon } = require("./conductor.view") as typeof import("./conductor.view");
return createElement(ConductorIcon, props);
}
export const conductorProjectConfigImportView = {
kind: "conductor",
Icon: ConductorImportIcon,
} satisfies ProjectConfigImportViewModule;

View File

@@ -1,4 +1,5 @@
import Svg, { Path } from "react-native-svg";
import type { ProjectConfigImportViewModule } from "./view-registry";
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",
@@ -31,3 +32,8 @@ export function ConductorIcon({ size = 18, color = "#282423" }: { size?: number;
</Svg>
);
}
export const conductorProjectConfigImportView = {
kind: "conductor",
Icon: ConductorIcon,
} satisfies ProjectConfigImportViewModule;

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
createProjectConfigImportSourceRegistry,
projectConfigImportSourceRegistry,
type ProjectConfigImportLogicModule,
} from ".";
import { projectConfigImportViewRegistry } from "./view";
import { createProjectConfigImportViewRegistry } from "./view-registry";
const fakeSource = {
kind: "fake-source",
source: { kind: "fake-source", profile: "alpha" },
displayName: "Fake Source",
routeValue: "fake",
} satisfies ProjectConfigImportLogicModule<{ kind: "fake-source"; profile: "alpha" }>;
describe("project config import app registry", () => {
it("filters future advertised sources through registered logic modules", () => {
const registry = createProjectConfigImportSourceRegistry([fakeSource]);
expect(
registry.advertised([
{ kind: "future-source", capability: "unknown" },
{ kind: "fake-source", profile: "alpha" },
]),
).toEqual([
{
kind: "fake-source",
source: { kind: "fake-source", profile: "alpha" },
protocolSource: null,
module: fakeSource,
},
]);
});
it("preserves advertised descriptors before normalizing RPC sources", () => {
const registry = createProjectConfigImportSourceRegistry([
{
kind: "conductor",
source: { kind: "conductor" },
displayName: "Known Source",
routeValue: "known",
},
]);
expect(registry.advertised([{ kind: "conductor", profile: "alpha" }])).toMatchObject([
{
kind: "conductor",
source: { kind: "conductor", profile: "alpha" },
protocolSource: { kind: "conductor" },
module: { displayName: "Known Source" },
},
]);
});
it("rejects duplicate logic kinds and route values", () => {
expect(() => createProjectConfigImportSourceRegistry([fakeSource, fakeSource])).toThrow(
"Duplicate project config import source: fake-source",
);
expect(() =>
createProjectConfigImportSourceRegistry([
fakeSource,
{ ...fakeSource, kind: "other-source" },
]),
).toThrow("Duplicate project config import route value: fake");
});
it("keeps production protocol sources registered", () => {
expect(() => projectConfigImportSourceRegistry.assertProtocolCoverage()).not.toThrow();
});
});
describe("project config import view registry", () => {
const FakeIcon = () => null;
it("rejects duplicate views and missing view modules", () => {
expect(() =>
createProjectConfigImportViewRegistry([
{ kind: "fake-source", Icon: FakeIcon },
{ kind: "fake-source", Icon: FakeIcon },
]),
).toThrow("Duplicate project config import view: fake-source");
const registry = createProjectConfigImportViewRegistry([]);
expect(() => registry.get(fakeSource.source)).toThrow(
"Missing project config import view: fake-source",
);
});
it("keeps production logic and view registries in parity", () => {
const kinds = projectConfigImportSourceRegistry.all().map((source) => source.kind);
expect(projectConfigImportViewRegistry.kinds()).toEqual(kinds);
});
});

View File

@@ -0,0 +1,106 @@
import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages";
import { conductorProjectConfigImportSource } from "./conductor";
export interface ProjectConfigImportSourceDescriptor {
kind: string;
[key: string]: unknown;
}
export interface ProjectConfigImportLogicModule<
TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor,
> {
readonly kind: TSource["kind"];
readonly source: TSource;
readonly displayName: string;
readonly routeValue: string;
}
export interface ProjectConfigImportSourceRegistration<
TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor,
> {
readonly kind: string;
readonly source: TSource;
readonly protocolSource: ProjectConfigImportSource | null;
readonly module: ProjectConfigImportLogicModule;
}
const PROJECT_CONFIG_IMPORT_LOGIC_MODULES = [conductorProjectConfigImportSource];
export const projectConfigImportSourceRegistry = createProjectConfigImportSourceRegistry(
PROJECT_CONFIG_IMPORT_LOGIC_MODULES,
);
export type ProjectConfigImportSourceRegistry = ReturnType<
typeof createProjectConfigImportSourceRegistry
>;
export function createProjectConfigImportSourceRegistry<
const TModules extends readonly ProjectConfigImportLogicModule[],
>(modules: TModules) {
const byKind = new Map<string, ProjectConfigImportLogicModule>();
const byRouteValue = new Map<string, ProjectConfigImportLogicModule>();
for (const module of modules) {
if (byKind.has(module.kind)) {
throw new Error(`Duplicate project config import source: ${module.kind}`);
}
if (byRouteValue.has(module.routeValue)) {
throw new Error(`Duplicate project config import route value: ${module.routeValue}`);
}
byKind.set(module.kind, module);
byRouteValue.set(module.routeValue, module);
}
return {
all(): ProjectConfigImportLogicModule[] {
return Array.from(byKind.values());
},
get(source: ProjectConfigImportSourceDescriptor): ProjectConfigImportLogicModule | null {
return byKind.get(source.kind) ?? null;
},
fromRouteValue(routeValue: string): ProjectConfigImportLogicModule | null {
return byRouteValue.get(routeValue) ?? null;
},
routeValue(source: ProjectConfigImportSourceDescriptor): string | null {
return byKind.get(source.kind)?.routeValue ?? null;
},
assertProtocolCoverage(): void {
const missing = readProtocolSourceKinds().filter((kind) => !byKind.has(kind));
if (missing.length > 0) {
throw new Error(`Missing project config import sources: ${missing.join(", ")}`);
}
},
advertised(
sources: readonly ProjectConfigImportSourceDescriptor[] | null | undefined,
): ProjectConfigImportSourceRegistration[] {
if (!sources) {
return [];
}
return sources
.map((source) => {
const module = byKind.get(source.kind);
return module
? {
kind: source.kind,
source,
protocolSource: toProjectConfigImportProtocolSource(source),
module,
}
: null;
})
.filter((source): source is ProjectConfigImportSourceRegistration => source !== null);
},
};
}
export function toProjectConfigImportProtocolSource(
source: ProjectConfigImportSourceDescriptor,
): ProjectConfigImportSource | null {
const parsed = ProjectConfigImportSourceSchema.safeParse(source);
return parsed.success ? parsed.data : null;
}
function readProtocolSourceKinds(): string[] {
return ProjectConfigImportSourceSchema.options.map((option) => option.shape.kind.value);
}

View File

@@ -0,0 +1,37 @@
import type { ComponentType } from "react";
import type { ProjectConfigImportSourceDescriptor } from ".";
export interface ProjectConfigImportIconProps {
size?: number;
color?: string;
}
export interface ProjectConfigImportViewModule {
readonly kind: string;
readonly Icon: ComponentType<ProjectConfigImportIconProps>;
}
export function createProjectConfigImportViewRegistry(
modules: readonly ProjectConfigImportViewModule[],
) {
const byKind = new Map<string, ProjectConfigImportViewModule>();
for (const module of modules) {
if (byKind.has(module.kind)) {
throw new Error(`Duplicate project config import view: ${module.kind}`);
}
byKind.set(module.kind, module);
}
return {
kinds(): string[] {
return Array.from(byKind.keys());
},
get(source: ProjectConfigImportSourceDescriptor): ProjectConfigImportViewModule {
const module = byKind.get(source.kind);
if (!module) {
throw new Error(`Missing project config import view: ${source.kind}`);
}
return module;
},
};
}

View File

@@ -0,0 +1,17 @@
import type { ProjectConfigImportSourceDescriptor } from ".";
import { conductorProjectConfigImportView } from "./conductor.view-registration";
import { createProjectConfigImportViewRegistry } from "./view-registry";
import type { ProjectConfigImportViewModule } from "./view-registry";
export type { ProjectConfigImportIconProps, ProjectConfigImportViewModule } from "./view-registry";
const PROJECT_CONFIG_IMPORT_VIEW_MODULES = [conductorProjectConfigImportView];
export const projectConfigImportViewRegistry = createProjectConfigImportViewRegistry(
PROJECT_CONFIG_IMPORT_VIEW_MODULES,
);
export function getProjectConfigImportViewModule(
source: ProjectConfigImportSourceDescriptor,
): ProjectConfigImportViewModule {
return projectConfigImportViewRegistry.get(source);
}

View File

@@ -1,67 +1,124 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, 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 ProjectConfigImportAdvertisedSource,
type ProjectConfigImportSource,
type ProjectConfigRpcError,
} from "@getpaseo/protocol/messages";
import { useToast } from "@/contexts/toast-context";
import { useFetchQueries, useFetchQuery } from "@/data/query";
import { useSessionStore } from "@/stores/session-store";
import {
projectConfigImportPreviewQueryInput,
stableProjectConfigImportSourceKey,
type ProjectConfigImportPreviewResult,
} from "./preview-cache";
import type {
ProjectConfigImportState,
ProjectConfigImportVisibleError,
} from "./project-config-import-sheet";
import {
createProjectConfigImportIntentFromRegistration,
type ProjectConfigImportIntent,
type ProjectConfigImportRetryAction,
type ProjectConfigImportState,
type ProjectConfigImportVisibleError,
} from "./project-config-import-model";
} from "./route";
import {
type ProjectConfigImportSourceRegistration,
projectConfigImportSourceRegistry,
type ProjectConfigImportSourceRegistry,
} from "./sources";
export interface ProjectConfigImportModel {
state: ProjectConfigImportState;
apply: () => void;
}
const EMPTY_IMPORT_SOURCES: readonly ProjectConfigImportAdvertisedSource[] = [];
export function useProjectConfigImportModel(input: {
intent: ProjectConfigImportIntent;
routeIntent: ProjectConfigImportIntent | null;
repoRoot: string;
client: DaemonClient;
preview: ProjectConfigImportPreview | null;
isPreviewLoading: boolean;
previewError: ProjectConfigImportVisibleError | null;
serverId: string;
client: DaemonClient | null;
projectConfigLoaded: boolean;
projectConfigQueryKey: readonly [string, string, string];
onClose: () => void;
}): ProjectConfigImportModel {
const { t } = useTranslation();
registry?: ProjectConfigImportSourceRegistry;
onRouteIntentConsumed?: () => void;
}) {
const registry = input.registry ?? projectConfigImportSourceRegistry;
const onRouteIntentConsumed = input.onRouteIntentConsumed;
const sources = useAdvertisedProjectConfigImportSources(input.serverId, registry);
const [intent, setIntent] = useState<ProjectConfigImportIntent | null>(null);
const consumedRouteIntentKeyRef = useRef<string | null>(null);
const acknowledgedRouteIntentKeyRef = useRef<string | null>(null);
const [applyError, setApplyError] = useState<ProjectConfigImportVisibleError | null>(null);
const [retryAction, setRetryAction] = useState<ProjectConfigImportRetryAction>("apply");
const activeSource = intent ? registry.get(intent.source) : null;
const activePreview = useProjectConfigImportPreviewQuery({
client: input.client,
serverId: input.serverId,
repoRoot: input.repoRoot,
source: intent?.source ?? null,
protocolSource: intent?.protocolSource ?? null,
enabled: Boolean(intent && input.projectConfigLoaded),
});
const preview = activePreview.data?.ok ? activePreview.data : null;
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(":");
const { t } = useTranslation();
useEffect(() => {
const routeIntentKey = input.routeIntent
? `${input.routeIntent.serverId}:${stableProjectConfigImportSourceKey(input.routeIntent.source)}:${input.routeIntent.intentId}`
: null;
if (
input.routeIntent?.serverId === input.serverId &&
routeIntentKey &&
consumedRouteIntentKeyRef.current !== routeIntentKey
) {
consumedRouteIntentKeyRef.current = routeIntentKey;
setIntent(input.routeIntent);
}
}, [input.routeIntent, input.serverId]);
useEffect(() => {
const routeIntentKey = input.routeIntent
? `${input.routeIntent.serverId}:${stableProjectConfigImportSourceKey(input.routeIntent.source)}:${input.routeIntent.intentId}`
: null;
const openIntentKey = intent
? `${intent.serverId}:${stableProjectConfigImportSourceKey(intent.source)}:${intent.intentId}`
: null;
if (
routeIntentKey &&
routeIntentKey === openIntentKey &&
acknowledgedRouteIntentKeyRef.current !== routeIntentKey
) {
acknowledgedRouteIntentKeyRef.current = routeIntentKey;
onRouteIntentConsumed?.();
}
}, [input.routeIntent, intent, onRouteIntentConsumed]);
useEffect(() => {
setApplyError(null);
}, [previewRevisionKey]);
}, [
intent?.intentId,
preview?.sourceRevision,
preview?.paseoRevision?.mtimeMs,
preview?.paseoRevision?.size,
]);
const applyMutation = useMutation({
mutationFn: async () => {
if (!input.preview?.sourceRevision) {
if (!input.client || !intent || !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,
source: intent.protocolSource,
expectedSourceRevision: preview.sourceRevision,
expectedPaseoRevision: preview.paseoRevision,
});
},
onSuccess: (result) => {
if (!result.ok) {
setApplyError(result.error);
setApplyErrorRetryAction(projectConfigImportApplyFailureRetryAction(result.error));
setRetryAction(projectConfigImportApplyFailureRetryAction(result.error));
return;
}
queryClient.setQueryData(input.projectConfigQueryKey, {
@@ -72,48 +129,211 @@ export function useProjectConfigImportModel(input: {
repoRoot: input.repoRoot,
});
queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.show(t("settings.project.import.success"), { variant: "success" });
setApplyError(null);
input.onClose();
const appliedSource = registry.get(result.source);
toast.show(
t("settings.project.import.success", {
source: appliedSource?.displayName ?? result.source.kind,
}),
{ variant: "success" },
);
setIntent(null);
},
onError: (error) => {
onError: (cause) => {
setApplyError(
normalizeProjectConfigImportError(
error instanceof Error ? error : new Error(String(error)),
cause instanceof Error ? cause : new Error(String(cause)),
),
);
setApplyErrorRetryAction("apply");
setRetryAction("apply");
},
});
const apply = useCallback(() => {
if (applyMutation.isPending) {
return;
const availability = useProjectConfigImportAvailability({
client: input.client,
serverId: input.serverId,
repoRoot: input.repoRoot,
enabled: input.projectConfigLoaded,
registry,
});
const state = useMemo<ProjectConfigImportState | null>(() => {
if (!intent) {
return null;
}
setApplyError(null);
applyMutation.mutate();
}, [applyMutation]);
const error =
applyError ??
normalizeProjectConfigImportError(
activePreview.data && !activePreview.data.ok
? activePreview.data.error
: activePreview.error,
);
if (error) {
return {
status: "error",
intent,
preview,
error,
retryAction: applyError ? retryAction : "refresh",
};
}
if (preview && applyMutation.isPending) {
return { status: "applying", intent, preview, error: null };
}
if (preview) {
return { status: "ready", intent, preview, error: null };
}
return { status: "loading", intent, preview: null, error: null };
}, [
activePreview.data,
activePreview.error,
applyError,
applyMutation.isPending,
intent,
preview,
retryAction,
]);
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 {
sources,
intent,
state,
activeSourceName: activeSource?.displayName ?? null,
availability,
open: (source: ProjectConfigImportSourceRegistration) => {
const nextIntent = createProjectConfigImportIntentFromRegistration({
serverId: input.serverId,
registration: source,
intentId: String(Date.now()),
});
if (nextIntent) {
setIntent(nextIntent);
}
},
close: () => setIntent(null),
refresh: () => {
void activePreview.refetch();
},
apply: () => {
if (!applyMutation.isPending) {
setApplyError(null);
applyMutation.mutate();
}
},
};
}
export function useProjectConfigImportAvailability(input: {
client: DaemonClient | null;
serverId: string | null | undefined;
repoRoot: string | null | undefined;
enabled: boolean;
registry?: ProjectConfigImportSourceRegistry;
}) {
const registry = input.registry ?? projectConfigImportSourceRegistry;
const serverId = input.serverId ?? "";
const repoRoot = input.repoRoot ?? "";
const sources = useAdvertisedProjectConfigImportSources(serverId, registry);
const previews = useProjectConfigImportPreviewQueries({
client: input.client,
serverId,
repoRoot,
sources,
enabled: input.enabled,
});
const availableSources = sources.filter((_, index) => {
const data = previews[index]?.data;
return data?.ok === true && data.status === "available";
});
const availableKinds = new Set(availableSources.map((source) => source.kind));
const availableSourceKeys = new Set(
availableSources.map((source) => stableProjectConfigImportSourceKey(source.source)),
);
return { state, apply };
return {
status: projectConfigImportAvailabilityStatus(availableSources.length),
source: availableSources.length === 1 ? availableSources[0] : null,
sources: availableSources,
availableKinds,
availableSourceKeys,
};
}
function projectConfigImportAvailabilityStatus(count: number): "none" | "one" | "many" {
if (count === 0) {
return "none";
}
return count === 1 ? "one" : "many";
}
function useAdvertisedProjectConfigImportSources(
serverId: string | null | undefined,
registry: ProjectConfigImportSourceRegistry,
): ProjectConfigImportSourceRegistration[] {
const advertised = useSessionStore(
useCallback(
(state) => {
const id = serverId?.trim();
return id
? (state.sessions[id]?.serverInfo?.features?.projectConfigImportSources ??
EMPTY_IMPORT_SOURCES)
: EMPTY_IMPORT_SOURCES;
},
[serverId],
),
);
return useMemo(() => registry.advertised(advertised), [advertised, registry]);
}
function useProjectConfigImportPreviewQueries(input: {
client: DaemonClient | null;
serverId: string;
repoRoot: string;
sources: readonly ProjectConfigImportSourceRegistration[];
enabled: boolean;
}) {
return useFetchQueries<ProjectConfigImportPreviewResult>(
input.sources.map((source) =>
projectConfigImportPreviewQueryInput({
client: input.client,
serverId: input.serverId,
repoRoot: input.repoRoot,
source: source.source,
protocolSource: source.protocolSource,
enabled: input.enabled,
}),
),
);
}
function useProjectConfigImportPreviewQuery(input: {
client: DaemonClient | null;
serverId: string;
repoRoot: string;
source: ProjectConfigImportSourceRegistration["source"] | null;
protocolSource: ProjectConfigImportSource | null;
enabled: boolean;
}) {
return useFetchQuery(projectConfigImportPreviewQueryInput(input));
}
type ProjectConfigImportRetryAction = "refresh" | "apply";
function projectConfigImportApplyFailureRetryAction(
error: ProjectConfigImportVisibleError,
): ProjectConfigImportRetryAction {
return error.code === "stale_source_config" ||
error.code === "stale_project_config" ||
error.code === "nothing_to_import"
? "refresh"
: "apply";
}
function normalizeProjectConfigImportError(
error: ProjectConfigRpcError | Error | null,
): ProjectConfigImportVisibleError | null {
if (!error) {
return null;
}
return error instanceof Error
? { code: "transport", message: error.message || "The host did not respond." }
: error;
}

View File

@@ -26,15 +26,8 @@ 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 type { ProjectConfigImportIntent } from "@/project-config-import/route";
import { ProjectConfigImportSection } from "@/project-config-import/project-config-import-section";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -95,11 +88,13 @@ type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"
export interface ProjectSettingsScreenProps {
projectKey: string;
importIntent?: ProjectConfigImportIntent | null;
onImportIntentConsumed?: () => void;
}
export default function ProjectSettingsScreen({
projectKey,
importIntent = null,
onImportIntentConsumed,
}: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
@@ -148,6 +143,7 @@ export default function ProjectSettingsScreen({
client={client}
isHostGone={isHostGone}
importIntent={importIntent}
onImportIntentConsumed={onImportIntentConsumed}
/>
);
}
@@ -206,6 +202,7 @@ interface ProjectSettingsBodyProps {
client: DaemonClient;
isHostGone: boolean;
importIntent: ProjectConfigImportIntent | null;
onImportIntentConsumed?: () => void;
}
function ProjectSettingsBody({
@@ -216,12 +213,8 @@ function ProjectSettingsBody({
client,
isHostGone,
importIntent,
onImportIntentConsumed,
}: 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],
@@ -248,54 +241,11 @@ function ProjectSettingsBody({
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
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 readState = resolveProjectConfigReadState(data);
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;
@@ -317,32 +267,40 @@ function ProjectSettingsBody({
{renderContent({
readQuery,
loadedConfig,
loadedRevision,
readError,
loadedConfig: readState.loadedConfig,
loadedRevision: readState.loadedRevision,
readError: readState.readError,
selectedHost,
queryKey,
client,
onReload: handleReload,
hasMultipleHosts,
isHostGone,
importPreviewQuery,
importIntent: openImportIntent,
onOpenImport: setOpenImportIntent,
importIntent,
onImportIntentConsumed,
})}
{openImportIntent ? (
<ProjectConfigImportSheet
visible
state={importModel.state}
onClose={handleCloseImport}
onRefresh={handleRefreshImport}
onApply={importModel.apply}
/>
) : null}
</View>
);
}
function resolveProjectConfigReadState(data: ReadProjectConfigData | undefined): {
loadedConfig: PaseoConfigRaw | null;
loadedRevision: PaseoConfigRevision | null;
readError: ProjectConfigRpcError | null;
} {
if (!data) {
return { loadedConfig: null, loadedRevision: null, readError: null };
}
if (!data.ok) {
return { loadedConfig: null, loadedRevision: null, readError: data.error };
}
return {
loadedConfig: data.config ?? {},
loadedRevision: data.revision,
readError: null,
};
}
interface RenderContentInput {
readQuery: ReturnType<typeof useQuery<ReadProjectConfigData>>;
loadedConfig: PaseoConfigRaw | null;
@@ -354,9 +312,8 @@ interface RenderContentInput {
onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean;
importPreviewQuery: ReturnType<typeof useProjectConfigImportPreview>;
importIntent: ProjectConfigImportIntent | null;
onOpenImport: (intent: ProjectConfigImportIntent | null) => void;
onImportIntentConsumed?: () => void;
}
function renderContent({
@@ -370,9 +327,8 @@ function renderContent({
onReload,
hasMultipleHosts,
isHostGone,
importPreviewQuery,
importIntent,
onOpenImport,
onImportIntentConsumed,
}: RenderContentInput) {
if (readQuery.isLoading) {
return (
@@ -427,9 +383,8 @@ function renderContent({
queryKey={queryKey}
client={client}
onReload={onReload}
importPreviewQuery={importPreviewQuery}
importIntent={importIntent}
onOpenImport={onOpenImport}
onImportIntentConsumed={onImportIntentConsumed}
/>
);
}
@@ -516,9 +471,8 @@ interface ProjectConfigFormProps {
queryKey: readonly [string, string, string];
client: DaemonClient;
onReload: () => void;
importPreviewQuery: ReturnType<typeof useProjectConfigImportPreview>;
importIntent: ProjectConfigImportIntent | null;
onOpenImport: (intent: ProjectConfigImportIntent | null) => void;
onImportIntentConsumed?: () => void;
}
function ProjectConfigForm({
@@ -529,9 +483,8 @@ function ProjectConfigForm({
queryKey,
client,
onReload,
importPreviewQuery,
importIntent,
onOpenImport,
onImportIntentConsumed,
}: ProjectConfigFormProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -736,11 +689,14 @@ function ProjectConfigForm({
info={t("settings.project.worktree.info")}
testID="worktree-group"
>
<ConductorImportRow
previewQuery={importPreviewQuery}
<ProjectConfigImportSection
client={client}
serverId={serverId}
onOpenImport={onOpenImport}
importIntent={importIntent}
repoRoot={repoRoot}
routeIntent={importIntent}
onRouteIntentConsumed={onImportIntentConsumed}
projectConfigLoaded
projectConfigQueryKey={queryKey}
/>
<SettingsSection
@@ -894,62 +850,6 @@ 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;
@@ -1483,26 +1383,6 @@ 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,7 +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 type { ProjectConfigImportIntent } from "@/project-config-import/route";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
@@ -124,7 +124,12 @@ export type SettingsView =
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; projectKey: string; importIntent?: ProjectConfigImportIntent | null };
| {
kind: "project";
projectKey: string;
importIntent?: ProjectConfigImportIntent | null;
onImportIntentConsumed?: () => void;
};
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -1387,7 +1392,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
}
if (view.kind === "project") {
return (
<ProjectSettingsScreen projectKey={view.projectKey} importIntent={view.importIntent} />
<ProjectSettingsScreen
projectKey={view.projectKey}
importIntent={view.importIntent}
onImportIntentConsumed={view.onImportIntentConsumed}
/>
);
}
if (view.kind === "section") {

View File

@@ -213,12 +213,12 @@ describe("projects settings routes", () => {
expect(
buildProjectSettingsImportRoute({
projectKey: "remote:github.com/acme/app",
source: "conductor",
source: "fake",
serverId: "server-1",
intentId: "intent 1",
}),
).toBe(
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=conductor&importServerId=server-1&importIntentId=intent+1",
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=fake&importServerId=server-1&importIntentId=intent+1",
);
});

View File

@@ -571,7 +571,7 @@ export function buildProjectSettingsRoute(projectKey: string) {
export function buildProjectSettingsImportRoute(input: {
projectKey: string;
source: "conductor";
source: string;
serverId: string;
intentId: string;
}) {

View File

@@ -3,6 +3,10 @@ import { z } from "zod";
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
ProjectConfigImportSourceSchema,
type ProjectConfigImportSource,
} from "@getpaseo/protocol/messages";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
@@ -25,6 +29,11 @@ expectTypeOf<
"exploreFileSystem" extends keyof DaemonClient ? true : false
>().toEqualTypeOf<false>();
const PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE: ProjectConfigImportSource =
ProjectConfigImportSourceSchema.options[0].parse({
kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value,
});
function createMockLogger() {
return {
debug: vi.fn(),
@@ -3128,14 +3137,14 @@ test("previews and applies project config import via correlated RPC", async () =
const previewPromise = client.getProjectConfigImport({
requestId: "get-import-1",
repoRoot: "/repo/app",
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "project.config.get_import.request",
requestId: "get-import-1",
repoRoot: "/repo/app",
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
mock.triggerMessage(
@@ -3144,12 +3153,12 @@ test("previews and applies project config import via correlated RPC", async () =
payload: {
requestId: "get-import-1",
repoRoot: "/repo/app",
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
ok: true,
status: "available",
sourceRevision: "source-1",
paseoRevision: null,
inputs: [{ role: "shared", relativePath: ".conductor/settings.toml" }],
inputs: [{ role: "shared", relativePath: "source/config.json" }],
items: [{ key: "worktree.setup", label: "Setup", outcome: "import" }],
preview: { worktree: { setup: "npm ci" } },
},
@@ -3165,7 +3174,7 @@ test("previews and applies project config import via correlated RPC", async () =
const applyPromise = client.applyProjectConfigImport({
requestId: "apply-import-1",
repoRoot: "/repo/app",
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: null,
});
@@ -3174,7 +3183,7 @@ test("previews and applies project config import via correlated RPC", async () =
type: "project.config.apply_import.request",
requestId: "apply-import-1",
repoRoot: "/repo/app",
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: null,
});
@@ -3186,7 +3195,7 @@ test("previews and applies project config import via correlated RPC", async () =
requestId: "apply-import-1",
repoRoot: "/repo/app",
ok: false,
error: { code: "stale_source_config", source: { kind: "conductor" } },
error: { code: "stale_source_config", source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE },
},
}),
);
@@ -3195,7 +3204,7 @@ test("previews and applies project config import via correlated RPC", async () =
requestId: "apply-import-1",
repoRoot: "/repo/app",
ok: false,
error: { code: "stale_source_config", source: { kind: "conductor" } },
error: { code: "stale_source_config", source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE },
});
});

View File

@@ -34,94 +34,37 @@ 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);",
)
) {
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;
return;
}
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};`);";
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(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;
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 (changed) {
await writeFile(discriminatedUnionPath, discriminatedUnionEmitter);
}
discriminatedUnionEmitter = discriminatedUnionEmitter
.replace(importBefore, importAfter)
.replace(outputFlagBefore, outputFlagAfter)
.replace(propagationBefore, propagationAfter);
await writeFile(discriminatedUnionPath, discriminatedUnionEmitter);
}
await Promise.all([

View File

@@ -308,6 +308,23 @@ describe("agent detach RPC", () => {
}
expect(parsed.features?.importSessionWorkspaceTarget).toBe(true);
});
test("parses future project config import source advertisements", () => {
const parsed = parseServerInfoStatusPayload({
status: "server_info",
serverId: "srv-test",
features: {
projectConfigImportSources: [{ kind: "future-source", extra: { version: 2 } }],
},
});
if (!parsed) {
throw new Error("Expected server info payload to parse");
}
expect(parsed.features?.projectConfigImportSources).toEqual([
{ kind: "future-source", extra: { version: 2 } },
]);
});
});
describe("agent setting action responses", () => {

View File

@@ -66,6 +66,7 @@ import {
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
PaseoConfigRevisionSchema,
ProjectConfigImportAdvertisedSourceSchema,
ProjectConfigImportItemSchema,
ProjectConfigImportPreviewSchema,
ProjectConfigImportSourceSchema,
@@ -76,6 +77,7 @@ import {
type PaseoMetadataGenerationEntry,
type PaseoScriptEntryRaw,
type ProjectConfigImportInput,
type ProjectConfigImportAdvertisedSource,
type ProjectConfigImportItem,
type ProjectConfigImportPreview,
type ProjectConfigImportSource,
@@ -88,6 +90,7 @@ export {
PaseoMetadataGenerationSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
ProjectConfigImportAdvertisedSourceSchema,
ProjectConfigImportItemSchema,
ProjectConfigImportPreviewSchema,
ProjectConfigImportSourceSchema,
@@ -97,6 +100,7 @@ export {
type PaseoMetadataGenerationEntry,
type PaseoScriptEntryRaw,
type ProjectConfigImportInput,
type ProjectConfigImportAdvertisedSource,
type ProjectConfigImportItem,
type ProjectConfigImportPreview,
type ProjectConfigImportSource,
@@ -2706,8 +2710,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.110, drop the gate when floor >= v0.1.110.
projectConfigImportConductor: z.boolean().optional(),
// COMPAT(projectConfigImportSources): added in v0.1.110, remove the gate after 2027-01-17.
projectConfigImportSources: z.array(ProjectConfigImportAdvertisedSourceSchema).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.
@@ -3660,7 +3664,7 @@ export const WriteProjectConfigResponseMessageSchema = z.object({
export const GetProjectConfigImportResponseMessageSchema = z.object({
type: z.literal("project.config.get_import.response"),
payload: z.discriminatedUnion("ok", [
payload: z.union([
ProjectConfigImportPreviewSchema.extend({
requestId: z.string(),
ok: z.literal(true),
@@ -3676,7 +3680,7 @@ export const GetProjectConfigImportResponseMessageSchema = z.object({
export const ApplyProjectConfigImportResponseMessageSchema = z.object({
type: z.literal("project.config.apply_import.response"),
payload: z.discriminatedUnion("ok", [
payload: z.union([
z.object({
requestId: z.string(),
repoRoot: z.string(),

View File

@@ -83,6 +83,10 @@ export const ProjectConfigImportSourceSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("conductor") }),
]);
export const ProjectConfigImportAdvertisedSourceSchema = z
.object({ kind: z.string().min(1) })
.passthrough();
export const ProjectConfigImportInputSchema = z.object({
role: z.string(),
relativePath: z.string(),
@@ -150,6 +154,9 @@ 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 ProjectConfigImportAdvertisedSource = z.infer<
typeof ProjectConfigImportAdvertisedSourceSchema
>;
export type ProjectConfigImportInput = z.infer<typeof ProjectConfigImportInputSchema>;
export type ProjectConfigImportItem = z.infer<typeof ProjectConfigImportItemSchema>;
export type ProjectConfigImportPreview = z.infer<typeof ProjectConfigImportPreviewSchema>;

View File

@@ -82,24 +82,6 @@ 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

@@ -1,127 +0,0 @@
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

@@ -1,5 +1,5 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./model.js";
import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./service.js";
interface MergeProjectConfigImportInput {
repoRoot: string;
@@ -31,28 +31,21 @@ export function mergeProjectConfigImport(
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;
for (const key of ["setup", "teardown"] as const) {
if (!Object.hasOwn(patchWorktree, key)) continue;
if (hasLifecycle(base.worktree?.[key])) {
setOutcome(items, `worktree.${key}`, `Paseo already has ${key} commands.`);
continue;
}
merged.worktree = { ...merged.worktree, [key]: patchWorktree[key] };
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.`);
setOutcome(items, key, `Paseo already has a "${scriptId}" script.`);
continue;
}
merged.scripts = { ...merged.scripts, [scriptId]: script };
@@ -61,7 +54,7 @@ export function mergeProjectConfigImport(
return {
repoRoot: input.repoRoot,
source: input.candidate.source,
source: input.source,
status: importedCount > 0 ? "available" : "nothing_to_import",
sourceRevision: input.candidate.sourceRevision,
paseoRevision: input.paseoRevision,
@@ -81,22 +74,17 @@ function hasLifecycle(value: unknown): boolean {
return false;
}
function setOutcome(
items: ProjectConfigImportPreview["items"],
key: string,
outcome: "collision",
detail: string,
): void {
function setOutcome(items: ProjectConfigImportPreview["items"], key: string, detail: string): void {
const item = items.find((entry) => entry.key === key);
if (!item) {
items.push({
key,
label: key,
outcome,
outcome: "collision",
detail,
});
return;
}
item.outcome = outcome;
item.outcome = "collision";
item.detail = detail;
}

View File

@@ -1,60 +0,0 @@
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,80 @@
import { describe, expect, test } from "vitest";
import {
createProjectConfigImportRegistry,
productionProjectConfigImportSourceSet,
projectConfigImportRegistry,
type ProjectConfigImportAdapter,
type ProjectConfigImportSourceSet,
} from "./registry.js";
interface FakeSource {
kind: "fake-source";
profile: string;
}
const fakeSourceSet = {
parse: (source) =>
source.kind === "fake-source" && typeof source.profile === "string"
? { kind: "fake-source", profile: source.profile }
: null,
kinds: () => ["fake-source"],
} satisfies ProjectConfigImportSourceSet<FakeSource>;
const fakeAdapter = {
source: { kind: "fake-source", profile: "alpha" },
inspect: () => null,
} satisfies ProjectConfigImportAdapter<FakeSource>;
describe("project config import adapter registry", () => {
test("enumerates every advertised source from registered adapters", () => {
const productionSource = productionProjectConfigImportSourceSet.parse({
kind: productionProjectConfigImportSourceSet.kinds()[0],
})!;
expect(projectConfigImportRegistry.sources()).toEqual([productionSource]);
expect(projectConfigImportRegistry.get(productionSource.kind)).toBeTruthy();
expect(() => projectConfigImportRegistry.assertProtocolCoverage()).not.toThrow();
});
test("rejects duplicate adapters before coordinators run", () => {
expect(() =>
createProjectConfigImportRegistry([fakeAdapter, fakeAdapter], fakeSourceSet),
).toThrow("Duplicate project config import adapter: fake-source");
});
test("rejects adapter kinds outside the protocol source union", () => {
expect(() =>
createProjectConfigImportRegistry(
[
{
source: { kind: "not-in-protocol" },
inspect: () => null,
},
],
productionProjectConfigImportSourceSet,
),
).toThrow("Unknown project config import adapter: not-in-protocol");
});
test("accepts explicit test source sets without protocol casts", () => {
const registry = createProjectConfigImportRegistry([fakeAdapter], fakeSourceSet);
expect(registry.sources()).toEqual([{ kind: "fake-source", profile: "alpha" }]);
expect(registry.get("fake-source")).toBe(fakeAdapter);
expect(() => registry.assertProtocolCoverage()).not.toThrow();
});
test("validates full adapter source descriptors", () => {
expect(() =>
createProjectConfigImportRegistry(
[
{
source: { kind: "fake-source" },
inspect: () => null,
},
],
fakeSourceSet,
),
).toThrow("Unknown project config import adapter: fake-source");
});
});

View File

@@ -0,0 +1,77 @@
import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportCandidate } from "./service.js";
import { conductorProjectConfigImporter } from "./sources/conductor/importer.js";
const PROJECT_CONFIG_IMPORT_ADAPTERS = [conductorProjectConfigImporter];
export interface ProjectConfigImportAdapter<
TSource extends { kind: string } = ProjectConfigImportSource,
> {
readonly source: { kind: string; [key: string]: unknown };
inspect(input: { repoRoot: string; source: TSource }): ProjectConfigImportCandidate | null;
}
export interface ProjectConfigImportSourceSet<TSource extends { kind: string }> {
parse(source: { kind: string; [key: string]: unknown }): TSource | null;
kinds(): readonly TSource["kind"][];
}
export interface ProjectConfigImportRegistry<
TSource extends { kind: string } = ProjectConfigImportSource,
> {
get(kind: TSource["kind"]): ProjectConfigImportAdapter<TSource> | null;
sources(): TSource[];
assertProtocolCoverage(): void;
}
export function createProjectConfigImportRegistry<TSource extends { kind: string }>(
adapters: readonly ProjectConfigImportAdapter<TSource>[],
sourceSet: ProjectConfigImportSourceSet<TSource>,
) {
const byKind = new Map<TSource["kind"], ProjectConfigImportAdapter<TSource>>();
for (const adapter of adapters) {
const source = sourceSet.parse(adapter.source);
if (!source) {
throw new Error(`Unknown project config import adapter: ${adapter.source.kind}`);
}
const kind = source.kind;
if (byKind.has(kind)) {
throw new Error(`Duplicate project config import adapter: ${adapter.source.kind}`);
}
byKind.set(kind, adapter);
}
return {
get(kind: TSource["kind"]): ProjectConfigImportAdapter<TSource> | null {
return byKind.get(kind) ?? null;
},
sources(): TSource[] {
return Array.from(byKind.values()).map((adapter) => sourceSet.parse(adapter.source)!);
},
assertProtocolCoverage(): void {
const missing = sourceSet.kinds().filter((kind) => !byKind.has(kind));
if (missing.length > 0) {
throw new Error(`Missing project config import adapters: ${missing.join(", ")}`);
}
},
} satisfies ProjectConfigImportRegistry<TSource>;
}
export const productionProjectConfigImportSourceSet = {
parse(source: { kind: string; [key: string]: unknown }): ProjectConfigImportSource | null {
const parsed = ProjectConfigImportSourceSchema.safeParse(source);
return parsed.success ? parsed.data : null;
},
kinds: readProtocolSourceKinds,
} satisfies ProjectConfigImportSourceSet<ProjectConfigImportSource>;
export const projectConfigImportRegistry = createProjectConfigImportRegistry(
PROJECT_CONFIG_IMPORT_ADAPTERS,
productionProjectConfigImportSourceSet,
);
function readProtocolSourceKinds(): ProjectConfigImportSource["kind"][] {
const options = ProjectConfigImportSourceSchema.options;
return options.map((option) => option.shape.kind.value);
}

View File

@@ -0,0 +1,156 @@
import { 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 {
createProjectConfigImportRegistry,
productionProjectConfigImportSourceSet,
type ProjectConfigImportAdapter,
} from "./registry.js";
import { createProjectConfigImportService, type ProjectConfigImportCandidate } from "./service.js";
const tempDirs: string[] = [];
const PROTOCOL_SOURCE = productionProjectConfigImportSourceSet.parse({
kind: productionProjectConfigImportSourceSet.kinds()[0],
})!;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "project-config-import-service-test-"));
tempDirs.push(repo);
return repo;
}
function createFakeService(candidate: () => ProjectConfigImportCandidate | null) {
const adapter = {
source: PROTOCOL_SOURCE,
inspect: () => candidateFor(candidate()),
} satisfies ProjectConfigImportAdapter<typeof PROTOCOL_SOURCE>;
return createProjectConfigImportService(
createProjectConfigImportRegistry([adapter], productionProjectConfigImportSourceSet),
);
}
function candidateFor(
candidate: ProjectConfigImportCandidate | null,
): ProjectConfigImportCandidate | null {
if (!candidate) {
return null;
}
return {
...candidate,
inputs: [{ role: "shared", relativePath: "source/config.json" }],
};
}
function baseCandidate(sourceRevision = "source-1"): ProjectConfigImportCandidate {
return {
sourceRevision,
inputs: [{ role: "shared", relativePath: "source/config.json" }],
items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }],
patch: { worktree: { setup: "npm ci" }, scripts: { dev: { command: "npm run dev" } } },
};
}
describe("project config import service", () => {
test("previews missing, available, and collision-only imports through an injected adapter", () => {
const repo = makeRepo();
expect(
createFakeService(() => null).inspect({
repoRoot: repo,
source: PROTOCOL_SOURCE,
paseoConfig: {},
paseoRevision: null,
}),
).toMatchObject({ status: "not_found", preview: null, sourceRevision: null });
const service = createFakeService(() => baseCandidate());
expect(
service.inspect({
repoRoot: repo,
source: PROTOCOL_SOURCE,
paseoConfig: {},
paseoRevision: null,
}),
).toMatchObject({
status: "available",
preview: { worktree: { setup: "npm ci" }, scripts: { dev: { command: "npm run dev" } } },
});
expect(
service.inspect({
repoRoot: repo,
source: PROTOCOL_SOURCE,
paseoConfig: {
worktree: { setup: "pnpm install" },
scripts: { dev: { command: "pnpm dev" } },
},
paseoRevision: null,
}),
).toMatchObject({
status: "nothing_to_import",
preview: null,
items: [
expect.objectContaining({ key: "worktree.setup", outcome: "collision" }),
expect.objectContaining({ key: "scripts.dev", outcome: "collision" }),
],
});
});
test("apply recomputes from disk and writes formatted paseo.json", () => {
const repo = makeRepo();
let revision = "source-1";
const service = createFakeService(() => baseCandidate(revision));
const result = service.apply({
repoRoot: repo,
source: PROTOCOL_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: null,
});
expect(result).toMatchObject({
ok: true,
repoRoot: repo,
config: { worktree: { setup: "npm ci" } },
});
expect(readFileSync(join(repo, "paseo.json"), "utf8")).toContain('"setup": "npm ci"');
revision = "source-2";
expect(
service.apply({
repoRoot: repo,
source: PROTOCOL_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: null,
}),
).toEqual({
ok: false,
repoRoot: repo,
error: { code: "stale_source_config", source: PROTOCOL_SOURCE },
});
});
test("apply rejects stale paseo.json revision after recomputing the source", () => {
const repo = makeRepo();
writeFileSync(join(repo, "paseo.json"), '{"custom":true}\n');
const service = createFakeService(() => baseCandidate());
expect(
service.apply({
repoRoot: repo,
source: PROTOCOL_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: { mtimeMs: 1, size: 1 },
}),
).toMatchObject({
ok: false,
repoRoot: repo,
error: { code: "stale_project_config" },
});
});
});

View File

@@ -0,0 +1,173 @@
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
} from "../../../../utils/paseo-config-file.js";
import { mergeProjectConfigImport } from "./merge.js";
import type { ProjectConfigImportRegistry } from "./registry.js";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
ProjectConfigImportInput,
ProjectConfigImportItem,
ProjectConfigImportPreview,
ProjectConfigImportSource,
ProjectConfigRpcError,
} from "@getpaseo/protocol/messages";
export type {
ProjectConfigImportInput,
ProjectConfigImportItem,
ProjectConfigImportPreview,
ProjectConfigImportSource,
};
export interface ProjectConfigImportCandidate {
sourceRevision: string;
inputs: ProjectConfigImportInput[];
items: ProjectConfigImportItem[];
patch: PaseoConfigRaw;
}
interface InspectProjectConfigImportInput {
repoRoot: string;
source: ProjectConfigImportSource;
paseoConfig: PaseoConfigRaw;
paseoRevision: PaseoConfigRevision | null;
}
interface ApplyProjectConfigImportInput {
repoRoot: string;
source: ProjectConfigImportSource;
expectedSourceRevision: string;
expectedPaseoRevision: PaseoConfigRevision | null;
}
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;
}
}
export interface ProjectConfigImportService {
inspect(input: InspectProjectConfigImportInput): ProjectConfigImportPreview;
apply(input: ApplyProjectConfigImportInput): ProjectConfigImportApplyResult;
}
export function createProjectConfigImportService(
registry: ProjectConfigImportRegistry,
): ProjectConfigImportService {
function inspect(input: InspectProjectConfigImportInput): ProjectConfigImportPreview {
const adapter = registry.get(input.source.kind);
const candidate: ProjectConfigImportCandidate | null = adapter
? adapter.inspect({ repoRoot: input.repoRoot, source: input.source })
: null;
return mergeProjectConfigImport({
repoRoot: input.repoRoot,
source: input.source,
candidate,
paseoConfig: input.paseoConfig,
paseoRevision: input.paseoRevision,
});
}
function apply(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 = inspect({
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,
};
}
return { inspect, apply };
}
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

@@ -1,13 +1,25 @@
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, 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";
import {
createProjectConfigImportRegistry,
productionProjectConfigImportSourceSet,
} from "../../registry.js";
import {
createProjectConfigImportService,
InvalidProjectConfigImportSourceError,
} from "../../service.js";
import { conductorProjectConfigImporter } from "./importer.js";
const tempDirs: string[] = [];
const CONDUCTOR_SOURCE = { kind: "conductor" } as const;
const service = createProjectConfigImportService(
createProjectConfigImportRegistry(
[conductorProjectConfigImporter],
productionProjectConfigImportSourceSet,
),
);
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
@@ -32,7 +44,7 @@ function writeLocalToml(repo: string, contents: string): void {
}
function inspect(repo: string, paseoConfig = {}) {
return inspectProjectConfigImport({
return service.inspect({
repoRoot: repo,
source: CONDUCTOR_SOURCE,
paseoConfig,
@@ -51,16 +63,6 @@ function captureInvalidSourceError(repo: string): InvalidProjectConfigImportSour
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();
@@ -125,7 +127,6 @@ enabled = true
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");
@@ -209,34 +210,6 @@ command = "local dev"
});
});
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(
@@ -265,102 +238,6 @@ setup = "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH"
);
});
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");

View File

@@ -3,13 +3,14 @@ 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 type { ProjectConfigImportAdapter } from "../../registry.js";
import {
InvalidProjectConfigImportSourceError,
type ProjectConfigImportCandidate,
type ProjectConfigImportInput,
type ProjectConfigImportItem,
type ProjectConfigImportSource,
} from "../model.js";
} from "../../service.js";
interface SourceFile {
role: string;
@@ -40,14 +41,16 @@ interface ConductorRunScript {
cwd?: string;
};
available_in?: string;
hide?: boolean;
icon?: unknown;
default?: unknown;
}
type RewriteContext = "lifecycle" | "run";
export function inspectConductorImport(input: {
export const conductorProjectConfigImporter = {
source: { kind: "conductor" },
inspect: inspectConductorImport,
} satisfies ProjectConfigImportAdapter<{ kind: "conductor" }>;
function inspectConductorImport(input: {
repoRoot: string;
source: ProjectConfigImportSource;
}): ProjectConfigImportCandidate | null {
@@ -82,7 +85,6 @@ export function inspectConductorImport(input: {
reportUnsupported(input.repoRoot, settings, items);
return {
source: input.source,
sourceRevision: hashSourceFiles(sourceFiles),
inputs,
items,
@@ -217,9 +219,6 @@ function normalizeRunScript(entry: Record<string, unknown>, command: string): Co
...(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 } : {}),
};
}
@@ -269,15 +268,6 @@ function mapRunScript(
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(

View File

@@ -1,21 +1,25 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import {
ProjectConfigImportSourceSchema,
type ProjectConfigImportSource,
} from "@getpaseo/protocol/messages";
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
import type { PersistedProjectRecord } from "../../workspace-registry.js";
import type { SessionOutboundMessage } from "../../messages.js";
import {
InvalidProjectConfigImportSourceError,
type ProjectConfigImportService,
} from "./import/service.js";
const tempDirs: string[] = [];
const PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE: ProjectConfigImportSource =
ProjectConfigImportSourceSchema.options[0].parse({
kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value,
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
@@ -41,34 +45,21 @@ function projectRecord(rootPath: string, archivedAt: string | null = null): Pers
};
}
function makeSubsystem(records: PersistedProjectRecord[]) {
function makeSubsystem(
records: PersistedProjectRecord[],
importService?: ProjectConfigImportService,
) {
const emitted: SessionOutboundMessage[] = [];
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
const subsystem = new ProjectConfigSession({
host,
projectRegistry: { list: async () => records },
...(importService ? { importService } : {}),
logger: pino({ level: "silent" }),
});
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();
@@ -253,24 +244,35 @@ describe("ProjectConfigSession", () => {
]);
});
test("import preview rejects archived and unknown roots with project_not_found", async () => {
test("import preview rejects archived and unknown roots without touching the import service", async () => {
const archivedRoot = makeRoot();
const unknownRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([
projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z"),
]);
const serviceCalls: string[] = [];
const { subsystem, emitted } = makeSubsystem(
[projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z")],
{
inspect: () => {
serviceCalls.push("inspect");
throw new Error("unexpected import inspect");
},
apply: () => {
serviceCalls.push("apply");
throw new Error("unexpected import apply");
},
},
);
await subsystem.handleGetProjectConfigImportRequest({
type: "project.config.get_import.request",
requestId: "import-archived-1",
repoRoot: archivedRoot,
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
await subsystem.handleGetProjectConfigImportRequest({
type: "project.config.get_import.request",
requestId: "import-unknown-1",
repoRoot: unknownRoot,
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
expect(emitted).toEqual([
@@ -293,62 +295,108 @@ describe("ProjectConfigSession", () => {
},
},
]);
expect(serviceCalls).toEqual([]);
});
test("import preview reports malformed Conductor config with a safe relative path", async () => {
test("import preview emits a fake service preview", 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",
},
},
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], {
inspect: (input) => ({
repoRoot: input.repoRoot,
source: input.source,
status: "available",
sourceRevision: "source-revision-1",
paseoRevision: input.paseoRevision,
inputs: [{ role: "shared", relativePath: "source/config.json" }],
items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }],
preview: { worktree: { setup: "npm ci" } },
}),
apply: () => {
throw new Error("unexpected import apply");
},
]);
});
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" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
expect(emitted[0]).toMatchObject({
type: "project.config.get_import.response",
payload: {
requestId: "import-preview-1",
repoRoot,
ok: true,
sourceRevision: "source-revision-1",
inputs: [{ role: "shared", relativePath: "source/config.json" }],
},
});
});
test("import preview reports invalid source errors from the injected service", async () => {
const repoRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], {
inspect: () => {
throw new InvalidProjectConfigImportSourceError(
PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
"source/config.json",
);
},
apply: () => {
throw new Error("unexpected import apply");
},
});
await subsystem.handleGetProjectConfigImportRequest({
type: "project.config.get_import.request",
requestId: "import-invalid-1",
repoRoot,
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
expect(emitted[0]).toMatchObject({
type: "project.config.get_import.response",
payload: {
requestId: "import-invalid-1",
repoRoot,
ok: false,
error: {
code: "invalid_source_config",
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
relativePath: "source/config.json",
},
},
});
});
test("apply import returns the injected service result", async () => {
const repoRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], {
inspect: () => {
throw new Error("unexpected import inspect");
},
apply: (input) => ({
ok: true,
repoRoot: input.repoRoot,
source: input.source,
config: { worktree: { setup: "npm ci" } },
revision: { mtimeMs: 2, size: 42 },
items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }],
}),
});
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,
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
expectedSourceRevision: "source-revision-1",
expectedPaseoRevision: null,
});
expect(emitted[1]).toMatchObject({
expect(emitted[0]).toMatchObject({
type: "project.config.apply_import.response",
payload: {
requestId: "import-apply-1",
@@ -357,72 +405,5 @@ describe("ProjectConfigSession", () => {
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,8 +8,12 @@ import {
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../../../utils/paseo-config-file.js";
import { applyProjectConfigImport, inspectProjectConfigImport } from "./import/index.js";
import { InvalidProjectConfigImportSourceError } from "./import/model.js";
import { projectConfigImportRegistry } from "./import/registry.js";
import {
createProjectConfigImportService,
InvalidProjectConfigImportSourceError,
type ProjectConfigImportService,
} from "./import/service.js";
export interface ProjectConfigSessionHost {
emit(msg: SessionOutboundMessage): void;
@@ -18,6 +22,7 @@ export interface ProjectConfigSessionHost {
export interface ProjectConfigSessionOptions {
host: ProjectConfigSessionHost;
projectRegistry: Pick<ProjectRegistry, "list">;
importService?: ProjectConfigImportService;
logger: pino.Logger;
}
@@ -31,11 +36,14 @@ export interface ProjectConfigSessionOptions {
export class ProjectConfigSession {
private readonly host: ProjectConfigSessionHost;
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
private readonly importService: ProjectConfigImportService;
private readonly logger: pino.Logger;
constructor(options: ProjectConfigSessionOptions) {
this.host = options.host;
this.projectRegistry = options.projectRegistry;
this.importService =
options.importService ?? createProjectConfigImportService(projectConfigImportRegistry);
this.logger = options.logger;
}
@@ -136,7 +144,7 @@ export class ProjectConfigSession {
}
try {
const preview = inspectProjectConfigImport({
const preview = this.importService.inspect({
repoRoot,
source: msg.source,
paseoConfig: config.config ?? {},
@@ -176,7 +184,7 @@ export class ProjectConfigSession {
return;
}
const result = applyProjectConfigImport({
const result = this.importService.apply({
repoRoot,
source: msg.source,
expectedSourceRevision: msg.expectedSourceRevision,

View File

@@ -66,6 +66,7 @@ import {
type WebSocketRuntimeDiagnosticSnapshot,
} from "./websocket/runtime-metrics.js";
import { ProviderUsageService } from "../services/quota-fetcher/service.js";
import { projectConfigImportRegistry } from "./session/project-config/import/registry.js";
import { getProcessMemoryDiagnostics, getProcessUptimeSeconds } from "./process-diagnostics.js";
import {
CLIENT_SHUTDOWN_RPC_REASON,
@@ -1279,8 +1280,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.110, drop the gate when floor >= v0.1.110.
projectConfigImportConductor: true,
// COMPAT(projectConfigImportSources): added in v0.1.110, remove the gate after 2027-01-17.
projectConfigImportSources: projectConfigImportRegistry.sources(),
// COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106.
forgeProviders: true,
},