1import {
2 defaultInstanceIdForDriver,
3 PROVIDER_DISPLAY_NAMES,
4 type EnvironmentId,
5 type ExecutionEnvironmentPlatformOs,
6 type ProviderDriverKind,
7 type ProviderInstanceId,
8 type ServerProvider,
9} from "@t3tools/contracts";
10import {
11 isAtomCommandInterrupted,
12 squashAtomCommandFailure,
13 type AtomCommandResult,
14} from "@t3tools/client-runtime/state/runtime";
15
16export type ProviderUpdateCandidate = ServerProvider & {
17 readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
18 readonly status: "behind_latest";
19 readonly latestVersion: string;
20 };
21};
22
23export type ProviderSettingsUpdateCandidate = ServerProvider & {
24 readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
25 readonly canUpdate: true;
26 readonly updateCommand: string;
27 };
28};
29
30export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success";
31export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded";
32
33export interface ProviderUpdateToastView {
34 readonly phase: ProviderUpdateToastPhase;
35 readonly type: ProviderUpdateToastType;
36 readonly title: string;
37 readonly description: string;
38 readonly dismissAfterVisibleMs?: number;
39}
40
41/**
42 * Terminal update phases — outcomes that are safe to persist as a one-shot row
43 * result. A non-terminal ("initial"/"running") snapshot never re-polls itself,
44 * so storing one pins an update row's spinner indefinitely once its pending flag
45 * expires; such phases must be dropped in favor of the live per-environment
46 * provider state instead of being persisted.
47 */
48export function isTerminalProviderUpdatePhase(phase: ProviderUpdateToastPhase): boolean {
49 return phase === "succeeded" || phase === "failed" || phase === "unchanged";
50}
51
52export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success";
53
54export interface ProviderUpdateSidebarPillView {
55 readonly key: string;
56 readonly tone: ProviderUpdateSidebarPillTone;
57 readonly title: string;
58 readonly description: string;
59 readonly dismissible?: boolean;
60 readonly dismissAfterVisibleMs?: number;
61}
62
63interface ProviderUpdateSidebarPillOptions {
64 readonly visibleAfterIso?: string;
65 readonly dismissedKeys?: ReadonlySet<string>;
66}
67
68const PROVIDER_UPDATE_SUCCESS_VISIBLE_MS = 3_000;
69
70function formatVersion(value: string): string {
71 return value.startsWith("v") ? value : `v${value}`;
72}
73
74function chooseRepresentativeProvider(
75 current: ServerProvider | undefined,
76 candidate: ServerProvider,
77): ServerProvider {
78 if (!current) {
79 return candidate;
80 }
81 const defaultInstanceId = defaultInstanceIdForDriver(candidate.driver);
82 if (candidate.instanceId === defaultInstanceId) {
83 return candidate;
84 }
85 if (current.instanceId === defaultInstanceId) {
86 return current;
87 }
88 return candidate.checkedAt.localeCompare(current.checkedAt) >= 0 ? candidate : current;
89}
90
91function dedupeProvidersByDriver<T extends ServerProvider>(providers: ReadonlyArray<T>): T[] {
92 const latestProviderByDriver = new Map<ProviderDriverKind, T>();
93
94 for (const provider of providers) {
95 latestProviderByDriver.set(
96 provider.driver,
97 chooseRepresentativeProvider(latestProviderByDriver.get(provider.driver), provider) as T,
98 );
99 }
100
101 return [...latestProviderByDriver.values()];
102}
103
104function dedupeProvidersByInstanceId<T extends ServerProvider>(providers: ReadonlyArray<T>): T[] {
105 const latestProviderByInstanceId = new Map<ProviderInstanceId, T>();
106
107 for (const provider of providers) {
108 const current = latestProviderByInstanceId.get(provider.instanceId);
109 if (!current || provider.checkedAt.localeCompare(current.checkedAt) >= 0) {
110 latestProviderByInstanceId.set(provider.instanceId, provider);
111 }
112 }
113
114 return [...latestProviderByInstanceId.values()];
115}
116
117function getProviderUpdatedTitle(provider: Pick<ServerProvider, "driver" | "version">): string {
118 const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
119 return provider.version
120 ? `${providerName} updated: ${formatVersion(provider.version)}`
121 : `${providerName} updated`;
122}
123
124function getProviderUpdatedDescription(providerCount: number): string {
125 return providerCount === 1
126 ? "New sessions will use the updated provider."
127 : "New sessions will use the updated providers.";
128}
129
130function getProviderFailedUpdateTitle(
131 provider: Pick<ServerProvider, "driver" | "versionAdvisory">,
132): string {
133 const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
134 const attemptedVersion = provider.versionAdvisory?.latestVersion;
135 return attemptedVersion
136 ? `${providerName} ${formatVersion(attemptedVersion)} update failed`
137 : `${providerName} update failed`;
138}
139
140export function isProviderUpdateCandidate(
141 provider: ServerProvider,
142): provider is ProviderUpdateCandidate {
143 return (
144 provider.enabled &&
145 provider.compatibilityAdvisory?.latestVersionStatus !== "broken" &&
146 provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" &&
147 provider.versionAdvisory?.status === "behind_latest" &&
148 provider.versionAdvisory.latestVersion !== null
149 );
150}
151
152export function isProviderUpdateActive(provider: Pick<ServerProvider, "updateState">): boolean {
153 return provider.updateState?.status === "queued" || provider.updateState?.status === "running";
154}
155
156export function collectProviderUpdateCandidates(
157 providers: ReadonlyArray<ServerProvider>,
158): ProviderUpdateCandidate[] {
159 return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate));
160}
161
162export function isProviderSettingsUpdateCandidate(
163 provider: ServerProvider,
164): provider is ProviderSettingsUpdateCandidate {
165 return (
166 provider.enabled &&
167 provider.compatibilityAdvisory?.latestVersionStatus !== "broken" &&
168 provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" &&
169 provider.versionAdvisory?.status === "behind_latest" &&
170 provider.versionAdvisory.canUpdate === true &&
171 provider.versionAdvisory.updateCommand !== null
172 );
173}
174
175export function hasOneClickUpdateProviderCandidate(
176 candidate: ProviderUpdateCandidate,
177 providers: ReadonlyArray<ServerProvider>,
178): boolean {
179 if (
180 candidate.versionAdvisory.canUpdate !== true ||
181 candidate.versionAdvisory.updateCommand === null
182 ) {
183 return false;
184 }
185
186 const driverProviders = providers.filter((provider) => provider.driver === candidate.driver);
187 if (driverProviders.length === 0) {
188 return false;
189 }
190
191 const updateCommands = new Set<string>();
192 for (const provider of driverProviders) {
193 if (!isProviderUpdateCandidate(provider)) {
194 continue;
195 }
196 const advisory = provider.versionAdvisory;
197 if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) {
198 return false;
199 }
200 updateCommands.add(advisory.updateCommand);
201 }
202
203 return updateCommands.size === 1;
204}
205
206export function canOneClickUpdateProviderCandidate(
207 candidate: ProviderUpdateCandidate,
208 providers: ReadonlyArray<ServerProvider>,
209): boolean {
210 return (
211 !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers)
212 );
213}
214
215export function providerUpdateNotificationKey(
216 providers: ReadonlyArray<ProviderUpdateCandidate>,
217): string | null {
218 const parts = dedupeProvidersByDriver(providers)
219 .map((provider) => {
220 const advisory = provider.versionAdvisory;
221 return [provider.driver, advisory.latestVersion].join(":");
222 })
223 .toSorted();
224
225 return parts.length > 0 ? parts.join("|") : null;
226}
227
228function formatProviderList(providers: ReadonlyArray<Pick<ServerProvider, "driver">>) {
229 const names = providers.map(
230 (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver,
231 );
232 if (names.length <= 2) {
233 return names.join(" and ");
234 }
235 return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
236}
237
238export function getProviderUpdateInitialToastView(input: {
239 readonly updateProviders: ReadonlyArray<ProviderUpdateCandidate>;
240 readonly oneClickProviders: ReadonlyArray<ProviderUpdateCandidate>;
241}): ProviderUpdateToastView {
242 return {
243 phase: "initial",
244 type: "warning",
245 title: getProviderUpdateInitialToastTitle(input.updateProviders),
246 description:
247 input.oneClickProviders.length > 0
248 ? "Install the update now or review provider settings."
249 : `${formatProviderList(input.updateProviders)} can be updated from provider settings.`,
250 };
251}
252
253export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastView): boolean {
254 return view.phase !== "running";
255}
256
257function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView {
258 return {
259 phase: "running",
260 type: "loading",
261 title: providerCount === 1 ? "Updating provider" : "Updating providers",
262 description: "Running provider update command.",
263 };
264}
265
266export function getProviderUpdateRejectedToastView(
267 providerCount: number,
268 message: string,
269): ProviderUpdateToastView {
270 return {
271 phase: "failed",
272 type: "error",
273 title: providerCount === 1 ? "Provider update failed" : "Provider updates failed",
274 description: message,
275 };
276}
277
278export function getProviderUpdateProgressToastView(input: {
279 readonly providers: ReadonlyArray<ServerProvider>;
280 readonly providerCount: number;
281}): ProviderUpdateToastView {
282 const providers = dedupeProvidersByDriver(input.providers);
283 const failedProviders = providers.filter((provider) => provider.updateState?.status === "failed");
284 if (failedProviders.length > 0) {
285 return {
286 phase: "failed",
287 type: "error",
288 title: failedProviders.length === 1 ? "Provider update failed" : "Provider updates failed",
289 description: getFailedProviderUpdateDescription(failedProviders),
290 };
291 }
292
293 const unchangedProviders = providers.filter(
294 (provider) => provider.updateState?.status === "unchanged",
295 );
296 if (unchangedProviders.length > 0) {
297 return {
298 phase: "unchanged",
299 type: "warning",
300 title:
301 unchangedProviders.length === 1
302 ? "Provider still needs an update"
303 : "Providers still need updates",
304 description: `${formatProviderList(unchangedProviders)} ${
305 unchangedProviders.length === 1 ? "still appears" : "still appear"
306 } outdated. Check provider settings for details.`,
307 };
308 }
309
310 if (providers.some(isProviderUpdateActive)) {
311 return getProviderUpdateRunningToastView(input.providerCount);
312 }
313
314 const hasCompleteProviderSnapshots = providers.length >= input.providerCount;
315 const allProvidersUpdated =
316 hasCompleteProviderSnapshots &&
317 providers.every(
318 (provider) =>
319 provider.updateState?.status === "succeeded" || !isProviderUpdateCandidate(provider),
320 );
321 if (allProvidersUpdated) {
322 return {
323 phase: "succeeded",
324 type: "success",
325 title: input.providerCount === 1 ? "Provider updated" : "Provider updates finished",
326 description: getProviderUpdatedDescription(input.providerCount),
327 dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS,
328 };
329 }
330
331 return getProviderUpdateRunningToastView(input.providerCount);
332}
333
334/** One provider update sent by the cross-machine "Update all", with its result. */
335export interface ProviderUpdateRun {
336 readonly environmentId: EnvironmentId;
337 readonly machineLabel: string;
338 readonly driver: ProviderDriverKind;
339 readonly instanceId: ProviderInstanceId;
340 readonly result: AtomCommandResult<
341 { readonly providers: ReadonlyArray<ServerProvider> },
342 unknown
343 >;
344}
345
346/**
347 * What one provider update in a cross-machine run did. A failure carries a
348 * line that names the machine and provider, such as "alvin · Codex: npm exited".
349 */
350export function getProviderUpdateRunOutcome(
351 run: ProviderUpdateRun,
352):
353 | { readonly status: "interrupted" | "updated" }
354 | { readonly status: "failed"; readonly line: string } {
355 if (isAtomCommandInterrupted(run.result)) return { status: "interrupted" };
356 const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`;
357 if (run.result._tag === "Failure") {
358 const error = squashAtomCommandFailure(run.result);
359 return {
360 status: "failed",
361 line: `${label}: ${error instanceof Error ? error.message : "Provider update failed."}`,
362 };
363 }
364 const updateState = run.result.value.providers.find(
365 (provider) => provider.instanceId === run.instanceId,
366 )?.updateState;
367 return updateState?.status === "succeeded"
368 ? { status: "updated" }
369 : {
370 status: "failed",
371 line: `${label}: ${updateState?.message ?? "Provider update did not finish."}`,
372 };
373}
374
375/**
376 * Summarize a cross-machine "Update all" as one toast, or null when every
377 * request was interrupted. Each update that did not succeed gets its own line,
378 * so a failure on one machine is not hidden by successes on the others.
379 */
380export function getProviderUpdateRunToastView(
381 runs: ReadonlyArray<ProviderUpdateRun>,
382): Pick<ProviderUpdateToastView, "type" | "title" | "description"> | null {
383 const settled = runs
384 .map(getProviderUpdateRunOutcome)
385 .filter((outcome) => outcome.status !== "interrupted");
386 if (settled.length === 0) {
387 return null;
388 }
389 const failureLines = settled.flatMap((outcome) =>
390 outcome.status === "failed" ? [outcome.line] : [],
391 );
392 if (failureLines.length === 0) {
393 return {
394 type: "success",
395 title: settled.length === 1 ? "Provider updated" : `${settled.length} providers updated`,
396 description: getProviderUpdatedDescription(settled.length),
397 };
398 }
399 return {
400 type: "error",
401 title:
402 failureLines.length < settled.length
403 ? `${failureLines.length} of ${settled.length} provider updates failed`
404 : settled.length === 1
405 ? "Provider update failed"
406 : "Provider updates failed",
407 description: failureLines.join("\n"),
408 };
409}
410
411export function collectUpdatedProviderSnapshots(input: {
412 readonly results: ReadonlyArray<
413 AtomCommandResult<{ readonly providers: ReadonlyArray<ServerProvider> }, unknown>
414 >;
415 readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>;
416}): ServerProvider[] {
417 const matchedProviders: ServerProvider[] = [];
418
419 for (const result of input.results) {
420 if (result._tag === "Failure") {
421 continue;
422 }
423 for (const provider of result.value.providers) {
424 if (input.providerInstanceIds.has(provider.instanceId)) {
425 matchedProviders.push(provider);
426 }
427 }
428 }
429
430 return dedupeProvidersByInstanceId(matchedProviders);
431}
432
433export function firstFailedProviderUpdateMessage(
434 results: ReadonlyArray<AtomCommandResult<unknown, unknown>>,
435): string | null {
436 const failed = results.find((result) => result._tag === "Failure");
437 if (!failed || failed._tag !== "Failure") {
438 return null;
439 }
440 const error = squashAtomCommandFailure(failed);
441 return error instanceof Error ? error.message : "Provider update failed.";
442}
443
444function getUpdateFinishedAt(provider: ServerProvider): string | null {
445 return provider.updateState?.finishedAt ?? null;
446}
447
448function isRecentTerminalProvider(
449 provider: ServerProvider,
450 visibleAfterIso: string | undefined,
451): boolean {
452 const status = provider.updateState?.status;
453 if (status !== "failed" && status !== "unchanged" && status !== "succeeded") {
454 return false;
455 }
456 if (visibleAfterIso === undefined) {
457 return true;
458 }
459 const finishedAt = getUpdateFinishedAt(provider);
460 return finishedAt !== null && finishedAt >= visibleAfterIso;
461}
462
463function latestFinishedAtForProviders(providers: ReadonlyArray<ServerProvider>): string | null {
464 return providers.reduce<string | null>((latest, provider) => {
465 const finishedAt = getUpdateFinishedAt(provider);
466 if (finishedAt === null) {
467 return latest;
468 }
469 return latest === null || finishedAt > latest ? finishedAt : latest;
470 }, null);
471}
472
473export function getProviderUpdateSidebarPillView(
474 providers: ReadonlyArray<ServerProvider>,
475 options?: ProviderUpdateSidebarPillOptions,
476): ProviderUpdateSidebarPillView | null {
477 const dedupedProviders = dedupeProvidersByDriver(providers);
478 const activeProviders = dedupedProviders.filter(isProviderUpdateActive);
479 if (activeProviders.length > 0) {
480 const activeProvider = activeProviders[0]!;
481 const activeProviderName =
482 PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver;
483 return {
484 key: `loading:${activeProviders
485 .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`)
486 .toSorted()
487 .join("|")}`,
488 tone: "loading",
489 title:
490 activeProviders.length === 1
491 ? `Updating ${activeProviderName}`
492 : `Updating ${activeProviders.length} providers`,
493 description:
494 activeProviders.length === 1
495 ? `${formatProviderList(activeProviders)} update in progress.`
496 : `${formatProviderList(activeProviders)} updates are in progress.`,
497 };
498 }
499
500 const recentTerminalProviders = dedupedProviders.filter((provider) =>
501 isRecentTerminalProvider(provider, options?.visibleAfterIso),
502 );
503 const terminalCandidates: ProviderUpdateSidebarPillView[] = [];
504
505 const failedProviders = recentTerminalProviders.filter(
506 (provider) => provider.updateState?.status === "failed",
507 );
508 if (failedProviders.length > 0) {
509 const failedProvider = failedProviders[0]!;
510 terminalCandidates.push({
511 key: `failed:${failedProviders
512 .map(
513 (provider) =>
514 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
515 )
516 .toSorted()
517 .join("|")}`,
518 tone: "error",
519 title:
520 failedProviders.length === 1
521 ? getProviderFailedUpdateTitle(failedProvider)
522 : `${failedProviders.length} provider updates failed`,
523 description: getFailedProviderUpdateDescription(failedProviders),
524 dismissible: true,
525 });
526 }
527
528 const unchangedProviders = recentTerminalProviders.filter(
529 (provider) => provider.updateState?.status === "unchanged",
530 );
531 if (unchangedProviders.length > 0) {
532 const unchangedProvider = unchangedProviders[0]!;
533 const unchangedProviderName =
534 PROVIDER_DISPLAY_NAMES[unchangedProvider.driver] ?? unchangedProvider.driver;
535 terminalCandidates.push({
536 key: `unchanged:${unchangedProviders
537 .map(
538 (provider) =>
539 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
540 )
541 .toSorted()
542 .join("|")}`,
543 tone: "warning",
544 title:
545 unchangedProviders.length === 1
546 ? `${unchangedProviderName} still needs an update`
547 : `${unchangedProviders.length} providers still need updates`,
548 description: `${formatProviderList(unchangedProviders)} ${
549 unchangedProviders.length === 1 ? "still appears" : "still appear"
550 } outdated. Review provider settings for details.`,
551 dismissible: true,
552 });
553 }
554
555 const succeededProviders = recentTerminalProviders.filter(
556 (provider) => provider.updateState?.status === "succeeded",
557 );
558 if (succeededProviders.length > 0) {
559 const succeededProvider = succeededProviders[0]!;
560 terminalCandidates.push({
561 key: `succeeded:${succeededProviders
562 .map(
563 (provider) =>
564 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
565 )
566 .toSorted()
567 .join("|")}`,
568 tone: "success",
569 title:
570 succeededProviders.length === 1
571 ? getProviderUpdatedTitle(succeededProvider)
572 : `${succeededProviders.length} providers updated`,
573 description: getProviderUpdatedDescription(succeededProviders.length),
574 dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS,
575 });
576 }
577
578 return (
579 terminalCandidates
580 .toSorted((left, right) => {
581 const leftProviders =
582 left.tone === "error"
583 ? failedProviders
584 : left.tone === "warning"
585 ? unchangedProviders
586 : succeededProviders;
587 const rightProviders =
588 right.tone === "error"
589 ? failedProviders
590 : right.tone === "warning"
591 ? unchangedProviders
592 : succeededProviders;
593 const leftFinishedAt = latestFinishedAtForProviders(leftProviders) ?? "";
594 const rightFinishedAt = latestFinishedAtForProviders(rightProviders) ?? "";
595 return rightFinishedAt.localeCompare(leftFinishedAt);
596 })
597 .find((candidate) => !options?.dismissedKeys?.has(candidate.key)) ?? null
598 );
599}
600
601function getProviderUpdateInitialToastTitle(
602 providers: ReadonlyArray<ProviderUpdateCandidate>,
603): string {
604 if (providers.length === 1) {
605 const provider = providers[0]!;
606 const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
607 return `Update Available: ${providerName} ${formatVersion(provider.versionAdvisory.latestVersion)}`;
608 }
609 return `Updates Available: ${providers.length} providers`;
610}
611
612function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvider>): string {
613 if (providers.length === 1) {
614 const provider = providers[0]!;
615 if (provider.updateState?.message) {
616 return provider.updateState.message;
617 }
618 }
619 return `${formatProviderList(providers)} failed to update. Check provider settings for details.`;
620}
621
622// ===========================================================================
623// Multi-environment provider updates
624//
625// With a desktop-local secondary backend present (the WSL backend alongside the
626// Windows primary), a provider update is applied across every local backend.
627// Each environment owns its own provider instances, so candidates and progress
628// are computed per environment and the dispatch targets that environment's
629// connection. These helpers are pure; the dispatch itself runs through the
630// `serverEnvironment.updateProvider` atom command in the components.
631// ===========================================================================
632
633/**
634 * The settled result of dispatching a provider update to one local backend.
635 * `provider` is the post-update snapshot of the targeted instance returned by
636 * that backend (null when the backend did not report the targeted instance,
637 * e.g. it does not have it installed).
638 */
639export interface LocalProviderUpdateOutcome {
640 readonly environmentId: EnvironmentId;
641 readonly isPrimary: boolean;
642 readonly driver: ProviderDriverKind;
643 readonly instanceId: ProviderInstanceId;
644 readonly provider: ServerProvider | null;
645}
646
647// Worst-case ordering across backends: a failed copy outranks an unchanged one,
648// which outranks a still-running one, which outranks a succeeded one.
649const PROVIDER_UPDATE_STATUS_SEVERITY: Record<string, number> = {
650 succeeded: 1,
651 queued: 2,
652 running: 2,
653 unchanged: 3,
654 failed: 4,
655};
656
657function providerUpdateOutcomeSeverity(provider: ServerProvider): number {
658 return PROVIDER_UPDATE_STATUS_SEVERITY[provider.updateState?.status ?? ""] ?? 0;
659}
660
661export function firstRejectedProviderUpdateMessage(
662 results: ReadonlyArray<PromiseSettledResult<unknown>>,
663): string | null {
664 const rejected = results.find((result) => result.status === "rejected");
665 if (!rejected) {
666 return null;
667 }
668 return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed.";
669}
670
671/**
672 * Reduce per-backend update outcomes to one representative snapshot per driver,
673 * keeping the worst-case status across every local backend. Because the same
674 * driver has a distinct instance id per environment, a secondary backend (e.g.
675 * WSL) that *resolved* with a failed or unchanged provider would otherwise be
676 * filtered out (its instance id is not the primary's) or collapsed behind the
677 * primary's success — this surfaces it instead.
678 */
679export function collectProviderUpdateOutcomeSnapshots(
680 results: ReadonlyArray<PromiseSettledResult<LocalProviderUpdateOutcome>>,
681): ServerProvider[] {
682 const worstByDriver = new Map<ProviderDriverKind, ServerProvider>();
683 for (const result of results) {
684 if (result.status !== "fulfilled" || result.value.provider === null) {
685 continue;
686 }
687 const provider = result.value.provider;
688 const current = worstByDriver.get(provider.driver);
689 if (
690 !current ||
691 providerUpdateOutcomeSeverity(provider) > providerUpdateOutcomeSeverity(current)
692 ) {
693 worstByDriver.set(provider.driver, provider);
694 }
695 }
696 return [...worstByDriver.values()];
697}
698
699/**
700 * A human label that distinguishes local environments by platform (so the
701 * popover shows "Windows" / "WSL" rather than the account name twice). WSL is
702 * identified by its backend instance id; everything else falls back to the
703 * reported OS, then the environment's own label.
704 */
705export function deriveEnvironmentDisplayLabel(input: {
706 readonly isWsl: boolean;
707 readonly wslDistro: string | null;
708 readonly platformOs: ExecutionEnvironmentPlatformOs | undefined;
709 readonly fallbackLabel: string;
710}): string {
711 if (input.isWsl) {
712 return input.wslDistro ? `WSL · ${input.wslDistro}` : "WSL";
713 }
714 switch (input.platformOs) {
715 case "windows":
716 return "Windows";
717 case "darwin":
718 return "macOS";
719 case "linux":
720 return "Linux";
721 default:
722 return input.fallbackLabel;
723 }
724}
725
726/** Connection state of a local environment, normalized across primary/secondary sources. */
727export type EnvironmentUpdateConnectionState = "connecting" | "ready" | "disconnected" | "error";
728
729export interface LocalEnvironmentProvidersInput {
730 readonly environmentId: EnvironmentId;
731 readonly label: string;
732 readonly isPrimary: boolean;
733 readonly connectionState: EnvironmentUpdateConnectionState;
734 readonly providers: ReadonlyArray<ServerProvider>;
735}
736
737export interface LocalEnvironmentUpdateGroup {
738 readonly environmentId: EnvironmentId;
739 readonly label: string;
740 readonly isPrimary: boolean;
741 /** True while this environment's backend is still connecting (e.g. WSL booting). */
742 readonly isSettling: boolean;
743 /** Outdated, one-click-updatable providers in this environment. */
744 readonly candidates: ProviderUpdateCandidate[];
745 /** Full provider list for this environment, used to derive live update progress. */
746 readonly providers: ReadonlyArray<ServerProvider>;
747}
748
749/**
750 * Build one update group per local environment, pairing each environment's
751 * outdated one-click candidates with its own provider list, and report whether
752 * any environment is still settling (so the caller can defer the popover).
753 */
754export function buildLocalEnvironmentUpdateGroups(
755 environments: ReadonlyArray<LocalEnvironmentProvidersInput>,
756): { groups: LocalEnvironmentUpdateGroup[]; isAnySettling: boolean } {
757 const groups = environments.map((environment) => ({
758 environmentId: environment.environmentId,
759 label: environment.label,
760 isPrimary: environment.isPrimary,
761 isSettling: environment.connectionState === "connecting",
762 candidates: collectProviderUpdateCandidates(environment.providers).filter((candidate) =>
763 canOneClickUpdateProviderCandidate(candidate, environment.providers),
764 ),
765 providers: environment.providers,
766 }));
767 const isAnySettling = environments.some(
768 (environment) => environment.connectionState === "connecting",
769 );
770 return { groups, isAnySettling };
771}
772
773/** Groups that actually have a one-click update available, in display order (primary first). */
774export function environmentGroupsWithUpdates(
775 groups: ReadonlyArray<LocalEnvironmentUpdateGroup>,
776): LocalEnvironmentUpdateGroup[] {
777 return groups.filter((group) => group.candidates.length > 0);
778}
779
780/**
781 * Stable key over the set of (environment, driver, latest version) updates on
782 * offer, so the popover is shown once per distinct set and re-shown when it
783 * changes.
784 */
785export function localEnvironmentUpdateNotificationKey(
786 groups: ReadonlyArray<LocalEnvironmentUpdateGroup>,
787): string | null {
788 const parts = environmentGroupsWithUpdates(groups)
789 .map((group) => {
790 const providerParts = group.candidates
791 .map((candidate) => `${candidate.driver}:${candidate.versionAdvisory.latestVersion}`)
792 .toSorted()
793 .join(",");
794 return `${group.environmentId}=${providerParts}`;
795 })
796 .toSorted();
797 return parts.length > 0 ? parts.join("|") : null;
798}
799
800export type ProviderUpdateRowStatusKind = "idle" | "loading" | "success" | "failed" | "unchanged";
801
802export interface ProviderUpdateRowStatus {
803 readonly kind: ProviderUpdateRowStatusKind;
804 readonly text: string;
805}
806
807function environmentProviderNames(group: LocalEnvironmentUpdateGroup): string {
808 return group.candidates
809 .map((candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver)
810 .join(", ");
811}
812
813/**
814 * Resolve one environment row's display from every available signal, in
815 * priority order: a transport rejection, then the dispatch's own *terminal*
816 * result payload (reliable even when a secondary backend's config does not
817 * re-sync), then live server state (reliable even when the dispatch RPC is lost
818 * to a reconnect), then the optimistic pending spinner, then the idle state.
819 *
820 * A non-terminal result snapshot ("running") is intentionally skipped rather
821 * than treated as authoritative, so live server state can still drive the row
822 * to its terminal status instead of pinning it on "Updating…".
823 */
824export function resolveEnvironmentUpdateRowStatus(input: {
825 readonly group: LocalEnvironmentUpdateGroup;
826 readonly error: string | undefined;
827 readonly result: ProviderUpdateToastView | undefined;
828 readonly pill: ProviderUpdateSidebarPillView | null;
829 readonly isPending: boolean;
830}): ProviderUpdateRowStatus {
831 if (input.error) {
832 return { kind: "failed", text: input.error };
833 }
834 if (input.result) {
835 switch (input.result.phase) {
836 case "succeeded":
837 return { kind: "success", text: "Updated" };
838 case "failed":
839 return { kind: "failed", text: input.result.description };
840 case "unchanged":
841 return { kind: "unchanged", text: input.result.description };
842 // "running" / "initial": non-terminal snapshot — fall through to live state.
843 }
844 }
845 if (input.pill) {
846 switch (input.pill.tone) {
847 case "success":
848 return { kind: "success", text: "Updated" };
849 case "error":
850 return { kind: "failed", text: input.pill.description };
851 case "warning":
852 return { kind: "unchanged", text: input.pill.description };
853 default:
854 return { kind: "loading", text: "Updating…" };
855 }
856 }
857 // A non-terminal result snapshot or the optimistic pending flag means an
858 // update is still in flight — keep showing the spinner rather than reverting
859 // to the Update button as if nothing happened.
860 if (input.result || input.isPending) {
861 return { kind: "loading", text: "Updating…" };
862 }
863 return { kind: "idle", text: environmentProviderNames(input.group) };
864}