| 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,7 +29,13 @@ import { |
| 29 | import { withInstanceIdentity } from "./instanceIdentity.ts"; | 29 | import { withInstanceIdentity } from "./instanceIdentity.ts"; |
| 30 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 30 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 31 | import { discoverGrokSkills } from "./GrokSkills.ts"; | 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 | import { | 39 | import { |
| 34 | haveProviderSnapshotSettingsChanged, | 40 | haveProviderSnapshotSettingsChanged, |
| 35 | makeProviderSnapshotSettingsSource, | 41 | makeProviderSnapshotSettingsSource, |
| ... | @@ -38,10 +44,32 @@ import { | ... | @@ -38,10 +44,32 @@ import { |
| 38 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 44 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 39 | 45 | ||
| 40 | const DRIVER_KIND = ProviderDriverKind.make("grok"); | 46 | const DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 41 | const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ | 47 | // npm's `latest` tracks Grok's stable channel, the one `grok update` installs |
| 42 | provider: DRIVER_KIND, | 48 | // by default, so the registry stays the source for "latest". |
| 43 | packageName: null, | 49 | const GROK_NPM_PACKAGE = "@xai-official/grok"; |
| 44 | }); | 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 | export type GrokDriverEnv = | 74 | export type GrokDriverEnv = |
| 47 | | BackgroundPolicy.BackgroundPolicy | 75 | | BackgroundPolicy.BackgroundPolicy |
| ... | @@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ... | @@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 85 | continuationGroupKey: continuationIdentity.continuationKey, | 113 | continuationGroupKey: continuationIdentity.continuationKey, |
| 86 | }); | 114 | }); |
| 87 | const effectiveConfig = { ...config, enabled } satisfies GrokSettings; | 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 | const adapter = yield* makeGrokAdapter(effectiveConfig, { | 126 | const adapter = yield* makeGrokAdapter(effectiveConfig, { |
| 89 | environment: processEnv, | 127 | environment: processEnv, |
| 90 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | 128 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), |
| ... | @@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ... | @@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 110 | 148 | ||
| 111 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); | 149 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| 112 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({ | 150 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({ |
| 113 | resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), | 151 | resolveMaintenance, |
| 114 | getSettings: snapshotSettings.getSettings, | 152 | getSettings: snapshotSettings.getSettings, |
| 115 | streamSettings: snapshotSettings.streamSettings, | 153 | streamSettings: snapshotSettings.streamSettings, |
| 116 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | 154 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| ... | @@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ... | @@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 118 | buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), | 156 | buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), |
| 119 | checkProvider, | 157 | checkProvider, |
| 120 | enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => | 158 | enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => |
| 121 | enrichGrokSnapshot({ | 159 | resolveMaintenance().pipe( |
| 122 | snapshot: currentSnapshot, | 160 | Effect.flatMap((maintenanceCapabilities) => |
| 123 | maintenanceCapabilities: MAINTENANCE_CAPABILITIES, | 161 | enrichGrokSnapshot({ |
| 124 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | 162 | snapshot: currentSnapshot, |
| 125 | publishSnapshot, | 163 | maintenanceCapabilities, |
| 126 | httpClient, | 164 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, |
| 127 | }), | 165 | publishSnapshot, |
| 166 | httpClient, | ||
| 167 | }), | ||
| 168 | ), | ||
| 169 | ), | ||
| 128 | }).pipe( | 170 | }).pipe( |
| 129 | Effect.mapError( | 171 | Effect.mapError( |
| 130 | (cause) => | 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,7 +64,9 @@ it. Homebrew and npm are proven by the real path (symlinks followed): a versione |
| 64 | `brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`). | 64 | `brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`). |
| 65 | Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either | 65 | Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either |
| 66 | the resolved path or its real target, since those installers place real files or their own symlinks | 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 | `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the | 70 | `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the |
| 69 | provider. Homebrew | 71 | provider. Homebrew |
| 70 | compares against `brew info` since casks trail npm by hours; native installs share npm's version | 72 | compares against `brew info` since casks trail npm by hours; native installs share npm's version |