import { defaultInstanceIdForDriver, ProviderDriverKind, ServerProviderUpdateError, type ProviderInstanceId, type ServerProvider, type ServerProviderUpdatedPayload, type ServerProviderUpdateState, } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCommandAction, ProviderVersionCache, } from "./providerMaintenance.ts"; import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; export interface ProviderMaintenanceCommandResult { readonly stdout: string; readonly stderr: string; readonly exitCode: number | null; readonly timedOut: boolean; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; } export interface ProviderMaintenanceRunnerShape { readonly updateProvider: ( target: | ProviderDriverKind | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; }, ) => Effect.Effect; } export class ProviderMaintenanceRunner extends Context.Service< ProviderMaintenanceRunner, ProviderMaintenanceRunnerShape >()("t3/provider/providerMaintenanceRunner") {} class ProviderMaintenanceCommandError extends Data.TaggedError("ProviderMaintenanceCommandError")<{ readonly message: string; readonly cause?: unknown; }> {} interface VerifiedProviderRefresh { readonly providers: ReadonlyArray; readonly verifiedProviders: ReadonlyArray; } const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")( function* (input: { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly command: string; readonly args: ReadonlyArray; readonly env?: NodeJS.ProcessEnv; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { // Resolve the executable for the host platform before spawning. On // Windows the update tools are batch shims (e.g. `npm` -> `npm.cmd`), // which a bare ChildProcess.spawn cannot launch (spawn npm ENOENT); // resolveSpawnCommand finds the real `.cmd` and routes it through the // shell. On Linux/macOS (incl. the WSL backend) this is a no-op. const resolved = yield* resolveSpawnCommand(input.command, input.args); const child = yield* input.spawner .spawn( ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell, ...(input.env ? { env: input.env, extendEnv: true } : {}), }), ) .pipe( Effect.mapError( (cause) => new ProviderMaintenanceCommandError({ message: `Failed to run update command ${input.command}: ${cause.message}`, cause, }), ), ); yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ stream: child.stdout, maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), collectUint8StreamText({ stream: child.stderr, maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), child.exitCode, ], { concurrency: "unbounded" }, ).pipe( Effect.mapError( (cause) => new ProviderMaintenanceCommandError({ message: cause instanceof Error ? cause.message : "Update command failed to run.", cause, }), ), ); return { stdout: stdout.text, stderr: stderr.text, exitCode: Number(exitCode), timedOut: false, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, } satisfies ProviderMaintenanceCommandResult; }, ); return yield* collectCommandResult().pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)), Effect.map((result) => Option.match(result, { onSome: (value) => value, onNone: () => ({ stdout: "", stderr: "", exitCode: null, timedOut: true, stdoutTruncated: false, stderrTruncated: false, }) satisfies ProviderMaintenanceCommandResult, }), ), ); }, ); function trimNullable(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function truncateText(value: string, maxLength: number): string { return value.length <= maxLength ? value : value.slice(0, maxLength); } function commandOutput(result: ProviderMaintenanceCommandResult): string | null { const output = trimNullable([result.stderr, result.stdout].filter(Boolean).join("\n\n")); if (!output) { return null; } return truncateText(output, UPDATE_OUTPUT_MAX_BYTES); } function failureMessage(result: ProviderMaintenanceCommandResult): string { if (result.timedOut) { return "Update timed out."; } if (result.exitCode !== null && result.exitCode !== 0) { return `Update command exited with code ${result.exitCode}.`; } return "Update command failed."; } function isOutdatedProvider(provider: ServerProvider | undefined): boolean { return provider?.versionAdvisory?.status === "behind_latest"; } function isStillInstalled(provider: ServerProvider): boolean { return provider.installed; } function makeUpdateState(input: { readonly status: ServerProviderUpdateState["status"]; readonly startedAt: string | null; readonly finishedAt: string | null; readonly message: string | null; readonly output?: string | null; }): ServerProviderUpdateState { return { status: input.status, startedAt: input.startedAt, finishedAt: input.finishedAt, message: input.message, output: input.output ?? null, }; } /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) => runProviderMaintenanceCommandWithSpawner({ spawner, command: update.executable, args: update.args, ...(update.env ? { env: update.env } : {}), }); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ makeAlreadyRunningError: () => new ServerProviderUpdateError({ provider: ProviderDriverKind.make("unknown"), reason: "An update is already running for this provider.", }), }); const verifyRefreshedProvider = ( provider: ProviderDriverKind, maintenanceCapabilities: ProviderMaintenanceCapabilities, instanceId: ProviderInstanceId, ): Effect.Effect => providerRegistry.getProviders.pipe( Effect.map((providers) => { const instanceIds: Array = []; for (const candidate of providers) { if (candidate.driver === provider && candidate.instanceId === instanceId) { instanceIds.push(candidate.instanceId); } } return instanceIds; }), Effect.flatMap((instanceIds) => instanceIds.length === 0 ? providerRegistry.refreshInstance(instanceId) : Effect.forEach( instanceIds, (instanceId) => providerRegistry.refreshInstance(instanceId), { concurrency: "unbounded", discard: true, }, ).pipe(Effect.andThen(providerRegistry.getProviders)), ), Effect.flatMap((providers) => { const refreshedProviders = providers.filter( (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, ); if (refreshedProviders.length === 0) { return Effect.succeed({ providers, verifiedProviders: [], }); } return Effect.forEach( refreshedProviders, (refreshedProvider) => enrichProviderSnapshotWithVersionAdvisory( refreshedProvider, maintenanceCapabilities, ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(ProviderVersionCache, versionCache), ), { concurrency: "unbounded", }, ).pipe( Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ providers, verifiedProviders, })), Effect.catchCause((cause) => Effect.logWarning("Provider post-update version verification failed", { provider, cause: Cause.pretty(cause), }).pipe( Effect.as({ providers, verifiedProviders: refreshedProviders, }), ), ), ); }), ); const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn( "ProviderMaintenanceRunner.updateProvider", )(function* (target) { const provider = typeof target === "string" ? target : target.provider; const instanceId = typeof target === "string" ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); const targetKey = `instance:${instanceId}`; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, ); const update = capabilities.update; if (!update) { return yield* new ServerProviderUpdateError({ provider, reason: "This provider does not support one-click updates.", }); } const setUpdateState = (state: ServerProviderUpdateState | null) => providerRegistry.setProviderMaintenanceActionState({ instanceId, action: "update", state, }); const setQueuedState = setUpdateState( makeUpdateState({ status: "queued", startedAt: null, finishedAt: null, message: "Waiting for another provider update to finish.", }), ).pipe(Effect.asVoid); const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")( function* () { const finish = (state: ServerProviderUpdateState) => setUpdateState(state).pipe(Effect.map((providers) => ({ providers }))); const startedAtRef = yield* Ref.make(null); const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")( function* () { const startedAt = yield* nowIso; yield* Ref.set(startedAtRef, startedAt); yield* setUpdateState( makeUpdateState({ status: "running", startedAt, finishedAt: null, message: "Updating provider.", }), ); // The cached capabilities chose the lock; re-derive ownership // now so the command that runs matches the executable as it is // at click time, not as it was at the last health refresh. const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, { fresh: true }, ); if (!fresh.update || fresh.update.lockKey !== update.lockKey) { return yield* finish( makeUpdateState({ status: "failed", startedAt, finishedAt: yield* nowIso, message: "Provider installation changed. Refresh and try again.", }), ); } const result = yield* runMaintenanceCommand(fresh.update); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( makeUpdateState({ status: "failed", startedAt, finishedAt, message: failureMessage(result), output: commandOutput(result), }), ); } // Homebrew's "latest" moves once the upgrade lands; read it again. const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, { fresh: true }, ); const { verifiedProviders } = yield* verifyRefreshedProvider( provider, verified, instanceId, ); // "Succeeded" needs the provider to still be installed: an // installer that exits 0 and leaves the binary missing is not a // success. A missing version alone is not held against it, since // Cursor's `about` probe can fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); const stillOutdated = verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider), ); return yield* finish( makeUpdateState({ status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", startedAt, finishedAt, message: couldNotVerify ? "Update command completed, but T3 Code could not verify the provider version." : stillOutdated ? "Update command completed, but T3 Code still detects an outdated provider version." : "Provider updated.", output: commandOutput(result), }), ); }, ); const recordFailedUpdate = Effect.fn("ProviderMaintenanceRunner.recordFailedUpdate")( function* (cause: Cause.Cause) { const failure = Cause.squash(cause); const startedAt = yield* Ref.get(startedAtRef); return yield* finish( makeUpdateState({ status: "failed", startedAt, finishedAt: yield* nowIso, message: failure instanceof Error ? failure.message : "Update command failed.", output: null, }), ); }, ); return yield* runCommandAndVerify().pipe(Effect.catchCause(recordFailedUpdate)); }, ); return yield* commandCoordinator .withCommandLock({ targetKey, lockKey: update.lockKey, onQueued: setQueuedState, run: runProviderUpdate(), }) .pipe( Effect.mapError((error) => isServerProviderUpdateError(error) ? new ServerProviderUpdateError({ provider, reason: error.reason, }) : error, ), ); }); return ProviderMaintenanceRunner.of({ updateProvider, }); }); export const layer = Layer.effect(ProviderMaintenanceRunner, make());