diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ade826e53f38b..7a28fbf9a376f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2175,7 +2175,10 @@ const layerWsRpc = ( Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; const maintenance = yield* instance.snapshot.resolveMaintenance({ - fresh: true, + fresh: + input.instanceId !== undefined && + input.cwd !== undefined && + input.fresh !== true, }); if (maintenance.packageName) yield* providerLatestVersions.invalidate(maintenance.packageName); diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 0cbb238cf1f80..a5556ba2664bc 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -12,12 +12,17 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "./ProviderHost.ts"; import { makeManagedServerProvider } from "./managedProvider.ts"; +import { + createProviderVersionAdvisory, + makeCachedProviderMaintenanceResolution, +} from "./maintenanceResolver.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const fastModeCapabilities = createModelCapabilities({ @@ -140,6 +145,56 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect( + "rereads installer metadata before explicit refresh while background checks stay cached", + () => + Effect.scoped( + Effect.gen(function* () { + const latestVersion = yield* Ref.make("1.0.0"); + const published = yield* Queue.unbounded(); + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + Ref.get(latestVersion).pipe( + Effect.map((version) => ({ ...maintenanceCapabilities, latestVersion: version })), + ), + ); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const capabilities = yield* resolveMaintenance(); + return { + ...refreshedSnapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: refreshedSnapshot.driver, + currentVersion: refreshedSnapshot.version, + latestVersion: capabilities.latestVersion ?? null, + maintenanceCapabilities: capabilities, + }), + }; + }), + enrichSnapshot: ({ snapshot }) => Queue.offer(published, snapshot).pipe(Effect.asVoid), + refreshInterval: "1 minute", + }); + assert.strictEqual((yield* Queue.take(published)).versionAdvisory?.status, "current"); + + yield* Ref.set(latestVersion, "2.0.0"); + yield* TestClock.adjust("1 minute"); + assert.strictEqual( + (yield* Queue.take(published)).versionAdvisory?.latestVersion, + "1.0.0", + ); + + const refreshed = yield* provider.refresh; + assert.strictEqual(refreshed.versionAdvisory?.latestVersion, "2.0.0"); + assert.strictEqual(refreshed.versionAdvisory?.status, "behind_latest"); + assert.deepStrictEqual(yield* provider.getSnapshot, refreshed); + }), + ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), + ); + it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index e90d5eefa04b7..c63534c3a89f3 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -293,7 +293,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return { resolveMaintenance: input.resolveMaintenance, getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), - refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), + refresh: input + .resolveMaintenance({ fresh: true }) + .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, get streamChanges() { return Stream.fromPubSub(changesPubSub); diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 9a70f74eed835..bf0024a009793 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -119,7 +119,16 @@ export const MuseDriver: ProviderDriver = { return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; }); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, + // Status refresh respects update checks; explicit updates use the resolver below. + resolveMaintenance: (options) => + host.settings.get.pipe( + Effect.orDie, + Effect.flatMap((settings) => + settings.enableProviderUpdateChecks + ? resolveMaintenance(options) + : resolveInstallation(options), + ), + ), getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -176,7 +185,7 @@ export const MuseDriver: ProviderDriver = { displayName, accentColor, enabled, - snapshot, + snapshot: { ...snapshot, resolveMaintenance }, orchestrationAdapter, textGeneration, } satisfies ProviderInstance; diff --git a/packages/provider-muse/src/server/maintenance.test.ts b/packages/provider-muse/src/server/maintenance.test.ts index 51550fe9f143a..8271e70946b14 100644 --- a/packages/provider-muse/src/server/maintenance.test.ts +++ b/packages/provider-muse/src/server/maintenance.test.ts @@ -1,8 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -15,6 +24,11 @@ import { } from "./maintenance.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +import { MuseDriver } from "./driver.ts"; const maintenanceCapabilities = { ...makeManualOnlyProviderMaintenanceCapabilities({ @@ -38,6 +52,87 @@ const snapshot: ServerProvider = { }; it.layer(NodeServices.layer)("Muse maintenance", (it) => { + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "refreshes installation metadata without release requests when update checks are disabled", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-refresh-" }); + const binaryPath = `${directory}/muse`; + // A failing version probe keeps the installed version without starting an SDK host. + const source = "#!/bin/sh\nprintf 'muse 1.0.3-R2198.1\\n'\nexit 1\n"; + yield* fs.writeFileString(binaryPath, source); + yield* fs.chmod(binaryPath, 0o755); + const requests: string[] = []; + yield* Effect.gen(function* () { + const { snapshot: provider } = yield* MuseDriver.create({ + instanceId: ProviderInstanceId.make("muse-refresh"), + displayName: "Muse refresh", + enabled: true, + environment: [{ name: "MUSE_CHANNEL", value: "muse-stable", sensitive: false }], + config: { ...MuseDriver.defaultConfig(), binaryPath }, + }); + const enriched = provider.streamChanges.pipe( + Stream.filter((current) => current.versionAdvisory !== undefined), + Stream.take(1), + Stream.runCollect, + Effect.map((snapshots) => Array.from(snapshots)[0]!), + ); + const current = yield* provider.getSnapshot; + const initial = current.versionAdvisory ? current : yield* enriched; + expect(initial.version).toBe("1.0.3-R2198.1"); + expect(initial.versionAdvisory?.canUpdate).toBe(false); + expect(requests).toEqual([]); + + yield* fs.writeFileString( + binaryPath, + `${source}# muse-code/launcher-2 MUSE_SYNC_UPDATE\n`, + ); + const refreshed = yield* enriched.pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* provider.refresh; + const latest = yield* Fiber.join(refreshed); + expect(requests).toEqual([]); + expect(latest.versionAdvisory).toMatchObject({ + status: "unknown", + latestVersion: null, + canUpdate: true, + }); + expect((yield* provider.resolveMaintenance()).update?.executable).toBe(binaryPath); + + // Explicit update verification must still fetch its target despite the preference. + const verification = yield* provider.resolveMaintenance({ fresh: true }); + expect(verification.latestVersion).toBe("1.1.1-R10.1"); + expect(requests).toEqual(["https://api.meta.ai/muse-code/channels/muse-stable"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + TestProviderHost.layer({ + runBackgroundWork: false, + settings: { ...DEFAULT_SERVER_SETTINGS, enableProviderUpdateChecks: false }, + }), + IdAllocator.layer, + McpProviderSessions.layer, + ProviderLatestVersions.layer, + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ version: "1.1.1-R10.1" })), + ); + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.effect( "refreshes a cached channel failure when an explicit update requests verification", () =>