icebear icebear0828 commited on
Commit
50720ce
·
unverified ·
1 Parent(s): 20dcebd

fix: write runtime cache to data/ instead of git-tracked config/ (#138)

Browse files

model-store synced merged catalog back to config/models.yaml on every
backend fetch (~1h), and update-checker mutated config/default.yaml on
version detection. Both made the repo dirty after normal runtime.

- syncStaticModels() → data/models-cache.yaml (gitignored)
- applyVersionUpdate() → data/version-state.json (gitignored)
- loadStaticModels() overlays cache on cold start for fallback
- loadCurrentConfig() checks version override before YAML baseline
- config/ is now read-only for runtime operations

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -18,6 +18,11 @@
18
 
19
  ### Fixed
20
 
 
 
 
 
 
21
  - Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
22
  - 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
23
  - 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
 
18
 
19
  ### Fixed
20
 
21
+ - 运行时缓存(模型目录同步、版本检测结果)直接写入 git 跟踪的 `config/` 文件,导致仓库频繁变脏
22
+ - `model-store` 的 `syncStaticModels()` 改写 `data/models-cache.yaml`(gitignored)
23
+ - `update-checker` 的 `applyVersionUpdate()` 改写 `data/version-state.json`(gitignored)
24
+ - `config/` 目录现在对运行时操作只读,仅 admin API 设置变更例外
25
+
26
  - Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
27
  - 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
28
  - 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
src/__tests__/update-checker.test.ts ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests that update-checker writes version state to data/ (gitignored),
3
+ * NOT to config/default.yaml (git-tracked).
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, vi } from "vitest";
7
+
8
+ const mockWriteFileSync = vi.fn();
9
+ const mockExistsSync = vi.fn(() => false);
10
+ const mockMkdirSync = vi.fn();
11
+ const mockReadFileSync = vi.fn();
12
+ const mockMutateClientConfig = vi.fn();
13
+
14
+ vi.mock("../config.js", () => ({
15
+ mutateClientConfig: mockMutateClientConfig,
16
+ reloadAllConfigs: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("../paths.js", () => ({
20
+ getConfigDir: vi.fn(() => "/fake/config"),
21
+ getDataDir: vi.fn(() => "/fake/data"),
22
+ isEmbedded: vi.fn(() => false),
23
+ }));
24
+
25
+ vi.mock("../utils/jitter.js", () => ({
26
+ jitterInt: vi.fn((ms: number) => ms),
27
+ }));
28
+
29
+ vi.mock("../tls/curl-fetch.js", () => ({
30
+ curlFetchGet: vi.fn(),
31
+ }));
32
+
33
+ vi.mock("fs", async (importOriginal) => {
34
+ const actual = await importOriginal<typeof import("fs")>();
35
+ return {
36
+ ...actual,
37
+ readFileSync: mockReadFileSync,
38
+ writeFileSync: mockWriteFileSync,
39
+ existsSync: mockExistsSync,
40
+ mkdirSync: mockMkdirSync,
41
+ };
42
+ });
43
+
44
+ vi.mock("js-yaml", () => ({
45
+ default: {
46
+ load: vi.fn(() => ({
47
+ client: { app_version: "1.0.0", build_number: "100" },
48
+ })),
49
+ },
50
+ }));
51
+
52
+ import { curlFetchGet } from "../tls/curl-fetch.js";
53
+
54
+ const APPCAST_XML = `<?xml version="1.0"?>
55
+ <rss><channel><item>
56
+ <enclosure sparkle:shortVersionString="2.0.0" sparkle:version="200" url="https://example.com/download"/>
57
+ </item></channel></rss>`;
58
+
59
+ describe("update-checker writes to data/, not config/", () => {
60
+ beforeEach(() => {
61
+ vi.clearAllMocks();
62
+ mockExistsSync.mockReturnValue(false);
63
+ mockReadFileSync.mockReturnValue("");
64
+ });
65
+
66
+ it("applyVersionUpdate writes to data/version-state.json, not config/default.yaml", async () => {
67
+ vi.mocked(curlFetchGet).mockResolvedValue({
68
+ ok: true,
69
+ status: 200,
70
+ body: APPCAST_XML,
71
+ headers: {},
72
+ });
73
+
74
+ // Dynamic import to get fresh module state
75
+ const { checkForUpdate } = await import("../update-checker.js");
76
+ await checkForUpdate();
77
+
78
+ // Should write version-state.json to data/
79
+ const versionWrites = mockWriteFileSync.mock.calls.filter(
80
+ (call) => (call[0] as string).includes("version-state.json"),
81
+ );
82
+ expect(versionWrites.length).toBeGreaterThanOrEqual(1);
83
+ const writePath = versionWrites[0][0] as string;
84
+ expect(writePath).toBe("/fake/data/version-state.json");
85
+
86
+ // Parse the written content
87
+ const written = JSON.parse(versionWrites[0][1] as string) as {
88
+ app_version: string;
89
+ build_number: string;
90
+ };
91
+ expect(written.app_version).toBe("2.0.0");
92
+ expect(written.build_number).toBe("200");
93
+ });
94
+
95
+ it("never calls mutateYaml on config/default.yaml", async () => {
96
+ vi.mocked(curlFetchGet).mockResolvedValue({
97
+ ok: true,
98
+ status: 200,
99
+ body: APPCAST_XML,
100
+ headers: {},
101
+ });
102
+
103
+ const { checkForUpdate } = await import("../update-checker.js");
104
+ await checkForUpdate();
105
+
106
+ // No writes should target config/default.yaml
107
+ const configWrites = mockWriteFileSync.mock.calls.filter(
108
+ (call) => (call[0] as string).includes("/fake/config/"),
109
+ );
110
+ expect(configWrites).toHaveLength(0);
111
+ });
112
+
113
+ it("updates runtime config via mutateClientConfig", async () => {
114
+ vi.mocked(curlFetchGet).mockResolvedValue({
115
+ ok: true,
116
+ status: 200,
117
+ body: APPCAST_XML,
118
+ headers: {},
119
+ });
120
+
121
+ const { checkForUpdate } = await import("../update-checker.js");
122
+ await checkForUpdate();
123
+
124
+ expect(mockMutateClientConfig).toHaveBeenCalledWith({
125
+ app_version: "2.0.0",
126
+ build_number: "200",
127
+ });
128
+ });
129
+ });
src/models/__tests__/model-cache.test.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests that model-store writes cache to data/ (gitignored),
3
+ * NOT to config/models.yaml (git-tracked).
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, vi } from "vitest";
7
+
8
+ vi.mock("../../config.js", () => ({
9
+ getConfig: vi.fn(() => ({
10
+ server: {},
11
+ model: { default: "gpt-5.2-codex" },
12
+ api: { base_url: "https://chatgpt.com/backend-api" },
13
+ client: { app_version: "1.0.0" },
14
+ })),
15
+ }));
16
+
17
+ vi.mock("../../paths.js", () => ({
18
+ getConfigDir: vi.fn(() => "/fake/config"),
19
+ getDataDir: vi.fn(() => "/fake/data"),
20
+ }));
21
+
22
+ vi.mock("fs", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("fs")>();
24
+ return {
25
+ ...actual,
26
+ readFileSync: vi.fn(() => "models: []\naliases: {}"),
27
+ writeFileSync: vi.fn(),
28
+ writeFile: vi.fn((_p: string, _d: string, _e: string, cb: (err: Error | null) => void) => cb(null)),
29
+ existsSync: vi.fn(() => false),
30
+ mkdirSync: vi.fn(),
31
+ };
32
+ });
33
+
34
+ vi.mock("js-yaml", () => ({
35
+ default: {
36
+ load: vi.fn(() => ({ models: [], aliases: {} })),
37
+ dump: vi.fn(() => "models: []"),
38
+ },
39
+ }));
40
+
41
+ import { writeFile, mkdirSync, existsSync } from "fs";
42
+ import { loadStaticModels, applyBackendModels } from "../model-store.js";
43
+
44
+ describe("model cache writes to data/, not config/", () => {
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ vi.mocked(existsSync).mockReturnValue(false);
48
+ loadStaticModels();
49
+ });
50
+
51
+ it("syncStaticModels writes to data/models-cache.yaml", () => {
52
+ applyBackendModels([{ slug: "gpt-5.4", name: "GPT 5.4" }]);
53
+
54
+ expect(writeFile).toHaveBeenCalledOnce();
55
+ const writePath = vi.mocked(writeFile).mock.calls[0][0] as string;
56
+ expect(writePath).toContain("/fake/data/models-cache.yaml");
57
+ });
58
+
59
+ it("syncStaticModels never writes to config/models.yaml", () => {
60
+ applyBackendModels([{ slug: "gpt-5.4", name: "GPT 5.4" }]);
61
+
62
+ const writePath = vi.mocked(writeFile).mock.calls[0][0] as string;
63
+ expect(writePath).not.toContain("/fake/config/");
64
+ });
65
+
66
+ it("ensures data dir exists before writing cache", () => {
67
+ applyBackendModels([{ slug: "gpt-5.4", name: "GPT 5.4" }]);
68
+
69
+ expect(mkdirSync).toHaveBeenCalledWith("/fake/data", { recursive: true });
70
+ });
71
+ });
src/models/model-store.ts CHANGED
@@ -9,11 +9,11 @@
9
  * Aliases always come from YAML (user-customizable), never from backend.
10
  */
11
 
12
- import { readFileSync, writeFile } from "fs";
13
  import { resolve } from "path";
14
  import yaml from "js-yaml";
15
  import { getConfig } from "../config.js";
16
- import { getConfigDir } from "../paths.js";
17
 
18
  export interface CodexModelInfo {
19
  id: string;
@@ -60,6 +60,30 @@ export function loadStaticModels(configDir?: string): void {
60
  _planModelMap = new Map(); // Reset plan maps on reload
61
  _modelPlanIndex = new Map();
62
  console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
64
 
65
  // ── Backend merge ──────────────────────────────────────────────────
@@ -201,24 +225,26 @@ export function applyBackendModels(backendModels: BackendModelEntry[]): void {
201
  }
202
 
203
  /**
204
- * Write the current merged catalog back to config/models.yaml so it stays
205
- * up-to-date as a fallback for future cold starts. Fire-and-forget.
 
 
206
  */
207
  function syncStaticModels(): void {
208
- const configPath = resolve(getConfigDir(), "models.yaml");
 
209
  const today = new Date().toISOString().slice(0, 10);
210
 
211
  // Strip internal `source` field before serializing
212
  const models = _catalog.map(({ source: _s, ...rest }) => rest);
213
 
214
  const header = [
215
- "# Codex model catalog",
216
  "#",
217
- "# Sources:",
218
- "# 1. Static (below)baseline for cold starts",
219
- "# 2. Dynamic — fetched from /backend-api/codex/models, auto-synced here",
220
  "#",
221
- `# Last updated: ${today} (auto-synced by model-store)`,
222
  "",
223
  ].join("\n");
224
 
@@ -227,11 +253,17 @@ function syncStaticModels(): void {
227
  { lineWidth: 120, noRefs: true, sortKeys: false },
228
  );
229
 
230
- writeFile(configPath, header + body, "utf-8", (err) => {
 
 
 
 
 
 
231
  if (err) {
232
- console.warn(`[ModelStore] Failed to sync models.yaml: ${err.message}`);
233
  } else {
234
- console.log(`[ModelStore] Synced ${models.length} models to models.yaml`);
235
  }
236
  });
237
  }
 
9
  * Aliases always come from YAML (user-customizable), never from backend.
10
  */
11
 
12
+ import { readFileSync, writeFile, existsSync, mkdirSync } from "fs";
13
  import { resolve } from "path";
14
  import yaml from "js-yaml";
15
  import { getConfig } from "../config.js";
16
+ import { getConfigDir, getDataDir } from "../paths.js";
17
 
18
  export interface CodexModelInfo {
19
  id: string;
 
60
  _planModelMap = new Map(); // Reset plan maps on reload
61
  _modelPlanIndex = new Map();
62
  console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
63
+
64
+ // Overlay cached backend models from data/ (cold-start fallback)
65
+ try {
66
+ const cachePath = resolve(getDataDir(), "models-cache.yaml");
67
+ if (existsSync(cachePath)) {
68
+ const cached = yaml.load(readFileSync(cachePath, "utf-8")) as ModelsConfig;
69
+ const cachedModels = cached.models ?? [];
70
+ if (cachedModels.length > 0) {
71
+ const staticIds = new Set(_catalog.map((m) => m.id));
72
+ let added = 0;
73
+ for (const m of cachedModels) {
74
+ if (!staticIds.has(m.id)) {
75
+ _catalog.push({ ...m, source: "backend" as const });
76
+ added++;
77
+ }
78
+ }
79
+ if (added > 0) {
80
+ console.log(`[ModelStore] Overlaid ${added} cached backend models from data/models-cache.yaml`);
81
+ }
82
+ }
83
+ }
84
+ } catch {
85
+ // Cache missing or corrupt — safe to ignore, backend fetch will repopulate
86
+ }
87
  }
88
 
89
  // ── Backend merge ──────────────────────────────────────────────────
 
225
  }
226
 
227
  /**
228
+ * Write the current merged catalog to data/models-cache.yaml so it serves
229
+ * as a fallback for future cold starts. Fire-and-forget.
230
+ *
231
+ * config/models.yaml stays read-only (git-tracked baseline).
232
  */
233
  function syncStaticModels(): void {
234
+ const dataDir = getDataDir();
235
+ const cachePath = resolve(dataDir, "models-cache.yaml");
236
  const today = new Date().toISOString().slice(0, 10);
237
 
238
  // Strip internal `source` field before serializing
239
  const models = _catalog.map(({ source: _s, ...rest }) => rest);
240
 
241
  const header = [
242
+ "# Codex model cache",
243
  "#",
244
+ "# Auto-synced by model-store from backend fetch results.",
245
+ "# This is a runtime cache do NOT commit to git.",
 
246
  "#",
247
+ `# Last updated: ${today}`,
248
  "",
249
  ].join("\n");
250
 
 
253
  { lineWidth: 120, noRefs: true, sortKeys: false },
254
  );
255
 
256
+ try {
257
+ mkdirSync(dataDir, { recursive: true });
258
+ } catch {
259
+ // already exists
260
+ }
261
+
262
+ writeFile(cachePath, header + body, "utf-8", (err) => {
263
  if (err) {
264
+ console.warn(`[ModelStore] Failed to sync models cache: ${err.message}`);
265
  } else {
266
+ console.log(`[ModelStore] Synced ${models.length} models to data/models-cache.yaml`);
267
  }
268
  });
269
  }
src/update-checker.ts CHANGED
@@ -3,14 +3,13 @@
3
  * Automatically applies version updates to config file and runtime.
4
  */
5
 
6
- import { readFileSync, writeFileSync, mkdirSync } from "fs";
7
  import { resolve } from "path";
8
  import { fork } from "child_process";
9
  import yaml from "js-yaml";
10
  import { mutateClientConfig, reloadAllConfigs } from "./config.js";
11
  import { jitterInt } from "./utils/jitter.js";
12
  import { curlFetchGet } from "./tls/curl-fetch.js";
13
- import { mutateYaml } from "./utils/yaml-mutate.js";
14
  import { getConfigDir, getDataDir, isEmbedded } from "./paths.js";
15
 
16
  function getConfigPath(): string {
@@ -36,7 +35,27 @@ let _currentState: UpdateState | null = null;
36
  let _pollTimer: ReturnType<typeof setTimeout> | null = null;
37
  let _updateInProgress = false;
38
 
 
 
 
 
39
  function loadCurrentConfig(): { app_version: string; build_number: string } {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  const raw = yaml.load(readFileSync(getConfigPath(), "utf-8")) as Record<string, unknown>;
41
  const client = raw.client as Record<string, string>;
42
  return {
@@ -69,11 +88,18 @@ function parseAppcast(xml: string): {
69
  }
70
 
71
  function applyVersionUpdate(version: string, build: string): void {
72
- mutateYaml(getConfigPath(), (data) => {
73
- const client = data.client as Record<string, unknown>;
74
- client.app_version = version;
75
- client.build_number = build;
76
- });
 
 
 
 
 
 
 
77
  mutateClientConfig({ app_version: version, build_number: build });
78
  }
79
 
 
3
  * Automatically applies version updates to config file and runtime.
4
  */
5
 
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
7
  import { resolve } from "path";
8
  import { fork } from "child_process";
9
  import yaml from "js-yaml";
10
  import { mutateClientConfig, reloadAllConfigs } from "./config.js";
11
  import { jitterInt } from "./utils/jitter.js";
12
  import { curlFetchGet } from "./tls/curl-fetch.js";
 
13
  import { getConfigDir, getDataDir, isEmbedded } from "./paths.js";
14
 
15
  function getConfigPath(): string {
 
35
  let _pollTimer: ReturnType<typeof setTimeout> | null = null;
36
  let _updateInProgress = false;
37
 
38
+ function getVersionOverridePath(): string {
39
+ return resolve(getDataDir(), "version-state.json");
40
+ }
41
+
42
  function loadCurrentConfig(): { app_version: string; build_number: string } {
43
+ // Check runtime override first (written by applyVersionUpdate)
44
+ try {
45
+ const overridePath = getVersionOverridePath();
46
+ if (existsSync(overridePath)) {
47
+ const override = JSON.parse(readFileSync(overridePath, "utf-8")) as {
48
+ app_version: string;
49
+ build_number: string;
50
+ };
51
+ if (override.app_version && override.build_number) {
52
+ return override;
53
+ }
54
+ }
55
+ } catch {
56
+ // Corrupt override — fall through to YAML baseline
57
+ }
58
+
59
  const raw = yaml.load(readFileSync(getConfigPath(), "utf-8")) as Record<string, unknown>;
60
  const client = raw.client as Record<string, string>;
61
  return {
 
88
  }
89
 
90
  function applyVersionUpdate(version: string, build: string): void {
91
+ // Persist to data/ (gitignored) config/default.yaml stays read-only
92
+ try {
93
+ const dataDir = getDataDir();
94
+ mkdirSync(dataDir, { recursive: true });
95
+ writeFileSync(
96
+ getVersionOverridePath(),
97
+ JSON.stringify({ app_version: version, build_number: build }, null, 2),
98
+ );
99
+ } catch {
100
+ // best-effort persistence
101
+ }
102
+ // Update runtime config in memory
103
  mutateClientConfig({ app_version: version, build_number: build });
104
  }
105