Skip to content

Commit 9dd3c42

Browse files
authored
fix(codex): report catalog and cache write signals
* fix(codex): report whether a sync actually wrote the catalog or cache syncCatalogModels() and invalidateCodexModelsCache() both succeeded silently whether or not they wrote anything, so a caller could not tell a real catalog update from a no-op on a missing or unreadable catalog. Return that fact: syncCatalogModels() gains catalogWritten, and invalidateCodexModelsCache() returns whether it rewrote models_cache. refreshCodexModelCatalog() carries both outward. This is the signal half of #518, split out so it can be reviewed on its own. Nothing acts on it yet — the consumer (warning about, and optionally restarting, stale app-server processes) is the other half, and it touches process termination, which deserves its own review. * test(codex): cover real catalog sync write signals
1 parent 7c74e0a commit 9dd3c42

6 files changed

Lines changed: 208 additions & 15 deletions

File tree

src/codex/catalog/sync.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -457,11 +457,12 @@ export function mergeCatalogEntriesForSync(
457457
export async function syncCatalogModels(config: OcxConfig): Promise<{
458458
added: number;
459459
path: string;
460+
catalogWritten: boolean;
460461
comboOmissions: ComboCatalogOmission[];
461462
}> {
462463
const catalogPath = readCodexCatalogPath();
463464
const catalog = loadCatalogForSync(catalogPath);
464-
if (!catalog) return { added: 0, path: catalogPath, comboOmissions: [] };
465+
if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
465466

466467
const template = findNativeTemplate(catalog);
467468

@@ -500,7 +501,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
500501
clampCatalogModelsToCodexSupport(catalog.models);
501502

502503
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
503-
return { added: goEntries.length, path: catalogPath, comboOmissions };
504+
return { added: goEntries.length, path: catalogPath, catalogWritten: true, comboOmissions };
504505
}
505506

506507
export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
@@ -531,10 +532,11 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
531532
return { removed, kept: native.length, path: catalogPath };
532533
}
533534

534-
export function invalidateCodexModelsCache(): void {
535+
/** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */
536+
export function invalidateCodexModelsCache(): boolean {
535537
try {
536538
const catalogPath = readCodexCatalogPath();
537-
if (!existsSync(catalogPath)) return;
539+
if (!existsSync(catalogPath)) return false;
538540
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
539541
const models = catalog.models ?? catalog;
540542
const wrapper = {
@@ -543,5 +545,8 @@ export function invalidateCodexModelsCache(): void {
543545
models,
544546
};
545547
atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
546-
} catch { /* best-effort */ }
548+
return true;
549+
} catch {
550+
return false;
551+
}
547552
}

src/codex/refresh.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface CodexCatalogRefreshResult {
99
added: number;
1010
path: string;
1111
catalogExists: boolean;
12+
catalogWritten: boolean;
1213
cacheSynced: boolean;
1314
comboOmissions: ComboCatalogOmission[];
1415
}
@@ -42,8 +43,11 @@ export async function refreshCodexModelCatalog(
4243
): Promise<CodexCatalogRefreshResult> {
4344
const result = await deps.syncCatalogModels(config);
4445
const catalogExists = deps.existsSync(result.path);
46+
const catalogWritten = result.catalogWritten === true;
4547
const comboOmissions = result.comboOmissions ?? [];
46-
if (!catalogExists) return { ...result, catalogExists, cacheSynced: false, comboOmissions };
47-
deps.invalidateCodexModelsCache();
48-
return { ...result, catalogExists, cacheSynced: true, comboOmissions };
48+
if (!catalogExists) {
49+
return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions };
50+
}
51+
const cacheSynced = deps.invalidateCodexModelsCache();
52+
return { ...result, catalogExists, catalogWritten, cacheSynced, comboOmissions };
4953
}

src/codex/sync.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface CodexSyncResult {
1111
added: number;
1212
catalogPath: string | null;
1313
catalogExists: boolean;
14+
catalogWritten: boolean;
1415
cacheSynced: boolean;
1516
message: string;
1617
warning?: string;
@@ -61,6 +62,7 @@ export async function syncModelsToCodex(
6162
added: 0,
6263
catalogPath: null,
6364
catalogExists: false,
65+
catalogWritten: false,
6466
cacheSynced: false,
6567
message: result.message,
6668
};
@@ -71,6 +73,7 @@ export async function syncModelsToCodex(
7173
let catalogPath: string | null = null;
7274
let catalogPathForInjection: string | null | undefined;
7375
let catalogExists = false;
76+
let catalogWritten = false;
7477
let cacheSynced = false;
7578
let warning: string | undefined;
7679
let comboOmissions: ComboCatalogOmission[] = [];
@@ -79,6 +82,7 @@ export async function syncModelsToCodex(
7982
const cat = await deps.refreshCodexModelCatalog(config);
8083
added = cat.added;
8184
catalogExists = cat.catalogExists;
85+
catalogWritten = cat.catalogWritten;
8286
cacheSynced = cat.cacheSynced;
8387
catalogPathForInjection = cat.catalogExists ? cat.path : null;
8488
catalogPath = catalogPathForInjection;
@@ -110,6 +114,7 @@ export async function syncModelsToCodex(
110114
added,
111115
catalogPath,
112116
catalogExists,
117+
catalogWritten,
113118
cacheSynced,
114119
message: result.message,
115120
...(warning ? { warning } : {}),

tests/codex-refresh.test.ts

Lines changed: 179 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { describe, expect, test } from "bun:test";
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { invalidateCodexModelsCache, syncCatalogModels } from "../src/codex/catalog";
26
import { refreshCodexModelCatalog } from "../src/codex/refresh";
37
import type { OcxConfig } from "../src/types";
48

@@ -8,19 +12,73 @@ const config = {
812
providers: {},
913
} as OcxConfig;
1014

15+
const tempHomes: string[] = [];
16+
17+
function installTempHomes(): { codexHome: string; opencodexHome: string; restore(): void } {
18+
const previousCodexHome = process.env.CODEX_HOME;
19+
const previousOpenCodexHome = process.env.OPENCODEX_HOME;
20+
const codexHome = mkdtempSync(join(tmpdir(), "ocx-refresh-codex-"));
21+
const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-refresh-ocx-"));
22+
tempHomes.push(codexHome, opencodexHome);
23+
process.env.CODEX_HOME = codexHome;
24+
process.env.OPENCODEX_HOME = opencodexHome;
25+
26+
return {
27+
codexHome,
28+
opencodexHome,
29+
restore() {
30+
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
31+
else process.env.CODEX_HOME = previousCodexHome;
32+
if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME;
33+
else process.env.OPENCODEX_HOME = previousOpenCodexHome;
34+
rmSync(codexHome, { recursive: true, force: true });
35+
rmSync(opencodexHome, { recursive: true, force: true });
36+
},
37+
};
38+
}
39+
40+
function nativeCatalogFixture(slug = "gpt-5.5"): string {
41+
return JSON.stringify({
42+
models: [{
43+
slug,
44+
display_name: slug,
45+
description: "native",
46+
priority: 9,
47+
visibility: "list",
48+
base_instructions: "You are Codex, a coding agent based on GPT-5.",
49+
supported_reasoning_levels: [{ effort: "medium", description: "m" }],
50+
}],
51+
}, null, 2) + "\n";
52+
}
53+
54+
afterEach(() => {
55+
for (const path of tempHomes.splice(0)) {
56+
rmSync(path, { recursive: true, force: true });
57+
}
58+
});
59+
1160
describe("Codex catalog refresh", () => {
1261
test("writes an expired Codex models cache whenever the materialized catalog exists", async () => {
1362
let invalidated = 0;
1463
const result = await refreshCodexModelCatalog(config, {
15-
syncCatalogModels: async () => ({ added: 0, path: "/tmp/opencodex-catalog.json", comboOmissions: [] }),
16-
invalidateCodexModelsCache: () => { invalidated += 1; },
64+
syncCatalogModels: async () => ({
65+
added: 0,
66+
path: "/tmp/opencodex-catalog.json",
67+
catalogWritten: true,
68+
comboOmissions: [],
69+
}),
70+
invalidateCodexModelsCache: () => {
71+
invalidated += 1;
72+
return true;
73+
},
1774
existsSync: () => true,
1875
});
1976

2077
expect(result).toEqual({
2178
added: 0,
2279
path: "/tmp/opencodex-catalog.json",
2380
catalogExists: true,
81+
catalogWritten: true,
2482
cacheSynced: true,
2583
comboOmissions: [],
2684
});
@@ -30,14 +88,130 @@ describe("Codex catalog refresh", () => {
3088
test("does not touch the cache when no Codex catalog can be materialized", async () => {
3189
let invalidated = 0;
3290
const result = await refreshCodexModelCatalog(config, {
33-
syncCatalogModels: async () => ({ added: 0, path: "/tmp/missing-catalog.json", comboOmissions: [] }),
34-
invalidateCodexModelsCache: () => { invalidated += 1; },
91+
syncCatalogModels: async () => ({
92+
added: 0,
93+
path: "/tmp/missing-catalog.json",
94+
catalogWritten: false,
95+
comboOmissions: [],
96+
}),
97+
invalidateCodexModelsCache: () => {
98+
invalidated += 1;
99+
return true;
100+
},
35101
existsSync: () => false,
36102
});
37103

38104
expect(result.catalogExists).toBe(false);
105+
expect(result.catalogWritten).toBe(false);
39106
expect(result.cacheSynced).toBe(false);
40107
expect(result.comboOmissions).toEqual([]);
41108
expect(invalidated).toBe(0);
42109
});
110+
111+
test("reports cacheSynced false when invalidate cannot write", async () => {
112+
const result = await refreshCodexModelCatalog(config, {
113+
syncCatalogModels: async () => ({
114+
added: 0,
115+
path: "/tmp/opencodex-catalog.json",
116+
catalogWritten: true,
117+
comboOmissions: [],
118+
}),
119+
invalidateCodexModelsCache: () => false,
120+
existsSync: () => true,
121+
});
122+
123+
expect(result.catalogExists).toBe(true);
124+
expect(result.catalogWritten).toBe(true);
125+
expect(result.cacheSynced).toBe(false);
126+
expect(result.comboOmissions).toEqual([]);
127+
});
128+
129+
test("preserves catalogWritten false when the catalog path exists but sync did not write", async () => {
130+
const result = await refreshCodexModelCatalog(config, {
131+
syncCatalogModels: async () => ({
132+
added: 0,
133+
path: "/tmp/broken-catalog.json",
134+
catalogWritten: false,
135+
comboOmissions: [],
136+
}),
137+
invalidateCodexModelsCache: () => false,
138+
existsSync: () => true,
139+
});
140+
141+
expect(result.catalogExists).toBe(true);
142+
expect(result.catalogWritten).toBe(false);
143+
expect(result.cacheSynced).toBe(false);
144+
});
145+
146+
test("reports catalogWritten true after syncCatalogModels rewrites a real catalog file", async () => {
147+
const home = installTempHomes();
148+
try {
149+
const catalogPath = join(home.codexHome, "nested", "catalog.json");
150+
mkdirSync(join(home.codexHome, "nested"), { recursive: true });
151+
writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "nested/catalog.json"\n', "utf8");
152+
writeFileSync(catalogPath, nativeCatalogFixture("gpt-5.6-sol"), "utf8");
153+
const before = readFileSync(catalogPath, "utf8");
154+
155+
const result = await syncCatalogModels(config);
156+
const after = readFileSync(catalogPath, "utf8");
157+
const rewritten = JSON.parse(after);
158+
159+
expect(result.path).toBe(join(realpathSync.native(home.codexHome), "nested", "catalog.json"));
160+
expect(result.catalogWritten).toBe(true);
161+
expect(after).not.toBe(before);
162+
expect(rewritten.models[0].slug).toBe("gpt-5.6-sol");
163+
expect(rewritten.models[0].display_name).toBe("GPT-5.6-Sol");
164+
expect(rewritten.models[0].context_window).toBeGreaterThan(0);
165+
} finally {
166+
home.restore();
167+
}
168+
});
169+
170+
test("invalidateCodexModelsCache reports real cache write success and failure cases", () => {
171+
const success = installTempHomes();
172+
try {
173+
writeFileSync(join(success.codexHome, "config.toml"), 'model_catalog_json = "opencodex-catalog.json"\n', "utf8");
174+
writeFileSync(join(success.codexHome, "opencodex-catalog.json"), nativeCatalogFixture("gpt-5.6-sol"), "utf8");
175+
176+
expect(invalidateCodexModelsCache()).toBe(true);
177+
const cache = JSON.parse(readFileSync(join(success.codexHome, "models_cache.json"), "utf8"));
178+
expect(cache.fetched_at).toBe("2000-01-01T00:00:00Z");
179+
expect(cache.client_version).toBe("0.0.0");
180+
expect(cache.models[0].slug).toBe("gpt-5.6-sol");
181+
} finally {
182+
success.restore();
183+
}
184+
185+
const missingCatalog = installTempHomes();
186+
try {
187+
writeFileSync(join(missingCatalog.codexHome, "config.toml"), 'model_catalog_json = "missing-catalog.json"\n', "utf8");
188+
189+
expect(invalidateCodexModelsCache()).toBe(false);
190+
expect(existsSync(join(missingCatalog.codexHome, "models_cache.json"))).toBe(false);
191+
} finally {
192+
missingCatalog.restore();
193+
}
194+
195+
const malformedCatalog = installTempHomes();
196+
try {
197+
writeFileSync(join(malformedCatalog.codexHome, "config.toml"), 'model_catalog_json = "opencodex-catalog.json"\n', "utf8");
198+
writeFileSync(join(malformedCatalog.codexHome, "opencodex-catalog.json"), "{not-json", "utf8");
199+
200+
expect(invalidateCodexModelsCache()).toBe(false);
201+
expect(existsSync(join(malformedCatalog.codexHome, "models_cache.json"))).toBe(false);
202+
} finally {
203+
malformedCatalog.restore();
204+
}
205+
206+
const unwritableCache = installTempHomes();
207+
try {
208+
writeFileSync(join(unwritableCache.codexHome, "config.toml"), 'model_catalog_json = "opencodex-catalog.json"\n', "utf8");
209+
writeFileSync(join(unwritableCache.codexHome, "opencodex-catalog.json"), nativeCatalogFixture(), "utf8");
210+
mkdirSync(join(unwritableCache.codexHome, "models_cache.json"));
211+
212+
expect(invalidateCodexModelsCache()).toBe(false);
213+
} finally {
214+
unwritableCache.restore();
215+
}
216+
});
43217
});

tests/codex-sync-api.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe("GUI/CLI Codex sync backend", () => {
5353
added: 3,
5454
path: "/tmp/opencodex-catalog.json",
5555
catalogExists: true,
56+
catalogWritten: true,
5657
cacheSynced: true,
5758
comboOmissions: [],
5859
}),
@@ -72,6 +73,7 @@ describe("GUI/CLI Codex sync backend", () => {
7273
added: 3,
7374
catalogPath: "/tmp/opencodex-catalog.json",
7475
catalogExists: true,
76+
catalogWritten: true,
7577
cacheSynced: true,
7678
message: "injected",
7779
});
@@ -93,6 +95,7 @@ describe("GUI/CLI Codex sync backend", () => {
9395
added: 1,
9496
path: "/tmp/opencodex-catalog.json",
9597
catalogExists: true,
98+
catalogWritten: true,
9699
cacheSynced: true,
97100
comboOmissions: [omission],
98101
}),
@@ -121,6 +124,7 @@ describe("GUI/CLI Codex sync backend", () => {
121124
added: 0,
122125
path: "/tmp/opencodex-catalog.json",
123126
catalogExists: true,
127+
catalogWritten: true,
124128
cacheSynced: true,
125129
comboOmissions: [omission],
126130
}),
@@ -192,6 +196,7 @@ describe("GUI/CLI Codex sync backend", () => {
192196
added: 0,
193197
catalogPath: null,
194198
catalogExists: false,
199+
catalogWritten: false,
195200
cacheSynced: false,
196201
message: "external provider preserved",
197202
});

tests/injection-model-api.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,9 @@ describe("/api/injection-model guidance kill switch + partial update", () => {
249249
await refreshCodexModelCatalog(config, {
250250
syncCatalogModels: async syncedConfig => {
251251
flagSeenBySync = syncedConfig.multiAgentGuidanceEnabled;
252-
return { added: 0, path: join(tempHome!, "missing-catalog.json") };
252+
return { added: 0, path: join(tempHome!, "missing-catalog.json"), catalogWritten: false, comboOmissions: [] };
253253
},
254-
invalidateCodexModelsCache: () => {},
254+
invalidateCodexModelsCache: () => false,
255255
existsSync: () => false,
256256
});
257257
expect(flagSeenBySync).toBe(false);

0 commit comments

Comments
 (0)