pi-hypercharm-provider 1.3.38 → 1.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Quiet startup sync, end to end through the extension's real session_start
3
+ * handler: the live /models catalog is fetched only with a key, only when the
4
+ * disk cache is stale (or the key changed), only by one process at a time, and
5
+ * never on the session_start critical path.
6
+ *
7
+ * Porting: only the "Provider specifics" block below differs between
8
+ * pi-*-provider repos. HyperCharm additionally covers the stable OAuth
9
+ * identity, the /login forced revalidation, and the shared startup account
10
+ * snapshot (see the "HyperCharm" describe block at the end).
11
+ */
12
+
13
+ import { afterEach, describe, expect, it, vi } from "vitest";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { keyFingerprint, syncLockPath, syncMetaPath } from "../sync-policy";
18
+ import embeddedModels from "../models.json" with { type: "json" };
19
+
20
+ // ─── Provider specifics ───────────────────────────────────────────────────────
21
+ const PROVIDER_ID = "hypercharm";
22
+ const LIVE_ONLY_ID = "live-only-test-model";
23
+ /** A minimal successful /v1/provider response containing LIVE_ONLY_ID. */
24
+ const liveModelsResponse = () => Response.json({
25
+ models: [{
26
+ id: LIVE_ONLY_ID,
27
+ name: "Live only",
28
+ context_window: 65536,
29
+ default_max_tokens: 4096,
30
+ cost_per_1m_in: 1,
31
+ cost_per_1m_out: 2,
32
+ }],
33
+ });
34
+ const isCatalogUrl = (url: string) => url.endsWith("/v1/provider");
35
+ // ──────────────────────────────────────────────────────────────────────────────
36
+
37
+ const HOUR = 60 * 60 * 1000;
38
+ const KEY = "sk-quiet-sync-test";
39
+
40
+ let home: string;
41
+ let cachePath: string;
42
+
43
+ afterEach(() => {
44
+ vi.unstubAllEnvs();
45
+ vi.unstubAllGlobals();
46
+ vi.doUnmock("../oauth");
47
+ vi.resetModules();
48
+ if (home) fs.rmSync(home, { recursive: true, force: true });
49
+ });
50
+
51
+ function setupHome(): void {
52
+ home = fs.mkdtempSync(path.join(os.tmpdir(), `pi-${PROVIDER_ID}-sync-`));
53
+ cachePath = path.join(home, "cache", `${PROVIDER_ID}-models.json`);
54
+ vi.stubEnv("PI_CODING_AGENT_DIR", home);
55
+ vi.stubEnv("PI_PROVIDER_SYNC_TTL_MS", "");
56
+ vi.stubEnv("HYPERCHARM_API_KEY", "");
57
+ }
58
+
59
+ function writeCache(ageMs: number, models: unknown[] = embeddedModels, fingerprintKey: string | null = KEY): void {
60
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
61
+ fs.writeFileSync(cachePath, JSON.stringify(models));
62
+ const t = new Date(Date.now() - ageMs);
63
+ fs.utimesSync(cachePath, t, t);
64
+ if (fingerprintKey !== null) {
65
+ fs.writeFileSync(syncMetaPath(cachePath), JSON.stringify({ version: 1, keyFingerprint: keyFingerprint(fingerprintKey) }));
66
+ }
67
+ }
68
+
69
+ const urlOf = (input: unknown) => String(input instanceof Request ? input.url : input);
70
+
71
+ function stubFetch(impl: (url: string, init?: RequestInit) => Promise<Response> = async () => liveModelsResponse()) {
72
+ const fetch = vi.fn((input: unknown, init?: RequestInit) => impl(urlOf(input), init));
73
+ vi.stubGlobal("fetch", fetch);
74
+ return fetch;
75
+ }
76
+
77
+ /** Only the catalog requests (account endpoints are covered separately). */
78
+ const catalogCalls = (fetch: ReturnType<typeof stubFetch>) => fetch.mock.calls.filter(([input]) => isCatalogUrl(urlOf(input)));
79
+
80
+ interface CtxOptions { hasUI?: boolean; provider?: string }
81
+
82
+ /** Load the extension fresh (module-level cache paths read PI_CODING_AGENT_DIR). */
83
+ async function loadExtension() {
84
+ vi.resetModules();
85
+ const mod = await import("../index");
86
+ const handlers = new Map<string, (...args: any[]) => any>();
87
+ const registrations: Array<{ models: Array<{ id: string }>; oauth?: any }> = [];
88
+ mod.default({
89
+ on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler),
90
+ registerProvider: (_id: string, config: any) => registrations.push(config),
91
+ registerCommand: () => {},
92
+ registerEntryRenderer: () => {},
93
+ appendEntry: () => {},
94
+ } as any);
95
+ const getApiKeyForProvider = vi.fn(async (): Promise<string | undefined> => KEY);
96
+ const widgets: string[] = [];
97
+ const makeCtx = ({ hasUI = false, provider }: CtxOptions = {}) => ({
98
+ hasUI,
99
+ model: provider ? { provider } : undefined,
100
+ modelRegistry: { getApiKeyForProvider },
101
+ ui: {
102
+ notify: () => {},
103
+ setStatus: () => {},
104
+ setWidget: (_key: string, value: any) => {
105
+ if (typeof value !== "function") return;
106
+ const theme = { fg: (_c: string, text: string) => text };
107
+ widgets.push(value(undefined, theme).render(160).join("\n"));
108
+ },
109
+ theme: { fg: (_c: string, text: string) => text },
110
+ },
111
+ });
112
+ const startSession = (ctx: CtxOptions = {}) =>
113
+ handlers.get("session_start")!({ type: "session_start", reason: "startup" }, makeCtx(ctx));
114
+ return { handlers, registrations, getApiKeyForProvider, startSession, widgets };
115
+ }
116
+
117
+ /** Let the fire-and-forget revalidation chain run to completion. */
118
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
119
+ const hasLiveModel = (config: { models: Array<{ id: string }> } | undefined) => !!config?.models.some((m) => m.id === LIVE_ONLY_ID);
120
+
121
+ describe("quiet startup sync", () => {
122
+ it("skips the live fetch while the disk cache is fresher than the TTL", async () => {
123
+ setupHome();
124
+ writeCache(10 * 60 * 1000);
125
+ const fetch = stubFetch();
126
+ const ext = await loadExtension();
127
+ await ext.startSession();
128
+ await settle();
129
+ expect(ext.getApiKeyForProvider).toHaveBeenCalled();
130
+ expect(fetch).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("revalidates a stale cache once, hot-swaps the catalog and rewrites the cache", async () => {
134
+ setupHome();
135
+ writeCache(2 * HOUR);
136
+ const fetch = stubFetch();
137
+ const ext = await loadExtension();
138
+ await ext.startSession();
139
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
140
+ expect(fetch).toHaveBeenCalledOnce();
141
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf8"));
142
+ // Cache stays a plain JSON array so older extension versions still read it.
143
+ expect(Array.isArray(cached)).toBe(true);
144
+ expect(cached.some((m: { id: string }) => m.id === LIVE_ONLY_ID)).toBe(true);
145
+ expect(Date.now() - fs.statSync(cachePath).mtimeMs).toBeLessThan(60_000);
146
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
147
+ const meta = fs.readFileSync(syncMetaPath(cachePath), "utf8");
148
+ expect(meta).not.toContain(KEY);
149
+ expect(JSON.parse(meta).keyFingerprint).toBe(keyFingerprint(KEY));
150
+
151
+ // A second session in the same process (/new) is now inside the TTL.
152
+ await ext.startSession();
153
+ await settle();
154
+ expect(fetch).toHaveBeenCalledOnce();
155
+ });
156
+
157
+ it("never touches the network without an API key", async () => {
158
+ setupHome();
159
+ const fetch = stubFetch();
160
+ const ext = await loadExtension();
161
+ ext.getApiKeyForProvider.mockResolvedValue(undefined);
162
+ await ext.startSession({ hasUI: true, provider: PROVIDER_ID });
163
+ await settle();
164
+ expect(fetch).not.toHaveBeenCalled();
165
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
166
+ });
167
+
168
+ it("skips when another process holds the revalidation lock", async () => {
169
+ setupHome();
170
+ writeCache(2 * HOUR);
171
+ fs.writeFileSync(syncLockPath(cachePath), JSON.stringify({ pid: 999999, token: "other-process" }));
172
+ const fetch = stubFetch();
173
+ const ext = await loadExtension();
174
+ await ext.startSession();
175
+ await settle();
176
+ expect(fetch).not.toHaveBeenCalled();
177
+ expect(JSON.parse(fs.readFileSync(syncLockPath(cachePath), "utf8")).token).toBe("other-process");
178
+ });
179
+
180
+ it("reclaims a stale lock left by a crashed process", async () => {
181
+ setupHome();
182
+ writeCache(2 * HOUR);
183
+ const lockPath = syncLockPath(cachePath);
184
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: 999999, token: "crashed" }));
185
+ const old = new Date(Date.now() - 10 * 60 * 1000);
186
+ fs.utimesSync(lockPath, old, old);
187
+ const fetch = stubFetch();
188
+ const ext = await loadExtension();
189
+ await ext.startSession();
190
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
191
+ expect(fetch).toHaveBeenCalledOnce();
192
+ expect(fs.existsSync(lockPath)).toBe(false);
193
+ });
194
+
195
+ it("forces a revalidation when the API key changed, even inside the TTL", async () => {
196
+ setupHome();
197
+ writeCache(60 * 1000, embeddedModels, "previous-key");
198
+ const fetch = stubFetch();
199
+ const ext = await loadExtension();
200
+ await ext.startSession();
201
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
202
+ expect(fetch).toHaveBeenCalledOnce();
203
+ });
204
+
205
+ it("revalidates once when the cache predates the sync sidecar (older extension version)", async () => {
206
+ setupHome();
207
+ writeCache(60 * 1000, embeddedModels, null);
208
+ const fetch = stubFetch();
209
+ const ext = await loadExtension();
210
+ expect(ext.registrations[0].models.length).toBeGreaterThan(0); // old cache is still served
211
+ await ext.startSession();
212
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
213
+ expect(fetch).toHaveBeenCalledOnce();
214
+ });
215
+
216
+ it("does not throw or fetch when the cache dir is unwritable", async () => {
217
+ setupHome();
218
+ fs.writeFileSync(path.join(home, "cache"), "a file where the cache dir should be");
219
+ const fetch = stubFetch();
220
+ const ext = await loadExtension();
221
+ await expect(ext.startSession({ hasUI: true, provider: PROVIDER_ID })).resolves.toBeUndefined();
222
+ await settle();
223
+ expect(fetch).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("does not await the network in session_start", async () => {
227
+ setupHome();
228
+ writeCache(2 * HOUR);
229
+ let release!: () => void;
230
+ const fetch = stubFetch(() => new Promise<Response>((resolve) => { release = () => resolve(liveModelsResponse()); }));
231
+ const ext = await loadExtension();
232
+ const outcome = await Promise.race([
233
+ Promise.resolve(ext.startSession()).then(() => "returned"),
234
+ new Promise((resolve) => setTimeout(() => resolve("blocked"), 1000)),
235
+ ]);
236
+ expect(outcome).toBe("returned");
237
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
238
+ release();
239
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
240
+ });
241
+
242
+ it("swallows API key resolution failures (no unhandled rejection)", async () => {
243
+ setupHome();
244
+ const fetch = stubFetch();
245
+ const ext = await loadExtension();
246
+ const unhandled = vi.fn();
247
+ process.on("unhandledRejection", unhandled);
248
+ try {
249
+ ext.getApiKeyForProvider.mockRejectedValue(new Error("keychain locked"));
250
+ await ext.startSession();
251
+ await settle();
252
+ expect(unhandled).not.toHaveBeenCalled();
253
+ expect(fetch).not.toHaveBeenCalled();
254
+ } finally {
255
+ process.off("unhandledRejection", unhandled);
256
+ }
257
+ });
258
+
259
+ it("backs off after a failed fetch instead of retrying in every new process", async () => {
260
+ setupHome();
261
+ writeCache(2 * HOUR);
262
+ const fetch = stubFetch(async () => new Response("unauthorized", { status: 401 }));
263
+ let ext = await loadExtension();
264
+ await ext.startSession();
265
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
266
+ await settle();
267
+ ext = await loadExtension(); // next pi process
268
+ await ext.startSession();
269
+ await settle();
270
+ expect(fetch).toHaveBeenCalledOnce();
271
+ });
272
+
273
+ it("adopts a cache refreshed by another process without fetching", async () => {
274
+ setupHome();
275
+ writeCache(30 * 60 * 1000);
276
+ const fetch = stubFetch();
277
+ const ext = await loadExtension();
278
+ // Another pi process revalidated after this one started.
279
+ const refreshed = [...embeddedModels, { ...embeddedModels[0], id: LIVE_ONLY_ID }];
280
+ fs.writeFileSync(cachePath, JSON.stringify(refreshed));
281
+ await ext.startSession();
282
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
283
+ expect(fetch).not.toHaveBeenCalled();
284
+ });
285
+
286
+ it("PI_PROVIDER_SYNC_TTL_MS=0 revalidates on every session start", async () => {
287
+ setupHome();
288
+ vi.stubEnv("PI_PROVIDER_SYNC_TTL_MS", "0");
289
+ writeCache(0);
290
+ const fetch = stubFetch();
291
+ const ext = await loadExtension();
292
+ await ext.startSession();
293
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
294
+ });
295
+ });
296
+
297
+ // ─── HyperCharm: OAuth identity, /login, shared account snapshot ─────────────
298
+
299
+ function writeOAuthAuth(refresh: string, access: string): void {
300
+ fs.writeFileSync(path.join(home, "auth.json"), JSON.stringify({
301
+ [PROVIDER_ID]: { type: "oauth", refresh, access, expires: Date.now() + HOUR },
302
+ }));
303
+ }
304
+
305
+ const json = (body: unknown) => Response.json(body);
306
+ /** Catalog + account endpoints; counts every request per path suffix. */
307
+ function accountApi() {
308
+ const counts: Record<string, number> = {};
309
+ const fetch = stubFetch(async (url) => {
310
+ const key = url.slice(url.lastIndexOf("/"));
311
+ counts[key] = (counts[key] ?? 0) + 1;
312
+ if (url.endsWith("/credits")) return json({ balance: 249 });
313
+ if (url.endsWith("/teams")) return json({ items: [{ name: "Xu's Team" }] });
314
+ if (url.endsWith("/devices")) return json({ items: [] });
315
+ if (isCatalogUrl(url)) return liveModelsResponse();
316
+ throw new Error("unexpected request: " + url);
317
+ });
318
+ return { fetch, counts };
319
+ }
320
+
321
+ describe("HyperCharm quiet sync variants", () => {
322
+ it("keys the fingerprint on the stored OAuth refresh token, not the rotating access token", async () => {
323
+ setupHome();
324
+ writeOAuthAuth("refresh-1", "access-rotated-2");
325
+ writeCache(10 * 60 * 1000, embeddedModels, "oauth:refresh-1");
326
+ const fetch = stubFetch();
327
+ let ext = await loadExtension();
328
+ ext.getApiKeyForProvider.mockResolvedValue("access-rotated-2");
329
+ await ext.startSession();
330
+ await settle();
331
+ expect(fetch).not.toHaveBeenCalled(); // same account, new access token: still fresh
332
+
333
+ // A re-login stores a new refresh token: that is an identity change.
334
+ writeOAuthAuth("refresh-2", "access-3");
335
+ ext = await loadExtension();
336
+ ext.getApiKeyForProvider.mockResolvedValue("access-3");
337
+ await ext.startSession();
338
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
339
+ expect(fetch).toHaveBeenCalledOnce();
340
+ // The fetch itself uses the real access token.
341
+ expect(new Headers(fetch.mock.calls[0][1]?.headers).get("Authorization")).toBe("Bearer access-3");
342
+ expect(JSON.parse(fs.readFileSync(syncMetaPath(cachePath), "utf8")).keyFingerprint).toBe(keyFingerprint("oauth:refresh-2"));
343
+ });
344
+
345
+ it("forces a revalidation after /login completes", async () => {
346
+ setupHome();
347
+ writeCache(10 * 60 * 1000);
348
+ vi.doMock("../oauth", () => ({
349
+ USER_AGENT: "pi-hypercharm-provider/test",
350
+ loginHypercharm: async () => ({ type: "oauth", refresh: "fresh-refresh", access: "fresh-access", expires: Date.now() + HOUR }),
351
+ refreshHypercharmToken: async (c: unknown) => c,
352
+ }));
353
+ const fetch = stubFetch();
354
+ const ext = await loadExtension();
355
+ await ext.startSession();
356
+ await settle();
357
+ expect(fetch).not.toHaveBeenCalled();
358
+
359
+ const credentials = await ext.registrations[0].oauth.login({});
360
+ expect(credentials.access).toBe("fresh-access");
361
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
362
+ expect(fetch).toHaveBeenCalledOnce();
363
+ expect(new Headers(fetch.mock.calls[0][1]?.headers).get("Authorization")).toBe("Bearer fresh-access");
364
+ });
365
+
366
+ it("skips the startup account poll without a UI (print mode, child agents)", async () => {
367
+ setupHome();
368
+ writeCache(10 * 60 * 1000);
369
+ const { counts } = accountApi();
370
+ const ext = await loadExtension();
371
+ await ext.startSession({ hasUI: false, provider: PROVIDER_ID });
372
+ await settle();
373
+ expect(counts).toEqual({});
374
+ });
375
+
376
+ it("polls the account once across processes and renders the shared snapshot from disk", async () => {
377
+ setupHome();
378
+ writeCache(10 * 60 * 1000);
379
+ const { counts } = accountApi();
380
+ const first = await loadExtension();
381
+ await first.startSession({ hasUI: true, provider: PROVIDER_ID });
382
+ await vi.waitFor(() => expect(first.widgets.some((w) => w.includes("249 hc"))).toBe(true));
383
+ expect(counts).toEqual({ "/credits": 1, "/teams": 1, "/devices": 1 });
384
+ const accountCache = path.join(home, "cache", `${PROVIDER_ID}-account.json`);
385
+ const snapshot = JSON.parse(fs.readFileSync(accountCache, "utf8"));
386
+ expect(snapshot).toMatchObject({ balance: 249, teamName: "Xu's Team" });
387
+ expect(fs.readFileSync(syncMetaPath(accountCache), "utf8")).not.toContain(KEY);
388
+ expect(fs.existsSync(syncLockPath(accountCache))).toBe(false);
389
+
390
+ // Next pi process within the account TTL: renders from disk, no polling.
391
+ const second = await loadExtension();
392
+ await second.startSession({ hasUI: true, provider: PROVIDER_ID });
393
+ await settle();
394
+ expect(second.widgets.some((w) => w.includes("Xu's Team") && w.includes("249 hc"))).toBe(true);
395
+ expect(counts).toEqual({ "/credits": 1, "/teams": 1, "/devices": 1 });
396
+ });
397
+
398
+ it("does not adopt an account snapshot written for another credential", async () => {
399
+ setupHome();
400
+ writeCache(10 * 60 * 1000, embeddedModels, "other-key");
401
+ const accountCache = path.join(home, "cache", `${PROVIDER_ID}-account.json`);
402
+ fs.writeFileSync(accountCache, JSON.stringify({ version: 1, balance: 999, teamName: "Someone Else", authExpiresAt: null }));
403
+ fs.writeFileSync(syncMetaPath(accountCache), JSON.stringify({ version: 1, keyFingerprint: keyFingerprint("other-key") }));
404
+ const { counts } = accountApi();
405
+ const ext = await loadExtension();
406
+ await ext.startSession({ hasUI: true, provider: PROVIDER_ID });
407
+ await vi.waitFor(() => expect(ext.widgets.some((w) => w.includes("249 hc"))).toBe(true));
408
+ expect(ext.widgets.some((w) => w.includes("Someone Else"))).toBe(false);
409
+ expect(counts["/credits"]).toBe(1);
410
+ });
411
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Unit tests for sync-policy.ts — the provider-agnostic quiet startup sync
3
+ * policy (TTL, key fingerprint, failure backoff, cross-process lock).
4
+ *
5
+ * Provider-agnostic: copied verbatim into every pi-*-provider repo alongside
6
+ * sync-policy.ts.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { execFile } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import {
16
+ DEFAULT_SYNC_TTL_MS,
17
+ SYNC_TTL_ENV,
18
+ acquireModelSync,
19
+ cacheChangedSinceLoad,
20
+ keyFingerprint,
21
+ markCacheLoaded,
22
+ planModelSync,
23
+ resolveSyncTtlMs,
24
+ syncLockPath,
25
+ syncMetaPath,
26
+ writeFileAtomic,
27
+ } from "../sync-policy";
28
+
29
+ const HOUR = 60 * 60 * 1000;
30
+ const KEY = "sk-test-secret-key";
31
+
32
+ let dir: string;
33
+ let cachePath: string;
34
+
35
+ beforeEach(() => {
36
+ vi.stubEnv(SYNC_TTL_ENV, "");
37
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sync-policy-"));
38
+ cachePath = path.join(dir, "cache", "example-models.json");
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.unstubAllEnvs();
43
+ fs.rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ function writeCache(ageMs: number, models: unknown = [{ id: "m" }]): void {
47
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
48
+ fs.writeFileSync(cachePath, JSON.stringify(models));
49
+ const t = new Date(Date.now() - ageMs);
50
+ fs.utimesSync(cachePath, t, t);
51
+ }
52
+
53
+ function writeMeta(meta: Record<string, unknown>): void {
54
+ fs.writeFileSync(syncMetaPath(cachePath), JSON.stringify({ version: 1, ...meta }));
55
+ }
56
+
57
+ function writeLock(ageMs: number, token = "someone-else"): void {
58
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
59
+ fs.writeFileSync(syncLockPath(cachePath), JSON.stringify({ pid: 999999, token }));
60
+ const t = new Date(Date.now() - ageMs);
61
+ fs.utimesSync(syncLockPath(cachePath), t, t);
62
+ }
63
+
64
+ describe("keyFingerprint", () => {
65
+ it("is a stable 16-hex digest that never contains the key", () => {
66
+ const fp = keyFingerprint(KEY);
67
+ expect(fp).toMatch(/^[0-9a-f]{16}$/);
68
+ expect(keyFingerprint(KEY)).toBe(fp);
69
+ expect(keyFingerprint(`${KEY}x`)).not.toBe(fp);
70
+ expect(fp).not.toContain(KEY);
71
+ });
72
+ });
73
+
74
+ describe("resolveSyncTtlMs", () => {
75
+ it("defaults to 1 hour", () => {
76
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
77
+ expect(DEFAULT_SYNC_TTL_MS).toBe(HOUR);
78
+ });
79
+
80
+ it(`honors ${SYNC_TTL_ENV} (0 = always revalidate) and ignores garbage`, () => {
81
+ vi.stubEnv(SYNC_TTL_ENV, "0");
82
+ expect(resolveSyncTtlMs()).toBe(0);
83
+ vi.stubEnv(SYNC_TTL_ENV, "120000");
84
+ expect(resolveSyncTtlMs()).toBe(120000);
85
+ vi.stubEnv(SYNC_TTL_ENV, "-5");
86
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
87
+ vi.stubEnv(SYNC_TTL_ENV, "soon");
88
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
89
+ });
90
+
91
+ it("prefers an explicit ttlMs over the env", () => {
92
+ vi.stubEnv(SYNC_TTL_ENV, "0");
93
+ expect(resolveSyncTtlMs(5000)).toBe(5000);
94
+ });
95
+ });
96
+
97
+ describe("planModelSync", () => {
98
+ it("never fetches without a key", () => {
99
+ expect(planModelSync({ cachePath, apiKey: undefined })).toEqual({ fetch: false, reason: "no-key" });
100
+ expect(planModelSync({ cachePath, apiKey: "" })).toEqual({ fetch: false, reason: "no-key" });
101
+ });
102
+
103
+ it("fetches when the cache is missing or corrupt", () => {
104
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
105
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
106
+ fs.writeFileSync(cachePath, "{not json");
107
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
108
+ writeCache(0, []);
109
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
110
+ });
111
+
112
+ it("skips inside the TTL and fetches once it expires", () => {
113
+ writeCache(10 * 60 * 1000);
114
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
115
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "fresh" });
116
+ writeCache(2 * HOUR);
117
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
118
+ vi.stubEnv(SYNC_TTL_ENV, "0");
119
+ writeCache(0);
120
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
121
+ });
122
+
123
+ it("forces a fetch when the key changed or the cache predates the sidecar", () => {
124
+ writeCache(0);
125
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "key-changed" });
126
+ writeMeta({ keyFingerprint: keyFingerprint("old-key") });
127
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "key-changed" });
128
+ });
129
+
130
+ it("forces a fetch on demand (e.g. after /login) regardless of TTL", () => {
131
+ writeCache(0);
132
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
133
+ expect(planModelSync({ cachePath, apiKey: KEY, force: true })).toEqual({ fetch: true, reason: "forced" });
134
+ });
135
+
136
+ it("backs off after a recent failure with the same key, unless forced", () => {
137
+ writeCache(2 * HOUR);
138
+ writeMeta({ keyFingerprint: keyFingerprint(KEY), failedAt: Date.now() - 1000, failedKeyFingerprint: keyFingerprint(KEY) });
139
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "backoff" });
140
+ expect(planModelSync({ cachePath, apiKey: KEY, force: true }).fetch).toBe(true);
141
+ // A different key is not held back by another key's failure.
142
+ expect(planModelSync({ cachePath, apiKey: "new-key" })).toEqual({ fetch: true, reason: "key-changed" });
143
+ // Backoff expires.
144
+ writeMeta({ keyFingerprint: keyFingerprint(KEY), failedAt: Date.now() - HOUR, failedKeyFingerprint: keyFingerprint(KEY) });
145
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
146
+ });
147
+ });
148
+
149
+ describe("acquireModelSync", () => {
150
+ it("is single-flight: a second caller skips while the lock is held", () => {
151
+ const skipped: string[] = [];
152
+ const first = acquireModelSync({ cachePath, apiKey: KEY });
153
+ expect(first?.reason).toBe("missing");
154
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(true);
155
+ expect(acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).toBeNull();
156
+ expect(skipped).toEqual(["locked"]);
157
+ first!.release("aborted");
158
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
159
+ expect(acquireModelSync({ cachePath, apiKey: KEY })).not.toBeNull();
160
+ });
161
+
162
+ it("skips while another process holds a live lock", () => {
163
+ writeLock(1000);
164
+ const skipped: string[] = [];
165
+ expect(acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).toBeNull();
166
+ expect(skipped).toEqual(["locked"]);
167
+ });
168
+
169
+ it("reclaims a stale lock left by a crashed process", () => {
170
+ writeLock(5 * 60 * 1000);
171
+ const lease = acquireModelSync({ cachePath, apiKey: KEY });
172
+ expect(lease).not.toBeNull();
173
+ lease!.release("aborted");
174
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
175
+ });
176
+
177
+ it("never removes a lock it no longer owns", () => {
178
+ const lease = acquireModelSync({ cachePath, apiKey: KEY, lockStaleMs: 0 })!;
179
+ writeLock(0, "reclaimed-by-another-process");
180
+ lease.release("aborted");
181
+ expect(JSON.parse(fs.readFileSync(syncLockPath(cachePath), "utf8")).token).toBe("reclaimed-by-another-process");
182
+ });
183
+
184
+ it("records the key fingerprint on success (never the key) and the failure on error", () => {
185
+ let lease = acquireModelSync({ cachePath, apiKey: KEY })!;
186
+ writeCache(0);
187
+ lease.release("fetched");
188
+ const raw = fs.readFileSync(syncMetaPath(cachePath), "utf8");
189
+ expect(raw).not.toContain(KEY);
190
+ expect(JSON.parse(raw)).toMatchObject({ version: 1, keyFingerprint: keyFingerprint(KEY) });
191
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "fresh" });
192
+
193
+ lease = acquireModelSync({ cachePath, apiKey: KEY, force: true })!;
194
+ lease.release("failed");
195
+ expect(JSON.parse(fs.readFileSync(syncMetaPath(cachePath), "utf8"))).toMatchObject({
196
+ keyFingerprint: keyFingerprint(KEY),
197
+ failedKeyFingerprint: keyFingerprint(KEY),
198
+ });
199
+ });
200
+
201
+ it("does not record a failure for an aborted fetch", () => {
202
+ acquireModelSync({ cachePath, apiKey: KEY })!.release("aborted");
203
+ expect(fs.existsSync(syncMetaPath(cachePath))).toBe(false);
204
+ });
205
+
206
+ it("fails open (null, no throw) when the cache dir is unwritable", () => {
207
+ // A regular file where the cache directory should be: mkdir/open fail even as root.
208
+ fs.writeFileSync(path.join(dir, "cache"), "not a directory");
209
+ const skipped: string[] = [];
210
+ expect(() => acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).not.toThrow();
211
+ expect(acquireModelSync({ cachePath, apiKey: KEY })).toBeNull();
212
+ expect(skipped).toEqual(["unwritable"]);
213
+ });
214
+
215
+ // Real processes: children load sync-policy.ts via Node's built-in type stripping (Node >= 22.18).
216
+ it.skipIf(!(process.features as { typescript?: unknown }).typescript)("lets exactly one of many concurrent processes fetch", async () => {
217
+ writeCache(2 * HOUR);
218
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
219
+ const modulePath = fileURLToPath(new URL("../sync-policy.ts", import.meta.url));
220
+ // Each child: try to acquire; the winner "fetches" for 300ms, writes the
221
+ // cache and releases. Late starters must then see a fresh cache.
222
+ const child = `
223
+ const { acquireModelSync, writeFileAtomic } = await import(${JSON.stringify(modulePath)});
224
+ const [cachePath, key] = process.argv.slice(-2);
225
+ const lease = acquireModelSync({ cachePath, apiKey: key });
226
+ if (lease) {
227
+ await new Promise((r) => setTimeout(r, 300));
228
+ writeFileAtomic(cachePath, JSON.stringify([{ id: "fresh" }]));
229
+ lease.release("fetched");
230
+ }
231
+ process.stdout.write(lease ? "fetch" : "skip");
232
+ `;
233
+ const run = () => new Promise<string>((resolve, reject) => {
234
+ execFile(process.execPath, ["--input-type=module", "-e", child, cachePath, KEY], (err, stdout) => err ? reject(err) : resolve(stdout));
235
+ });
236
+ const results = await Promise.all(Array.from({ length: 8 }, run));
237
+ expect(results.filter((r) => r === "fetch")).toHaveLength(1);
238
+ expect(results.filter((r) => r === "skip")).toHaveLength(7);
239
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
240
+ }, 20000);
241
+ });
242
+
243
+ describe("cache helpers", () => {
244
+ it("writeFileAtomic replaces the file without leaving temp files", () => {
245
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
246
+ writeFileAtomic(cachePath, "[1]");
247
+ writeFileAtomic(cachePath, "[2]");
248
+ expect(fs.readFileSync(cachePath, "utf8")).toBe("[2]");
249
+ expect(fs.readdirSync(path.dirname(cachePath))).toEqual([path.basename(cachePath)]);
250
+ });
251
+
252
+ it("cacheChangedSinceLoad spots a cache rewritten by another process", () => {
253
+ writeCache(HOUR);
254
+ markCacheLoaded(cachePath);
255
+ expect(cacheChangedSinceLoad(cachePath)).toBe(false);
256
+ writeCache(0);
257
+ expect(cacheChangedSinceLoad(cachePath)).toBe(true);
258
+ });
259
+ });
package/tsconfig.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "types": ["node"],
8
8
  "esModuleInterop": true,
9
9
  "strict": false,
10
+ "strictNullChecks": true,
10
11
  "skipLibCheck": true,
11
12
  "noEmit": true,
12
13
  "resolveJsonModule": true