1import {
2 defaultInstanceIdForDriver,
3 ProviderDriverKind,
4 ServerProviderUpdateError,
5 type ProviderInstanceId,
6 type ServerProvider,
7 type ServerProviderUpdatedPayload,
8 type ServerProviderUpdateState,
9} from "@t3tools/contracts";
10import { resolveSpawnCommand } from "@t3tools/shared/shell";
11import * as Cause from "effect/Cause";
12import * as Context from "effect/Context";
13import * as DateTime from "effect/DateTime";
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import * as Schedule from "effect/Schedule";
20import * as Schema from "effect/Schema";
21import * as Stream from "effect/Stream";
22import { HttpClient } from "effect/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/process";
24import * as NodeUtil from "node:util";
25
26import * as ModelManifest from "./ModelManifest.ts";
27import { resolveProviderCompatibility } from "./providerCompatibility.ts";
28import * as ProviderInstallationGate from "./ProviderInstallationGate.ts";
29import * as ProviderRegistry from "./ProviderRegistry.ts";
30import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
31import {
32 enrichProviderSnapshotWithVersionAdvisory,
33 invalidateLatestProviderVersion,
34 makeTargetedProviderUpdateAction,
35 resolveLatestProviderUpdateAction,
36 resolveLatestProviderVersion,
37 type ProviderMaintenanceCommandAction,
38} from "@t3tools/provider-core/server/maintenanceResolver";
39import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
40import type { ProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
41import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
42const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
43
44// Native updaters (`claude update`) download a ~300 MB binary without printing
45// progress, and killing one mid-download leaves a truncated binary that the
46// next run treats as installed. Allow a slow connection to finish.
47const UPDATE_TIMEOUT_MINUTES = 30;
48const UPDATE_TIMEOUT_MS = UPDATE_TIMEOUT_MINUTES * 60_000;
49const UPDATE_OUTPUT_MAX_BYTES = 10_000;
50// Sample installer output because every progress update resends the provider list to all clients.
51const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1);
52const UPDATE_PROGRESS_MAX_LENGTH = 200;
53// An installer may write for minutes without a line ending; only its tail matters.
54const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096;
55
56export interface ProviderMaintenanceCommandResult {
57 readonly stdout: string;
58 readonly stderr: string;
59 readonly exitCode: number | null;
60 readonly timedOut: boolean;
61 readonly stdoutTruncated: boolean;
62 readonly stderrTruncated: boolean;
63}
64
65export interface ProviderMaintenanceRunnerShape {
66 readonly updateProvider: (
67 target:
68 | ProviderDriverKind
69 | {
70 readonly provider: ProviderDriverKind;
71 readonly instanceId?: ProviderInstanceId | undefined;
72 readonly targetVersion?: string | undefined;
73 },
74 ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>;
75}
76
77export class ProviderMaintenanceRunner extends Context.Service<
78 ProviderMaintenanceRunner,
79 ProviderMaintenanceRunnerShape
80>()("t3/provider/providerMaintenanceRunner") {}
81
82class ProviderMaintenanceCommandError extends Schema.TaggedError<ProviderMaintenanceCommandError>()(
83 "ProviderMaintenanceCommandError",
84 {
85 message: Schema.String,
86 cause: Schema.optional(Schema.Defect()),
87 },
88) {}
89
90interface VerifiedProviderRefresh {
91 readonly providers: ReadonlyArray<ServerProvider>;
92 readonly verifiedProviders: ReadonlyArray<ServerProvider>;
93 readonly advisoryEnriched: boolean;
94}
95
96const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
97
98const OPENCODE_SERVER_RESTART_MESSAGE =
99 "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version.";
100
101const needsOpenCodeServerRestart = (providers: ReadonlyArray<ServerProvider>) =>
102 providers.some(
103 (provider) =>
104 provider.driver === ProviderDriverKind.make("opencode") &&
105 provider.installedVersion != null &&
106 provider.version != null &&
107 provider.installedVersion !== provider.version,
108 );
109
110const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")(
111 function* (input: {
112 readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
113 readonly command: string;
114 readonly args: ReadonlyArray<string>;
115 readonly env?: NodeJS.ProcessEnv;
116 readonly onProgress?: (line: string) => Effect.Effect<void>;
117 }) {
118 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
119 function* () {
120 const env = input.env ? { env: input.env, extendEnv: true } : {};
121 // Windows batch shims require shell dispatch through resolveSpawnCommand.
122 const resolved = yield* resolveSpawnCommand(input.command, input.args, env);
123 const child = yield* input.spawner
124 .spawn(
125 ChildProcess.make(resolved.command, resolved.args, {
126 shell: resolved.shell,
127 // Nothing answers an update command, so one that reads stdin must see EOF, not wait.
128 stdin: "ignore",
129 ...env,
130 }),
131 )
132 .pipe(
133 Effect.mapError(
134 (cause) =>
135 new ProviderMaintenanceCommandError({
136 message: `Failed to run update command ${input.command}: ${cause.message}`,
137 cause,
138 }),
139 ),
140 );
141 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
142
143 const pendingProgress = yield* Ref.make<string | null>(null);
144 const onProgress = input.onProgress;
145 if (onProgress) {
146 yield* Ref.getAndSet(pendingProgress, null).pipe(
147 Effect.flatMap((line) => (line === null ? Effect.void : onProgress(line))),
148 Effect.repeat(Schedule.spaced(UPDATE_PROGRESS_INTERVAL)),
149 Effect.forkScoped,
150 );
151 }
152 const trackProgress = <E>(stream: Stream.Stream<Uint8Array, E>) => {
153 if (!onProgress) {
154 return stream;
155 }
156 const decoder = new TextDecoder();
157 let partialLine = "";
158 return stream.pipe(
159 Stream.tap((chunk) => {
160 const split = splitOutputLines(partialLine, decoder.decode(chunk, { stream: true }));
161 partialLine = split.partialLine;
162 const line =
163 toProgressLine(split.partialLine) ??
164 split.lines.map(toProgressLine).findLast((value) => value !== null);
165 return line ? Ref.set(pendingProgress, line) : Effect.void;
166 }),
167 );
168 };
169
170 const [stdout, stderr, exitCode] = yield* Effect.all(
171 [
172 collectUint8StreamText({
173 stream: trackProgress(child.stdout),
174 maxBytes: UPDATE_OUTPUT_MAX_BYTES,
175 }),
176 collectUint8StreamText({
177 stream: trackProgress(child.stderr),
178 maxBytes: UPDATE_OUTPUT_MAX_BYTES,
179 }),
180 child.exitCode,
181 ],
182 { concurrency: "unbounded" },
183 ).pipe(
184 Effect.mapError(
185 (cause) =>
186 new ProviderMaintenanceCommandError({
187 message: cause instanceof Error ? cause.message : "Update command failed to run.",
188 cause,
189 }),
190 ),
191 );
192
193 return {
194 stdout: stdout.text,
195 stderr: stderr.text,
196 exitCode: Number(exitCode),
197 timedOut: false,
198 stdoutTruncated: stdout.truncated,
199 stderrTruncated: stderr.truncated,
200 } satisfies ProviderMaintenanceCommandResult;
201 },
202 );
203
204 return yield* collectCommandResult().pipe(
205 Effect.scoped,
206 Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)),
207 Effect.map((result) =>
208 Option.match(result, {
209 onSome: (value) => value,
210 onNone: () =>
211 ({
212 stdout: "",
213 stderr: "",
214 exitCode: null,
215 timedOut: true,
216 stdoutTruncated: false,
217 stderrTruncated: false,
218 }) satisfies ProviderMaintenanceCommandResult,
219 }),
220 ),
221 );
222 },
223);
224
225export function splitOutputLines(
226 partialLine: string,
227 text: string,
228): { readonly lines: ReadonlyArray<string>; readonly partialLine: string } {
229 const parts = (partialLine + text).replace(/\x1b\[(?:0*1?G|999D)/g, "\r").split(/\r\n|\r|\n/);
230 return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts };
231}
232
233export function toProgressLine(line: string): string | null {
234 if (/\x1b(?:\[[0-9]*)?$/.test(line)) {
235 return null;
236 }
237 const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim();
238 if (text.length === 0) {
239 return null;
240 }
241 return text.length <= UPDATE_PROGRESS_MAX_LENGTH
242 ? text
243 : `${text.slice(0, UPDATE_PROGRESS_MAX_LENGTH - 1)}…`;
244}
245
246function describeCommand(command: ProviderMaintenanceCommandAction): string {
247 const executable = command.executable.split(/[\\/]/).pop() || command.executable;
248 return [executable, ...command.args].join(" ");
249}
250
251function trimNullable(value: string): string | null {
252 const trimmed = value.trim();
253 return trimmed.length > 0 ? trimmed : null;
254}
255
256function truncateText(value: string, maxLength: number): string {
257 return value.length <= maxLength ? value : value.slice(0, maxLength);
258}
259
260function commandOutput(result: ProviderMaintenanceCommandResult): string | null {
261 const output = trimNullable([result.stderr, result.stdout].filter(Boolean).join("\n\n"));
262 if (!output) {
263 return null;
264 }
265 return truncateText(output, UPDATE_OUTPUT_MAX_BYTES);
266}
267
268// Updaters report on stderr, but may follow their error with hints (`claude update`
269// ends with "Try running claude doctor"), so prefer the last line naming an error.
270function failureReason(result: ProviderMaintenanceCommandResult): string | null {
271 const streams = [result.stderr, result.stdout].map((output) =>
272 output
273 .split(/\r\n|\r|\n/)
274 .map(toProgressLine)
275 .filter((line) => line !== null),
276 );
277 return (
278 streams.map((lines) => lines.findLast((line) => /error|fail/i.test(line))).find(Boolean) ??
279 streams.map((lines) => lines.at(-1)).find(Boolean) ??
280 null
281 );
282}
283
284function failureMessage(result: ProviderMaintenanceCommandResult): string {
285 if (result.timedOut) {
286 return `Update timed out after ${UPDATE_TIMEOUT_MINUTES} minutes.`;
287 }
288 const summary =
289 result.exitCode !== null && result.exitCode !== 0
290 ? `Update command exited with code ${result.exitCode}`
291 : "Update command failed";
292 const reason = failureReason(result);
293 return reason ? `${summary}: ${reason}` : `${summary}.`;
294}
295
296function isOutdatedProvider(provider: ServerProvider | undefined): boolean {
297 return provider?.versionAdvisory?.status === "behind_latest";
298}
299
300function isStillInstalled(provider: ServerProvider): boolean {
301 return provider.installed;
302}
303
304function makeUpdateState(input: {
305 readonly status: ServerProviderUpdateState["status"];
306 readonly startedAt: string | null;
307 readonly finishedAt: string | null;
308 readonly message: string | null;
309 readonly output?: string | null;
310}): ServerProviderUpdateState {
311 return {
312 status: input.status,
313 startedAt: input.startedAt,
314 finishedAt: input.finishedAt,
315 message: input.message,
316 output: input.output ?? null,
317 };
318}
319
320/** @public */
321export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
322 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
323 const manifestService = yield* ModelManifest.ModelManifest;
324 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
325 const httpClient = yield* HttpClient.HttpClient;
326 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
327 const installationGate = yield* ProviderInstallationGate.ProviderInstallationGate;
328 const runMaintenanceCommand = (
329 update: ProviderMaintenanceCommandAction,
330 onProgress: (line: string) => Effect.Effect<void>,
331 ) =>
332 runProviderMaintenanceCommandWithSpawner({
333 spawner,
334 command: update.executable,
335 args: update.args,
336 onProgress,
337 ...(update.env ? { env: update.env } : {}),
338 });
339 // Instances with other homes or accounts can still run the binary this update replaces. The
340 // lock key names the package manager location, so other drivers there keep their own package.
341 const instancesSharingInstallation = Effect.fn(
342 "ProviderMaintenanceRunner.instancesSharingInstallation",
343 )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind, lockKey: string) {
344 const providers = yield* providerRegistry.getProviders;
345 const shared = yield* Effect.forEach(
346 providers.filter(
347 (candidate) => candidate.instanceId !== instanceId && candidate.driver === provider,
348 ),
349 (candidate) =>
350 providerRegistry
351 .getProviderMaintenanceCapabilitiesForInstance(candidate.instanceId, candidate.driver)
352 .pipe(
353 Effect.map((capabilities) =>
354 capabilities.update?.lockKey === lockKey ? [candidate.instanceId] : [],
355 ),
356 ),
357 { concurrency: "unbounded" },
358 );
359 return [instanceId, ...shared.flat()];
360 });
361 const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
362 makeAlreadyRunningError: () =>
363 new ServerProviderUpdateError({
364 provider: ProviderDriverKind.make("unknown"),
365 reason: "An update is already running for this provider.",
366 }),
367 });
368
369 const verifyFromRefresh = (
370 refresh: Effect.Effect<ReadonlyArray<ServerProvider>>,
371 provider: ProviderDriverKind,
372 maintenanceCapabilities: ProviderMaintenanceCapabilities,
373 instanceId: ProviderInstanceId,
374 phase: "pre-update" | "post-update",
375 ): Effect.Effect<VerifiedProviderRefresh> =>
376 refresh.pipe(
377 Effect.flatMap((providers) => {
378 const refreshedProviders = providers.filter(
379 (candidate) => candidate.driver === provider && candidate.instanceId === instanceId,
380 );
381 if (refreshedProviders.length === 0) {
382 return Effect.succeed<VerifiedProviderRefresh>({
383 providers,
384 verifiedProviders: [],
385 advisoryEnriched: true,
386 });
387 }
388 return Effect.forEach(
389 refreshedProviders,
390 (refreshedProvider) =>
391 enrichProviderSnapshotWithVersionAdvisory(
392 refreshedProvider,
393 maintenanceCapabilities,
394 ).pipe(
395 Effect.provideService(HttpClient.HttpClient, httpClient),
396 Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions),
397 ),
398 {
399 concurrency: "unbounded",
400 },
401 ).pipe(
402 Effect.map((verifiedProviders): VerifiedProviderRefresh => ({
403 providers,
404 verifiedProviders,
405 advisoryEnriched: true,
406 })),
407 Effect.catchCause((cause) =>
408 Effect.logWarning("Provider version verification failed", {
409 provider,
410 phase,
411 cause: Cause.pretty(cause),
412 }).pipe(
413 Effect.as<VerifiedProviderRefresh>({
414 providers,
415 verifiedProviders: refreshedProviders,
416 advisoryEnriched: false,
417 }),
418 ),
419 ),
420 );
421 }),
422 );
423
424 const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn(
425 "ProviderMaintenanceRunner.updateProvider",
426 )(function* (target) {
427 const provider = typeof target === "string" ? target : target.provider;
428 const instanceId =
429 typeof target === "string"
430 ? defaultInstanceIdForDriver(provider)
431 : (target.instanceId ?? defaultInstanceIdForDriver(provider));
432 const targetVersion = typeof target === "string" ? undefined : target.targetVersion;
433 const targetKey = `instance:${instanceId}`;
434 const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
435 instanceId,
436 provider,
437 );
438 const update = capabilities.update;
439 if (!update) {
440 return yield* new ServerProviderUpdateError({
441 provider,
442 reason: "This provider does not support one-click updates.",
443 });
444 }
445
446 const startedAtRef = yield* Ref.make<string | null>(null);
447 const settled = yield* Ref.make(false);
448 const setUpdateState = (state: ServerProviderUpdateState | null) =>
449 providerRegistry.setProviderMaintenanceActionState({
450 instanceId,
451 action: "update",
452 state,
453 targetVersion,
454 });
455 const setQueuedState = setUpdateState(
456 makeUpdateState({
457 status: "queued",
458 startedAt: null,
459 finishedAt: null,
460 message: "Waiting for another provider update to finish.",
461 }),
462 ).pipe(Effect.asVoid);
463
464 const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")(
465 function* () {
466 // Uninterruptible, so a dropped request cannot leave settled set without the state.
467 const finish = (state: ServerProviderUpdateState) =>
468 Ref.set(settled, true).pipe(
469 Effect.andThen(setUpdateState(state)),
470 Effect.map((providers) => ({ providers })),
471 Effect.uninterruptible,
472 );
473
474 const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")(
475 function* () {
476 const startedAt = yield* nowIso;
477 yield* Ref.set(startedAtRef, startedAt);
478 const setRunningMessage = (message: string) =>
479 setUpdateState(
480 makeUpdateState({
481 status: "running",
482 startedAt,
483 finishedAt: null,
484 message,
485 }),
486 ).pipe(Effect.asVoid);
487 yield* setRunningMessage("Checking for the latest version");
488
489 // Recheck ownership after acquiring the cached lock in case the executable changed.
490 const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
491 instanceId,
492 provider,
493 { fresh: true },
494 );
495 if (!fresh.update || fresh.update.lockKey !== update.lockKey) {
496 return yield* finish(
497 makeUpdateState({
498 status: "failed",
499 startedAt,
500 finishedAt: yield* nowIso,
501 message: "Provider installation changed. Refresh and try again.",
502 }),
503 );
504 }
505
506 const manifest = yield* manifestService.current;
507 // Check compatibility against what an unpinned command would install now.
508 const candidateVersion =
509 targetVersion ??
510 (yield* invalidateLatestProviderVersion(fresh).pipe(
511 Effect.andThen(resolveLatestProviderVersion(fresh)),
512 Effect.provideService(HttpClient.HttpClient, httpClient),
513 Effect.provideService(
514 ProviderLatestVersions.ProviderLatestVersions,
515 latestVersions,
516 ),
517 ));
518 const advisory =
519 resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ??
520 resolveProviderCompatibility(
521 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
522 provider,
523 candidateVersion,
524 );
525 const command =
526 targetVersion !== undefined
527 ? makeTargetedProviderUpdateAction(fresh, targetVersion)
528 : resolveLatestProviderUpdateAction(fresh, candidateVersion);
529 const rejected =
530 targetVersion !== undefined
531 ? !command ||
532 advisory?.recommendedVersion !== targetVersion ||
533 advisory.status !== "supported"
534 : advisory?.status === "broken" || advisory?.status === "unsupported";
535 if (rejected || !command) {
536 return yield* finish(
537 makeUpdateState({
538 status: "failed",
539 startedAt,
540 finishedAt: yield* nowIso,
541 message:
542 targetVersion !== undefined
543 ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings."
544 : "The latest provider version is incompatible with this T3 Code release. Review provider settings.",
545 }),
546 );
547 }
548
549 if (targetVersion === undefined) {
550 const prior = (yield* providerRegistry.getProviders).find(
551 (candidate) => candidate.instanceId === instanceId,
552 );
553 const precheck = yield* verifyFromRefresh(
554 providerRegistry.refreshInstance(instanceId),
555 provider,
556 fresh,
557 instanceId,
558 "pre-update",
559 );
560 const alreadyCurrent =
561 precheck.advisoryEnriched &&
562 prior !== undefined &&
563 precheck.verifiedProviders.length > 0 &&
564 precheck.verifiedProviders.every(
565 (verified) =>
566 verified.checkedAt !== prior.checkedAt &&
567 verified.installed &&
568 verified.status !== "error" &&
569 verified.versionAdvisory?.status === "current",
570 );
571 if (alreadyCurrent) {
572 return yield* finish(
573 makeUpdateState({
574 status: "succeeded",
575 startedAt,
576 finishedAt: yield* nowIso,
577 message: needsOpenCodeServerRestart(precheck.verifiedProviders)
578 ? OPENCODE_SERVER_RESTART_MESSAGE
579 : "Already up to date.",
580 }),
581 );
582 }
583 }
584
585 const sharingInstances = yield* instancesSharingInstallation(
586 instanceId,
587 provider,
588 update.lockKey,
589 );
590 yield* setRunningMessage("Waiting for running provider work to finish.");
591 const result = yield* installationGate.update(
592 sharingInstances,
593 setRunningMessage(`Running ${describeCommand(command)}`).pipe(
594 Effect.andThen(runMaintenanceCommand(command, setRunningMessage)),
595 ),
596 );
597 const finishedAt = yield* nowIso;
598 if (result.timedOut || result.exitCode !== 0) {
599 yield* Effect.logWarning("Provider update command failed", {
600 provider,
601 command: describeCommand(command),
602 exitCode: result.exitCode,
603 timedOut: result.timedOut,
604 output: commandOutput(result),
605 });
606 return yield* finish(
607 makeUpdateState({
608 status: "failed",
609 startedAt,
610 finishedAt,
611 message: failureMessage(result),
612 output: commandOutput(result),
613 }),
614 );
615 }
616
617 yield* setRunningMessage("Verifying the installed version");
618 const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
619 instanceId,
620 provider,
621 { fresh: true },
622 );
623 yield* invalidateLatestProviderVersion(verified).pipe(
624 Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions),
625 );
626 const { verifiedProviders } = yield* verifyFromRefresh(
627 providerRegistry.refreshAfterUpdate(provider, instanceId),
628 provider,
629 verified,
630 instanceId,
631 "post-update",
632 );
633 // Cursor about probes can fail transiently; missing versions alone do not imply failure.
634 const couldNotVerify =
635 verifiedProviders.length === 0 ||
636 verifiedProviders.some((verifiedProvider) => {
637 const installedVersion =
638 verifiedProvider.installedVersion !== undefined
639 ? verifiedProvider.installedVersion
640 : verifiedProvider.version;
641 return (
642 !isStillInstalled(verifiedProvider) ||
643 (verifiedProvider.status === "error" &&
644 (verifiedProvider.auth.status !== "unauthenticated" ||
645 !verifiedProvider.version)) ||
646 (targetVersion !== undefined &&
647 installedVersion?.replace(/^v/, "") !== targetVersion)
648 );
649 });
650 const stillOutdated =
651 targetVersion === undefined &&
652 verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider));
653 return yield* finish(
654 makeUpdateState({
655 status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded",
656 startedAt,
657 finishedAt,
658 message: couldNotVerify
659 ? "Update command completed, but T3 Code could not verify the provider version."
660 : stillOutdated
661 ? "Update command completed, but T3 Code still detects an outdated provider version."
662 : needsOpenCodeServerRestart(verifiedProviders)
663 ? OPENCODE_SERVER_RESTART_MESSAGE
664 : "Provider updated.",
665 output: commandOutput(result),
666 }),
667 );
668 },
669 );
670
671 const recordFailedUpdate = Effect.fn("ProviderMaintenanceRunner.recordFailedUpdate")(
672 function* (cause: Cause.Cause<unknown>) {
673 const failure = Cause.squash(cause);
674 const startedAt = yield* Ref.get(startedAtRef);
675 return yield* finish(
676 makeUpdateState({
677 status: "failed",
678 startedAt,
679 finishedAt: yield* nowIso,
680 message: failure instanceof Error ? failure.message : "Update command failed.",
681 output: null,
682 }),
683 );
684 },
685 );
686
687 return yield* runCommandAndVerify().pipe(Effect.catchCause(recordFailedUpdate));
688 },
689 );
690
691 return yield* commandCoordinator
692 .withCommandLock({
693 targetKey,
694 lockKey: update.lockKey,
695 onQueued: setQueuedState,
696 run: runProviderUpdate(),
697 })
698 .pipe(
699 // A dropped request interrupts the update; a "running" state would spin until restart.
700 Effect.onInterrupt(() =>
701 Effect.gen(function* () {
702 // A finished state stays, even if the request drops while it is being written.
703 if (yield* Ref.get(settled)) return;
704 yield* setUpdateState(
705 makeUpdateState({
706 status: "failed",
707 startedAt: yield* Ref.get(startedAtRef),
708 finishedAt: yield* nowIso,
709 message: "Update stopped before it finished.",
710 }),
711 );
712 }).pipe(Effect.ignore),
713 ),
714 Effect.mapError((error) =>
715 isServerProviderUpdateError(error)
716 ? new ServerProviderUpdateError({
717 provider,
718 reason: error.reason,
719 })
720 : error,
721 ),
722 );
723 });
724
725 return ProviderMaintenanceRunner.of({
726 updateProvider,
727 });
728});
729
730export const layer = Layer.effect(ProviderMaintenanceRunner, make());