@sunerpy/opencode-kiro-auth 0.15.0 → 0.15.2
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.
- package/dist/core/auth/token-refresher.d.ts +13 -1
- package/dist/core/auth/token-refresher.js +93 -32
- package/dist/core/request/error-handler.js +5 -0
- package/dist/core/request/request-handler.js +55 -12
- package/dist/plugin/accounts.d.ts +3 -0
- package/dist/plugin/accounts.js +50 -28
- package/dist/plugin/errors.d.ts +3 -0
- package/dist/plugin/errors.js +6 -0
- package/dist/plugin/storage/locked-operations.d.ts +0 -1
- package/dist/plugin/storage/locked-operations.js +21 -46
- package/dist/plugin/storage/migrations.js +86 -9
- package/dist/plugin/storage/sqlite.d.ts +1 -0
- package/dist/plugin/storage/sqlite.js +108 -78
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccountRepository } from '../../infrastructure/database/account-repository.js';
|
|
2
2
|
import type { AccountManager } from '../../plugin/accounts.js';
|
|
3
|
+
import { refreshAccessToken } from '../../plugin/token.js';
|
|
3
4
|
import type { KiroAuthDetails, ManagedAccount } from '../../plugin/types.js';
|
|
4
5
|
type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void;
|
|
5
6
|
interface TokenRefresherConfig {
|
|
@@ -7,6 +8,11 @@ interface TokenRefresherConfig {
|
|
|
7
8
|
auto_sync_kiro_cli: boolean;
|
|
8
9
|
account_selection_strategy: 'sticky' | 'round-robin' | 'lowest-usage';
|
|
9
10
|
}
|
|
11
|
+
interface TokenRefresherDependencies {
|
|
12
|
+
refreshAccessToken: typeof refreshAccessToken;
|
|
13
|
+
sleep: (delayMs: number) => Promise<void>;
|
|
14
|
+
random: () => number;
|
|
15
|
+
}
|
|
10
16
|
/** Outcome of a forced refresh; `dead` distinguishes refresh-token-dead
|
|
11
17
|
* (needs re-login) from a transient failure (network/5xx). */
|
|
12
18
|
export interface ForceRefreshResult {
|
|
@@ -27,8 +33,12 @@ export declare class TokenRefresher {
|
|
|
27
33
|
private syncFromKiroCli;
|
|
28
34
|
private repository;
|
|
29
35
|
private readonly inFlight;
|
|
36
|
+
private readonly pendingPersistence;
|
|
30
37
|
private readonly lastDeadToastAt;
|
|
31
|
-
|
|
38
|
+
private readonly refreshAccessToken;
|
|
39
|
+
private readonly sleep;
|
|
40
|
+
private readonly random;
|
|
41
|
+
constructor(config: TokenRefresherConfig, accountManager: AccountManager, syncFromKiroCli: () => Promise<void>, repository: AccountRepository, dependencies?: Partial<TokenRefresherDependencies>);
|
|
32
42
|
refreshIfNeeded(account: ManagedAccount, auth: KiroAuthDetails, showToast: ToastFunction): Promise<{
|
|
33
43
|
account: ManagedAccount;
|
|
34
44
|
shouldContinue: boolean;
|
|
@@ -36,7 +46,9 @@ export declare class TokenRefresher {
|
|
|
36
46
|
forceRefresh(account: ManagedAccount, showToast: ToastFunction): Promise<ForceRefreshResult>;
|
|
37
47
|
private startOrJoinRefresh;
|
|
38
48
|
private runLockedRefresh;
|
|
49
|
+
private persistRefreshedAccount;
|
|
39
50
|
private readLatestAuth;
|
|
51
|
+
private syncPersistedAccountReference;
|
|
40
52
|
private handleRefreshError;
|
|
41
53
|
}
|
|
42
54
|
export {};
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { accessTokenExpired } from '../../kiro/auth.js';
|
|
2
|
-
import { KiroTokenRefreshError } from '../../plugin/errors.js';
|
|
2
|
+
import { KiroTokenRefreshError, TokenPersistenceError } from '../../plugin/errors.js';
|
|
3
3
|
import { isRefreshTokenDead, toDeadReason } from '../../plugin/health.js';
|
|
4
4
|
import * as logger from '../../plugin/logger.js';
|
|
5
5
|
import { withRefreshLock } from '../../plugin/storage/locked-operations.js';
|
|
6
6
|
import { refreshAccessToken } from '../../plugin/token.js';
|
|
7
7
|
const DEAD_TOAST_DEBOUNCE_MS = 60000;
|
|
8
|
+
const TOKEN_PERSISTENCE_MAX_ATTEMPTS = 3;
|
|
9
|
+
const TOKEN_PERSISTENCE_RETRY_DELAY_MS = 250;
|
|
10
|
+
function isTransientPersistenceError(error) {
|
|
11
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
12
|
+
? String(error.code).toUpperCase()
|
|
13
|
+
: '';
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
return (code === 'SQLITE_BUSY' ||
|
|
16
|
+
code === 'SQLITE_LOCKED' ||
|
|
17
|
+
/SQLITE_(?:BUSY|LOCKED)|database is locked|lock file is already being held/i.test(message));
|
|
18
|
+
}
|
|
19
|
+
function defaultSleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
8
22
|
/**
|
|
9
23
|
* Decide whether a refresh failure means the refresh token / OIDC client is
|
|
10
24
|
* dead (permanent, needs re-login) or is merely transient (network/5xx).
|
|
@@ -13,6 +27,9 @@ const DEAD_TOAST_DEBOUNCE_MS = 60000;
|
|
|
13
27
|
* the stored credentials are unusable, so the account needs a re-login.
|
|
14
28
|
*/
|
|
15
29
|
export function isRefreshErrorDead(error) {
|
|
30
|
+
if (error instanceof TokenPersistenceError) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
16
33
|
if (error instanceof KiroTokenRefreshError) {
|
|
17
34
|
if (error.code === 'MISSING_CREDENTIALS' || error.code === 'INVALID_RESPONSE') {
|
|
18
35
|
return true;
|
|
@@ -39,12 +56,19 @@ export class TokenRefresher {
|
|
|
39
56
|
syncFromKiroCli;
|
|
40
57
|
repository;
|
|
41
58
|
inFlight = new Map();
|
|
59
|
+
pendingPersistence = new Map();
|
|
42
60
|
lastDeadToastAt = new Map();
|
|
43
|
-
|
|
61
|
+
refreshAccessToken;
|
|
62
|
+
sleep;
|
|
63
|
+
random;
|
|
64
|
+
constructor(config, accountManager, syncFromKiroCli, repository, dependencies = {}) {
|
|
44
65
|
this.config = config;
|
|
45
66
|
this.accountManager = accountManager;
|
|
46
67
|
this.syncFromKiroCli = syncFromKiroCli;
|
|
47
68
|
this.repository = repository;
|
|
69
|
+
this.refreshAccessToken = dependencies.refreshAccessToken ?? refreshAccessToken;
|
|
70
|
+
this.sleep = dependencies.sleep ?? defaultSleep;
|
|
71
|
+
this.random = dependencies.random ?? Math.random;
|
|
48
72
|
}
|
|
49
73
|
async refreshIfNeeded(account, auth, showToast) {
|
|
50
74
|
if (!accessTokenExpired(auth, this.config.token_expiry_buffer_ms)) {
|
|
@@ -64,6 +88,10 @@ export class TokenRefresher {
|
|
|
64
88
|
return { ok: true, dead: false };
|
|
65
89
|
}
|
|
66
90
|
catch (e) {
|
|
91
|
+
if (e instanceof TokenPersistenceError) {
|
|
92
|
+
logger.error('Forced token refresh persistence failed', { email: account.email });
|
|
93
|
+
return { ok: false, dead: false };
|
|
94
|
+
}
|
|
67
95
|
const dead = isRefreshErrorDead(e);
|
|
68
96
|
logger.error('Forced token refresh failed', {
|
|
69
97
|
email: account.email,
|
|
@@ -94,16 +122,50 @@ export class TokenRefresher {
|
|
|
94
122
|
}
|
|
95
123
|
async runLockedRefresh(account, getAuthFallback) {
|
|
96
124
|
await withRefreshLock(account.id, async () => {
|
|
97
|
-
const { latestAuth } = await this.readLatestAuth(account);
|
|
98
|
-
if (
|
|
99
|
-
|
|
125
|
+
const { latestAccount, latestAuth } = await this.readLatestAuth(account);
|
|
126
|
+
if (latestAccount &&
|
|
127
|
+
latestAuth &&
|
|
128
|
+
!accessTokenExpired(latestAuth, this.config.token_expiry_buffer_ms)) {
|
|
129
|
+
this.pendingPersistence.delete(account.id);
|
|
130
|
+
this.accountManager.publishAuthCandidate(latestAccount, false);
|
|
100
131
|
return;
|
|
101
132
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
133
|
+
const pendingCandidate = this.pendingPersistence.get(account.id);
|
|
134
|
+
if (pendingCandidate) {
|
|
135
|
+
await this.persistRefreshedAccount(pendingCandidate);
|
|
136
|
+
this.pendingPersistence.delete(account.id);
|
|
137
|
+
this.accountManager.publishAuthCandidate(pendingCandidate);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const newAuth = await this.refreshAccessToken(latestAuth ?? getAuthFallback());
|
|
141
|
+
const candidate = this.accountManager.createAuthCandidate(latestAccount ?? account, newAuth);
|
|
142
|
+
this.pendingPersistence.set(account.id, candidate);
|
|
143
|
+
await this.persistRefreshedAccount(candidate);
|
|
144
|
+
this.pendingPersistence.delete(account.id);
|
|
145
|
+
this.accountManager.publishAuthCandidate(candidate);
|
|
105
146
|
});
|
|
106
147
|
}
|
|
148
|
+
async persistRefreshedAccount(candidate) {
|
|
149
|
+
for (let attempt = 1; attempt <= TOKEN_PERSISTENCE_MAX_ATTEMPTS; attempt++) {
|
|
150
|
+
try {
|
|
151
|
+
await this.repository.save(candidate);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const retryable = isTransientPersistenceError(error);
|
|
156
|
+
if (!retryable || attempt === TOKEN_PERSISTENCE_MAX_ATTEMPTS) {
|
|
157
|
+
throw new TokenPersistenceError();
|
|
158
|
+
}
|
|
159
|
+
logger.warn('Token persistence failed; retrying', {
|
|
160
|
+
email: candidate.email,
|
|
161
|
+
attempt,
|
|
162
|
+
maxAttempts: TOKEN_PERSISTENCE_MAX_ATTEMPTS
|
|
163
|
+
});
|
|
164
|
+
const jitter = Math.floor(TOKEN_PERSISTENCE_RETRY_DELAY_MS * 0.25 * this.random());
|
|
165
|
+
await this.sleep(TOKEN_PERSISTENCE_RETRY_DELAY_MS * attempt + jitter);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
107
169
|
async readLatestAuth(account) {
|
|
108
170
|
this.repository.invalidateCache();
|
|
109
171
|
const accounts = await this.repository.findAll();
|
|
@@ -111,42 +173,41 @@ export class TokenRefresher {
|
|
|
111
173
|
if (!latestAccount) {
|
|
112
174
|
return { latestAccount: null, latestAuth: null };
|
|
113
175
|
}
|
|
176
|
+
this.syncPersistedAccountReference(account, latestAccount);
|
|
177
|
+
return {
|
|
178
|
+
latestAccount,
|
|
179
|
+
latestAuth: this.accountManager.toAuthDetails(account)
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
syncPersistedAccountReference(account, latestAccount) {
|
|
114
183
|
account.email = latestAccount.email;
|
|
115
184
|
account.authMethod = latestAccount.authMethod;
|
|
116
185
|
account.region = latestAccount.region;
|
|
117
|
-
if (latestAccount.oidcRegion
|
|
118
|
-
account.oidcRegion = latestAccount.oidcRegion;
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
186
|
+
if (latestAccount.oidcRegion === undefined)
|
|
121
187
|
delete account.oidcRegion;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
188
|
+
else
|
|
189
|
+
account.oidcRegion = latestAccount.oidcRegion;
|
|
190
|
+
if (latestAccount.clientId === undefined)
|
|
127
191
|
delete account.clientId;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
192
|
+
else
|
|
193
|
+
account.clientId = latestAccount.clientId;
|
|
194
|
+
if (latestAccount.clientSecret === undefined)
|
|
133
195
|
delete account.clientSecret;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
196
|
+
else
|
|
197
|
+
account.clientSecret = latestAccount.clientSecret;
|
|
198
|
+
if (latestAccount.profileArn === undefined)
|
|
139
199
|
delete account.profileArn;
|
|
140
|
-
|
|
200
|
+
else
|
|
201
|
+
account.profileArn = latestAccount.profileArn;
|
|
141
202
|
account.refreshToken = latestAccount.refreshToken;
|
|
142
203
|
account.accessToken = latestAccount.accessToken;
|
|
143
204
|
account.expiresAt = latestAccount.expiresAt;
|
|
144
|
-
return {
|
|
145
|
-
latestAccount,
|
|
146
|
-
latestAuth: this.accountManager.toAuthDetails(account)
|
|
147
|
-
};
|
|
148
205
|
}
|
|
149
206
|
async handleRefreshError(error, account, showToast) {
|
|
207
|
+
if (error instanceof TokenPersistenceError) {
|
|
208
|
+
logger.error('Token refresh persistence failed', { email: account.email });
|
|
209
|
+
return { account, shouldContinue: true };
|
|
210
|
+
}
|
|
150
211
|
const message = error instanceof Error ? error.message : String(error);
|
|
151
212
|
logger.error('Token refresh failed', {
|
|
152
213
|
email: account.email,
|
|
@@ -118,6 +118,11 @@ export class ErrorHandler {
|
|
|
118
118
|
if (result.dead) {
|
|
119
119
|
return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, nextForced, showToast);
|
|
120
120
|
}
|
|
121
|
+
// Record this account as already force-refreshed even on a transient
|
|
122
|
+
// failure: the at-most-once-per-request force-refresh invariant bounds
|
|
123
|
+
// the retry loop. A candidate that was refreshed but not yet persisted
|
|
124
|
+
// is retried by TokenRefresher's pendingPersistence path, which does
|
|
125
|
+
// not re-call AWS, so dropping nextForced here is unnecessary.
|
|
121
126
|
return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast, signal);
|
|
122
127
|
}
|
|
123
128
|
return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, forced, showToast);
|
|
@@ -14,6 +14,7 @@ import { RetryStrategy } from './retry-strategy.js';
|
|
|
14
14
|
import { SdkEventStreamIterationError, UpstreamUnexpectedError } from './stream-error.js';
|
|
15
15
|
const KIRO_API_PATTERN = /^(https?:\/\/)?q\.[a-z0-9-]+\.amazonaws\.com/;
|
|
16
16
|
const REAUTH_FAILURE_COOLDOWN_MS = 60000;
|
|
17
|
+
const MAX_STREAM_ATTEMPTS = 3;
|
|
17
18
|
function describeError(error, depth = 0) {
|
|
18
19
|
if (!(error instanceof Error))
|
|
19
20
|
return String(error);
|
|
@@ -182,6 +183,18 @@ export class RequestHandler {
|
|
|
182
183
|
continue;
|
|
183
184
|
}
|
|
184
185
|
const sdkPrep = this.prepareSdkRequest(init?.body, model, auth, think, budget, showToast);
|
|
186
|
+
const streamAttempt = streamFailureCount + 1;
|
|
187
|
+
const streamLogDetails = (details = {}) => ({
|
|
188
|
+
conversationId: sdkPrep.conversationId,
|
|
189
|
+
model,
|
|
190
|
+
effectiveModel: sdkPrep.effectiveModel,
|
|
191
|
+
region: sdkPrep.region,
|
|
192
|
+
account: acc.email,
|
|
193
|
+
accountId: acc.id,
|
|
194
|
+
streamAttempt,
|
|
195
|
+
maxStreamAttempts: MAX_STREAM_ATTEMPTS,
|
|
196
|
+
...details
|
|
197
|
+
});
|
|
185
198
|
if (this.config.enable_log_effort_debug) {
|
|
186
199
|
try {
|
|
187
200
|
logger.log('[effort-debug] request effort resolution', {
|
|
@@ -231,6 +244,7 @@ export class RequestHandler {
|
|
|
231
244
|
if (this.config.sdk_response_timeout_enabled) {
|
|
232
245
|
const messageContext = sdkPrep.conversationState.currentMessage?.userInputMessage?.userInputMessageContext;
|
|
233
246
|
beginUpstreamWait('SDK response', this.config.sdk_response_timeout_ms, {
|
|
247
|
+
conversationId: sdkPrep.conversationId,
|
|
234
248
|
model,
|
|
235
249
|
effectiveModel: sdkPrep.effectiveModel,
|
|
236
250
|
effort: sdkPrep.effort,
|
|
@@ -261,6 +275,7 @@ export class RequestHandler {
|
|
|
261
275
|
if (!this.config.stream_event_timeout_enabled)
|
|
262
276
|
return;
|
|
263
277
|
beginUpstreamWait('stream event', this.config.request_timeout_ms, {
|
|
278
|
+
conversationId: sdkPrep.conversationId,
|
|
264
279
|
model,
|
|
265
280
|
effectiveModel: sdkPrep.effectiveModel,
|
|
266
281
|
region: sdkPrep.region,
|
|
@@ -268,20 +283,32 @@ export class RequestHandler {
|
|
|
268
283
|
});
|
|
269
284
|
},
|
|
270
285
|
onUpstreamWaitEnd: endUpstreamWait,
|
|
271
|
-
onIterationError: (error, afterCompletionMetadata) =>
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
286
|
+
onIterationError: (error, afterCompletionMetadata) => {
|
|
287
|
+
if (!afterCompletionMetadata)
|
|
288
|
+
return;
|
|
289
|
+
logger.log('Kiro SDK event stream closed after completion metadata', streamLogDetails({
|
|
290
|
+
outcome: 'ignored_after_completion_metadata',
|
|
291
|
+
platform: process.platform,
|
|
292
|
+
afterCompletionMetadata,
|
|
293
|
+
error: describeError(error)
|
|
294
|
+
}));
|
|
295
|
+
},
|
|
280
296
|
onComplete: completeRequest,
|
|
281
297
|
onTerminal: cleanupRequest,
|
|
282
298
|
onCancel: (reason) => requestController.abort(reason),
|
|
283
|
-
mapError: (error) =>
|
|
299
|
+
mapError: (error) => {
|
|
300
|
+
logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
|
|
301
|
+
outcome: 'terminated_after_output',
|
|
302
|
+
platform: process.platform,
|
|
303
|
+
emittedOutput: true,
|
|
304
|
+
error: describeError(error)
|
|
305
|
+
}));
|
|
306
|
+
return new UpstreamUnexpectedError(error, true);
|
|
307
|
+
}
|
|
284
308
|
});
|
|
309
|
+
if (streamFailureCount > 0) {
|
|
310
|
+
logger.log('Kiro SDK event stream retry recovered', streamLogDetails({ outcome: 'recovered', attempts: streamAttempt }));
|
|
311
|
+
}
|
|
285
312
|
if (sdkPrep.streaming) {
|
|
286
313
|
responseOwnsLifecycle = true;
|
|
287
314
|
}
|
|
@@ -296,9 +323,17 @@ export class RequestHandler {
|
|
|
296
323
|
if (e instanceof SdkEventStreamIterationError) {
|
|
297
324
|
streamFailureCount++;
|
|
298
325
|
const streamError = new UpstreamUnexpectedError(e, false);
|
|
299
|
-
if (streamFailureCount >=
|
|
326
|
+
if (streamFailureCount >= MAX_STREAM_ATTEMPTS) {
|
|
327
|
+
logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
|
|
328
|
+
outcome: 'exhausted',
|
|
329
|
+
platform: process.platform,
|
|
330
|
+
attempts: streamFailureCount,
|
|
331
|
+
error: describeError(e)
|
|
332
|
+
}));
|
|
300
333
|
return streamError.toResponse();
|
|
301
|
-
|
|
334
|
+
}
|
|
335
|
+
const delayMs = this.getStreamRetryDelay(streamFailureCount);
|
|
336
|
+
await this.sleep(delayMs, signal);
|
|
302
337
|
if (streamFailureCount === 1) {
|
|
303
338
|
forcedStreamAccount = acc;
|
|
304
339
|
}
|
|
@@ -306,6 +341,14 @@ export class RequestHandler {
|
|
|
306
341
|
forcedStreamAccount =
|
|
307
342
|
(await this.accountSelector.selectAlternativeAccount(new Set([acc.id]))) ?? acc;
|
|
308
343
|
}
|
|
344
|
+
logger.warn('Kiro SDK event stream iteration failed', streamLogDetails({
|
|
345
|
+
outcome: 'retrying',
|
|
346
|
+
platform: process.platform,
|
|
347
|
+
nextAttempt: streamFailureCount + 1,
|
|
348
|
+
delayMs,
|
|
349
|
+
nextAccount: forcedStreamAccount.email,
|
|
350
|
+
error: describeError(e)
|
|
351
|
+
}));
|
|
309
352
|
continue;
|
|
310
353
|
}
|
|
311
354
|
if (sendResolved)
|
|
@@ -50,6 +50,9 @@ export declare class AccountManager {
|
|
|
50
50
|
addAccount(a: ManagedAccount): void;
|
|
51
51
|
removeAccount(a: ManagedAccount): void;
|
|
52
52
|
updateFromAuth(a: ManagedAccount, auth: KiroAuthDetails): void;
|
|
53
|
+
createAuthCandidate(a: ManagedAccount, auth: KiroAuthDetails): ManagedAccount;
|
|
54
|
+
publishAuthCandidate(candidate: ManagedAccount, syncKiroCli?: boolean): void;
|
|
55
|
+
private writeAuthCandidateToKiroCli;
|
|
53
56
|
markRateLimited(a: ManagedAccount, ms: number): void;
|
|
54
57
|
markUnhealthy(a: ManagedAccount, reason: string, recovery?: number): void;
|
|
55
58
|
saveToDisk(): Promise<void>;
|
package/dist/plugin/accounts.js
CHANGED
|
@@ -287,34 +287,56 @@ export class AccountManager {
|
|
|
287
287
|
this.cursor--;
|
|
288
288
|
}
|
|
289
289
|
updateFromAuth(a, auth) {
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
290
|
+
const account = this.accounts.find((item) => item.id === a.id);
|
|
291
|
+
if (!account)
|
|
292
|
+
return;
|
|
293
|
+
const candidate = this.createAuthCandidate(account, auth);
|
|
294
|
+
this.publishAuthCandidate(candidate, false);
|
|
295
|
+
kiroDb.upsertAccount(candidate).catch((e) => logger.warn('DB write failed', {
|
|
296
|
+
method: 'updateFromAuth',
|
|
297
|
+
email: candidate.email,
|
|
298
|
+
error: e instanceof Error ? e.message : String(e)
|
|
299
|
+
}));
|
|
300
|
+
this.writeAuthCandidateToKiroCli(candidate);
|
|
301
|
+
}
|
|
302
|
+
createAuthCandidate(a, auth) {
|
|
303
|
+
const candidate = { ...a };
|
|
304
|
+
candidate.accessToken = auth.access;
|
|
305
|
+
candidate.expiresAt = auth.expires;
|
|
306
|
+
candidate.lastUsed = Date.now();
|
|
307
|
+
if (auth.email)
|
|
308
|
+
candidate.email = auth.email;
|
|
309
|
+
const p = decodeRefreshToken(auth.refresh);
|
|
310
|
+
candidate.refreshToken = p.refreshToken;
|
|
311
|
+
if (p.profileArn)
|
|
312
|
+
candidate.profileArn = p.profileArn;
|
|
313
|
+
if (p.clientId)
|
|
314
|
+
candidate.clientId = p.clientId;
|
|
315
|
+
candidate.failCount = 0;
|
|
316
|
+
candidate.isHealthy = true;
|
|
317
|
+
delete candidate.unhealthyReason;
|
|
318
|
+
delete candidate.recoveryTime;
|
|
319
|
+
return candidate;
|
|
320
|
+
}
|
|
321
|
+
publishAuthCandidate(candidate, syncKiroCli = true) {
|
|
322
|
+
const account = this.accounts.find((item) => item.id === candidate.id);
|
|
323
|
+
if (!account)
|
|
324
|
+
return;
|
|
325
|
+
Object.assign(account, candidate);
|
|
326
|
+
if (candidate.unhealthyReason === undefined)
|
|
327
|
+
delete account.unhealthyReason;
|
|
328
|
+
if (candidate.recoveryTime === undefined)
|
|
329
|
+
delete account.recoveryTime;
|
|
330
|
+
if (!syncKiroCli)
|
|
331
|
+
return;
|
|
332
|
+
this.writeAuthCandidateToKiroCli(account);
|
|
333
|
+
}
|
|
334
|
+
writeAuthCandidateToKiroCli(account) {
|
|
335
|
+
writeToKiroCli(account).catch((e) => logger.warn('CLI write failed', {
|
|
336
|
+
method: 'updateFromAuth',
|
|
337
|
+
email: account.email,
|
|
338
|
+
error: e instanceof Error ? e.message : String(e)
|
|
339
|
+
}));
|
|
318
340
|
}
|
|
319
341
|
markRateLimited(a, ms) {
|
|
320
342
|
const acc = this.accounts.find((x) => x.id === a.id);
|
package/dist/plugin/errors.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ export declare class KiroTokenRefreshError extends Error {
|
|
|
3
3
|
originalError?: Error;
|
|
4
4
|
constructor(message: string, code?: string, originalError?: Error);
|
|
5
5
|
}
|
|
6
|
+
export declare class TokenPersistenceError extends Error {
|
|
7
|
+
constructor();
|
|
8
|
+
}
|
|
6
9
|
export declare class KiroQuotaExhaustedError extends Error {
|
|
7
10
|
recoveryTime?: number;
|
|
8
11
|
constructor(message: string, recoveryTime?: number);
|
package/dist/plugin/errors.js
CHANGED
|
@@ -8,6 +8,12 @@ export class KiroTokenRefreshError extends Error {
|
|
|
8
8
|
this.originalError = originalError;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
export class TokenPersistenceError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super('Failed to persist refreshed credentials');
|
|
14
|
+
this.name = 'TokenPersistenceError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
11
17
|
export class KiroQuotaExhaustedError extends Error {
|
|
12
18
|
recoveryTime;
|
|
13
19
|
constructor(message, recoveryTime) {
|
|
@@ -2,7 +2,6 @@ import type { ManagedAccount } from '../types.js';
|
|
|
2
2
|
export { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
|
|
3
3
|
type LockRelease = () => Promise<void>;
|
|
4
4
|
export declare function withDatabaseLockSync<T>(dbPath: string, fn: () => T): T;
|
|
5
|
-
export declare function withDatabaseLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T>;
|
|
6
5
|
export declare function withRefreshLock<T>(accountId: string, fn: () => Promise<T>): Promise<T>;
|
|
7
6
|
export declare function tryAcquireKeepAliveLock(): Promise<LockRelease | null>;
|
|
8
7
|
export declare function withKeepAliveLock<T>(fn: () => Promise<T>): Promise<T | null>;
|
|
@@ -5,24 +5,14 @@ import lockfile from 'proper-lockfile';
|
|
|
5
5
|
import { isPermanentError } from '../health.js';
|
|
6
6
|
import { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
|
|
7
7
|
export { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
|
|
8
|
-
const DATABASE_LOCK_OPTIONS = {
|
|
9
|
-
stale: 10000,
|
|
10
|
-
retries: 0,
|
|
11
|
-
realpath: false
|
|
12
|
-
};
|
|
13
|
-
const DATABASE_LOCK_DEADLINE_MS = 10000;
|
|
14
|
-
const DATABASE_LOCK_MIN_BACKOFF_MS = 25;
|
|
15
|
-
const DATABASE_LOCK_MAX_BACKOFF_MS = 250;
|
|
16
8
|
const REFRESH_LOCK_OPTIONS = {
|
|
17
9
|
stale: 15000,
|
|
18
|
-
retries:
|
|
19
|
-
retries: 10,
|
|
20
|
-
minTimeout: 100,
|
|
21
|
-
maxTimeout: 1000,
|
|
22
|
-
factor: 2
|
|
23
|
-
},
|
|
10
|
+
retries: 0,
|
|
24
11
|
realpath: false
|
|
25
12
|
};
|
|
13
|
+
const REFRESH_LOCK_DEADLINE_MS = 15000;
|
|
14
|
+
const REFRESH_LOCK_MIN_BACKOFF_MS = 25;
|
|
15
|
+
const REFRESH_LOCK_MAX_BACKOFF_MS = 250;
|
|
26
16
|
const KEEP_ALIVE_LOCK_OPTIONS = {
|
|
27
17
|
stale: 120000,
|
|
28
18
|
retries: 0,
|
|
@@ -33,29 +23,35 @@ const SYNC_LOCK_DEADLINE_MS = 10000;
|
|
|
33
23
|
function blockingBackoff(ms) {
|
|
34
24
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
35
25
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
26
|
+
function isRetryableLockAcquisitionError(e) {
|
|
27
|
+
if (typeof e !== 'object' || e === null || !('code' in e))
|
|
28
|
+
return false;
|
|
29
|
+
// proper-lockfile may surface ENOENT from mtimePrecision.probe() when another
|
|
30
|
+
// process removes the newly-created lock directory during stale takeover.
|
|
31
|
+
// Keep this scoped to the bounded lock-acquisition loops below so unrelated
|
|
32
|
+
// filesystem ENOENT errors still propagate immediately.
|
|
33
|
+
return e.code === 'ELOCKED' || e.code === 'ENOENT';
|
|
38
34
|
}
|
|
39
|
-
function asyncBackoff(attempt, remainingMs) {
|
|
40
|
-
const ceiling = Math.min(
|
|
35
|
+
function asyncBackoff(attempt, remainingMs, minBackoffMs, maxBackoffMs) {
|
|
36
|
+
const ceiling = Math.min(minBackoffMs * 2 ** Math.min(attempt, 4), maxBackoffMs, remainingMs);
|
|
41
37
|
const floor = Math.max(1, Math.floor(ceiling / 2));
|
|
42
38
|
const delay = floor + Math.floor(Math.random() * (ceiling - floor + 1));
|
|
43
39
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
44
40
|
}
|
|
45
|
-
async function
|
|
41
|
+
async function acquireRefreshLock(lockPath) {
|
|
46
42
|
// A deadline avoids fixed retry-count starvation; jitter keeps contenders
|
|
47
43
|
// from repeatedly attempting the atomic mkdir in lockstep.
|
|
48
|
-
const deadline = Date.now() +
|
|
44
|
+
const deadline = Date.now() + REFRESH_LOCK_DEADLINE_MS;
|
|
49
45
|
let attempt = 0;
|
|
50
46
|
for (;;) {
|
|
51
47
|
try {
|
|
52
|
-
return await lockfile.lock(
|
|
48
|
+
return await lockfile.lock(lockPath, REFRESH_LOCK_OPTIONS);
|
|
53
49
|
}
|
|
54
50
|
catch (e) {
|
|
55
51
|
const remainingMs = deadline - Date.now();
|
|
56
|
-
if (!
|
|
52
|
+
if (!isRetryableLockAcquisitionError(e) || remainingMs <= 0)
|
|
57
53
|
throw e;
|
|
58
|
-
await asyncBackoff(attempt++, remainingMs);
|
|
54
|
+
await asyncBackoff(attempt++, remainingMs, REFRESH_LOCK_MIN_BACKOFF_MS, REFRESH_LOCK_MAX_BACKOFF_MS);
|
|
59
55
|
}
|
|
60
56
|
}
|
|
61
57
|
}
|
|
@@ -75,7 +71,7 @@ export function withDatabaseLockSync(dbPath, fn) {
|
|
|
75
71
|
break;
|
|
76
72
|
}
|
|
77
73
|
catch (e) {
|
|
78
|
-
if (!
|
|
74
|
+
if (!isRetryableLockAcquisitionError(e) || Date.now() >= deadline)
|
|
79
75
|
throw e;
|
|
80
76
|
blockingBackoff(Math.min(100 * 2 ** attempt++, 500));
|
|
81
77
|
}
|
|
@@ -92,27 +88,6 @@ export function withDatabaseLockSync(dbPath, fn) {
|
|
|
92
88
|
}
|
|
93
89
|
}
|
|
94
90
|
}
|
|
95
|
-
export async function withDatabaseLock(dbPath, fn) {
|
|
96
|
-
if (!existsSync(dbPath)) {
|
|
97
|
-
await fs.mkdir(dirname(dbPath), { recursive: true });
|
|
98
|
-
await fs.writeFile(dbPath, '');
|
|
99
|
-
}
|
|
100
|
-
let release = null;
|
|
101
|
-
try {
|
|
102
|
-
release = await acquireDatabaseLock(dbPath);
|
|
103
|
-
return await fn();
|
|
104
|
-
}
|
|
105
|
-
finally {
|
|
106
|
-
if (release) {
|
|
107
|
-
try {
|
|
108
|
-
await release();
|
|
109
|
-
}
|
|
110
|
-
catch (e) {
|
|
111
|
-
console.warn('Failed to release lock:', e);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
91
|
export async function withRefreshLock(accountId, fn) {
|
|
117
92
|
const lockPath = getRefreshLockPath(accountId);
|
|
118
93
|
if (!existsSync(lockPath)) {
|
|
@@ -121,7 +96,7 @@ export async function withRefreshLock(accountId, fn) {
|
|
|
121
96
|
}
|
|
122
97
|
let release = null;
|
|
123
98
|
try {
|
|
124
|
-
release = await
|
|
99
|
+
release = await acquireRefreshLock(lockPath);
|
|
125
100
|
return await fn();
|
|
126
101
|
}
|
|
127
102
|
finally {
|
|
@@ -1,4 +1,56 @@
|
|
|
1
|
+
const REFRESH_TOKEN_DEDUP_MARKER = 'refresh_token_dedup_migration_version';
|
|
2
|
+
const REFRESH_TOKEN_DEDUP_VERSION = 1;
|
|
3
|
+
const MIGRATION_LOCK_DEADLINE_MS = 30_000;
|
|
4
|
+
const MIGRATION_LOCK_MIN_BACKOFF_MS = 25;
|
|
5
|
+
const MIGRATION_LOCK_MAX_BACKOFF_MS = 500;
|
|
6
|
+
class MigrationLockTimeoutError extends Error {
|
|
7
|
+
name = 'MigrationLockTimeoutError';
|
|
8
|
+
code = 'KIRO_DB_MIGRATION_LOCK_TIMEOUT';
|
|
9
|
+
constructor(cause) {
|
|
10
|
+
super(`Timed out after ${MIGRATION_LOCK_DEADLINE_MS}ms waiting for the migration write lock`, {
|
|
11
|
+
cause
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isSqliteBusy(error) {
|
|
16
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
17
|
+
return false;
|
|
18
|
+
return typeof error.code === 'string' && error.code.startsWith('SQLITE_BUSY');
|
|
19
|
+
}
|
|
20
|
+
function blockingBackoff(ms) {
|
|
21
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
22
|
+
}
|
|
23
|
+
function beginImmediateWithRetry(db) {
|
|
24
|
+
const deadline = Date.now() + MIGRATION_LOCK_DEADLINE_MS;
|
|
25
|
+
let backoffMs = MIGRATION_LOCK_MIN_BACKOFF_MS;
|
|
26
|
+
for (;;) {
|
|
27
|
+
try {
|
|
28
|
+
db.exec('BEGIN IMMEDIATE');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (!isSqliteBusy(error))
|
|
33
|
+
throw error;
|
|
34
|
+
const remainingMs = deadline - Date.now();
|
|
35
|
+
if (remainingMs <= 0)
|
|
36
|
+
throw new MigrationLockTimeoutError(error);
|
|
37
|
+
const jitterMs = Math.floor(Math.random() * Math.max(1, backoffMs / 4));
|
|
38
|
+
blockingBackoff(Math.min(backoffMs + jitterMs, remainingMs));
|
|
39
|
+
backoffMs = Math.min(backoffMs * 2, MIGRATION_LOCK_MAX_BACKOFF_MS);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function hasRefreshTokenDedupMarker(db) {
|
|
44
|
+
const marker = db
|
|
45
|
+
.prepare('SELECT value FROM plugin_meta WHERE key = ?')
|
|
46
|
+
.get(REFRESH_TOKEN_DEDUP_MARKER);
|
|
47
|
+
if (typeof marker !== 'object' || marker === null)
|
|
48
|
+
return false;
|
|
49
|
+
const value = 'value' in marker ? marker.value : undefined;
|
|
50
|
+
return typeof value === 'number' && value >= REFRESH_TOKEN_DEDUP_VERSION;
|
|
51
|
+
}
|
|
1
52
|
export function runMigrations(db) {
|
|
53
|
+
migratePluginMetaTable(db);
|
|
2
54
|
migrateToUniqueRefreshToken(db);
|
|
3
55
|
migrateRealEmailColumn(db);
|
|
4
56
|
migrateUsageTable(db);
|
|
@@ -7,19 +59,18 @@ export function runMigrations(db) {
|
|
|
7
59
|
migrateDropRefreshTokenUniqueIndex(db);
|
|
8
60
|
migrateRemovedAccountsTable(db);
|
|
9
61
|
migrateOverageColumn(db);
|
|
10
|
-
migratePluginMetaTable(db);
|
|
11
62
|
}
|
|
12
63
|
function migrateToUniqueRefreshToken(db) {
|
|
13
|
-
|
|
14
|
-
if (indexProbe.get())
|
|
64
|
+
if (hasRefreshTokenDedupMarker(db))
|
|
15
65
|
return;
|
|
66
|
+
const indexProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'");
|
|
16
67
|
// BEGIN IMMEDIATE (not deferred BEGIN): a deferred read-then-write txn raises unrecoverable
|
|
17
68
|
// SQLITE_BUSY_SNAPSHOT (busy_timeout cannot retry it) if another connection writes after the
|
|
18
69
|
// read snapshot. Taking the write lock at BEGIN makes concurrent processes serialize via
|
|
19
70
|
// busy_timeout instead of racing into a snapshot conflict.
|
|
20
|
-
db
|
|
71
|
+
beginImmediateWithRetry(db);
|
|
21
72
|
try {
|
|
22
|
-
if (
|
|
73
|
+
if (hasRefreshTokenDedupMarker(db)) {
|
|
23
74
|
db.exec('COMMIT');
|
|
24
75
|
return;
|
|
25
76
|
}
|
|
@@ -55,7 +106,11 @@ function migrateToUniqueRefreshToken(db) {
|
|
|
55
106
|
}
|
|
56
107
|
}
|
|
57
108
|
}
|
|
58
|
-
|
|
109
|
+
if (!indexProbe.get()) {
|
|
110
|
+
db.exec('CREATE UNIQUE INDEX idx_refresh_token_unique ON accounts(refresh_token)');
|
|
111
|
+
}
|
|
112
|
+
db.prepare(`INSERT INTO plugin_meta(key, value) VALUES(?, ?)
|
|
113
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(REFRESH_TOKEN_DEDUP_MARKER, REFRESH_TOKEN_DEDUP_VERSION);
|
|
59
114
|
db.exec('COMMIT');
|
|
60
115
|
}
|
|
61
116
|
catch (e) {
|
|
@@ -134,14 +189,23 @@ function migrateOidcRegionColumn(db) {
|
|
|
134
189
|
db.exec('ALTER TABLE accounts ADD COLUMN oidc_region TEXT');
|
|
135
190
|
}
|
|
136
191
|
// Backfill: historically `region` was used for both service + OIDC.
|
|
137
|
-
|
|
192
|
+
const needsBackfill = db
|
|
193
|
+
.prepare("SELECT 1 FROM accounts WHERE oidc_region IS NULL OR oidc_region = '' LIMIT 1")
|
|
194
|
+
.get();
|
|
195
|
+
if (needsBackfill) {
|
|
196
|
+
db.exec("UPDATE accounts SET oidc_region = region WHERE oidc_region IS NULL OR oidc_region = ''");
|
|
197
|
+
}
|
|
138
198
|
}
|
|
139
199
|
function migrateDropRefreshTokenUniqueIndex(db) {
|
|
140
200
|
// Drop the UNIQUE index on refresh_token — it was only needed for ON CONFLICT(refresh_token)
|
|
141
201
|
// upsert mechanics. Now that we use ON CONFLICT(id), this index is unnecessary and actively
|
|
142
202
|
// harmful: duplicate rows (same account, different legacy vs hash id) share the same
|
|
143
203
|
// refresh_token, causing UNIQUE constraint violations on every upsert.
|
|
144
|
-
|
|
204
|
+
const hasUniqueIndex = db
|
|
205
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'")
|
|
206
|
+
.get();
|
|
207
|
+
if (hasUniqueIndex)
|
|
208
|
+
db.exec('DROP INDEX IF EXISTS idx_refresh_token_unique');
|
|
145
209
|
// Clean up duplicate rows: same email + same refresh_token but different ids.
|
|
146
210
|
// Keep the deterministic hash id (64-char hex), delete legacy kiro-cli-sync-* rows.
|
|
147
211
|
const duplicates = db
|
|
@@ -175,5 +239,18 @@ function migrateOverageColumn(db) {
|
|
|
175
239
|
}
|
|
176
240
|
}
|
|
177
241
|
function migratePluginMetaTable(db) {
|
|
178
|
-
|
|
242
|
+
const tableProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_meta'");
|
|
243
|
+
if (tableProbe.get())
|
|
244
|
+
return;
|
|
245
|
+
beginImmediateWithRetry(db);
|
|
246
|
+
try {
|
|
247
|
+
if (!tableProbe.get()) {
|
|
248
|
+
db.exec('CREATE TABLE plugin_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL)');
|
|
249
|
+
}
|
|
250
|
+
db.exec('COMMIT');
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
db.exec('ROLLBACK');
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
179
256
|
}
|
|
@@ -2,8 +2,32 @@ import Database from 'libsql';
|
|
|
2
2
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { deduplicateAccounts, mergeAccounts,
|
|
5
|
+
import { deduplicateAccounts, mergeAccounts, withDatabaseLockSync } from './locked-operations.js';
|
|
6
6
|
import { runMigrations } from './migrations.js';
|
|
7
|
+
const DATABASE_BUSY_TIMEOUT_MS = 5_000;
|
|
8
|
+
const WRITE_LOCK_DEADLINE_MS = 30_000;
|
|
9
|
+
const WRITE_LOCK_MIN_BACKOFF_MS = 25;
|
|
10
|
+
const WRITE_LOCK_MAX_BACKOFF_MS = 500;
|
|
11
|
+
class DatabaseWriteLockTimeoutError extends Error {
|
|
12
|
+
name = 'DatabaseWriteLockTimeoutError';
|
|
13
|
+
code = 'KIRO_DB_WRITE_LOCK_TIMEOUT';
|
|
14
|
+
constructor(cause) {
|
|
15
|
+
super(`Timed out after ${WRITE_LOCK_DEADLINE_MS}ms waiting for the database write lock`, {
|
|
16
|
+
cause
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function isSqliteBusy(error) {
|
|
21
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
22
|
+
return false;
|
|
23
|
+
return typeof error.code === 'string' && error.code.startsWith('SQLITE_BUSY');
|
|
24
|
+
}
|
|
25
|
+
function asyncBackoff(attempt, remainingMs) {
|
|
26
|
+
const ceiling = Math.min(WRITE_LOCK_MIN_BACKOFF_MS * 2 ** Math.min(attempt, 5), WRITE_LOCK_MAX_BACKOFF_MS, remainingMs);
|
|
27
|
+
const floor = Math.max(1, Math.floor(ceiling / 2));
|
|
28
|
+
const delay = floor + Math.floor(Math.random() * (ceiling - floor + 1));
|
|
29
|
+
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
30
|
+
}
|
|
7
31
|
function getBaseDir() {
|
|
8
32
|
const p = process.platform;
|
|
9
33
|
if (p === 'win32')
|
|
@@ -20,9 +44,55 @@ export class KiroDatabase {
|
|
|
20
44
|
if (!existsSync(dir))
|
|
21
45
|
mkdirSync(dir, { recursive: true });
|
|
22
46
|
this.db = new Database(path);
|
|
23
|
-
this.db.pragma(
|
|
47
|
+
this.db.pragma(`busy_timeout = ${DATABASE_BUSY_TIMEOUT_MS}`);
|
|
24
48
|
withDatabaseLockSync(this.path, () => this.init());
|
|
25
49
|
}
|
|
50
|
+
async withImmediateTransaction(fn) {
|
|
51
|
+
const deadline = Date.now() + WRITE_LOCK_DEADLINE_MS;
|
|
52
|
+
let attempt = 0;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const busyTimeoutResult = this.db.pragma('busy_timeout', { simple: true });
|
|
55
|
+
const busyTimeout = typeof busyTimeoutResult === 'number'
|
|
56
|
+
? busyTimeoutResult
|
|
57
|
+
: typeof busyTimeoutResult === 'object' && busyTimeoutResult !== null
|
|
58
|
+
? Reflect.get(busyTimeoutResult, 'timeout')
|
|
59
|
+
: undefined;
|
|
60
|
+
if (typeof busyTimeout !== 'number') {
|
|
61
|
+
throw new TypeError('Expected numeric busy_timeout from libsql');
|
|
62
|
+
}
|
|
63
|
+
let begun = false;
|
|
64
|
+
let beginError;
|
|
65
|
+
try {
|
|
66
|
+
this.db.pragma('busy_timeout = 0');
|
|
67
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
68
|
+
begun = true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
beginError = error;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
this.db.pragma(`busy_timeout = ${busyTimeout}`);
|
|
75
|
+
}
|
|
76
|
+
if (!begun) {
|
|
77
|
+
if (!isSqliteBusy(beginError))
|
|
78
|
+
throw beginError;
|
|
79
|
+
const remainingMs = deadline - Date.now();
|
|
80
|
+
if (remainingMs <= 0)
|
|
81
|
+
throw new DatabaseWriteLockTimeoutError(beginError);
|
|
82
|
+
await asyncBackoff(attempt++, remainingMs);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const result = fn();
|
|
87
|
+
this.db.exec('COMMIT');
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.db.exec('ROLLBACK');
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
26
96
|
init() {
|
|
27
97
|
this.db.pragma('journal_mode = WAL');
|
|
28
98
|
this.db.exec(`
|
|
@@ -79,96 +149,64 @@ export class KiroDatabase {
|
|
|
79
149
|
this.db.prepare('DELETE FROM accounts WHERE id IN (SELECT id FROM removed_accounts)').run();
|
|
80
150
|
}
|
|
81
151
|
async upsertAccount(acc) {
|
|
82
|
-
await
|
|
152
|
+
await this.withImmediateTransaction(() => {
|
|
83
153
|
const existing = this.getAccounts().map(this.rowToAccount);
|
|
84
154
|
const merged = mergeAccounts(existing, [acc]);
|
|
85
155
|
const deduplicated = deduplicateAccounts(merged);
|
|
86
156
|
const writable = deduplicated.filter((a) => !this.isRemovedSync(a.id));
|
|
87
|
-
this.
|
|
88
|
-
|
|
89
|
-
this.
|
|
90
|
-
for (const account of writable) {
|
|
91
|
-
this.upsertAccountInternal(account);
|
|
92
|
-
}
|
|
93
|
-
this.db.exec('COMMIT');
|
|
94
|
-
}
|
|
95
|
-
catch (e) {
|
|
96
|
-
this.db.exec('ROLLBACK');
|
|
97
|
-
throw e;
|
|
157
|
+
this.purgeRemovedAccountsSync();
|
|
158
|
+
for (const account of writable) {
|
|
159
|
+
this.upsertAccountInternal(account);
|
|
98
160
|
}
|
|
99
161
|
});
|
|
100
162
|
}
|
|
101
163
|
async batchUpsertAccounts(accounts) {
|
|
102
|
-
await
|
|
164
|
+
await this.withImmediateTransaction(() => {
|
|
103
165
|
const existing = this.getAccounts().map(this.rowToAccount);
|
|
104
166
|
const merged = mergeAccounts(existing, accounts);
|
|
105
167
|
const deduplicated = deduplicateAccounts(merged);
|
|
106
168
|
const writable = deduplicated.filter((a) => !this.isRemovedSync(a.id));
|
|
107
|
-
this.
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
for (const account of writable) {
|
|
111
|
-
this.upsertAccountInternal(account);
|
|
112
|
-
}
|
|
113
|
-
this.db.exec('COMMIT');
|
|
114
|
-
}
|
|
115
|
-
catch (e) {
|
|
116
|
-
this.db.exec('ROLLBACK');
|
|
117
|
-
throw e;
|
|
169
|
+
this.purgeRemovedAccountsSync();
|
|
170
|
+
for (const account of writable) {
|
|
171
|
+
this.upsertAccountInternal(account);
|
|
118
172
|
}
|
|
119
173
|
});
|
|
120
174
|
}
|
|
121
175
|
async deleteAccount(id) {
|
|
122
|
-
await
|
|
176
|
+
await this.withImmediateTransaction(() => {
|
|
123
177
|
this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
|
|
124
178
|
});
|
|
125
179
|
}
|
|
126
180
|
async removeAccountWithTombstone(id) {
|
|
127
|
-
await
|
|
128
|
-
this.db.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
|
|
133
|
-
.run(id, Date.now());
|
|
134
|
-
this.db.exec('COMMIT');
|
|
135
|
-
}
|
|
136
|
-
catch (e) {
|
|
137
|
-
this.db.exec('ROLLBACK');
|
|
138
|
-
throw e;
|
|
139
|
-
}
|
|
181
|
+
await this.withImmediateTransaction(() => {
|
|
182
|
+
this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
|
|
183
|
+
this.db
|
|
184
|
+
.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
|
|
185
|
+
.run(id, Date.now());
|
|
140
186
|
});
|
|
141
187
|
}
|
|
142
188
|
async cleanupSupersededIdentities(keepId, email, authMethod, profileArn) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.db
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (typeof row === 'object' && row !== null && 'id' in row && typeof row.id === 'string')
|
|
152
|
-
supersededIds.push(row.id);
|
|
153
|
-
}
|
|
154
|
-
const deleteStmt = this.db.prepare('DELETE FROM accounts WHERE id = ?');
|
|
155
|
-
const tombstoneStmt = this.db.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)');
|
|
156
|
-
const removedAt = Date.now();
|
|
157
|
-
for (const id of supersededIds) {
|
|
158
|
-
deleteStmt.run(id);
|
|
159
|
-
tombstoneStmt.run(id, removedAt);
|
|
160
|
-
}
|
|
161
|
-
this.db.exec('COMMIT');
|
|
189
|
+
return this.withImmediateTransaction(() => {
|
|
190
|
+
const supersededIds = [];
|
|
191
|
+
const rows = this.db
|
|
192
|
+
.prepare('SELECT id FROM accounts WHERE email = ? AND auth_method = ? AND profile_arn IS ? AND id != ?')
|
|
193
|
+
.all(email, authMethod, profileArn ?? null, keepId);
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
if (typeof row === 'object' && row !== null && 'id' in row && typeof row.id === 'string')
|
|
196
|
+
supersededIds.push(row.id);
|
|
162
197
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
198
|
+
const deleteStmt = this.db.prepare('DELETE FROM accounts WHERE id = ?');
|
|
199
|
+
const tombstoneStmt = this.db.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)');
|
|
200
|
+
const removedAt = Date.now();
|
|
201
|
+
for (const id of supersededIds) {
|
|
202
|
+
deleteStmt.run(id);
|
|
203
|
+
tombstoneStmt.run(id, removedAt);
|
|
166
204
|
}
|
|
205
|
+
return supersededIds;
|
|
167
206
|
});
|
|
168
|
-
return supersededIds;
|
|
169
207
|
}
|
|
170
208
|
async addRemovedAccount(id) {
|
|
171
|
-
await
|
|
209
|
+
await this.withImmediateTransaction(() => {
|
|
172
210
|
this.db
|
|
173
211
|
.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
|
|
174
212
|
.run(id, Date.now());
|
|
@@ -178,7 +216,7 @@ export class KiroDatabase {
|
|
|
178
216
|
return !!this.db.prepare('SELECT id FROM removed_accounts WHERE id = ?').get(id);
|
|
179
217
|
}
|
|
180
218
|
async clearRemovedAccount(id) {
|
|
181
|
-
await
|
|
219
|
+
await this.withImmediateTransaction(() => {
|
|
182
220
|
this.db.prepare('DELETE FROM removed_accounts WHERE id = ?').run(id);
|
|
183
221
|
});
|
|
184
222
|
}
|
|
@@ -186,7 +224,7 @@ export class KiroDatabase {
|
|
|
186
224
|
return this.db.prepare('SELECT id FROM removed_accounts').all().map((r) => r.id);
|
|
187
225
|
}
|
|
188
226
|
async nextAssignmentIndex() {
|
|
189
|
-
return
|
|
227
|
+
return this.withImmediateTransaction(() => {
|
|
190
228
|
const row = this.db
|
|
191
229
|
.prepare("INSERT INTO plugin_meta(key,value) VALUES('assignment_cursor',0) ON CONFLICT(key) DO UPDATE SET value=value+1 RETURNING value")
|
|
192
230
|
.get();
|
|
@@ -196,11 +234,9 @@ export class KiroDatabase {
|
|
|
196
234
|
async markAccountsUnhealthy(ids, reason) {
|
|
197
235
|
if (ids.length === 0)
|
|
198
236
|
return;
|
|
199
|
-
await
|
|
237
|
+
await this.withImmediateTransaction(() => {
|
|
200
238
|
const now = Date.now();
|
|
201
|
-
this.db.
|
|
202
|
-
try {
|
|
203
|
-
const stmt = this.db.prepare(`
|
|
239
|
+
const stmt = this.db.prepare(`
|
|
204
240
|
UPDATE accounts
|
|
205
241
|
SET is_healthy = 0,
|
|
206
242
|
unhealthy_reason = ?,
|
|
@@ -210,14 +246,8 @@ export class KiroDatabase {
|
|
|
210
246
|
last_sync = ?
|
|
211
247
|
WHERE id = ?
|
|
212
248
|
`);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
this.db.exec('COMMIT');
|
|
217
|
-
}
|
|
218
|
-
catch (e) {
|
|
219
|
-
this.db.exec('ROLLBACK');
|
|
220
|
-
throw e;
|
|
249
|
+
for (const id of ids) {
|
|
250
|
+
stmt.run(reason, now, id);
|
|
221
251
|
}
|
|
222
252
|
});
|
|
223
253
|
}
|