@sunerpy/opencode-kiro-auth 0.15.1 → 0.15.3
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/README.md +14 -0
- 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 +4 -3
- package/dist/core/request/response-handler.d.ts +1 -0
- package/dist/core/request/response-handler.js +34 -10
- package/dist/plugin/accounts.d.ts +3 -0
- package/dist/plugin/accounts.js +50 -28
- package/dist/plugin/config/loader.js +2 -0
- package/dist/plugin/config/schema.d.ts +15 -0
- package/dist/plugin/config/schema.js +13 -0
- 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 +10 -50
- 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
package/README.md
CHANGED
|
@@ -109,6 +109,14 @@ multi-account or long-idle setups, enable
|
|
|
109
109
|
(`token_keepalive_enabled: true`) to keep idle accounts' tokens fresh while
|
|
110
110
|
OpenCode is running.
|
|
111
111
|
|
|
112
|
+
For long-running agent tasks that are frequently interrupted by upstream
|
|
113
|
+
`ECONNRESET` event-stream failures, enable
|
|
114
|
+
`"stream_buffer_until_complete": true`. The plugin then withholds a failed
|
|
115
|
+
attempt from OpenCode and safely retries it instead of exposing a partial
|
|
116
|
+
assistant response or partial tool call. See
|
|
117
|
+
[stream recovery configuration](docs/CONFIGURATION.md#options) for the latency
|
|
118
|
+
and quota tradeoffs.
|
|
119
|
+
|
|
112
120
|
Paid-overage protection is on by default; see
|
|
113
121
|
[Overage protection](docs/CONFIGURATION.md#overage-protection) before disabling
|
|
114
122
|
`stop_on_overage`.
|
|
@@ -231,6 +239,12 @@ accounts", `/connect` vs `opencode auth login`, and Kiro CLI OAuth users whose
|
|
|
231
239
|
sync doesn't start — are covered in
|
|
232
240
|
[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
|
|
233
241
|
|
|
242
|
+
`Kiro upstream event stream failed unexpectedly` means the upstream HTTP 200
|
|
243
|
+
event stream ended before completion metadata. Live-stream mode cannot safely
|
|
244
|
+
replay after output because that could duplicate text or execute a tool twice.
|
|
245
|
+
Enable `stream_buffer_until_complete` when task continuity is more important
|
|
246
|
+
than seeing tokens arrive live.
|
|
247
|
+
|
|
234
248
|
## Migration
|
|
235
249
|
|
|
236
250
|
If you're upgrading from a version that used the provider id `kiro` instead of
|
|
@@ -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,7 +14,6 @@ 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;
|
|
18
17
|
function describeError(error, depth = 0) {
|
|
19
18
|
if (!(error instanceof Error))
|
|
20
19
|
return String(error);
|
|
@@ -192,7 +191,8 @@ export class RequestHandler {
|
|
|
192
191
|
account: acc.email,
|
|
193
192
|
accountId: acc.id,
|
|
194
193
|
streamAttempt,
|
|
195
|
-
maxStreamAttempts:
|
|
194
|
+
maxStreamAttempts: this.config.stream_max_attempts,
|
|
195
|
+
streamDeliveryMode: this.config.stream_buffer_until_complete ? 'buffered' : 'live',
|
|
196
196
|
...details
|
|
197
197
|
});
|
|
198
198
|
if (this.config.enable_log_effort_debug) {
|
|
@@ -296,6 +296,7 @@ export class RequestHandler {
|
|
|
296
296
|
onComplete: completeRequest,
|
|
297
297
|
onTerminal: cleanupRequest,
|
|
298
298
|
onCancel: (reason) => requestController.abort(reason),
|
|
299
|
+
bufferUntilComplete: this.config.stream_buffer_until_complete,
|
|
299
300
|
mapError: (error) => {
|
|
300
301
|
logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
|
|
301
302
|
outcome: 'terminated_after_output',
|
|
@@ -323,7 +324,7 @@ export class RequestHandler {
|
|
|
323
324
|
if (e instanceof SdkEventStreamIterationError) {
|
|
324
325
|
streamFailureCount++;
|
|
325
326
|
const streamError = new UpstreamUnexpectedError(e, false);
|
|
326
|
-
if (streamFailureCount >=
|
|
327
|
+
if (streamFailureCount >= this.config.stream_max_attempts) {
|
|
327
328
|
logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
|
|
328
329
|
outcome: 'exhausted',
|
|
329
330
|
platform: process.platform,
|
|
@@ -10,6 +10,7 @@ export interface SdkResponseLifecycle {
|
|
|
10
10
|
onTerminal?: () => void;
|
|
11
11
|
onCancel?: (reason: unknown) => void;
|
|
12
12
|
mapError?: (error: SdkEventStreamIterationError, emittedOutput: true) => unknown;
|
|
13
|
+
bufferUntilComplete?: boolean;
|
|
13
14
|
}
|
|
14
15
|
export declare class ResponseHandler {
|
|
15
16
|
handleSuccess(response: Response, model: string, conversationId: string, streaming: boolean): Promise<Response>;
|
|
@@ -115,6 +115,18 @@ function isSemanticChunk(chunk) {
|
|
|
115
115
|
function encodeSseChunk(chunk) {
|
|
116
116
|
return new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
117
117
|
}
|
|
118
|
+
function bufferedSseResponse(chunks) {
|
|
119
|
+
let index = 0;
|
|
120
|
+
return new Response(new ReadableStream({
|
|
121
|
+
pull(controller) {
|
|
122
|
+
const chunk = chunks[index++];
|
|
123
|
+
if (chunk)
|
|
124
|
+
controller.enqueue(chunk);
|
|
125
|
+
if (index >= chunks.length)
|
|
126
|
+
controller.close();
|
|
127
|
+
}
|
|
128
|
+
}, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
|
|
129
|
+
}
|
|
118
130
|
export class ResponseHandler {
|
|
119
131
|
async handleSuccess(response, model, conversationId, streaming) {
|
|
120
132
|
if (streaming) {
|
|
@@ -148,22 +160,34 @@ export class ResponseHandler {
|
|
|
148
160
|
const wrapped = wrapSdkEventStream(sdkResponse, lifecycle.signal, lifecycle.onUpstreamWaitStart, lifecycle.onUpstreamWaitEnd, lifecycle.onIterationError);
|
|
149
161
|
const transformed = transformSdkStream(wrapped.response, model, conversationId);
|
|
150
162
|
const buffered = [];
|
|
163
|
+
if (lifecycle.bufferUntilComplete) {
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
const item = await transformed.next();
|
|
167
|
+
if (item.done) {
|
|
168
|
+
await lifecycle.onComplete?.();
|
|
169
|
+
lifecycle.onTerminal?.();
|
|
170
|
+
return bufferedSseResponse(buffered);
|
|
171
|
+
}
|
|
172
|
+
buffered.push(encodeSseChunk(item.value));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
try {
|
|
177
|
+
await transformed.return(undefined);
|
|
178
|
+
}
|
|
179
|
+
catch { }
|
|
180
|
+
await wrapped.closeRaw();
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
151
184
|
let firstSemantic;
|
|
152
185
|
while (true) {
|
|
153
186
|
const item = await transformed.next();
|
|
154
187
|
if (item.done) {
|
|
155
188
|
await lifecycle.onComplete?.();
|
|
156
189
|
lifecycle.onTerminal?.();
|
|
157
|
-
|
|
158
|
-
return new Response(new ReadableStream({
|
|
159
|
-
pull(controller) {
|
|
160
|
-
const chunk = buffered[index++];
|
|
161
|
-
if (chunk)
|
|
162
|
-
controller.enqueue(chunk);
|
|
163
|
-
if (index >= buffered.length)
|
|
164
|
-
controller.close();
|
|
165
|
-
}
|
|
166
|
-
}, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
|
|
190
|
+
return bufferedSseResponse(buffered);
|
|
167
191
|
}
|
|
168
192
|
const encoded = encodeSseChunk(item.value);
|
|
169
193
|
if (isSemanticChunk(item.value)) {
|
|
@@ -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);
|
|
@@ -139,6 +139,8 @@ function applyEnvOverrides(config) {
|
|
|
139
139
|
sdk_response_timeout_ms: parseNumberEnv(env.KIRO_SDK_RESPONSE_TIMEOUT_MS, config.sdk_response_timeout_ms),
|
|
140
140
|
stream_event_timeout_enabled: parseBooleanEnv(env.KIRO_STREAM_EVENT_TIMEOUT_ENABLED, config.stream_event_timeout_enabled),
|
|
141
141
|
request_timeout_ms: parseNumberEnv(env.KIRO_REQUEST_TIMEOUT_MS, config.request_timeout_ms),
|
|
142
|
+
stream_buffer_until_complete: parseBooleanEnv(env.KIRO_STREAM_BUFFER_UNTIL_COMPLETE, config.stream_buffer_until_complete),
|
|
143
|
+
stream_max_attempts: parseNumberEnv(env.KIRO_STREAM_MAX_ATTEMPTS, config.stream_max_attempts),
|
|
142
144
|
token_expiry_buffer_ms: parseNumberEnv(env.KIRO_TOKEN_EXPIRY_BUFFER_MS, config.token_expiry_buffer_ms),
|
|
143
145
|
usage_sync_max_retries: parseNumberEnv(env.KIRO_USAGE_SYNC_MAX_RETRIES, config.usage_sync_max_retries),
|
|
144
146
|
auth_server_port_start: parseNumberEnv(env.KIRO_AUTH_SERVER_PORT_START, config.auth_server_port_start),
|
|
@@ -77,6 +77,17 @@ export declare const KiroConfigSchema: z.ZodObject<{
|
|
|
77
77
|
* Only used when stream_event_timeout_enabled is true.
|
|
78
78
|
*/
|
|
79
79
|
request_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
80
|
+
/**
|
|
81
|
+
* Consume the complete Kiro event stream before exposing any semantic output
|
|
82
|
+
* downstream. This trades live token display for safe retries after a
|
|
83
|
+
* mid-stream transport failure without duplicating content or tool calls.
|
|
84
|
+
*/
|
|
85
|
+
stream_buffer_until_complete: z.ZodDefault<z.ZodBoolean>;
|
|
86
|
+
/**
|
|
87
|
+
* Maximum number of complete event-stream attempts. Buffered mode can safely
|
|
88
|
+
* use every attempt even when the failed upstream stream produced output.
|
|
89
|
+
*/
|
|
90
|
+
stream_max_attempts: z.ZodDefault<z.ZodNumber>;
|
|
80
91
|
token_expiry_buffer_ms: z.ZodDefault<z.ZodNumber>;
|
|
81
92
|
/**
|
|
82
93
|
* Opt-in leader-elected keep-alive that proactively rotates idle-account
|
|
@@ -129,6 +140,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
|
|
|
129
140
|
sdk_response_timeout_ms: number;
|
|
130
141
|
stream_event_timeout_enabled: boolean;
|
|
131
142
|
request_timeout_ms: number;
|
|
143
|
+
stream_buffer_until_complete: boolean;
|
|
144
|
+
stream_max_attempts: number;
|
|
132
145
|
token_expiry_buffer_ms: number;
|
|
133
146
|
token_keepalive_enabled: boolean;
|
|
134
147
|
token_keepalive_interval_ms: number;
|
|
@@ -165,6 +178,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
|
|
|
165
178
|
sdk_response_timeout_ms?: number | undefined;
|
|
166
179
|
stream_event_timeout_enabled?: boolean | undefined;
|
|
167
180
|
request_timeout_ms?: number | undefined;
|
|
181
|
+
stream_buffer_until_complete?: boolean | undefined;
|
|
182
|
+
stream_max_attempts?: number | undefined;
|
|
168
183
|
token_expiry_buffer_ms?: number | undefined;
|
|
169
184
|
token_keepalive_enabled?: boolean | undefined;
|
|
170
185
|
token_keepalive_interval_ms?: number | undefined;
|
|
@@ -109,6 +109,17 @@ export const KiroConfigSchema = z.object({
|
|
|
109
109
|
* Only used when stream_event_timeout_enabled is true.
|
|
110
110
|
*/
|
|
111
111
|
request_timeout_ms: z.number().min(30000).max(600000).default(120000),
|
|
112
|
+
/**
|
|
113
|
+
* Consume the complete Kiro event stream before exposing any semantic output
|
|
114
|
+
* downstream. This trades live token display for safe retries after a
|
|
115
|
+
* mid-stream transport failure without duplicating content or tool calls.
|
|
116
|
+
*/
|
|
117
|
+
stream_buffer_until_complete: z.boolean().default(false),
|
|
118
|
+
/**
|
|
119
|
+
* Maximum number of complete event-stream attempts. Buffered mode can safely
|
|
120
|
+
* use every attempt even when the failed upstream stream produced output.
|
|
121
|
+
*/
|
|
122
|
+
stream_max_attempts: z.number().int().min(1).max(10).default(3),
|
|
112
123
|
token_expiry_buffer_ms: z.number().min(30000).max(300000).default(300000),
|
|
113
124
|
/**
|
|
114
125
|
* Opt-in leader-elected keep-alive that proactively rotates idle-account
|
|
@@ -162,6 +173,8 @@ export const DEFAULT_CONFIG = {
|
|
|
162
173
|
sdk_response_timeout_ms: 300000,
|
|
163
174
|
stream_event_timeout_enabled: false,
|
|
164
175
|
request_timeout_ms: 120000,
|
|
176
|
+
stream_buffer_until_complete: false,
|
|
177
|
+
stream_max_attempts: 3,
|
|
165
178
|
token_expiry_buffer_ms: 300000,
|
|
166
179
|
token_keepalive_enabled: false,
|
|
167
180
|
token_keepalive_interval_ms: 600000,
|
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,14 +5,6 @@ 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
10
|
retries: 0,
|
|
@@ -31,8 +23,14 @@ const SYNC_LOCK_DEADLINE_MS = 10000;
|
|
|
31
23
|
function blockingBackoff(ms) {
|
|
32
24
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
33
25
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
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';
|
|
36
34
|
}
|
|
37
35
|
function asyncBackoff(attempt, remainingMs, minBackoffMs, maxBackoffMs) {
|
|
38
36
|
const ceiling = Math.min(minBackoffMs * 2 ** Math.min(attempt, 4), maxBackoffMs, remainingMs);
|
|
@@ -40,23 +38,6 @@ function asyncBackoff(attempt, remainingMs, minBackoffMs, maxBackoffMs) {
|
|
|
40
38
|
const delay = floor + Math.floor(Math.random() * (ceiling - floor + 1));
|
|
41
39
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
42
40
|
}
|
|
43
|
-
async function acquireDatabaseLock(dbPath) {
|
|
44
|
-
// A deadline avoids fixed retry-count starvation; jitter keeps contenders
|
|
45
|
-
// from repeatedly attempting the atomic mkdir in lockstep.
|
|
46
|
-
const deadline = Date.now() + DATABASE_LOCK_DEADLINE_MS;
|
|
47
|
-
let attempt = 0;
|
|
48
|
-
for (;;) {
|
|
49
|
-
try {
|
|
50
|
-
return await lockfile.lock(dbPath, DATABASE_LOCK_OPTIONS);
|
|
51
|
-
}
|
|
52
|
-
catch (e) {
|
|
53
|
-
const remainingMs = deadline - Date.now();
|
|
54
|
-
if (!isLockContention(e) || remainingMs <= 0)
|
|
55
|
-
throw e;
|
|
56
|
-
await asyncBackoff(attempt++, remainingMs, DATABASE_LOCK_MIN_BACKOFF_MS, DATABASE_LOCK_MAX_BACKOFF_MS);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
41
|
async function acquireRefreshLock(lockPath) {
|
|
61
42
|
// A deadline avoids fixed retry-count starvation; jitter keeps contenders
|
|
62
43
|
// from repeatedly attempting the atomic mkdir in lockstep.
|
|
@@ -68,7 +49,7 @@ async function acquireRefreshLock(lockPath) {
|
|
|
68
49
|
}
|
|
69
50
|
catch (e) {
|
|
70
51
|
const remainingMs = deadline - Date.now();
|
|
71
|
-
if (!
|
|
52
|
+
if (!isRetryableLockAcquisitionError(e) || remainingMs <= 0)
|
|
72
53
|
throw e;
|
|
73
54
|
await asyncBackoff(attempt++, remainingMs, REFRESH_LOCK_MIN_BACKOFF_MS, REFRESH_LOCK_MAX_BACKOFF_MS);
|
|
74
55
|
}
|
|
@@ -90,7 +71,7 @@ export function withDatabaseLockSync(dbPath, fn) {
|
|
|
90
71
|
break;
|
|
91
72
|
}
|
|
92
73
|
catch (e) {
|
|
93
|
-
if (!
|
|
74
|
+
if (!isRetryableLockAcquisitionError(e) || Date.now() >= deadline)
|
|
94
75
|
throw e;
|
|
95
76
|
blockingBackoff(Math.min(100 * 2 ** attempt++, 500));
|
|
96
77
|
}
|
|
@@ -107,27 +88,6 @@ export function withDatabaseLockSync(dbPath, fn) {
|
|
|
107
88
|
}
|
|
108
89
|
}
|
|
109
90
|
}
|
|
110
|
-
export async function withDatabaseLock(dbPath, fn) {
|
|
111
|
-
if (!existsSync(dbPath)) {
|
|
112
|
-
await fs.mkdir(dirname(dbPath), { recursive: true });
|
|
113
|
-
await fs.writeFile(dbPath, '');
|
|
114
|
-
}
|
|
115
|
-
let release = null;
|
|
116
|
-
try {
|
|
117
|
-
release = await acquireDatabaseLock(dbPath);
|
|
118
|
-
return await fn();
|
|
119
|
-
}
|
|
120
|
-
finally {
|
|
121
|
-
if (release) {
|
|
122
|
-
try {
|
|
123
|
-
await release();
|
|
124
|
-
}
|
|
125
|
-
catch (e) {
|
|
126
|
-
console.warn('Failed to release lock:', e);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
91
|
export async function withRefreshLock(accountId, fn) {
|
|
132
92
|
const lockPath = getRefreshLockPath(accountId);
|
|
133
93
|
if (!existsSync(lockPath)) {
|
|
@@ -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
|
}
|