Skip to content
Source
Sign inSign up
Open History to see the last commit

ProviderUpdateLaunchNotification.logic.ts

462 lines15.8 KB

1import {
2 defaultInstanceIdForDriver,
3 PROVIDER_DISPLAY_NAMES,
4 type EnvironmentId,
5 type ProviderDriverKind,
6 type ProviderInstanceId,
7 type ServerProvider,
8} from "@t3tools/contracts";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12 type AtomCommandResult,
13} from "@t3tools/client-runtime/state/runtime";
14​
15export type ProviderUpdateCandidate = ServerProvider & {
16 readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
17 readonly status: "behind_latest";
18 readonly latestVersion: string;
19 };
20};
21​
22export type ProviderSettingsUpdateCandidate = ServerProvider & {
23 readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
24 readonly canUpdate: true;
25 readonly updateCommand: string;
26 };
27};
28​
29export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success";
30​
31export interface ProviderUpdateSidebarPillView {
32 readonly key: string;
33 readonly tone: ProviderUpdateSidebarPillTone;
34 readonly title: string;
35 readonly description: string;
36 readonly dismissible?: boolean;
37 readonly dismissAfterVisibleMs?: number;
39​
40interface ProviderUpdateSidebarPillOptions {
41 readonly visibleAfterIso?: string;
42 readonly dismissedKeys?: ReadonlySet<string>;
44​
45const PROVIDER_UPDATE_SUCCESS_VISIBLE_MS = 3_000;
46​
47function formatVersion(value: string): string {
48 return value.startsWith("v") ? value : `v${value}`;
50​
51function chooseRepresentativeProvider(
52 current: ServerProvider | undefined,
53 candidate: ServerProvider,
54): ServerProvider {
55 if (!current) {
56 return candidate;
57 }
58 const defaultInstanceId = defaultInstanceIdForDriver(candidate.driver);
59 if (candidate.instanceId === defaultInstanceId) {
60 return candidate;
61 }
62 if (current.instanceId === defaultInstanceId) {
63 return current;
64 }
65 return candidate.checkedAt.localeCompare(current.checkedAt) >= 0 ? candidate : current;
67​
68function dedupeProvidersByDriver<T extends ServerProvider>(providers: ReadonlyArray<T>): T[] {
69 const latestProviderByDriver = new Map<ProviderDriverKind, T>();
70​
71 for (const provider of providers) {
72 latestProviderByDriver.set(
73 provider.driver,
74 chooseRepresentativeProvider(latestProviderByDriver.get(provider.driver), provider) as T,
75 );
76 }
77​
78 return [...latestProviderByDriver.values()];
80​
81function getProviderUpdatedTitle(
82 provider: Pick<ServerProvider, "driver" | "version" | "installedVersion">,
83): string {
84 const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
85 const version = provider.installedVersion ?? provider.version;
86 return version ? `${providerName} updated: ${formatVersion(version)}` : `${providerName} updated`;
88​
89// Only OpenCode reports an installed CLI apart from a running external server.
90export function getProviderRestartNotice(
91 provider: Pick<ServerProvider, "version" | "installedVersion" | "updateState">,
92): string | null {
93 return provider.updateState?.status === "succeeded" &&
94 provider.installedVersion != null &&
95 provider.version != null &&
96 provider.installedVersion !== provider.version
97 ? provider.updateState.message
98 : null;
100​
101function getProviderUpdatedDescription(providerCount: number): string {
102 return providerCount === 1
103 ? "New sessions will use the updated provider."
104 : "New sessions will use the updated providers.";
106​
107function getProviderFailedUpdateTitle(
108 provider: Pick<ServerProvider, "driver" | "versionAdvisory">,
109): string {
110 const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
111 const attemptedVersion = provider.versionAdvisory?.latestVersion;
112 return attemptedVersion
113 ? `${providerName} ${formatVersion(attemptedVersion)} update failed`
114 : `${providerName} update failed`;
116​
117export function isProviderUpdateCandidate(
118 provider: ServerProvider,
119): provider is ProviderUpdateCandidate {
120 return (
121 provider.enabled &&
122 provider.compatibilityAdvisory?.latestVersionStatus !== "broken" &&
123 provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" &&
124 provider.versionAdvisory?.status === "behind_latest" &&
125 provider.versionAdvisory.latestVersion !== null
126 );
128​
129export function isProviderUpdateActive(provider: Pick<ServerProvider, "updateState">): boolean {
130 return provider.updateState?.status === "queued" || provider.updateState?.status === "running";
132​
133export function collectProviderUpdateCandidates(
134 providers: ReadonlyArray<ServerProvider>,
135): ProviderUpdateCandidate[] {
136 return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate));
138​
139export function isProviderSettingsUpdateCandidate(
140 provider: ServerProvider,
141): provider is ProviderSettingsUpdateCandidate {
142 return (
143 provider.enabled &&
144 provider.compatibilityAdvisory?.latestVersionStatus !== "broken" &&
145 provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" &&
146 provider.versionAdvisory?.status === "behind_latest" &&
147 provider.versionAdvisory.canUpdate === true &&
148 provider.versionAdvisory.updateCommand !== null
149 );
151​
152export function hasOneClickUpdateProviderCandidate(
153 candidate: ProviderUpdateCandidate,
154 providers: ReadonlyArray<ServerProvider>,
155): boolean {
156 if (
157 candidate.versionAdvisory.canUpdate !== true ||
158 candidate.versionAdvisory.updateCommand === null
159 ) {
160 return false;
161 }
162​
163 const driverProviders = providers.filter((provider) => provider.driver === candidate.driver);
164 if (driverProviders.length === 0) {
165 return false;
166 }
167​
168 const updateCommands = new Set<string>();
169 for (const provider of driverProviders) {
170 if (!isProviderUpdateCandidate(provider)) {
171 continue;
172 }
173 const advisory = provider.versionAdvisory;
174 if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) {
175 return false;
176 }
177 updateCommands.add(advisory.updateCommand);
178 }
179​
180 return updateCommands.size === 1;
182​
183export function canOneClickUpdateProviderCandidate(
184 candidate: ProviderUpdateCandidate,
185 providers: ReadonlyArray<ServerProvider>,
186): boolean {
187 return (
188 !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers)
189 );
191​
192function formatProviderList(providers: ReadonlyArray<Pick<ServerProvider, "driver">>) {
193 const names = providers.map(
194 (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver,
195 );
196 if (names.length <= 2) {
197 return names.join(" and ");
198 }
199 return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
201​
202export interface ProviderUpdateRun {
203 readonly environmentId: EnvironmentId;
204 readonly machineLabel: string;
205 readonly driver: ProviderDriverKind;
206 readonly instanceId: ProviderInstanceId;
207 readonly result: AtomCommandResult<
208 { readonly providers: ReadonlyArray<ServerProvider> },
209 unknown
210 >;
212​
213export function getProviderUpdateRunOutcome(
214 run: ProviderUpdateRun,
215):
216 | { readonly status: "interrupted" }
217 | { readonly status: "updated"; readonly message: string | null }
218 | { readonly status: "failed"; readonly line: string } {
219 if (isAtomCommandInterrupted(run.result)) return { status: "interrupted" };
220 const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`;
221 if (run.result._tag === "Failure") {
222 const error = squashAtomCommandFailure(run.result);
223 return {
224 status: "failed",
225 line: `${label}: ${error instanceof Error ? error.message : "Provider update failed."}`,
226 };
227 }
228 const provider = run.result.value.providers.find(
229 (candidate) => candidate.instanceId === run.instanceId,
230 );
231 const updateState = provider?.updateState;
232 const restartNotice = provider ? getProviderRestartNotice(provider) : null;
233 return updateState?.status === "succeeded"
234 ? { status: "updated", message: restartNotice ? `${label}: ${restartNotice}` : null }
235 : {
236 status: "failed",
237 line: `${label}: ${updateState?.message ?? "Provider update did not finish."}`,
238 };
240​
241export function getProviderUpdateRunToastView(runs: ReadonlyArray<ProviderUpdateRun>): {
242 readonly type: "success" | "error";
243 readonly title: string;
244 readonly description: string;
245} | null {
246 const settled = runs
247 .map(getProviderUpdateRunOutcome)
248 .filter((outcome) => outcome.status !== "interrupted");
249 if (settled.length === 0) {
250 return null;
251 }
252 const failureLines = settled.flatMap((outcome) =>
253 outcome.status === "failed" ? [outcome.line] : [],
254 );
255 const successMessages = [
256 ...new Set(
257 settled.flatMap((outcome) =>
258 outcome.status === "updated" && outcome.message ? [outcome.message] : [],
259 ),
260 ),
261 ];
262 if (failureLines.length === 0) {
263 return {
264 type: "success",
265 title: settled.length === 1 ? "Provider updated" : `${settled.length} providers updated`,
266 description: successMessages.join("\n") || getProviderUpdatedDescription(settled.length),
267 };
268 }
269 return {
270 type: "error",
271 title:
272 failureLines.length < settled.length
273 ? `${failureLines.length} of ${settled.length} provider updates failed`
274 : settled.length === 1
275 ? "Provider update failed"
276 : "Provider updates failed",
277 description: [...failureLines, ...successMessages].join("\n"),
278 };
280​
281function getUpdateFinishedAt(provider: ServerProvider): string | null {
282 return provider.updateState?.finishedAt ?? null;
284​
285function isRecentTerminalProvider(
286 provider: ServerProvider,
287 visibleAfterIso: string | undefined,
288): boolean {
289 const status = provider.updateState?.status;
290 if (status !== "failed" && status !== "unchanged" && status !== "succeeded") {
291 return false;
292 }
293 if (visibleAfterIso === undefined) {
294 return true;
295 }
296 const finishedAt = getUpdateFinishedAt(provider);
297 return finishedAt !== null && finishedAt >= visibleAfterIso;
299​
300function latestFinishedAtForProviders(providers: ReadonlyArray<ServerProvider>): string | null {
301 return providers.reduce<string | null>((latest, provider) => {
302 const finishedAt = getUpdateFinishedAt(provider);
303 if (finishedAt === null) {
304 return latest;
305 }
306 return latest === null || finishedAt > latest ? finishedAt : latest;
307 }, null);
309​
310/** Use each driver's latest finished instance so an older default-instance result cannot hide it. */
311function latestTerminalProvidersByDriver(providers: ReadonlyArray<ServerProvider>) {
312 const latestProviderByDriver = new Map<ProviderDriverKind, ServerProvider>();
313​
314 for (const provider of providers) {
315 const current = latestProviderByDriver.get(provider.driver);
316 if (!current || (getUpdateFinishedAt(provider) ?? "") >= (getUpdateFinishedAt(current) ?? "")) {
317 latestProviderByDriver.set(provider.driver, provider);
318 }
319 }
320​
321 return [...latestProviderByDriver.values()];
323​
324export function getProviderUpdateSidebarPillView(
325 providers: ReadonlyArray<ServerProvider>,
326 options?: ProviderUpdateSidebarPillOptions,
327): ProviderUpdateSidebarPillView | null {
328 // Filter active instances before deduplicating drivers because update state is per instance.
329 const activeProviders = dedupeProvidersByDriver(providers.filter(isProviderUpdateActive));
330 if (activeProviders.length > 0) {
331 const activeProvider = activeProviders[0]!;
332 const activeProviderName =
333 PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver;
334 return {
335 key: `loading:${activeProviders
336 .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`)
337 .toSorted()
338 .join("|")}`,
339 tone: "loading",
340 title:
341 activeProviders.length === 1
342 ? `Updating ${activeProviderName}`
343 : `Updating ${activeProviders.length} providers`,
344 description:
345 activeProviders.length === 1
346 ? `${formatProviderList(activeProviders)} update in progress.`
347 : `${formatProviderList(activeProviders)} updates are in progress.`,
348 };
349 }
350​
351 const recentTerminalProviders = latestTerminalProvidersByDriver(
352 providers.filter((provider) => isRecentTerminalProvider(provider, options?.visibleAfterIso)),
353 );
354 const terminalCandidates: ProviderUpdateSidebarPillView[] = [];
355​
356 const failedProviders = recentTerminalProviders.filter(
357 (provider) => provider.updateState?.status === "failed",
358 );
359 if (failedProviders.length > 0) {
360 const failedProvider = failedProviders[0]!;
361 terminalCandidates.push({
362 key: `failed:${failedProviders
363 .map(
364 (provider) =>
365 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
366 )
367 .toSorted()
368 .join("|")}`,
369 tone: "error",
370 title:
371 failedProviders.length === 1
372 ? getProviderFailedUpdateTitle(failedProvider)
373 : `${failedProviders.length} provider updates failed`,
374 description: getFailedProviderUpdateDescription(failedProviders),
375 dismissible: true,
376 });
377 }
378​
379 const unchangedProviders = recentTerminalProviders.filter(
380 (provider) => provider.updateState?.status === "unchanged",
381 );
382 if (unchangedProviders.length > 0) {
383 const unchangedProvider = unchangedProviders[0]!;
384 const unchangedProviderName =
385 PROVIDER_DISPLAY_NAMES[unchangedProvider.driver] ?? unchangedProvider.driver;
386 terminalCandidates.push({
387 key: `unchanged:${unchangedProviders
388 .map(
389 (provider) =>
390 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
391 )
392 .toSorted()
393 .join("|")}`,
394 tone: "warning",
395 title:
396 unchangedProviders.length === 1
397 ? `${unchangedProviderName} still needs an update`
398 : `${unchangedProviders.length} providers still need updates`,
399 description: `${formatProviderList(unchangedProviders)} ${
400 unchangedProviders.length === 1 ? "still appears" : "still appear"
401 } outdated. Review provider settings for details.`,
402 dismissible: true,
403 });
404 }
405​
406 const succeededProviders = recentTerminalProviders.filter(
407 (provider) => provider.updateState?.status === "succeeded",
408 );
409 if (succeededProviders.length > 0) {
410 const succeededProvider = succeededProviders[0]!;
411 terminalCandidates.push({
412 key: `succeeded:${succeededProviders
413 .map(
414 (provider) =>
415 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
416 )
417 .toSorted()
418 .join("|")}`,
419 tone: "success",
420 title:
421 succeededProviders.length === 1
422 ? getProviderUpdatedTitle(succeededProvider)
423 : `${succeededProviders.length} providers updated`,
424 description:
425 [...new Set(succeededProviders.map(getProviderRestartNotice).filter(Boolean))].join("\n") ||
426 getProviderUpdatedDescription(succeededProviders.length),
427 dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS,
428 });
429 }
430​
431 return (
432 terminalCandidates
433 .toSorted((left, right) => {
434 const leftProviders =
435 left.tone === "error"
436 ? failedProviders
437 : left.tone === "warning"
438 ? unchangedProviders
439 : succeededProviders;
440 const rightProviders =
441 right.tone === "error"
442 ? failedProviders
443 : right.tone === "warning"
444 ? unchangedProviders
445 : succeededProviders;
446 const leftFinishedAt = latestFinishedAtForProviders(leftProviders) ?? "";
447 const rightFinishedAt = latestFinishedAtForProviders(rightProviders) ?? "";
448 return rightFinishedAt.localeCompare(leftFinishedAt);
449 })
450 .find((candidate) => !options?.dismissedKeys?.has(candidate.key)) ?? null
451 );
453​
454function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvider>): string {
455 if (providers.length === 1) {
456 const provider = providers[0]!;
457 if (provider.updateState?.message) {
458 return provider.updateState.message;
459 }
460 }
461 return `${formatProviderList(providers)} failed to update. Check provider settings for details.`;