Unified · Split
| @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines | |||
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | import * as Fiber from "effect/Fiber"; | 5 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | ||
| 7 | import * as PubSub from "effect/PubSub"; | 8 | import * as PubSub from "effect/PubSub"; |
| 8 | import * as Ref from "effect/Ref"; | 9 | import * as Ref from "effect/Ref"; |
| 9 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| @@ -752,7 +753,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T · 742 unmodified lines | |||
| 752 | ]); | 753 | ]); |
| 753 | yield* PubSub.publish(changes, refreshedProvider); | 754 | yield* PubSub.publish(changes, refreshedProvider); |
| 754 | 755 | ||
| 755 | let cachedProvider = yield* readProviderStatusCache(filePath); | 756 | let cachedProvider = Option.getOrUndefined(yield* readProviderStatusCache(filePath)); |
| 756 | for ( | 757 | for ( |
| 757 | let attempt = 0; | 758 | let attempt = 0; |
| 758 | attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; | 759 | attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; |
| @@ -760,7 +761,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T · 1 unmodified line | |||
| 760 | ) { | 761 | ) { |
| 761 | yield* TestClock.adjust("10 millis"); | 762 | yield* TestClock.adjust("10 millis"); |
| 762 | yield* Effect.yieldNow; | 763 | yield* Effect.yieldNow; |
| 763 | cachedProvider = yield* readProviderStatusCache(filePath); | 764 | cachedProvider = Option.getOrUndefined(yield* readProviderStatusCache(filePath)); |
| 764 | } | 765 | } |
| 765 | 766 | ||
| 766 | assert.deepStrictEqual(cachedProvider, { | 767 | assert.deepStrictEqual(cachedProvider, { |
| @@ -34,6 +34,7 @@ import * as Effect from "effect/Effect"; · 33 unmodified lines | |||
| 34 | import * as Equal from "effect/Equal"; | 34 | import * as Equal from "effect/Equal"; |
| 35 | import * as FileSystem from "effect/FileSystem"; | 35 | import * as FileSystem from "effect/FileSystem"; |
| 36 | import * as Layer from "effect/Layer"; | 36 | import * as Layer from "effect/Layer"; |
| 37 | import * as Option from "effect/Option"; | ||
| 37 | import * as Path from "effect/Path"; | 38 | import * as Path from "effect/Path"; |
| 38 | import * as PubSub from "effect/PubSub"; | 39 | import * as PubSub from "effect/PubSub"; |
| 39 | import * as Ref from "effect/Ref"; | 40 | import * as Ref from "effect/Ref"; |
| @@ -237,25 +238,27 @@ export const ProviderRegistryLive = Layer.effect( · 197 unmodified lines | |||
| 237 | } | 238 | } |
| 238 | return yield* readProviderStatusCache(filePath).pipe( | 239 | return yield* readProviderStatusCache(filePath).pipe( |
| 239 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 240 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 240 | Effect.flatMap((cachedProvider) => { | 241 | Effect.flatMap((cachedProvider) => |
| 241 | if (cachedProvider === undefined) { | 242 | Option.match(cachedProvider, { |
| 242 | return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); | 243 | onNone: () => Effect.succeed(undefined as ServerProvider | undefined), |
| 243 | } | 244 | onSome: (cachedProvider) => { |
| 244 | const correlation = { | 245 | const correlation = { |
| 245 | cachedProvider, | 246 | cachedProvider, |
| 246 | fallbackProvider, | 247 | fallbackProvider, |
| 247 | } as const; | 248 | } as const; |
| 248 | if (!isCachedProviderCorrelated(correlation)) { | 249 | if (!isCachedProviderCorrelated(correlation)) { |
| 249 | return Effect.logWarning("provider status cache identity mismatch, ignoring", { | 250 | return Effect.logWarning("provider status cache identity mismatch, ignoring", { |
| 250 | path: filePath, | 251 | path: filePath, |
| 251 | instanceId: source.instanceId, | 252 | instanceId: source.instanceId, |
| 252 | cachedInstanceId: cachedProvider.instanceId ?? null, | 253 | cachedInstanceId: cachedProvider.instanceId ?? null, |
| 253 | driver: source.driverKind, | 254 | driver: source.driverKind, |
| 254 | cachedDriver: cachedProvider.driver ?? null, | 255 | cachedDriver: cachedProvider.driver ?? null, |
| 255 | }).pipe(Effect.as(undefined as ServerProvider | undefined)); | 256 | }).pipe(Effect.as(undefined as ServerProvider | undefined)); |
| 256 | } | 257 | } |
| 257 | return Effect.succeed(hydrateCachedProvider(correlation)); | 258 | return Effect.succeed(hydrateCachedProvider(correlation)); |
| 258 | }), | 259 | }, |
| 260 | }), | ||
| 261 | ), | ||
| 259 | ); | 262 | ); |
| 260 | }), | 263 | }), |
| 261 | { concurrency: "unbounded" }, | 264 | { concurrency: "unbounded" }, |
| @@ -9,6 +9,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; · 8 unmodified lines | |||
| 9 | import { assert, it } from "@effect/vitest"; | 9 | import { assert, it } from "@effect/vitest"; |
| 10 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Option from "effect/Option"; | ||
| 12 | 13 | ||
| 13 | import { | 14 | import { |
| 14 | hydrateCachedProvider, | 15 | hydrateCachedProvider, |
| @@ -81,9 +82,41 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 66 unmodified lines | |||
| 81 | provider: openCodeProvider, | 82 | provider: openCodeProvider, |
| 82 | }); | 83 | }); |
| 83 | 84 | ||
| 84 | assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); | 85 | assert.deepStrictEqual(Option.getOrNull(yield* readProviderStatusCache(codexPath)), codexProvider); |
| 85 | assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); | 86 | assert.deepStrictEqual( |
| 86 | assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); | 87 | Option.getOrNull(yield* readProviderStatusCache(claudePath)), |
| 88 | claudeProvider, | ||
| 89 | ); | ||
| 90 | assert.deepStrictEqual( | ||
| 91 | Option.getOrNull(yield* readProviderStatusCache(openCodePath)), | ||
| 92 | openCodeProvider, | ||
| 93 | ); | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | it.effect("returns none for missing, empty, or invalid cache files", () => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const fs = yield* FileSystem.FileSystem; | ||
| 100 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-invalid-" }); | ||
| 101 | const missingPath = yield* resolveProviderStatusCachePath({ | ||
| 102 | cacheDir: tempDir, | ||
| 103 | instanceId: defaultInstanceIdForDriver(CODEX_DRIVER), | ||
| 104 | }); | ||
| 105 | const emptyPath = yield* resolveProviderStatusCachePath({ | ||
| 106 | cacheDir: tempDir, | ||
| 107 | instanceId: ProviderInstanceId.make("codex_empty"), | ||
| 108 | }); | ||
| 109 | const invalidPath = yield* resolveProviderStatusCachePath({ | ||
| 110 | cacheDir: tempDir, | ||
| 111 | instanceId: ProviderInstanceId.make("codex_invalid"), | ||
| 112 | }); | ||
| 113 | |||
| 114 | yield* fs.writeFileString(emptyPath, "\n"); | ||
| 115 | yield* fs.writeFileString(invalidPath, "{ invalid json"); | ||
| 116 | |||
| 117 | assert.strictEqual(Option.isNone(yield* readProviderStatusCache(missingPath)), true); | ||
| 118 | assert.strictEqual(Option.isNone(yield* readProviderStatusCache(emptyPath)), true); | ||
| 119 | assert.strictEqual(Option.isNone(yield* readProviderStatusCache(invalidPath)), true); | ||
| 87 | }), | 120 | }), |
| 88 | ); | 121 | ); |
| 89 | 122 | ||
| @@ -4,17 +4,19 @@ import { · 3 unmodified lines | |||
| 4 | type ServerProvider, | 4 | type ServerProvider, |
| 5 | ServerProvider as ServerProviderSchema, | 5 | ServerProvider as ServerProviderSchema, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; | ||
| 7 | import * as Cause from "effect/Cause"; | 8 | import * as Cause from "effect/Cause"; |
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Option from "effect/Option"; | ||
| 10 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 11 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 12 | 14 | ||
| 13 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | 15 | import { writeFileStringAtomically } from "../atomicWrite.ts"; |
| 14 | 16 | ||
| 15 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( | 17 | const ProviderStatusCacheJson = fromJsonStringPretty(ServerProviderSchema); |
| 16 | Schema.fromJsonString(ServerProviderSchema), | 18 | const decodeProviderStatusCache = Schema.decodeUnknownEffect(ProviderStatusCacheJson); |
| 17 | ); | 19 | const encodeProviderStatusCache = Schema.encodeEffect(ProviderStatusCacheJson); |
| 18 | 20 | ||
| 19 | const mergeProviderModels = ( | 21 | const mergeProviderModels = ( |
| 20 | fallbackModels: ReadonlyArray<ServerProvider["models"][number]>, | 22 | fallbackModels: ReadonlyArray<ServerProvider["models"][number]>, |
| @@ -120,13 +122,13 @@ export const readProviderStatusCache = (filePath: string) => · 99 unmodified lines | |||
| 120 | const fs = yield* FileSystem.FileSystem; | 122 | const fs = yield* FileSystem.FileSystem; |
| 121 | const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); | 123 | const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); |
| 122 | if (!exists) { | 124 | if (!exists) { |
| 123 | return undefined; | 125 | return Option.none<ServerProvider>(); |
| 124 | } | 126 | } |
| 125 | 127 | ||
| 126 | const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); | 128 | const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); |
| 127 | const trimmed = raw.trim(); | 129 | const trimmed = raw.trim(); |
| 128 | if (trimmed.length === 0) { | 130 | if (trimmed.length === 0) { |
| 129 | return undefined; | 131 | return Option.none<ServerProvider>(); |
| 130 | } | 132 | } |
| 131 | 133 | ||
| 132 | return yield* decodeProviderStatusCache(trimmed).pipe( | 134 | return yield* decodeProviderStatusCache(trimmed).pipe( |
| @@ -135,8 +137,8 @@ export const readProviderStatusCache = (filePath: string) => · 2 unmodified lines | |||
| 135 | Effect.logWarning("failed to parse provider status cache, ignoring", { | 137 | Effect.logWarning("failed to parse provider status cache, ignoring", { |
| 136 | path: filePath, | 138 | path: filePath, |
| 137 | issues: Cause.pretty(cause), | 139 | issues: Cause.pretty(cause), |
| 138 | }).pipe(Effect.as(undefined)), | 140 | }).pipe(Effect.as(Option.none<ServerProvider>())), |
| 139 | onSuccess: Effect.succeed, | 141 | onSuccess: (provider) => Effect.succeed(Option.some(provider)), |
| 140 | }), | 142 | }), |
| 141 | ); | 143 | ); |
| 142 | }); | 144 | }); |
| @@ -146,8 +148,12 @@ export const writeProviderStatusCache = (input: { · 3 unmodified lines | |||
| 146 | readonly provider: ServerProvider; | 148 | readonly provider: ServerProvider; |
| 147 | }) => { | 149 | }) => { |
| 148 | const { updateState: _updateState, ...cacheableProvider } = input.provider; | 150 | const { updateState: _updateState, ...cacheableProvider } = input.provider; |
| 149 | return writeFileStringAtomically({ | 151 | return encodeProviderStatusCache(cacheableProvider).pipe( |
| 150 | filePath: input.filePath, | 152 | Effect.flatMap((contents) => |
| 151 | contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`, | 153 | writeFileStringAtomically({ |
| 152 | }); | 154 | filePath: input.filePath, |
| 155 | contents: `${contents}\n`, | ||
| 156 | }), | ||
| 157 | ), | ||
| 158 | ); | ||
| 153 | }; | 159 | }; |