diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md index 83f13e0879..8ae79ae938 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -2,7 +2,7 @@ Depends on 010 filtered loader. Loop spec-satisfaction repair. Goal: a model visibility/selection change and explicit sync refresh existing connected Pi/Aside files. No adoption of unowned/manual files, no recreation of removed blocks, no override of drift. -NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input) iterates explicit client list [pi, aside] (sync may include mcode), passes lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. +NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input, clientIds) defaults clientIds to [pi, aside]; callers may supply an explicit list including mcode. It passes a lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. The later Aside-profile layer delegates Aside to its server-owned profile engine; direct CLI sync passes [mcode, pi] here and invokes the Aside server helper once separately, including an explicit unavailable-server diagnostic. MODIFY src/server/management/model-routes.ts: local async convergence helper calls existing convergeCodexCatalog then new owned refresh for pi/aside with port from URL/config and lazy loadExportModels(config); attach clientIntegrations outcome to disabled-models, model-visibility, selected-models and model-preset writes. Keep successful config persistence even when one file refuses refresh; return warning outcome. MODIFY src/server/management/config-routes.ts and src/cli/dispatch.ts: expand current MCode-only owned refresh to mcode/pi/aside via helper; preserve native Grok/Desktop gates and refused-sync behavior. MODIFY existing tests/clients/sync-client-integrations.test.ts and tests/server/management-integration-routes.test.ts: fake IO/store or isolated home seeds owned pi/aside with two models, refresh with selected one, assert hidden row removed and other provider fields preserved. Prove unowned, removed and drifted configs untouched; one failure does not block other client. Add route-driven visibility refresh coverage using injected convergence. @@ -15,3 +15,13 @@ Verification: standalone isolated writer probe using synthetic models and temp h The existing constant refresh mutation-flight key incorrectly joins different model selections. MODIFY src/integrations/owned-refresh.ts to use a unique per-refresh operation key (crypto.randomUUID), making overlapping refreshes explicitly busy rather than reporting another desired catalog as success. Implicit refresh never joins an explicit HTTP mutation. Add controlled overlap with distinct old/new rosters: second call reports integration_mutation_busy; first result describes only its own write. Subsequent retry applies the new roster. Return per-client failures; never retry stale snapshots automatically. Add a ManagementApiDeps refreshOwnedCatalogIntegrations seam for route verification, defaulting to the real helper. Creation: exported helper/deps type; consumption: model routes and explicit sync. No serialization/deserialization: runtime-only dependency injection. Tests use fake IO/store or temporary home, never actual user-owned files. + +## P revalidation and implementation interface + +010 b8010aebd passes the four standalone visibility probes and source review; all original hosted-CI/merge criteria are retained under the terminal stack cycle, not marked complete. Helper signature: refreshOwnedCatalogIntegrations(input: Omit, clientIds: readonly IntegrationClientId[] = ["pi", "aside"]): Promise. Memoize the lazy model load per fan-out; no owned record means no catalog load. Catch and redact each failure. Explicit sync passes [mcode,pi,aside]. Visibility routes attach both catalogRefresh and clientIntegrations; native Codex failure does not undo an already persisted selection. + +Delegate tests only to one worker: tests/clients/sync-client-integrations.test.ts owns helper refresh+overlap coverage; main owns implementation and route regression tests. The worker has no production writes, suite execution, FSM or git mutations. + +## Implementation audit synthesis + +Averroes found an indirect source-oracle dependency: codex-convergence-contract.test.ts counts direct convergence calls and two preset calls. The shared visibility helper changes direct count but preserves fourteen logical paths. Update the inventory to subtract the helper definition and add its five callers, assert exactly one Codex convergence inside the helper, and preserve the marker-only custom preset negative. Run that affected file remotely in addition to the writer/route tests. No runtime blockers in the ownership audit. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 9694394318..a6977f4066 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -218,9 +218,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Once connected, `ocx sync` also refreshes the owned MCode block with current context -windows and reasoning-effort ladders. It leaves missing, foreign-edited, unsafe, and -never-owned blocks untouched; re-enable explicitly when you intend to reconnect one. +Once connected, `ocx sync` refreshes owned MCode, Pi, and Aside catalogs with the current +model selection, context windows, and reasoning-effort ladders. Changes to model visibility, +provider selection, or presets also refresh connected Pi and Aside catalogs. Missing, +foreign-edited, unsafe, and never-owned blocks stay untouched; reconnect them explicitly. +A refused or overlapping refresh is reported separately for each client. Start a new Pi +session or fully quit and reopen Aside to load the updated file. The separate MiniMax platform CLI (`mmx`) is not a file-toggle integration. Its text commands use MiniMax's Anthropic-compatible endpoint, so OpenCodex provides a diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 48a1f44be3..6fef425ad5 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -387,25 +387,26 @@ const commandRunners: Record = { if (restartDesktopApp) await handleDesktopAppRestart(console); } // `ocx sync` is a direct CLI path; it does not call the management - // `/api/sync` route. Refresh the already-connected MCode block here too, + // `/api/sync` route. Refresh already-connected file integrations here too, // after Codex has published the catalog that supplies its capabilities. if (synced.status !== "refused" && live) { try { const config = deps.loadConfig(); - const { refreshOwnedIntegration } = await import("../integrations/owned-refresh"); - const result = await refreshOwnedIntegration({ - clientId: "mcode", + const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); + const results = await refreshOwnedCatalogIntegrations({ models: async () => { const { loadExportModels } = await import("../server/management/model-rows"); return loadExportModels(config); }, config, port: live.port, - }); - if (result?.changed) console.log("MCode integration refreshed from the current catalog."); - else if (result?.reason) console.warn(`MCode integration was not refreshed: ${result.reason}`); + }, ["mcode", "pi", "aside"]); + for (const result of results) { + if (result.changed) console.log(`${result.client} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${result.client} integration was not refreshed: ${result.reason}`); + } } catch (error) { - console.warn(`MCode integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } } return code; diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts new file mode 100644 index 0000000000..6ace895e00 --- /dev/null +++ b/src/integrations/catalog-refresh.ts @@ -0,0 +1,32 @@ +import { redactSecretString } from "../lib/redact"; +import type { ExportModel } from "../clients/config-export"; +import type { IntegrationClientId } from "./registry"; +import { + refreshOwnedIntegration, + type OwnedIntegrationRefreshInput, + type OwnedIntegrationRefreshOutcome, +} from "./owned-refresh"; + +/** Refresh only previously connected clients; a refused file never blocks its peers. */ +export async function refreshOwnedCatalogIntegrations( + input: Omit, + clientIds: readonly IntegrationClientId[] = ["pi", "aside"], +): Promise { + let models: Promise | undefined; + const loadModels = () => models ??= Promise.resolve().then(() => + typeof input.models === "function" ? input.models() : input.models); + const outcomes: OwnedIntegrationRefreshOutcome[] = []; + for (const clientId of clientIds) { + try { + const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); + if (result) outcomes.push(result); + } catch (error) { + outcomes.push({ + client: clientId, + ok: false, + reason: redactSecretString(error instanceof Error ? error.message : String(error)), + }); + } + } + return outcomes; +} diff --git a/src/integrations/owned-refresh.ts b/src/integrations/owned-refresh.ts index 9af6ad5e3c..6167c1aa35 100644 --- a/src/integrations/owned-refresh.ts +++ b/src/integrations/owned-refresh.ts @@ -59,7 +59,9 @@ export async function refreshOwnedIntegration( const bound = { ...rest, models, store }; const result = await runIntegrationMutationFlight( input.clientId, - "refresh", + // Separate catalog snapshots must not inherit another refresh's success. + // The shared flight owner returns busy for overlapping operations instead. + `refresh:${crypto.randomUUID()}`, input.io?.now ?? Date.now, () => refreshIntegrationCoordinated(bound, options), ); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 885fe10408..cb226314ea 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,3 +1,4 @@ +import type { IntegrationClientId } from "../../integrations/registry"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; @@ -151,7 +152,7 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ /** One client's outcome from a fan-out sync. Absent from the list means "left alone". */ interface ClientIntegrationSyncOutcome { - readonly client: "grok" | "claude-desktop" | "mcode"; + readonly client: "grok" | "claude-desktop" | IntegrationClientId; readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; @@ -215,30 +216,15 @@ async function syncEnabledClientIntegrations( } } - try { - const { refreshOwnedIntegration } = await import("../../integrations/owned-refresh"); - const result = await refreshOwnedIntegration({ - clientId: "mcode", - models: async () => { - const { loadExportModels } = await import("./model-rows"); - return loadExportModels(config); - }, - config, - port, - }); - if (result) { - out.push(result.ok - ? { - client: "mcode", - ok: true, - changed: result.changed === true, - ...(result.reason ? { reason: result.reason } : {}), - } - : { client: "mcode", ok: false, reason: result.reason }); - } - } catch (error) { - out.push({ client: "mcode", ok: false, reason: error instanceof Error ? error.message : String(error) }); - } + const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh"); + out.push(...await refreshOwnedCatalogIntegrations({ + models: async () => { + const { loadExportModels } = await import("./model-rows"); + return loadExportModels(config); + }, + config, + port, + }, ["mcode", "pi", "aside"])); return out; } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 13c922e575..f516dc00d9 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -6,6 +6,7 @@ import type { StartupHealth } from "../../codex/autostart-health"; import type { StartupInstallAction } from "../startup-action-control"; import type { ManagementPrincipal, ManagementSessionControl } from "../management-auth"; import type { CatalogModel } from "../../codex/catalog"; +import type { refreshOwnedCatalogIntegrations } from "../../integrations/catalog-refresh"; import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers"; import type { injectGrokConfig } from "../../grok/inject"; import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; @@ -20,6 +21,8 @@ import type { } from "../../codex/app-server-restart-service"; export interface ManagementApiDeps { + /** Isolates automatic owned-client writes in route tests. */ + refreshOwnedCatalogIntegrations?: typeof refreshOwnedCatalogIntegrations; /** Platform seam for capability projections; does not alter host-level startup behavior. */ platform?: NodeJS.Platform; toggleCodexMultiAgentV2?: (enabled: boolean) => void; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index c3e9d58cf9..28d1bef0ec 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -183,6 +183,17 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { + const catalogRefresh = await convergeCodexCatalog(); + const refresh = deps.refreshOwnedCatalogIntegrations + ?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations; + const clientIntegrations = await refresh({ + config, + port: Number(url.port) || config.port, + models: () => loadExportModels(config), + }); + return { catalogRefresh, clientIntegrations }; + }; if (url.pathname === "/api/model-discovery" && req.method === "GET") { const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [ @@ -518,8 +529,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise typeof m === "string") : []; config.disabledModels = disabled; persistConfig(config); - const catalogRefresh = await convergeCodexCatalog(); - return jsonResponse({ ok: true, disabled, catalogRefresh }); + return jsonResponse({ ok: true, disabled, ...await convergeVisibleCatalogs() }); } // One user-facing visibility switch spans two persisted filters: a provider allowlist and the @@ -644,8 +654,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { }); }); -test("the direct ocx sync command refreshes MCode instead of relying on /api/sync", async () => { +describe("owned Pi/Aside catalogs follow filtered model selections", () => { + const clients = ["pi", "aside"] as const; + const env: NodeJS.ProcessEnv = {}; + const config = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as OcxConfig; + const oldModels: ExportModel[] = [ + { namespaced: "mock/visible", provider: "mock", id: "visible", contextWindow: 128_000 }, + { namespaced: "mock/hidden", provider: "mock", id: "hidden", contextWindow: 64_000 }, + ]; + const filteredModels = oldModels.slice(0, 1); + const sibling = { baseUrl: "http://user.invalid/v1", models: [{ id: "personal" }] }; + let root: string; + let home: string; + let store: IntegrationStateStore; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-owned-catalog-refresh-")); + home = join(root, "home"); + store = createIntegrationStateStore(join(root, "state", "integrations")); + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 0 })); + for (const client of clients) { + mkdirSync(INTEGRATION_CLIENTS[client].detectDir(env, home), { recursive: true }); + mkdirSync(dirname(INTEGRATION_CLIENTS[client].configPath(env, home)), { recursive: true }); + writeFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), JSON.stringify({ + theme: "dark", providers: { personal: sibling }, + })); + } + }); + + afterEach(() => { + removeTreeWithRetry(root); + }); + + function input(models: readonly ExportModel[] | (() => Promise)) { + return { models, config, port: 10100, env, home, store }; + } + + function document(client: typeof clients[number]) { + return JSON.parse(readFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), "utf8")) as { + theme: string; + providers: { + personal: typeof sibling; + opencodex?: { baseUrl: string; api: string; apiKey: string; models: Array<{ id: string }> }; + }; + }; + } + + test("refreshes both owned catalogs from one lazy load and preserves unrelated settings", async () => { + for (const clientId of clients) { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)) + .toEqual(["mock/hidden", "mock/visible"]); + } + let loads = 0; + const outcomes = await refreshOwnedCatalogIntegrations(input(async () => { + loads += 1; + return filteredModels; + })); + expect(outcomes).toEqual(clients.map(client => ({ client, ok: true, changed: true }))); + expect(loads).toBe(1); + for (const client of clients) { + expect(document(client)).toMatchObject({ theme: "dark", providers: { personal: sibling } }); + expect(document(client).providers.opencodex).toMatchObject({ + baseUrl: "http://127.0.0.1:10100/v1", api: "openai-completions", apiKey: "opencodex-loopback", + }); + expect(document(client).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations(client).map(row => row.kind)).toEqual(["refresh", "apply"]); + } + }); + + test("never loads or writes unowned manual catalogs", async () => { + const before = JSON.stringify({ providers: { personal: sibling, opencodex: { models: [{ id: "manual" }] } } }); + for (const client of clients) writeFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), before); + let loads = 0; + const outcomes = await refreshOwnedCatalogIntegrations(input(async () => { + loads += 1; + return filteredModels; + })); + expect(outcomes).toEqual([]); + expect(loads).toBe(0); + for (const client of clients) { + expect(readFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), "utf8")).toBe(before); + } + expect(store.readRecords()).toEqual({}); + expect(store.listOperations()).toEqual([]); + expect(existsSync(store.root)).toBe(false); + }); + + test.each(clients)("does not reconnect a removed %s block", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const before = JSON.stringify({ theme: "dark", providers: { personal: sibling } }); + const path = INTEGRATION_CLIENTS[clientId].configPath(env, home); + writeFileSync(path, before); + expect(await refreshOwnedCatalogIntegrations(input(filteredModels))).toEqual([{ + client: clientId, ok: true, changed: false, + reason: "managed block is absent; refresh did not reconnect it", + }]); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test.each(clients)("does not recreate an uninstalled %s client", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const detectDir = INTEGRATION_CLIENTS[clientId].detectDir(env, home); + removeTreeWithRetry(detectDir); + const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels)); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); + expect(outcomes[0]?.reason).toContain(`${clientId} is not installed`); + expect(existsSync(detectDir)).toBe(false); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test.each(clients)("preserves a drifted %s provider and its ownership record", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const edited = document(clientId); + edited.providers.opencodex!.baseUrl = "http://user-edited.invalid/v1"; + const before = JSON.stringify(edited); + const path = INTEGRATION_CLIENTS[clientId].configPath(env, home); + writeFileSync(path, before); + const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels)); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); + expect(outcomes[0]?.reason).toContain("changed after opencodex wrote it"); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test("a thrown Pi filesystem error does not prevent the owned Aside refresh", async () => { + for (const clientId of clients) expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const path = INTEGRATION_CLIENTS.pi.configPath(env, home); + const before = readFileSync(path, "utf8"); + const recordBefore = store.readRecords().pi; + const io = store.io(); + const outcomes = await refreshOwnedCatalogIntegrations({ + ...input(filteredModels), + io: { ...io, statKind: candidate => { + if (candidate === path) throw new Error("synthetic Pi stat failure"); + return io.statKind(candidate); + } }, + }); + expect(outcomes).toEqual([ + { client: "pi", ok: false, reason: "synthetic Pi stat failure" }, + { client: "aside", ok: true, changed: true }, + ]); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords().pi).toEqual(recordBefore); + expect(store.listOperations("pi").map(row => row.kind)).toEqual(["apply"]); + expect(document("aside").providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations("aside").map(row => row.kind)).toEqual(["refresh", "apply"]); + }); + + test.each(clients)("overlapping %s selections report busy and a later retry applies the new roster", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const nextModels = oldModels.slice(1); + let release!: () => void; + let observeFirst!: () => void; + let observeSecond!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { observeFirst = resolve; }); + const contended = new Promise(resolve => { observeSecond = resolve; }); + setIntegrationMutationFlightTestHook(async operation => { + observeFirst(); + await gate; + return operation(); + }); + const first = refreshOwnedCatalogIntegrations(input(filteredModels), [clientId]); + let second: ReturnType | undefined; + try { + await started; + second = refreshOwnedCatalogIntegrations({ + ...input(nextModels), + io: { ...store.io(), now: () => { observeSecond(); return Date.now(); } }, + }, [clientId]); + await contended; + release(); + expect(await first).toEqual([{ client: clientId, ok: true, changed: true }]); + expect(await second).toEqual([{ client: clientId, ok: false, reason: "integration_mutation_busy" }]); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "apply"]); + } finally { + release(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + setIntegrationMutationFlightTestHook(null); + } + expect(await refreshOwnedCatalogIntegrations(input(nextModels), [clientId])) + .toEqual([{ client: clientId, ok: true, changed: true }]); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/hidden"]); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "refresh", "apply"]); + }); +}); + +test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relying on /api/sync", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); - expect(command).toContain("refreshOwnedIntegration"); - expect(command).toContain('clientId: "mcode"'); - expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedIntegration")); + expect(command).toContain("refreshOwnedCatalogIntegrations"); + expect(command).toContain('["mcode", "pi", "aside"]'); + expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); -test("refresh joins refresh but cannot swallow an explicit apply or disable", async () => { +test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); let refreshRuns = 0; diff --git a/tests/codex-integration/codex-convergence-contract.test.ts b/tests/codex-integration/codex-convergence-contract.test.ts index db37963095..9644523163 100644 --- a/tests/codex-integration/codex-convergence-contract.test.ts +++ b/tests/codex-integration/codex-convergence-contract.test.ts @@ -374,7 +374,7 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence paths", () => { const counts = Object.fromEntries([ ["provider-routes.ts", 8], ["model-routes.ts", 14], @@ -382,7 +382,14 @@ test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 converge ["agent-settings-routes.ts", 2], ].map(([file, expected]) => { const source = readFileSync(repoPath("src", "server", "management", file as string), "utf8"); - const count = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + const direct = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + const shared = source.match(/await convergeVisibleCatalogs\(\)/g)?.length ?? 0; + if (file === "model-routes.ts") { + const helper = source.slice(source.indexOf("const convergeVisibleCatalogs ="), source.indexOf('if (url.pathname ===')); + expect(helper.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); + expect(shared).toBe(5); + } + const count = file === "model-routes.ts" ? direct - 1 + shared : direct; expect(count).toBe(expected); expect(source).not.toContain("refreshCodexCatalogBestEffort"); return [file, count]; @@ -435,7 +442,9 @@ test("both model-preset write paths converge the Codex catalog", () => { const handlerBody = source.slice(handlerStart, source.indexOf("url.pathname ===", handlerStart + 1)); // The "all" branch and the materialize branch each converge; "custom" only moves the marker, // so it deliberately does not. - expect(handlerBody.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(2); + expect(handlerBody.match(/await convergeVisibleCatalogs\(\)/g)?.length).toBe(2); + const customBranch = handlerBody.slice(handlerBody.indexOf('if (mode === "custom")'), handlerBody.indexOf("const preset =")); + expect(customBranch).not.toMatch(/await converge(?:CodexCatalog|VisibleCatalogs)\(\)/); }); /** diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 7ed7fd17fe..d3d91aebdf 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -674,3 +674,38 @@ describe("Pi and Aside provider selection", () => { expect(await ids()).toEqual(["xai/grok-4.3", "xai/grok-4.5", "xai/grok-4.6"]); }); }); + +describe("visibility changes refresh connected client catalogs", () => { + test.each([ + ["/api/selected-models", { provider: "a", models: ["m1"] }, ["a/m1"]], + ["/api/disabled-models", { models: ["a/m2"] }, ["a/m1"]], + ["/api/model-visibility", { scope: "models", provider: "a", targets: [{ id: "m2" }], enabled: false }, ["a/m1"]], + ["/api/model-presets", { provider: "a", mode: "all" }, ["a/m1", "a/m2"]], + ] as const)("%s refreshes from the persisted selection and reports refused clients", async (path, body, expected) => { + const config = baseConfig({ fastRows: false }); + let saved = false; + let refreshCalls = 0; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, "content-type": "application/json" }, body: JSON.stringify(body), + }), url, config, { + saveConfigPreservingClaudeCode: () => { saved = true; }, + createManagementConvergeCodex: catalogConvergenceFactory(), + refreshOwnedCatalogIntegrations: async input => { + expect(saved).toBe(true); + expect(input.config).toBe(config); + expect(input.port).toBe(10100); + const models = typeof input.models === "function" ? await input.models() : input.models; + expect(models.filter(row => row.provider === "a").map(row => row.namespaced)).toEqual([...expected]); + refreshCalls += 1; + return [{ client: "pi", ok: false, reason: "integration_mutation_busy" }, { client: "aside", ok: true, changed: true }]; + }, + }); + expect(response?.status).toBe(200); + expect(refreshCalls).toBe(1); + expect(await response!.json()).toMatchObject({ + ok: true, + clientIntegrations: [{ client: "pi", ok: false, reason: "integration_mutation_busy" }, { client: "aside", ok: true, changed: true }], + }); + }); +});