474 lines · 16.6 KB · TypeScript
RawDownload
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<ServerProviderUpdatedPayload, ServerProviderUpdateError>;} 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<ServerProvider>;  readonly verifiedProviders: ReadonlyArray<ServerProvider>;} 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<string>;    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<VerifiedProviderRefresh> =>    providerRegistry.getProviders.pipe(      Effect.map((providers) => {        const instanceIds: Array<ProviderInstanceId> = [];        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<VerifiedProviderRefresh>({            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<VerifiedProviderRefresh>({                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<string | null>(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<unknown>) {            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());