From 28cac5859a84144661213b2bc7c7eea075410b17 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 17 Jul 2026 15:56:04 +0200 Subject: [PATCH] fix(settings): keep project imports current and complete --- .../project-config-import/preview-cache.ts | 8 ++- .../project-config-import-model.test.ts | 18 ++++++ .../app/src/project-config-import/retry.ts | 3 +- .../src/screens/project-settings-screen.tsx | 10 +++- .../import/sources/conductor/importer.test.ts | 60 +++++++++++++++++++ .../import/sources/conductor/importer.ts | 45 ++++++++++---- 6 files changed, 127 insertions(+), 17 deletions(-) diff --git a/packages/app/src/project-config-import/preview-cache.ts b/packages/app/src/project-config-import/preview-cache.ts index 89f731f012..3a1b0146d3 100644 --- a/packages/app/src/project-config-import/preview-cache.ts +++ b/packages/app/src/project-config-import/preview-cache.ts @@ -52,13 +52,15 @@ export function projectConfigImportPreviewQueryKey( source: ProjectConfigImportSourceDescriptor | null, ): ProjectConfigImportPreviewQueryKey { return [ - "project-config-import", - serverId, - repoRoot, + ...projectConfigImportPreviewQueryRoot(serverId, repoRoot), source ? stableProjectConfigImportSourceKey(source) : "none", ] as const; } +export function projectConfigImportPreviewQueryRoot(serverId: string, repoRoot: string) { + return ["project-config-import", serverId, repoRoot] as const; +} + export function stableProjectConfigImportSourceKey( source: ProjectConfigImportSourceDescriptor, ): string { diff --git a/packages/app/src/project-config-import/project-config-import-model.test.ts b/packages/app/src/project-config-import/project-config-import-model.test.ts index 2f0b6c7183..a24e0a62d4 100644 --- a/packages/app/src/project-config-import/project-config-import-model.test.ts +++ b/packages/app/src/project-config-import/project-config-import-model.test.ts @@ -9,6 +9,7 @@ import { fetchQueryOptions } from "@/data/query"; import { projectConfigImportPreviewQueryInput, projectConfigImportPreviewQueryKey, + projectConfigImportPreviewQueryRoot, stableProjectConfigImportSourceKey, } from "./preview-cache"; import { @@ -127,9 +128,26 @@ describe("project config import retries", () => { }), ).toBe("refresh"); }); + + it("refreshes the preview after an apply-time source parse failure", () => { + expect( + projectConfigImportApplyFailureRetryAction({ + code: "invalid_source_config", + source: protocolSource, + relativePath: ".conductor/settings.toml", + }), + ).toBe("refresh"); + }); }); describe("project config import preview cache keys", () => { + it("groups source previews under a repository cache root", () => { + expect(projectConfigImportPreviewQueryKey("server", "/repo", fakeSource)).toEqual([ + ...projectConfigImportPreviewQueryRoot("server", "/repo"), + stableProjectConfigImportSourceKey(fakeSource), + ]); + }); + it("uses the full source descriptor instead of kind alone", () => { const alpha = { kind: "variant-source", profile: "alpha" }; const beta = { profile: "beta", kind: "variant-source" }; diff --git a/packages/app/src/project-config-import/retry.ts b/packages/app/src/project-config-import/retry.ts index d2736ce3fb..cc114a15f0 100644 --- a/packages/app/src/project-config-import/retry.ts +++ b/packages/app/src/project-config-import/retry.ts @@ -8,7 +8,8 @@ export function projectConfigImportApplyFailureRetryAction( return error.code === "stale_source_config" || error.code === "stale_project_config" || error.code === "nothing_to_import" || - error.code === "source_config_not_found" + error.code === "source_config_not_found" || + error.code === "invalid_source_config" ? "refresh" : "apply"; } diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index f5c214e4d4..5c934ffac3 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -28,6 +28,7 @@ import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import type { ProjectConfigImportIntent } from "@/project-config-import/route"; import { ProjectConfigImportSection } from "@/project-config-import/project-config-import-section"; +import { projectConfigImportPreviewQueryRoot } from "@/project-config-import/preview-cache"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; @@ -216,6 +217,7 @@ function ProjectSettingsBody({ importIntent, onImportIntentConsumed, }: ProjectSettingsBodyProps) { + const queryClient = useQueryClient(); const queryKey = useMemo( () => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const, [selectedHost.serverId, selectedHost.repoRoot], @@ -245,8 +247,11 @@ function ProjectSettingsBody({ const readState = resolveProjectConfigReadState(data); const handleReload = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: projectConfigImportPreviewQueryRoot(selectedHost.serverId, selectedHost.repoRoot), + }); void readQuery.refetch(); - }, [readQuery]); + }, [queryClient, readQuery, selectedHost.repoRoot, selectedHost.serverId]); const hasMultipleHosts = hosts.length > 1; @@ -517,6 +522,9 @@ function ProjectConfigForm({ }); setWriteError(null); queryClient.invalidateQueries({ queryKey: ["projects"] }); + void queryClient.invalidateQueries({ + queryKey: projectConfigImportPreviewQueryRoot(serverId, repoRoot), + }); toast.show(t("settings.project.actions.saved"), { variant: "success" }); } else { setWriteError(result.error); diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts index 219b494045..baff6f613f 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts @@ -43,6 +43,16 @@ function writeLocalToml(repo: string, contents: string): void { writeFileSync(join(repo, ".conductor", "settings.local.toml"), contents); } +function writeSharedJson(repo: string, value: unknown): void { + mkdirSync(join(repo, ".conductor"), { recursive: true }); + writeFileSync(join(repo, ".conductor", "settings.json"), JSON.stringify(value)); +} + +function writeLocalJson(repo: string, value: unknown): void { + mkdirSync(join(repo, ".conductor"), { recursive: true }); + writeFileSync(join(repo, ".conductor", "settings.local.json"), JSON.stringify(value)); +} + function inspect(repo: string, paseoConfig = {}) { return service.inspect({ repoRoot: repo, @@ -151,6 +161,8 @@ setup = "local setup" command = "local dev" `, ); + writeSharedJson(repo, { scripts: { setup: "scoped legacy shared setup" } }); + writeLocalJson(repo, { scripts: { setup: "scoped legacy local setup" } }); writeFileSync( join(repo, "conductor.json"), JSON.stringify({ scripts: { setup: "legacy setup", run: "legacy run" } }), @@ -168,6 +180,54 @@ command = "local dev" }); }); + test("deep-merges local run-script fields with shared commands", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts.run.dev] +command = "npm run dev" +args = ["--shared"] +[scripts.run.dev.options] +cwd = "apps/web" +`, + ); + writeLocalToml( + repo, + ` +[scripts.run.dev] +args = ["--local"] +[scripts.run.dev.options] +cwd = "apps/local-web" +`, + ); + + expect(inspect(repo).preview).toMatchObject({ + scripts: { + dev: { command: "cd -- 'apps/local-web' && npm run dev '--local'" }, + }, + }); + }); + + test("imports legacy scoped JSON settings with local overrides", () => { + const repo = makeRepo(); + writeSharedJson(repo, { + scripts: { setup: "shared setup", run: { dev: { command: "npm run dev" } } }, + }); + writeLocalJson(repo, { scripts: { run: { dev: { args: ["--local"] } } } }); + + const preview = inspect(repo); + + expect(preview.inputs).toEqual([ + { role: "shared", relativePath: ".conductor/settings.json" }, + { role: "local", relativePath: ".conductor/settings.local.json" }, + ]); + expect(preview.preview).toMatchObject({ + worktree: { setup: "shared setup" }, + scripts: { dev: { command: "npm run dev '--local'" } }, + }); + }); + test("imports legacy conductor.json when shared TOML is absent", () => { const repo = makeRepo(); writeFileSync( diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts index 37c8f5ed06..23c8d308a1 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts @@ -94,18 +94,24 @@ function inspectConductorImport(input: { } function discoverConductorSources(repoRoot: string): SourceFile[] { - const localPath = join(repoRoot, ".conductor", "settings.local.toml"); - const sharedPath = join(repoRoot, ".conductor", "settings.toml"); - const legacyPath = join(repoRoot, "conductor.json"); + const localTomlPath = join(repoRoot, ".conductor", "settings.local.toml"); + const localJsonPath = join(repoRoot, ".conductor", "settings.local.json"); + const sharedTomlPath = join(repoRoot, ".conductor", "settings.toml"); + const sharedJsonPath = join(repoRoot, ".conductor", "settings.json"); + const rootLegacyPath = join(repoRoot, "conductor.json"); const files: SourceFile[] = []; - if (existsSync(sharedPath)) { - files.push(readSourceFile(repoRoot, sharedPath, "shared")); - } else if (existsSync(legacyPath)) { - files.push(readSourceFile(repoRoot, legacyPath, "legacy")); + if (existsSync(sharedTomlPath)) { + files.push(readSourceFile(repoRoot, sharedTomlPath, "shared")); + } else if (existsSync(sharedJsonPath)) { + files.push(readSourceFile(repoRoot, sharedJsonPath, "shared")); + } else if (existsSync(rootLegacyPath)) { + files.push(readSourceFile(repoRoot, rootLegacyPath, "legacy")); } - if (existsSync(localPath)) { - files.push(readSourceFile(repoRoot, localPath, "local")); + if (existsSync(localTomlPath)) { + files.push(readSourceFile(repoRoot, localTomlPath, "local")); + } else if (existsSync(localJsonPath)) { + files.push(readSourceFile(repoRoot, localJsonPath, "local")); } return files; } @@ -127,8 +133,7 @@ function loadConductorSettings( for (const file of sourceFiles) { let parsed: unknown; try { - parsed = - file.relativePath === "conductor.json" ? JSON.parse(file.bytes) : parseToml(file.bytes); + parsed = file.relativePath.endsWith(".json") ? JSON.parse(file.bytes) : parseToml(file.bytes); } catch { throw new InvalidProjectConfigImportSourceError(source, file.relativePath); } @@ -159,7 +164,23 @@ function mergeRunScripts(base: unknown, override: unknown): unknown { if (!isRecord(base) || !isRecord(override)) { return override ?? base; } - return { ...base, ...override }; + const merged: Record = { ...base }; + for (const [scriptId, overrideEntry] of Object.entries(override)) { + const baseEntry = base[scriptId]; + merged[scriptId] = mergeRunScriptEntry(baseEntry, overrideEntry); + } + return merged; +} + +function mergeRunScriptEntry(base: unknown, override: unknown): unknown { + if (!isRecord(base) || !isRecord(override)) { + return override ?? base; + } + const merged: Record = { ...base, ...override }; + if (isRecord(base.options) && isRecord(override.options)) { + merged.options = { ...base.options, ...override.options }; + } + return merged; } function mapLifecycle(