diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/apps/server/src/provider/Drivers/GrokDriver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..916028edc60c0ef30a897fbd0ab2b0fe5fb327f4 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokDriver.test.ts @@ -0,0 +1,97 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { GrokDriver } from "./GrokDriver.ts"; + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-grok-driver-update-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")), + ), + ), +); + +const noSpawner = ChildProcessSpawner.make(() => + Effect.die("Disabled Grok must not spawn a process"), +); + +// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + +it.layer(testLayer)("GrokDriver", (it) => { + it.effect.skipIf(windowsHost)("updates through the configured executable's own updater", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-driver-" }); + const grokHome = path.join(tempDir, "Grok Home"); + const binaryPath = path.join(grokHome, "bin", "grok"); + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-update"), + displayName: "Grok test", + enabled: false, + environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], + config: { ...GrokDriver.defaultConfig(), binaryPath }, + }); + + const capabilities = yield* instance.snapshot.resolveMaintenance(); + expect(capabilities.packageName).toBe("@xai-official/grok"); + expect(capabilities.update).toMatchObject({ + command: `'${binaryPath}' update`, + executable: binaryPath, + args: ["update"], + }); + // `grok update` installs under GROK_HOME, so it must target this instance's home. + expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), + Effect.scoped, + ), + ); + + it.effect("stays manual-only when the configured executable does not exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" }); + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-missing"), + displayName: "Grok test", + enabled: false, + environment: [], + config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") }, + }); + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), + Effect.scoped, + ), + ); +}); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 5f0cf4d90c716fe80961b818f71c8daae6859dec..3f2c2ece4308cefaa19d037c4d45c3f928b21ab9 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -29,7 +29,13 @@ import { import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { discoverGrokSkills } from "./GrokSkills.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -38,10 +44,32 @@ import { const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); -const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, -}); +// npm's `latest` tracks Grok's stable channel, the one `grok update` installs +// by default, so the registry stays the source for "latest". +const GROK_NPM_PACKAGE = "@xai-official/grok"; +// `grok update` finds the installer that owns the binary itself, so the +// resolved executable is its own updater. It installs under `GROK_HOME`, so it +// runs with the instance's environment. No executable means nothing to update, +// not "whatever is on PATH". +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (context) => + Effect.succeed( + context + ? makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["update"], + updateLockKey: "grok", + platform: context.platform, + env: context.env, + }) + : makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + }), + ), +}; export type GrokDriverEnv = | BackgroundPolicy.BackgroundPolicy @@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies GrokSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver = { const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver = { buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - enrichGrokSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities: MAINTENANCE_CAPABILITIES, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - httpClient, - }), + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichGrokSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + ), + ), }).pipe( Effect.mapError( (cause) => diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ec40c49810dcb410b82620ee392916d59478c48c..b843b26cb0e81ef9d749db71668535630d616b40 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -64,7 +64,9 @@ it. Homebrew and npm are proven by the real path (symlinks followed): a versione `brew --prefix`, or `/lib/node_modules//` (Windows: the shim beside `node_modules`). Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either the resolved path or its real target, since those installers place real files or their own symlinks -there. Anything unproven stays manual-only but still reports the version gap. npm updates pin +there. Cursor and Grok are the exception: their only updater is the CLI itself, which detects its +own installer, so any resolved executable runs ` update`. Anything unproven stays +manual-only but still reports the version gap. npm updates pin `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version