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

ProviderUpdateLaunchNotification.logic.ts

465 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 = 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."}\n${run.machineLabel}`,
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 ? {
235 status: "updated",
236 message: restartNotice ? `${label}: ${restartNotice}\n${run.machineLabel}` : null,
237 }
238 : {
239 status: "failed",
240 line: `${label}: ${updateState?.message ?? "Provider update did not finish."}\n${run.machineLabel}`,
241 };
243​
244export function getProviderUpdateRunToastView(runs: ReadonlyArray<ProviderUpdateRun>): {
245 readonly type: "success" | "error";
246 readonly title: string;
247 readonly description: string;
248} | null {
249 const settled = runs
250 .map(getProviderUpdateRunOutcome)
251 .filter((outcome) => outcome.status !== "interrupted");
252 if (settled.length === 0) {
253 return null;
254 }
255 const failureLines = settled.flatMap((outcome) =>
256 outcome.status === "failed" ? [outcome.line] : [],
257 );
258 const successMessages = [
259 ...new Set(
260 settled.flatMap((outcome) =>
261 outcome.status === "updated" && outcome.message ? [outcome.message] : [],
262 ),
263 ),
264 ];
265 if (failureLines.length === 0) {
266 return {
267 type: "success",
268 title: settled.length === 1 ? "Provider updated" : `${settled.length} providers updated`,
269 description: successMessages.join("\n") || getProviderUpdatedDescription(settled.length),
270 };
271 }
272 return {
273 type: "error",
274 title:
275 failureLines.length < settled.length
276 ? `${failureLines.length} of ${settled.length} provider updates failed`
277 : settled.length === 1
278 ? "Provider update failed"
279 : "Provider updates failed",
280 description: [...failureLines, ...successMessages].join("\n"),
281 };
283​
284function getUpdateFinishedAt(provider: ServerProvider): string | null {
285 return provider.updateState?.finishedAt ?? null;
287​
288function isRecentTerminalProvider(
289 provider: ServerProvider,
290 visibleAfterIso: string | undefined,
291): boolean {
292 const status = provider.updateState?.status;
293 if (status !== "failed" && status !== "unchanged" && status !== "succeeded") {
294 return false;
295 }
296 if (visibleAfterIso === undefined) {
297 return true;
298 }
299 const finishedAt = getUpdateFinishedAt(provider);
300 return finishedAt !== null && finishedAt >= visibleAfterIso;
302​
303function latestFinishedAtForProviders(providers: ReadonlyArray<ServerProvider>): string | null {
304 return providers.reduce<string | null>((latest, provider) => {
305 const finishedAt = getUpdateFinishedAt(provider);
306 if (finishedAt === null) {
307 return latest;
308 }
309 return latest === null || finishedAt > latest ? finishedAt : latest;
310 }, null);
312​
313/** Use each driver's latest finished instance so an older default-instance result cannot hide it. */
314function latestTerminalProvidersByDriver(providers: ReadonlyArray<ServerProvider>) {
315 const latestProviderByDriver = new Map<ProviderDriverKind, ServerProvider>();
316​
317 for (const provider of providers) {
318 const current = latestProviderByDriver.get(provider.driver);
319 if (!current || (getUpdateFinishedAt(provider) ?? "") >= (getUpdateFinishedAt(current) ?? "")) {
320 latestProviderByDriver.set(provider.driver, provider);
321 }
322 }
323​
324 return [...latestProviderByDriver.values()];
326​
327export function getProviderUpdateSidebarPillView(
328 providers: ReadonlyArray<ServerProvider>,
329 options?: ProviderUpdateSidebarPillOptions,
330): ProviderUpdateSidebarPillView | null {
331 // Filter active instances before deduplicating drivers because update state is per instance.
332 const activeProviders = dedupeProvidersByDriver(providers.filter(isProviderUpdateActive));
333 if (activeProviders.length > 0) {
334 const activeProvider = activeProviders[0]!;
335 const activeProviderName =
336 PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver;
337 return {
338 key: `loading:${activeProviders
339 .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`)
340 .toSorted()
341 .join("|")}`,
342 tone: "loading",
343 title:
344 activeProviders.length === 1
345 ? `Updating ${activeProviderName}`
346 : `Updating ${activeProviders.length} providers`,
347 description:
348 activeProviders.length === 1
349 ? `${formatProviderList(activeProviders)} update in progress.`
350 : `${formatProviderList(activeProviders)} updates are in progress.`,
351 };
352 }
353​
354 const recentTerminalProviders = latestTerminalProvidersByDriver(
355 providers.filter((provider) => isRecentTerminalProvider(provider, options?.visibleAfterIso)),
356 );
357 const terminalCandidates: ProviderUpdateSidebarPillView[] = [];
358​
359 const failedProviders = recentTerminalProviders.filter(
360 (provider) => provider.updateState?.status === "failed",
361 );
362 if (failedProviders.length > 0) {
363 const failedProvider = failedProviders[0]!;
364 terminalCandidates.push({
365 key: `failed:${failedProviders
366 .map(
367 (provider) =>
368 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
369 )
370 .toSorted()
371 .join("|")}`,
372 tone: "error",
373 title:
374 failedProviders.length === 1
375 ? getProviderFailedUpdateTitle(failedProvider)
376 : `${failedProviders.length} provider updates failed`,
377 description: getFailedProviderUpdateDescription(failedProviders),
378 dismissible: true,
379 });
380 }
381​
382 const unchangedProviders = recentTerminalProviders.filter(
383 (provider) => provider.updateState?.status === "unchanged",
384 );
385 if (unchangedProviders.length > 0) {
386 const unchangedProvider = unchangedProviders[0]!;
387 const unchangedProviderName =
388 PROVIDER_DISPLAY_NAMES[unchangedProvider.driver] ?? unchangedProvider.driver;
389 terminalCandidates.push({
390 key: `unchanged:${unchangedProviders
391 .map(
392 (provider) =>
393 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
394 )
395 .toSorted()
396 .join("|")}`,
397 tone: "warning",
398 title:
399 unchangedProviders.length === 1
400 ? `${unchangedProviderName} still needs an update`
401 : `${unchangedProviders.length} providers still need updates`,
402 description: `${formatProviderList(unchangedProviders)} ${
403 unchangedProviders.length === 1 ? "still appears" : "still appear"
404 } outdated. Review provider settings for details.`,
405 dismissible: true,
406 });
407 }
408​
409 const succeededProviders = recentTerminalProviders.filter(
410 (provider) => provider.updateState?.status === "succeeded",
411 );
412 if (succeededProviders.length > 0) {
413 const succeededProvider = succeededProviders[0]!;
414 terminalCandidates.push({
415 key: `succeeded:${succeededProviders
416 .map(
417 (provider) =>
418 `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
419 )
420 .toSorted()
421 .join("|")}`,
422 tone: "success",
423 title:
424 succeededProviders.length === 1
425 ? getProviderUpdatedTitle(succeededProvider)
426 : `${succeededProviders.length} providers updated`,
427 description:
428 [...new Set(succeededProviders.map(getProviderRestartNotice).filter(Boolean))].join("\n") ||
429 getProviderUpdatedDescription(succeededProviders.length),
430 dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS,
431 });
432 }
433​
434 return (
435 terminalCandidates
436 .toSorted((left, right) => {
437 const leftProviders =
438 left.tone === "error"
439 ? failedProviders
440 : left.tone === "warning"
441 ? unchangedProviders
442 : succeededProviders;
443 const rightProviders =
444 right.tone === "error"
445 ? failedProviders
446 : right.tone === "warning"
447 ? unchangedProviders
448 : succeededProviders;
449 const leftFinishedAt = latestFinishedAtForProviders(leftProviders) ?? "";
450 const rightFinishedAt = latestFinishedAtForProviders(rightProviders) ?? "";
451 return rightFinishedAt.localeCompare(leftFinishedAt);
452 })
453 .find((candidate) => !options?.dismissedKeys?.has(candidate.key)) ?? null
454 );
456​
457function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvider>): string {
458 if (providers.length === 1) {
459 const provider = providers[0]!;
460 if (provider.updateState?.message) {
461 return provider.updateState.message;
462 }
463 }
464 return `${formatProviderList(providers)} failed to update. Check provider settings for details.`;