| author | |
| committer | |
| log | 72447f23d9d9102f647f95f548aadf92edd5b400 |
| tree | 10562eb0295082aa994aa7cae390c10e256f1f28 |
| parent | 9c524d57718e639a6abe0a5fa3cbf6e9b086df86 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>3 files changed, 155 insertions(+), 14 deletions(-)
apps/server/src/provider/Drivers/GrokDriver.test.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Path from "effect/Path"; | |
| 9 | import { HttpClient } from "effect/unstable/http"; | |
| 10 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 11 | ||
| 12 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 13 | import { ServerConfig } from "../../config.ts"; | |
| 14 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 15 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | |
| 16 | import { GrokDriver } from "./GrokDriver.ts"; | |
| 17 | ||
| 18 | const testLayer = ServerConfig.layerTest(process.cwd(), { | |
| 19 | prefix: "t3-grok-driver-update-", | |
| 20 | }).pipe( | |
| 21 | Layer.provideMerge(NodeServices.layer), | |
| 22 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 23 | Layer.provideMerge( | |
| 24 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 25 | shouldRunScopeWork: () => Effect.succeed(false), | |
| 26 | }), | |
| 27 | ), | |
| 28 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | |
| 29 | Layer.provideMerge( | |
| 30 | Layer.succeed( | |
| 31 | HttpClient.HttpClient, | |
| 32 | HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")), | |
| 33 | ), | |
| 34 | ), | |
| 35 | ); | |
| 36 | ||
| 37 | const noSpawner = ChildProcessSpawner.make(() => | |
| 38 | Effect.die("Disabled Grok must not spawn a process"), | |
| 39 | ); | |
| 40 | ||
| 41 | // The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. | |
| 42 | const windowsHost = HostProcessPlatform.defaultValue() === "win32"; | |
| 43 | ||
| 44 | it.layer(testLayer)("GrokDriver", (it) => { | |
| 45 | it.effect.skipIf(windowsHost)("updates through the configured executable's own updater", () => | |
| 46 | Effect.gen(function* () { | |
| 47 | const fs = yield* FileSystem.FileSystem; | |
| 48 | const path = yield* Path.Path; | |
| 49 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-driver-" }); | |
| 50 | const grokHome = path.join(tempDir, "Grok Home"); | |
| 51 | const binaryPath = path.join(grokHome, "bin", "grok"); | |
| 52 | yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); | |
| 53 | yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); | |
| 54 | yield* fs.chmod(binaryPath, 0o755); | |
| 55 | ||
| 56 | const instance = yield* GrokDriver.create({ | |
| 57 | instanceId: ProviderInstanceId.make("grok-update"), | |
| 58 | displayName: "Grok test", | |
| 59 | enabled: false, | |
| 60 | environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], | |
| 61 | config: { ...GrokDriver.defaultConfig(), binaryPath }, | |
| 62 | }); | |
| 63 | ||
| 64 | const capabilities = yield* instance.snapshot.resolveMaintenance(); | |
| 65 | expect(capabilities.packageName).toBe("@xai-official/grok"); | |
| 66 | expect(capabilities.update).toMatchObject({ | |
| 67 | command: `'${binaryPath}' update`, | |
| 68 | executable: binaryPath, | |
| 69 | args: ["update"], | |
| 70 | }); | |
| 71 | // `grok update` installs under GROK_HOME, so it must target this instance's home. | |
| 72 | expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome); | |
| 73 | }).pipe( | |
| 74 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), | |
| 75 | Effect.scoped, | |
| 76 | ), | |
| 77 | ); | |
| 78 | ||
| 79 | it.effect("stays manual-only when the configured executable does not exist", () => | |
| 80 | Effect.gen(function* () { | |
| 81 | const fs = yield* FileSystem.FileSystem; | |
| 82 | const path = yield* Path.Path; | |
| 83 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" }); | |
| 84 | const instance = yield* GrokDriver.create({ | |
| 85 | instanceId: ProviderInstanceId.make("grok-missing"), | |
| 86 | displayName: "Grok test", | |
| 87 | enabled: false, | |
| 88 | environment: [], | |
| 89 | config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") }, | |
| 90 | }); | |
| 91 | expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); | |
| 92 | }).pipe( | |
| 93 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), | |
| 94 | Effect.scoped, | |
| 95 | ), | |
| 96 | ); | |
| 97 | }); |
apps/server/src/provider/Drivers/GrokDriver.ts+55-13| ... | ... | @@ -29,7 +29,13 @@ import { |
| 29 | 29 | import { withInstanceIdentity } from "./instanceIdentity.ts"; |
| 30 | 30 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 31 | 31 | import { discoverGrokSkills } from "./GrokSkills.ts"; |
| 32 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | |
| 32 | import { | |
| 33 | makeCachedProviderMaintenanceResolution, | |
| 34 | makeManualOnlyProviderMaintenanceCapabilities, | |
| 35 | makeProviderMaintenanceCapabilities, | |
| 36 | type ProviderMaintenanceCapabilitiesResolver, | |
| 37 | resolveProviderMaintenanceCapabilitiesEffect, | |
| 38 | } from "../providerMaintenance.ts"; | |
| 33 | 39 | import { |
| 34 | 40 | haveProviderSnapshotSettingsChanged, |
| 35 | 41 | makeProviderSnapshotSettingsSource, |
| ... | ... | @@ -38,10 +44,32 @@ import { |
| 38 | 44 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 39 | 45 | |
| 40 | 46 | const DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 41 | const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 42 | provider: DRIVER_KIND, | |
| 43 | packageName: null, | |
| 44 | }); | |
| 47 | // npm's `latest` tracks Grok's stable channel, the one `grok update` installs | |
| 48 | // by default, so the registry stays the source for "latest". | |
| 49 | const GROK_NPM_PACKAGE = "@xai-official/grok"; | |
| 50 | // `grok update` finds the installer that owns the binary itself, so the | |
| 51 | // resolved executable is its own updater. It installs under `GROK_HOME`, so it | |
| 52 | // runs with the instance's environment. No executable means nothing to update, | |
| 53 | // not "whatever is on PATH". | |
| 54 | const UPDATE: ProviderMaintenanceCapabilitiesResolver = { | |
| 55 | resolve: (context) => | |
| 56 | Effect.succeed( | |
| 57 | context | |
| 58 | ? makeProviderMaintenanceCapabilities({ | |
| 59 | provider: DRIVER_KIND, | |
| 60 | packageName: GROK_NPM_PACKAGE, | |
| 61 | updateExecutable: context.resolvedCommandPath, | |
| 62 | updateArgs: ["update"], | |
| 63 | updateLockKey: "grok", | |
| 64 | platform: context.platform, | |
| 65 | env: context.env, | |
| 66 | }) | |
| 67 | : makeManualOnlyProviderMaintenanceCapabilities({ | |
| 68 | provider: DRIVER_KIND, | |
| 69 | packageName: GROK_NPM_PACKAGE, | |
| 70 | }), | |
| 71 | ), | |
| 72 | }; | |
| 45 | 73 | |
| 46 | 74 | export type GrokDriverEnv = |
| 47 | 75 | | BackgroundPolicy.BackgroundPolicy |
| ... | ... | @@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 85 | 113 | continuationGroupKey: continuationIdentity.continuationKey, |
| 86 | 114 | }); |
| 87 | 115 | const effectiveConfig = { ...config, enabled } satisfies GrokSettings; |
| 116 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( | |
| 117 | resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | |
| 118 | binaryPath: effectiveConfig.binaryPath, | |
| 119 | env: processEnv, | |
| 120 | }).pipe( | |
| 121 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 122 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 123 | Effect.provideService(Path.Path, path), | |
| 124 | ), | |
| 125 | ); | |
| 88 | 126 | const adapter = yield* makeGrokAdapter(effectiveConfig, { |
| 89 | 127 | environment: processEnv, |
| 90 | 128 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), |
| ... | ... | @@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 110 | 148 | |
| 111 | 149 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| 112 | 150 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({ |
| 113 | resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), | |
| 151 | resolveMaintenance, | |
| 114 | 152 | getSettings: snapshotSettings.getSettings, |
| 115 | 153 | streamSettings: snapshotSettings.streamSettings, |
| 116 | 154 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| ... | ... | @@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 118 | 156 | buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), |
| 119 | 157 | checkProvider, |
| 120 | 158 | enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => |
| 121 | enrichGrokSnapshot({ | |
| 122 | snapshot: currentSnapshot, | |
| 123 | maintenanceCapabilities: MAINTENANCE_CAPABILITIES, | |
| 124 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 125 | publishSnapshot, | |
| 126 | httpClient, | |
| 127 | }), | |
| 159 | resolveMaintenance().pipe( | |
| 160 | Effect.flatMap((maintenanceCapabilities) => | |
| 161 | enrichGrokSnapshot({ | |
| 162 | snapshot: currentSnapshot, | |
| 163 | maintenanceCapabilities, | |
| 164 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 165 | publishSnapshot, | |
| 166 | httpClient, | |
| 167 | }), | |
| 168 | ), | |
| 169 | ), | |
| 128 | 170 | }).pipe( |
| 129 | 171 | Effect.mapError( |
| 130 | 172 | (cause) => |
docs/internals/providers.md+3-1| ... | ... | @@ -64,7 +64,9 @@ it. Homebrew and npm are proven by the real path (symlinks followed): a versione |
| 64 | 64 | `brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`). |
| 65 | 65 | Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either |
| 66 | 66 | the resolved path or its real target, since those installers place real files or their own symlinks |
| 67 | there. Anything unproven stays manual-only but still reports the version gap. npm updates pin | |
| 67 | there. Cursor and Grok are the exception: their only updater is the CLI itself, which detects its | |
| 68 | own installer, so any resolved executable runs `<binary> update`. Anything unproven stays | |
| 69 | manual-only but still reports the version gap. npm updates pin | |
| 68 | 70 | `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the |
| 69 | 71 | provider. Homebrew |
| 70 | 72 | compares against `brew info` since casks trail npm by hours; native installs share npm's version |