Skip to content
Source
Sign inSign up
Open History to see the last commit

providerStatusCache.ts

144 lines4.8 KB

1import {
2 type ProviderInstanceId,
3 type ServerProvider,
4 ServerProvider as ServerProviderSchema,
5} from "@t3tools/contracts";
6import { causeErrorTag } from "@t3tools/shared/observability";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11​
12import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
13​
14const decodeProviderStatusCache = Schema.decodeUnknownEffect(
15 Schema.fromJsonString(ServerProviderSchema),
16);
17​
18const mergeProviderModels = (
19 fallbackModels: ReadonlyArray<ServerProvider["models"][number]>,
20 cachedModels: ReadonlyArray<ServerProvider["models"][number]>,
21): ReadonlyArray<ServerProvider["models"][number]> => {
22 const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug));
23 // Settings already supply current custom models; cached rows could resurrect removed ones.
24 return [
25 ...fallbackModels,
26 ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)),
27 ];
28};
29​
30const BUILT_IN_DRIVER_ORDER: ReadonlyArray<string> = [
31 "codex",
32 "claudeAgent",
33 "cursor",
34 "grok",
35 "opencode",
36 "antigravity",
37 "fx",
38 "kiro",
39];
40​
41const driverRank = (driver: string): number => {
42 const index = BUILT_IN_DRIVER_ORDER.indexOf(driver);
43 return index === -1 ? BUILT_IN_DRIVER_ORDER.length : index;
44};
45​
46export const orderProviderSnapshots = (
47 providers: ReadonlyArray<ServerProvider>,
48): ReadonlyArray<ServerProvider> =>
49 [...providers].toSorted(
50 (left, right) =>
51 driverRank(left.driver) - driverRank(right.driver) ||
52 left.driver.localeCompare(right.driver) ||
53 (left.displayName ?? "").localeCompare(right.displayName ?? "") ||
54 left.instanceId.localeCompare(right.instanceId),
55 );
56​
57export const isCachedProviderCorrelated = (input: {
58 readonly cachedProvider: ServerProvider;
59 readonly fallbackProvider: ServerProvider;
60}): boolean =>
61 input.cachedProvider.instanceId === input.fallbackProvider.instanceId &&
62 input.cachedProvider.driver === input.fallbackProvider.driver;
63​
64export const hydrateCachedProvider = (input: {
65 readonly cachedProvider: ServerProvider;
66 readonly fallbackProvider: ServerProvider;
67}): ServerProvider => {
68 if (!isCachedProviderCorrelated(input)) {
69 return input.fallbackProvider;
70 }
71​
72 if (
73 !input.fallbackProvider.enabled ||
74 input.cachedProvider.enabled !== input.fallbackProvider.enabled
75 ) {
76 return input.fallbackProvider;
77 }
78​
79 const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider;
80 const hydratedProvider: ServerProvider = {
81 ...fallbackWithoutMessage,
82 models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models),
83 installed: input.cachedProvider.installed,
84 version: input.cachedProvider.version,
85 status: input.cachedProvider.status,
86 auth: input.cachedProvider.auth,
87 checkedAt: input.cachedProvider.checkedAt,
88 slashCommands: input.cachedProvider.slashCommands,
89 skills: input.cachedProvider.skills,
90 };
91​
92 return input.cachedProvider.message
93 ? { ...hydratedProvider, message: input.cachedProvider.message }
94 : hydratedProvider;
95};
96​
97/** Require matching instanceId and driver before hydration; filenames alone cannot authorize routing. */
98export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")(
99 function* (input: {
100 readonly cacheDir: string;
101 readonly instanceId: ProviderInstanceId;
102 }): Effect.fn.Return<string, never, Path.Path> {
103 const path = yield* Path.Path;
104 return path.join(input.cacheDir, `${input.instanceId}.json`);
105 },
106);
107​
108export const readProviderStatusCache = Effect.fn("providerStatusCache.readProviderStatusCache")(
109 function* (filePath: string) {
110 const fs = yield* FileSystem.FileSystem;
111 const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false));
112 if (!exists) {
113 return undefined;
114 }
115​
116 const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => ""));
117 const trimmed = raw.trim();
118 if (trimmed.length === 0) {
119 return undefined;
120 }
121​
122 return yield* decodeProviderStatusCache(trimmed).pipe(
123 Effect.matchCauseEffect({
124 onFailure: (cause) =>
125 Effect.logWarning("failed to parse provider status cache, ignoring", {
126 path: filePath,
127 errorTag: causeErrorTag(cause),
128 }).pipe(Effect.as(undefined)),
129 onSuccess: Effect.succeed,
130 }),
131 );
132 },
133);
134​
135export const writeProviderStatusCache = (input: {
136 readonly filePath: string;
137 readonly provider: ServerProvider;
138}) => {
139 const { updateState: _updateState, ...cacheableProvider } = input.provider;
140 return writeFileStringAtomically({
141 filePath: input.filePath,
142 contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`,
143 });
144};