@gitkraken/core-gitlens 0.4.101 → 0.5.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +11 -6
  2. package/dist/git/models/graph.d.ts +63 -33
  3. package/dist/git/models/graph.d.ts.map +1 -1
  4. package/dist/git/models/graph.js.map +1 -1
  5. package/dist/git/models/graphSession.d.ts +3 -84
  6. package/dist/git/models/graphSession.d.ts.map +1 -1
  7. package/dist/git/models/graphSession.js +1 -6
  8. package/dist/git/models/graphSession.js.map +1 -1
  9. package/dist/git/providers/graph.d.ts +1 -15
  10. package/dist/git/providers/graph.d.ts.map +1 -1
  11. package/dist/git/providers/pausedOperations.d.ts +2 -0
  12. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  13. package/dist/git/utils/reachability.utils.d.ts +1 -1
  14. package/dist/git/utils/reachability.utils.js +1 -1
  15. package/dist/git-cli/providers/graph.d.ts +1 -3
  16. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  17. package/dist/git-cli/providers/graph.js +37 -214
  18. package/dist/git-cli/providers/graph.js.map +1 -1
  19. package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
  20. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  21. package/dist/git-cli/providers/pausedOperations.js +21 -1
  22. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  23. package/dist/git-cli/providers/staging.d.ts.map +1 -1
  24. package/dist/git-cli/providers/staging.js +10 -1
  25. package/dist/git-cli/providers/staging.js.map +1 -1
  26. package/dist/plus/git-github/providers/github/graph.d.ts +1 -3
  27. package/dist/plus/git-github/providers/github/graph.d.ts.map +1 -1
  28. package/dist/plus/git-github/providers/github/graph.js +8 -10
  29. package/dist/plus/git-github/providers/github/graph.js.map +1 -1
  30. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts +38 -48
  31. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts.map +1 -1
  32. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +22 -5
  33. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
  34. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts +39 -0
  35. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts.map +1 -0
  36. package/dist/plus/integrations/authentication/rejectedTokenTracker.js +53 -0
  37. package/dist/plus/integrations/authentication/rejectedTokenTracker.js.map +1 -0
  38. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  39. package/dist/plus/integrations/models/gitHostIntegration.js +19 -6
  40. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  41. package/dist/plus/integrations/models/integration.d.ts +20 -0
  42. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  43. package/dist/plus/integrations/models/integration.js +61 -4
  44. package/dist/plus/integrations/models/integration.js.map +1 -1
  45. package/dist/plus/integrations/models/issuesIntegration.js +5 -5
  46. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  47. package/dist/utils/date.d.ts +1 -1
  48. package/dist/utils/date.d.ts.map +1 -1
  49. package/dist/utils/date.js +5 -3
  50. package/dist/utils/date.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/git/models/graph.ts +59 -41
  53. package/src/git/models/graphSession.ts +3 -106
  54. package/src/git/providers/graph.ts +1 -15
  55. package/src/git/providers/pausedOperations.ts +8 -1
  56. package/src/git/utils/reachability.utils.ts +1 -1
  57. package/src/git-cli/providers/graph.ts +37 -250
  58. package/src/git-cli/providers/pausedOperations.ts +29 -2
  59. package/src/git-cli/providers/staging.ts +8 -1
  60. package/src/plus/git-github/providers/github/graph.ts +8 -15
  61. package/src/plus/integrations/authentication/integrationAuthenticationProvider.ts +62 -40
  62. package/src/plus/integrations/authentication/rejectedTokenTracker.ts +56 -0
  63. package/src/plus/integrations/models/gitHostIntegration.ts +19 -6
  64. package/src/plus/integrations/models/integration.ts +68 -5
  65. package/src/plus/integrations/models/issuesIntegration.ts +5 -5
  66. package/src/utils/date.ts +6 -3
@@ -32,6 +32,38 @@ export interface IntegrationAuthenticationSessionDescriptor {
32
32
  [key: string]: unknown;
33
33
  }
34
34
 
35
+ /**
36
+ * Options for {@link IntegrationAuthenticationProvider.getSession}. The two variants keep `sync` exclusive
37
+ * with the interactive flags: a sync resolves whatever the cloud already has, so it must never open a connect
38
+ * window.
39
+ *
40
+ * `refreshRejectedToken` marks the stored token as known-refused by the provider (an `AuthenticationError` on
41
+ * a previous read), which forces a cloud `/refresh` even when the token's expiry still claims it is valid —
42
+ * the case a purely time-based refresh cannot see. The refresh token itself never reaches the client: the GK
43
+ * cloud performs the exchange server-side.
44
+ */
45
+ export type GetSessionOptions =
46
+ | {
47
+ createIfNeeded?: boolean;
48
+ forceNewSession?: boolean;
49
+ sync?: never;
50
+ refreshRejectedToken?: boolean;
51
+ source?: Sources;
52
+ }
53
+ | {
54
+ createIfNeeded?: never;
55
+ forceNewSession?: never;
56
+ sync: boolean;
57
+ refreshRejectedToken?: boolean;
58
+ source?: Sources;
59
+ };
60
+
61
+ /**
62
+ * What {@link IntegrationAuthenticationProviderBase.getSession} passes down once it has decided the stored
63
+ * session can't be served as-is. `refreshIfExpired` is its own conclusion, not a caller's option.
64
+ */
65
+ type GetNewSessionOptions = GetSessionOptions & { refreshIfExpired?: boolean };
66
+
35
67
  export interface IntegrationAuthenticationProvider extends Disposable {
36
68
  /**
37
69
  * Clears the stored secret for one connection only (never the whole provider). Unlike
@@ -46,11 +78,13 @@ export interface IntegrationAuthenticationProvider extends Disposable {
46
78
  options?: { preserveConfigured?: boolean },
47
79
  ): Promise<void>;
48
80
  deleteAllSessions(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<void>;
81
+ /**
82
+ * Resolves the session for `descriptor`, from storage when it is still usable and from the cloud
83
+ * otherwise. See {@link GetSessionOptions}.
84
+ */
49
85
  getSession(
50
86
  descriptor: IntegrationAuthenticationSessionDescriptor,
51
- options?:
52
- | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources }
53
- | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources },
87
+ options?: GetSessionOptions,
54
88
  ): Promise<ProviderAuthenticationSession | undefined>;
55
89
  get onDidChange(): Event<void>;
56
90
  }
@@ -120,9 +154,7 @@ abstract class IntegrationAuthenticationProviderBase<
120
154
  @trace()
121
155
  async getSession(
122
156
  descriptor: IntegrationAuthenticationSessionDescriptor,
123
- options?:
124
- | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources }
125
- | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources },
157
+ options?: GetSessionOptions,
126
158
  ): Promise<ProviderAuthenticationSession | undefined> {
127
159
  let session;
128
160
  let previousToken;
@@ -143,10 +175,15 @@ abstract class IntegrationAuthenticationProviderBase<
143
175
  }
144
176
 
145
177
  const isExpiredSession = session?.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now();
146
- if (session == null || isExpiredSession) {
178
+ // A rejected token has to re-resolve even when the stored session looks perfectly healthy: that is
179
+ // the whole point — the provider refused it while its expiry still says valid. Without this the
180
+ // stored session short-circuits here and the refresh below is never reached.
181
+ const refreshRejectedToken = options?.refreshRejectedToken === true && session != null;
182
+ if (session == null || isExpiredSession || refreshRejectedToken) {
147
183
  session = await this.getNewSession(descriptor, {
148
184
  ...options,
149
185
  refreshIfExpired: isExpiredSession,
186
+ refreshRejectedToken: refreshRejectedToken,
150
187
  });
151
188
 
152
189
  if (session != null) {
@@ -163,21 +200,7 @@ abstract class IntegrationAuthenticationProviderBase<
163
200
 
164
201
  protected abstract getNewSession(
165
202
  descriptor: IntegrationAuthenticationSessionDescriptor,
166
- options?:
167
- | {
168
- createIfNeeded?: boolean;
169
- forceNewSession?: boolean;
170
- sync?: never;
171
- refreshIfExpired?: boolean;
172
- source?: Sources;
173
- }
174
- | {
175
- createIfNeeded?: never;
176
- forceNewSession?: never;
177
- sync: boolean;
178
- refreshIfExpired?: boolean;
179
- source?: Sources;
180
- },
203
+ options?: GetNewSessionOptions,
181
204
  ): Promise<ProviderAuthenticationSession | undefined>;
182
205
 
183
206
  protected fireChange(): void {
@@ -202,21 +225,7 @@ export class CloudIntegrationAuthenticationProvider<
202
225
 
203
226
  protected override async getNewSession(
204
227
  descriptor: IntegrationAuthenticationSessionDescriptor,
205
- options?:
206
- | {
207
- createIfNeeded?: boolean;
208
- forceNewSession?: boolean;
209
- sync?: never;
210
- refreshIfExpired?: boolean;
211
- source?: Sources;
212
- }
213
- | {
214
- createIfNeeded?: never;
215
- forceNewSession?: never;
216
- sync: boolean;
217
- refreshIfExpired?: boolean;
218
- source?: Sources;
219
- },
228
+ options?: GetNewSessionOptions,
220
229
  ): Promise<ProviderAuthenticationSession | undefined> {
221
230
  if (options?.forceNewSession) {
222
231
  if ((await this.disconnectCloudSession()) === 'failure') {
@@ -230,9 +239,15 @@ export class CloudIntegrationAuthenticationProvider<
230
239
  // TODO: This is a stopgap to make sure we're not hammering the api on automatic calls to get the session.
231
240
  // Ultimately we want to timestamp calls to syncCloudIntegrations and use that to determine whether we should
232
241
  // make the call or not.
242
+ // `refreshRejectedToken` joins the list for the same reason `refreshIfExpired` is on it: both mean the
243
+ // session in hand is known-unusable, so skipping the cloud round trip would just re-serve it.
233
244
  let session =
234
- options?.refreshIfExpired || options?.createIfNeeded || options?.forceNewSession || options?.sync
235
- ? await this.getCloudSession(descriptor)
245
+ options?.refreshIfExpired ||
246
+ options?.refreshRejectedToken ||
247
+ options?.createIfNeeded ||
248
+ options?.forceNewSession ||
249
+ options?.sync
250
+ ? await this.getCloudSession(descriptor, { refreshRejectedToken: options?.refreshRejectedToken })
236
251
  : undefined;
237
252
 
238
253
  const shouldCreateSession = options?.createIfNeeded && session == null;
@@ -269,6 +284,7 @@ export class CloudIntegrationAuthenticationProvider<
269
284
 
270
285
  private async getCloudSession(
271
286
  descriptor: IntegrationAuthenticationSessionDescriptor,
287
+ options?: { refreshRejectedToken?: boolean },
272
288
  ): Promise<ProviderAuthenticationSession | undefined> {
273
289
  const loggedIn = (await this.authenticationService.ctx.account.getAccount()) != null;
274
290
  if (!loggedIn) return undefined;
@@ -304,7 +320,13 @@ export class CloudIntegrationAuthenticationProvider<
304
320
  session.expiresIn = maxSmallIntegerV8; // maximum expiration length
305
321
  }
306
322
 
307
- if (session != null && session.expiresIn < 60) {
323
+ // `expiresIn < 60` is a PREDICTION that the token is about to lapse, and it is the only trigger the
324
+ // refresh used to have. It cannot see a token the provider has already refused while the backend
325
+ // still believes it is valid — a revoked grant, retired scopes, an app removed from the org — where
326
+ // `expiresIn` stays high and the doomed token is re-sent on every read. `refreshRejectedToken` is
327
+ // the observed counterpart: the caller saw the provider reject this exact token, so refresh it
328
+ // regardless of what its expiry claims.
329
+ if (session != null && (session.expiresIn < 60 || options?.refreshRejectedToken)) {
308
330
  session = await cloudIntegrations.getConnectionSession(
309
331
  this.authProviderId,
310
332
  session.accessToken,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-connection recovery state for a cloud token the provider REFUSED — a revoked grant, retired scopes, an
3
+ * app removed from the org. A purely predictive refresh (`expiresIn < 60`) cannot see that case, because the
4
+ * backend still believes the token is valid, so without this the refused token is re-sent on every read and
5
+ * the failure only clears when the user reconnects by hand.
6
+ *
7
+ * One recovery refresh per connection, then the connection is the failure budget's problem:
8
+ * - {@link recordRejection} on an `AuthenticationError` — arms the refresh, or reports that the rejection is
9
+ * not recoverable so the caller counts it as a real failure instead.
10
+ * - {@link claimRefresh} before the next read resolves its session — spends the rejection, so the refresh
11
+ * fires once and no further read of that connection asks the cloud again.
12
+ *
13
+ * Deliberately NOT re-armed when the refresh hands back a different token. The GK cloud mints a new access
14
+ * token on every `/refresh`, so a credential the provider refuses for a reason a refresh cannot fix (retired
15
+ * scopes being the plain case) would look "rotated" on each attempt: re-arming on that would refresh on every
16
+ * read forever and the failure budget — the thing that disconnects and prompts a reconnect — would never
17
+ * advance. What resets this instead is {@link clear}, called by every path that replaces or removes the stored
18
+ * tokens; a connection that is simply working never reaches here at all.
19
+ */
20
+ export class RejectedTokenTracker {
21
+ private readonly _rejections = new Map<string, 'armed' | 'spent'>();
22
+
23
+ /**
24
+ * Whether a read of `connectionId` must force the cloud refresh of a token known to be refused, spending
25
+ * the rejection so the refresh runs at most once. Spent here rather than after the read because a resolve
26
+ * that throws must not leave the refresh armed and ask the cloud again on the next read.
27
+ */
28
+ claimRefresh(connectionId: string): boolean {
29
+ if (this._rejections.get(connectionId) !== 'armed') return false;
30
+
31
+ this._rejections.set(connectionId, 'spent');
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * Arms the recovery refresh for `connectionId`. Returns `false` once this connection has had its refresh
37
+ * (or already has one pending), so the caller falls back to its own failure handling: a token still
38
+ * refused after the refresh is not self-healing, and only the failure budget ends in a reconnect prompt.
39
+ */
40
+ recordRejection(connectionId: string): boolean {
41
+ if (this._rejections.has(connectionId)) return false;
42
+
43
+ this._rejections.set(connectionId, 'armed');
44
+ return true;
45
+ }
46
+
47
+ /** Drops all state, for when the stored tokens a rejection could name are replaced or gone. */
48
+ clear(): void {
49
+ this._rejections.clear();
50
+ }
51
+
52
+ /** Whether any connection is holding a rejection. For assertions; the read path uses {@link claimRefresh}. */
53
+ get empty(): boolean {
54
+ return this._rejections.size === 0;
55
+ }
56
+ }
@@ -373,7 +373,10 @@ export abstract class GitHostIntegration<
373
373
  this.resetRequestExceptionCount('getOrganizationsForUser');
374
374
  return { value: result, duration: performance.now() - start };
375
375
  } catch (ex) {
376
- this.handleProviderException('getOrganizationsForUser', ex, { scope: scope });
376
+ this.handleProviderException('getOrganizationsForUser', ex, {
377
+ scope: scope,
378
+ connectionId: connectionId,
379
+ });
377
380
  return { error: toError(ex), duration: performance.now() - start };
378
381
  }
379
382
  }
@@ -409,7 +412,7 @@ export abstract class GitHostIntegration<
409
412
  this.resetRequestExceptionCount('getProjectsForOrg');
410
413
  return { value: result, duration: performance.now() - start };
411
414
  } catch (ex) {
412
- this.handleProviderException('getProjectsForOrg', ex, { scope: scope });
415
+ this.handleProviderException('getProjectsForOrg', ex, { scope: scope, connectionId: connectionId });
413
416
  return { error: toError(ex), duration: performance.now() - start };
414
417
  }
415
418
  }
@@ -446,7 +449,10 @@ export abstract class GitHostIntegration<
446
449
  this.resetRequestExceptionCount('getRepositoriesForOrg');
447
450
  return { value: result, duration: performance.now() - start };
448
451
  } catch (ex) {
449
- this.handleProviderException('getRepositoriesForOrg', ex, { scope: scope });
452
+ this.handleProviderException('getRepositoriesForOrg', ex, {
453
+ scope: scope,
454
+ connectionId: options?.connectionId,
455
+ });
450
456
  return { error: toError(ex), duration: performance.now() - start };
451
457
  }
452
458
  }
@@ -484,7 +490,10 @@ export abstract class GitHostIntegration<
484
490
  this.resetRequestExceptionCount('getRepositoriesForUser');
485
491
  return { value: result, duration: performance.now() - start };
486
492
  } catch (ex) {
487
- this.handleProviderException('getRepositoriesForUser', ex, { scope: scope });
493
+ this.handleProviderException('getRepositoriesForUser', ex, {
494
+ scope: scope,
495
+ connectionId: options?.connectionId,
496
+ });
488
497
  return { error: toError(ex), duration: performance.now() - start };
489
498
  }
490
499
  }
@@ -1414,6 +1423,7 @@ export abstract class GitHostIntegration<
1414
1423
  this.handleProviderException('searchMyPullRequests', ex, {
1415
1424
  scope: scope,
1416
1425
  silent: true,
1426
+ connectionId: connectionId,
1417
1427
  });
1418
1428
  return {
1419
1429
  error: toError(ex),
@@ -1456,7 +1466,10 @@ export abstract class GitHostIntegration<
1456
1466
  this.resetRequestExceptionCount('getMyPullRequestsForUser');
1457
1467
  return { value: result, duration: performance.now() - start };
1458
1468
  } catch (ex) {
1459
- this.handleProviderException('getMyPullRequestsForUser', ex, { scope: scope });
1469
+ this.handleProviderException('getMyPullRequestsForUser', ex, {
1470
+ scope: scope,
1471
+ connectionId: connectionId,
1472
+ });
1460
1473
  return { error: toError(ex), duration: performance.now() - start };
1461
1474
  }
1462
1475
  }
@@ -1588,7 +1601,7 @@ export abstract class GitHostIntegration<
1588
1601
  this.resetRequestExceptionCount('searchPullRequests');
1589
1602
  return prs;
1590
1603
  } catch (ex) {
1591
- this.handleProviderException('searchPullRequests', ex, { scope: scope });
1604
+ this.handleProviderException('searchPullRequests', ex, { scope: scope, connectionId: connectionId });
1592
1605
  return undefined;
1593
1606
  }
1594
1607
  }
@@ -20,6 +20,7 @@ import type {
20
20
  } from '../authentication/integrationAuthenticationProvider.js';
21
21
  import type { IntegrationAuthenticationService } from '../authentication/integrationAuthenticationService.js';
22
22
  import type { ProviderAuthenticationSession } from '../authentication/models.js';
23
+ import { RejectedTokenTracker } from '../authentication/rejectedTokenTracker.js';
23
24
  import type { IntegrationIds, IssuesCloudHostIntegrationId } from '../constants.js';
24
25
  import { GitCloudHostIntegrationId } from '../constants.js';
25
26
  import type { IntegrationServiceContext } from '../context.js';
@@ -240,13 +241,18 @@ export abstract class IntegrationBase<
240
241
  // A truthy connectionId targets a specific account; an empty string is not a real target, so it falls
241
242
  // through to the primary path below.
242
243
  if (connectionId) {
244
+ // A read of this connection previously failed with an AuthenticationError, so the token in
245
+ // storage is known-refused. Ask for a refresh through the GK cloud before reading again, which
246
+ // is this branch's equivalent of the primary path's `refreshSessionIfExpired`. Claimed here so
247
+ // the forced refresh runs at most once per rejection.
248
+ const refreshRejectedToken = this._rejectedTokens.claimRefresh(connectionId);
243
249
  // Degrade to "no results" on failure, matching the primary path (whose ensureSession/
244
250
  // refreshSessionIfExpired swallow errors) so read methods keep their never-throws contract.
245
251
  try {
246
252
  const authProvider = await this.authenticationService.get(this.authProvider.id);
247
253
  const session = await authProvider.getSession(
248
254
  { ...this.authProviderDescriptor, connectionId: connectionId, cloud: true },
249
- { source: source },
255
+ { source: source, refreshRejectedToken: refreshRejectedToken },
250
256
  );
251
257
  return session != null && this.isSessionForIntegrationHost(session) ? session : undefined;
252
258
  } catch (ex) {
@@ -326,6 +332,10 @@ export abstract class IntegrationBase<
326
332
 
327
333
  @debug()
328
334
  async reauthenticate(): Promise<void> {
335
+ // `forceNewSession` below deletes the stored secrets to reconnect. Ahead of the guard — see
336
+ // `onStoredTokensReplaced`.
337
+ this.onStoredTokensReplaced();
338
+
329
339
  if (this._session === undefined) return;
330
340
 
331
341
  this._session = undefined;
@@ -343,6 +353,30 @@ export abstract class IntegrationBase<
343
353
  requestSessionSyncForUsecase(syncReqUsecase: SyncReqUsecase): void {
344
354
  this._syncRequestsPerFailedUsecase.add(syncReqUsecase);
345
355
  }
356
+
357
+ /**
358
+ * The per-connection counterpart of the primary session's expire-and-resync recovery: a token the
359
+ * provider refused is refreshed once through the GK cloud, which exchanges the refresh token server-side
360
+ * (the client never holds one). Armed by {@link handleProviderException} and consumed by
361
+ * {@link resolveReadSession}; a rejection it declines belongs to {@link trackRequestException} instead.
362
+ */
363
+ private readonly _rejectedTokens = new RejectedTokenTracker();
364
+
365
+ /**
366
+ * Called by every path that replaces or removes the stored tokens — a disconnect, a forced re-sync, a
367
+ * reauthentication, or the connection set changing. A rejection names a specific credential, so once that
368
+ * credential is gone the rejection describes nothing and must not force a refresh (or, for a deleted
369
+ * connection, outlive it).
370
+ *
371
+ * Deliberately NOT conditioned on `_session`. A per-connection read resolves through the auth provider and
372
+ * never populates the cached primary session, so the connections this recovery exists for are exactly the
373
+ * ones with no `_session` to inspect — the same blind spot the recovery itself was added to fix. Callers
374
+ * that guard on `_session` therefore invoke this ahead of that guard.
375
+ */
376
+ protected onStoredTokensReplaced(): void {
377
+ this._rejectedTokens.clear();
378
+ }
379
+
346
380
  private static readonly requestExceptionLimit = 5;
347
381
  private requestExceptionCount = 0;
348
382
 
@@ -350,6 +384,8 @@ export abstract class IntegrationBase<
350
384
  this.requestExceptionCount = 0;
351
385
  if (syncReqUsecase === 'all') {
352
386
  this._syncRequestsPerFailedUsecase.clear();
387
+ // 'all' is the whole-integration reset: a disconnect, or a re-sync that produced a new access token.
388
+ this.onStoredTokensReplaced();
353
389
  } else {
354
390
  this._syncRequestsPerFailedUsecase.delete(syncReqUsecase);
355
391
  }
@@ -374,6 +410,10 @@ export abstract class IntegrationBase<
374
410
  * `deleteConnection`). Unlike {@link reset}/{@link disconnect}, it deletes nothing from storage.
375
411
  */
376
412
  switchConnection(): void {
413
+ // A connection was deleted, or a different one became primary. Ahead of the guard — see
414
+ // `onStoredTokensReplaced`.
415
+ this.onStoredTokensReplaced();
416
+
377
417
  if (this._session === undefined) return;
378
418
 
379
419
  const wasConnected = this._session != null;
@@ -419,6 +459,9 @@ export abstract class IntegrationBase<
419
459
  // Reset our stored session so that we get a new one from the cloud
420
460
  const authProvider = await this.authenticationService.get(this.authProvider.id);
421
461
  await authProvider.deleteSession(this.authProviderDescriptor);
462
+ // The stored token was just deleted. Not left to the token-changed check below, which needs
463
+ // an `oldSession` — see `onStoredTokensReplaced`.
464
+ this.onStoredTokensReplaced();
422
465
  // Reset the session and clear our "stay disconnected" flag
423
466
  this._session = undefined;
424
467
  await this.ctx.storage.deleteWorkspace(this.connectedKey);
@@ -474,12 +517,29 @@ export abstract class IntegrationBase<
474
517
  protected handleProviderException(
475
518
  syncReqUsecase: SyncReqUsecase,
476
519
  ex: Error,
477
- options?: { scope?: ScopedLogger | undefined; silent?: boolean },
520
+ options?: { scope?: ScopedLogger | undefined; silent?: boolean; connectionId?: string },
478
521
  ): void {
479
522
  if (isCancellationError(ex)) return;
480
523
 
481
524
  options?.scope?.error(ex);
482
525
 
526
+ // A per-connection (multi-account) read resolved its session through `resolveReadSession`'s
527
+ // `connectionId` branch, which deliberately never touches the cached primary `_session`. So the
528
+ // primary-session recovery below cannot apply to it: expiring `_session` would mark a session this
529
+ // read never used, while the rejected connection kept its stored token and re-sent it on every
530
+ // later read — a token the provider has already refused (expired scopes, a revoked grant, an
531
+ // uninstalled app) is not self-healing, so the read failed identically until the user reconnected
532
+ // by hand. Record the rejection against the connection instead; `resolveReadSession` consumes it
533
+ // and forces the cloud `/refresh` on the next read of that connection. When there is nothing to
534
+ // recover, fall through to the shared failure budget, which disconnects after
535
+ // `requestExceptionLimit` and surfaces the reconnect prompt.
536
+ if (ex instanceof AuthenticationError && options?.connectionId) {
537
+ if (!this._rejectedTokens.recordRejection(options.connectionId)) {
538
+ this.trackRequestException(options);
539
+ }
540
+ return;
541
+ }
542
+
483
543
  if (ex instanceof AuthenticationError && this._session?.cloud) {
484
544
  if (!this.hasSessionSyncRequests()) {
485
545
  this.requestSessionSyncForUsecase(syncReqUsecase);
@@ -686,7 +746,7 @@ export abstract class IntegrationBase<
686
746
  this.resetRequestExceptionCount('searchMyIssues');
687
747
  return { value: issues };
688
748
  } catch (ex) {
689
- this.handleProviderException('searchMyIssues', ex, { scope: scope });
749
+ this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
690
750
  return { error: toError(ex) };
691
751
  }
692
752
  }
@@ -738,7 +798,7 @@ export abstract class IntegrationBase<
738
798
  this.resetRequestExceptionCount('searchMyIssues');
739
799
  return { value: result };
740
800
  } catch (ex) {
741
- this.handleProviderException('searchMyIssues', ex, { scope: scope });
801
+ this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
742
802
  return { error: toError(ex) };
743
803
  }
744
804
  }
@@ -858,7 +918,10 @@ export abstract class IntegrationBase<
858
918
  return undefined;
859
919
  }
860
920
 
861
- this.handleProviderException('getCurrentAccount', ex, { scope: scope });
921
+ this.handleProviderException('getCurrentAccount', ex, {
922
+ scope: scope,
923
+ connectionId: connectionId,
924
+ });
862
925
 
863
926
  // Invalidate the cache on error, except for auth errors
864
927
  if (!(ex instanceof AuthenticationError)) {
@@ -48,7 +48,7 @@ export abstract class IssuesIntegration<
48
48
  this.resetRequestExceptionCount('getAccountForResource');
49
49
  return { value: account };
50
50
  } catch (ex) {
51
- this.handleProviderException('getAccountForResource', ex);
51
+ this.handleProviderException('getAccountForResource', ex, { connectionId: connectionId });
52
52
  return { error: toError(ex) };
53
53
  }
54
54
  }
@@ -80,7 +80,7 @@ export abstract class IssuesIntegration<
80
80
  this.resetRequestExceptionCount('getResourcesForUser');
81
81
  return { value: resources };
82
82
  } catch (ex) {
83
- this.handleProviderException('getResourcesForUser', ex);
83
+ this.handleProviderException('getResourcesForUser', ex, { connectionId: connectionId });
84
84
  return { error: toError(ex) };
85
85
  }
86
86
  }
@@ -108,7 +108,7 @@ export abstract class IssuesIntegration<
108
108
  this.resetRequestExceptionCount('getProjectsForResources');
109
109
  return { value: projects };
110
110
  } catch (ex) {
111
- this.handleProviderException('getProjectsForResources', ex);
111
+ this.handleProviderException('getProjectsForResources', ex, { connectionId: connectionId });
112
112
  return { error: toError(ex) };
113
113
  }
114
114
  }
@@ -161,7 +161,7 @@ export abstract class IssuesIntegration<
161
161
  this.resetRequestExceptionCount('getIssuesForProject');
162
162
  return { value: issues };
163
163
  } catch (ex) {
164
- this.handleProviderException('getIssuesForProject', ex);
164
+ this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
165
165
  return { error: toError(ex) };
166
166
  }
167
167
  }
@@ -188,7 +188,7 @@ export abstract class IssuesIntegration<
188
188
  this.resetRequestExceptionCount('getIssuesForProject');
189
189
  return { value: result };
190
190
  } catch (ex) {
191
- this.handleProviderException('getIssuesForProject', ex);
191
+ this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
192
192
  return { error: toError(ex) };
193
193
  }
194
194
  }
package/src/utils/date.ts CHANGED
@@ -149,9 +149,12 @@ export function unitThresholdMs(unit: Intl.RelativeTimeFormatUnit): number {
149
149
 
150
150
  // fromNow keeps its OWN single-pass loop (not routed through fromNowUnit) — one iteration, no
151
151
  // intermediate {unit,value} object allocation on this hot formatting path; fromNowUnit below iterates
152
- // the SAME `relativeUnitThresholds` array, so the two classifications can't drift apart.
153
- export function fromNow(date: Date | number, short?: boolean): string {
154
- const elapsed = (typeof date === 'number' ? date : date.getTime()) - Date.now();
152
+ // the SAME `relativeUnitThresholds` array, so the two classifications can't drift apart. `now` defaults
153
+ // to `Date.now()`, like fromNowUnit's pass it to pin the instant (a caller comparing against its own
154
+ // clock read otherwise sits one unpredictable tick away from the bucket it expects).
155
+ export function fromNow(date: Date | number, short?: boolean, now: Date | number = Date.now()): string {
156
+ const elapsed =
157
+ (typeof date === 'number' ? date : date.getTime()) - (typeof now === 'number' ? now : now.getTime());
155
158
  // Guard against invalid dates (e.g. `new Date(<unparseable>)`), since a non-finite value would throw in `Intl.RelativeTimeFormat.format`
156
159
  if (!Number.isFinite(elapsed)) return '';
157
160