dsh-keys-manager 0.2.2 → 0.2.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/CHANGELOG.md +83 -19
- package/lib/client/styles.d.ts +1 -1
- package/lib/client/ui.d.ts +14 -0
- package/lib/client.js +141 -31
- package/lib/domain.d.ts +8 -0
- package/lib/index.d.ts +108 -22
- package/lib/index.js +774 -113
- package/lib/index.js.map +1 -1
- package/locale/en.json +12 -2
- package/locale/zh.json +12 -2
- package/package.json +2 -2
- package/scripts/prepare-release.mjs +318 -128
package/lib/index.js
CHANGED
|
@@ -844,12 +844,15 @@ export class KeysManagerService {
|
|
|
844
844
|
* order is preserved so the older observation can never commit after the
|
|
845
845
|
* newer one and leave the row on a stale generation.
|
|
846
846
|
*
|
|
847
|
-
* Round-8 finding I-000106
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
847
|
+
* Round-8 finding I-000106 assumed activation would join this chain. It does
|
|
848
|
+
* not: activation runs its compare-and-write inside the mutation queue turn,
|
|
849
|
+
* and the only writer of `row.oauth.grantRef` is {@link reconcileGrantRecord},
|
|
850
|
+
* which runs outside that queue. The fence is therefore a generation re-check
|
|
851
|
+
* on both sides of the write — before it (the row must still name the
|
|
852
|
+
* generation that was read) and after it (a rotation commit can land inside
|
|
853
|
+
* the seam's compare-and-write await, which only reads the projection key).
|
|
854
|
+
* Chain members are the echo reconciler, the key-test borrow and adoption
|
|
855
|
+
* classification.
|
|
853
856
|
*/
|
|
854
857
|
grantReconciliations = new Map();
|
|
855
858
|
/**
|
|
@@ -944,6 +947,20 @@ export class KeysManagerService {
|
|
|
944
947
|
credentialsMounted = false;
|
|
945
948
|
/** Providers whose bounded-wait warning already fired (warn once). */
|
|
946
949
|
hydrationWaitWarned = new Set();
|
|
950
|
+
/**
|
|
951
|
+
* Stream attempts each provider served without an attributed account
|
|
952
|
+
* (`I-000120`, D8): the ownership read had not landed, or it had failed.
|
|
953
|
+
* Counted at the one managed serving site (`wrapStream`), never in the
|
|
954
|
+
* hydration pass — a failed read serves no request, it only makes later
|
|
955
|
+
* attempts unattributed. Two earlier branches are outside this definition and
|
|
956
|
+
* stay uncounted: a provider this manager does not manage (no ownership is
|
|
957
|
+
* ever asked for) and the `mismatch` profile shape, which hands the request
|
|
958
|
+
* to pi-ai untouched precisely because this manager's own binding cannot
|
|
959
|
+
* describe what serves it. Cleared only when a later read decides the route's
|
|
960
|
+
* attribution; process-local, never persisted and never on the warnings
|
|
961
|
+
* channel.
|
|
962
|
+
*/
|
|
963
|
+
unattributedRequests = new Map();
|
|
947
964
|
/** Channels whose lost-subscription warning already fired (warn once). */
|
|
948
965
|
subscriptionLostWarnings = new Set();
|
|
949
966
|
/** Disposer for the `authorization/settled` subscription. */
|
|
@@ -1011,7 +1028,13 @@ export class KeysManagerService {
|
|
|
1011
1028
|
}
|
|
1012
1029
|
return (async () => {
|
|
1013
1030
|
try {
|
|
1014
|
-
|
|
1031
|
+
// allSettled, never all: under `all`, a rejection from any single
|
|
1032
|
+
// provider task settles this awaited pass at once, so the finally below
|
|
1033
|
+
// flips `hydrationSettled` while the other providers' reads are still
|
|
1034
|
+
// in flight and `awaitProjectionOwnership` calls them ready. No task
|
|
1035
|
+
// body can reject today (the read error is caught inside it), so this
|
|
1036
|
+
// is a structural guard — do not flip it back for style.
|
|
1037
|
+
await Promise.allSettled(Object.values(this.store.providers).map(async (providerRecord) => {
|
|
1015
1038
|
const provider = providerRecord.provider;
|
|
1016
1039
|
const accounts = providerRecord.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null);
|
|
1017
1040
|
try {
|
|
@@ -1071,8 +1094,13 @@ export class KeysManagerService {
|
|
|
1071
1094
|
// attributable now and must not wait behind another provider's
|
|
1072
1095
|
// stalled read (round-10 should-fix SF1). A superseded pass does
|
|
1073
1096
|
// not report readiness for a store it no longer describes.
|
|
1074
|
-
if (this.hydrationEpoch === epoch)
|
|
1097
|
+
if (this.hydrationEpoch === epoch) {
|
|
1098
|
+
// A landed read decided this route's attribution; a failed one
|
|
1099
|
+
// decided nothing and never clears the count (I-000120).
|
|
1100
|
+
if (!this.ownershipReadFailures.has(provider))
|
|
1101
|
+
this.unattributedRequests.delete(provider);
|
|
1075
1102
|
this.noteProviderHydrated(provider);
|
|
1103
|
+
}
|
|
1076
1104
|
}
|
|
1077
1105
|
}));
|
|
1078
1106
|
}
|
|
@@ -1129,10 +1157,24 @@ export class KeysManagerService {
|
|
|
1129
1157
|
* repair was skipped while that shape was unknown.
|
|
1130
1158
|
*/
|
|
1131
1159
|
noteProjectionReadSucceeded(provider) {
|
|
1132
|
-
|
|
1133
|
-
|
|
1160
|
+
// This read decided the route's attribution, so a count of requests served
|
|
1161
|
+
// without one no longer describes it (I-000120).
|
|
1162
|
+
this.unattributedRequests.delete(provider);
|
|
1163
|
+
const recovering = this.ownershipReadFailures.delete(provider);
|
|
1164
|
+
// Readiness is decided by the read, not by whether it was recovering: a
|
|
1165
|
+
// `record-updated` reconcile can answer the projection while startup
|
|
1166
|
+
// hydration is still pending for another provider, and the old early return
|
|
1167
|
+
// left this one out of `hydratedProviders` — waits kept timing out, routing
|
|
1168
|
+
// stayed skipped, and the count this read had just cleared started growing
|
|
1169
|
+
// again although attribution was already decided. `noteProviderHydrated` is
|
|
1170
|
+
// idempotent (a set add plus waking its waiters).
|
|
1134
1171
|
this.noteProviderHydrated(provider);
|
|
1135
|
-
|
|
1172
|
+
// Only recovery re-derives the binding: the normal call arrives from a
|
|
1173
|
+
// reconciliation that already owns that re-derive behind it, and the
|
|
1174
|
+
// startup repair is what a failed read skipped.
|
|
1175
|
+
if (recovering) {
|
|
1176
|
+
void this.scheduleGrantReconciliation(provider, () => this.syncBindingAfterProjectionChange(provider));
|
|
1177
|
+
}
|
|
1136
1178
|
}
|
|
1137
1179
|
/** Release every provider's readiness wait when hydration cannot proceed. */
|
|
1138
1180
|
settleHydrationWaits() {
|
|
@@ -1184,6 +1226,9 @@ export class KeysManagerService {
|
|
|
1184
1226
|
timer = setTimeout(() => {
|
|
1185
1227
|
wake(false);
|
|
1186
1228
|
}, ms);
|
|
1229
|
+
// I-000116: a request waiting on hydration must not hold the process
|
|
1230
|
+
// open for the rest of the wait; every exit path still clears it.
|
|
1231
|
+
timer.unref?.();
|
|
1187
1232
|
live.add(onLanded);
|
|
1188
1233
|
});
|
|
1189
1234
|
}
|
|
@@ -1270,6 +1315,16 @@ export class KeysManagerService {
|
|
|
1270
1315
|
this.ownershipGenerations.set(provider, generation);
|
|
1271
1316
|
return generation;
|
|
1272
1317
|
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Whether a claim landed after `generation` was captured. A reconciliation
|
|
1320
|
+
* reads its snapshot before its awaited record read and decides after it, so
|
|
1321
|
+
* an activation (or login) that claimed the route in between owns the answer
|
|
1322
|
+
* now: the stale body must not clear the claim, mark the route foreign, or
|
|
1323
|
+
* re-point the binding. The hydration write uses the same guard.
|
|
1324
|
+
*/
|
|
1325
|
+
ownershipSuperseded(provider, generation) {
|
|
1326
|
+
return (this.ownershipGenerations.get(provider) ?? 0) !== generation;
|
|
1327
|
+
}
|
|
1273
1328
|
/**
|
|
1274
1329
|
* Claim the projection owner for one account row, bumping the provider's
|
|
1275
1330
|
* ownership generation so an in-flight hydration read of an older state
|
|
@@ -1279,6 +1334,8 @@ export class KeysManagerService {
|
|
|
1279
1334
|
this.activeAccounts.set(provider, accountId);
|
|
1280
1335
|
this.foreignProjections.delete(provider);
|
|
1281
1336
|
this.noteProjectionCommitted(provider);
|
|
1337
|
+
// The route has an owner now, so requests are attributed from here on.
|
|
1338
|
+
this.unattributedRequests.delete(provider);
|
|
1282
1339
|
}
|
|
1283
1340
|
/**
|
|
1284
1341
|
* Log without ever echoing secret material. */
|
|
@@ -1307,17 +1364,32 @@ export class KeysManagerService {
|
|
|
1307
1364
|
}
|
|
1308
1365
|
}
|
|
1309
1366
|
/** Serialize mutations, rejecting submissions that arrive during shutdown. */
|
|
1310
|
-
run(operation) {
|
|
1367
|
+
run(operation, onFailure) {
|
|
1311
1368
|
if (this.closing)
|
|
1312
1369
|
return Promise.reject(new Error('Keys Manager is shutting down'));
|
|
1313
1370
|
const guarded = async () => {
|
|
1314
1371
|
if (this.store.readOnly) {
|
|
1315
1372
|
throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
|
|
1316
1373
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1374
|
+
try {
|
|
1375
|
+
const result = await operation();
|
|
1376
|
+
// A mutation is not successful until its state document is durable.
|
|
1377
|
+
await this.store.flush();
|
|
1378
|
+
return result;
|
|
1379
|
+
}
|
|
1380
|
+
catch (error) {
|
|
1381
|
+
// Failure cleanup belongs to THIS turn's exclusive window, not a later
|
|
1382
|
+
// one: a queued mutation (a removal, say) would otherwise run first and
|
|
1383
|
+
// destroy the state the undo needs to restore. `guarded` resolves only
|
|
1384
|
+
// after the hook, so everything already submitted behind this turn sees
|
|
1385
|
+
// the compensated state. Both awaits are best-effort — the caller must
|
|
1386
|
+
// receive the original error, not the cleanup's.
|
|
1387
|
+
if (onFailure !== undefined) {
|
|
1388
|
+
await onFailure(error).catch(() => undefined);
|
|
1389
|
+
await this.store.flush().catch(() => undefined);
|
|
1390
|
+
}
|
|
1391
|
+
throw error;
|
|
1392
|
+
}
|
|
1321
1393
|
};
|
|
1322
1394
|
const next = this.queue.then(guarded, guarded);
|
|
1323
1395
|
this.queue = next.catch(() => { });
|
|
@@ -2872,7 +2944,7 @@ export class KeysManagerService {
|
|
|
2872
2944
|
// can already be superseded. The newer claim owns the route and settled
|
|
2873
2945
|
// the binding itself: this stale removal decides nothing — no owner
|
|
2874
2946
|
// delete, no rebind — it only consumes its own one-shot removal marker.
|
|
2875
|
-
if (
|
|
2947
|
+
if (this.ownershipSuperseded(provider, generation)) {
|
|
2876
2948
|
this.intentionalProjectionRemovals.delete(provider);
|
|
2877
2949
|
return;
|
|
2878
2950
|
}
|
|
@@ -2928,6 +3000,11 @@ export class KeysManagerService {
|
|
|
2928
3000
|
// reference over it. With no OAuth row the unowned-grant branch below
|
|
2929
3001
|
// classifies a valid grant the same way.
|
|
2930
3002
|
if (record.kind === 'api-key') {
|
|
3003
|
+
// A newer claim (an activation that wrote and claimed after this body
|
|
3004
|
+
// captured its snapshot) owns the route now; this stale classification
|
|
3005
|
+
// must not clear it.
|
|
3006
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3007
|
+
return;
|
|
2931
3008
|
// Native api-key management took the route over: the manager no
|
|
2932
3009
|
// longer owns any projection here.
|
|
2933
3010
|
this.activeAccounts.delete(provider);
|
|
@@ -2939,11 +3016,15 @@ export class KeysManagerService {
|
|
|
2939
3016
|
this.noteProjectionCommitted(provider);
|
|
2940
3017
|
this.pushWarning(`The ${provider} route now holds a natively managed API-key credential; the Keys Manager account pool is not serving it.`);
|
|
2941
3018
|
// pi-ai resolves the broker reference before this record, so a route
|
|
2942
|
-
// left bound would keep serving the pool (PR review round 4).
|
|
3019
|
+
// left bound would keep serving the pool (PR review round 4). The sync
|
|
3020
|
+
// stamps the generation it entered with and re-checks before its own
|
|
3021
|
+
// write, so this body's own bump cannot strand it.
|
|
2943
3022
|
await this.syncBindingAfterProjectionChange(provider);
|
|
2944
3023
|
return;
|
|
2945
3024
|
}
|
|
2946
3025
|
if (!isOAuthGrantPayload(record.payload)) {
|
|
3026
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3027
|
+
return;
|
|
2947
3028
|
// An unrecognized payload is not ours to interpret: drop the stale
|
|
2948
3029
|
// owner entry so failure attribution and demotion never fire on it.
|
|
2949
3030
|
this.activeAccounts.delete(provider);
|
|
@@ -2961,6 +3042,8 @@ export class KeysManagerService {
|
|
|
2961
3042
|
// applies, so a restarted process and the live reconciler agree.
|
|
2962
3043
|
const owner = matchOwningAccount(provider, accounts, payload);
|
|
2963
3044
|
if (owner === undefined) {
|
|
3045
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3046
|
+
return;
|
|
2964
3047
|
// A foreign account's grant serves the route: no pool row owns the
|
|
2965
3048
|
// projection, so the previous owner entry must not survive it — and the
|
|
2966
3049
|
// live record keeps the pool from claiming the route (round 3).
|
|
@@ -2981,6 +3064,11 @@ export class KeysManagerService {
|
|
|
2981
3064
|
// is re-adopted below. Only a removal in progress is refused.
|
|
2982
3065
|
if (!this.accountRowIsManaged(provider, owner))
|
|
2983
3066
|
return;
|
|
3067
|
+
// A newer claim landed while this body was resolving its read: the
|
|
3068
|
+
// rotation write, the row commit and the binding sync below all describe a
|
|
3069
|
+
// generation that claim already superseded.
|
|
3070
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3071
|
+
return;
|
|
2984
3072
|
// F1 (I-000081): the matched grant IS the live projection, so this row
|
|
2985
3073
|
// owns it from now on — even when no in-process login or activation
|
|
2986
3074
|
// ran (the post-restart echo-rotation case: a hydrated row, no owner
|
|
@@ -2992,30 +3080,29 @@ export class KeysManagerService {
|
|
|
2992
3080
|
// the stale echo claim is discarded exactly like a stale hydration
|
|
2993
3081
|
// read (the rotation sync below still runs, but a commit that a newer
|
|
2994
3082
|
// claim has superseded is rejected — round-9 P1).
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
}
|
|
3083
|
+
// Unconditional: the guard above already returned when a newer claim
|
|
3084
|
+
// landed, and nothing between the two awaits.
|
|
3085
|
+
this.claimProjectionOwner(provider, owner.id);
|
|
3086
|
+
const claimedGeneration = this.ownershipGenerations.get(provider);
|
|
3087
|
+
// R7-SF3: the live projection proves this row's identity again, so a
|
|
3088
|
+
// demoted row is re-adopted — restore its owner claim (above) and end
|
|
3089
|
+
// the demoted state. `valueMissing` means "not serving but still
|
|
3090
|
+
// managed", not "removal in progress": a native sign-in or pi-ai
|
|
3091
|
+
// write that re-projects the grant recovers the account without a
|
|
3092
|
+
// manual re-activation. The flag is persisted with the rest of the
|
|
3093
|
+
// state document.
|
|
3094
|
+
if (owner.valueMissing === true) {
|
|
3095
|
+
owner.valueMissing = false;
|
|
3096
|
+
// R14-SF3: a demotion is earned by a failure, so the row usually
|
|
3097
|
+
// still carries the cooldown `markFailure` wrote. Re-adoption is the
|
|
3098
|
+
// same manual-recovery equivalence `activateAccount` and re-auth
|
|
3099
|
+
// implement: the live projection is serving again, so the old
|
|
3100
|
+
// window ends with it — otherwise the resurrected row renders
|
|
3101
|
+
// "cooling" and stays out of the healthy counts until the timer
|
|
3102
|
+
// expires. Only this proven-adoption branch clears; a row whose
|
|
3103
|
+
// projection did not reappear keeps its window.
|
|
3104
|
+
owner.cooldownUntil = 0;
|
|
3105
|
+
this.store.touch();
|
|
3019
3106
|
}
|
|
3020
3107
|
// Rotation sync: the rotated payload is authoritative (the plan's
|
|
3021
3108
|
// single-writer contract — "sync them back to the owning plugin-scope
|
|
@@ -3106,7 +3193,12 @@ export class KeysManagerService {
|
|
|
3106
3193
|
// shutdown that began under any of the awaits above refuses it.
|
|
3107
3194
|
if (this.persistenceRefused())
|
|
3108
3195
|
return;
|
|
3109
|
-
|
|
3196
|
+
// The commit is the row repoint plus the superseded record's delete, both
|
|
3197
|
+
// after the awaits above (most recently `rotationStillCurrent`'s own record
|
|
3198
|
+
// read). A claim that landed in that read owns the route now, so the commit
|
|
3199
|
+
// refuses like every other mutation in this body.
|
|
3200
|
+
const commitGeneration = claimedGeneration ?? generation;
|
|
3201
|
+
await this.commitGrantGeneration(provider, owner, payload, rotatedRef, () => !this.ownershipSuperseded(provider, commitGeneration));
|
|
3110
3202
|
// PR review round 44: the projection now serves this managed account again,
|
|
3111
3203
|
// so a profile that still names the broker would make pi-ai resolve the
|
|
3112
3204
|
// pool first and fail with MISSING_CREDENTIAL. Converge the binding with the
|
|
@@ -3227,66 +3319,543 @@ export class KeysManagerService {
|
|
|
3227
3319
|
* Make one account's grant payload the route's stored credential
|
|
3228
3320
|
* (D1 activation projection).
|
|
3229
3321
|
*
|
|
3230
|
-
*
|
|
3231
|
-
*
|
|
3232
|
-
*
|
|
3233
|
-
*
|
|
3234
|
-
*
|
|
3235
|
-
*
|
|
3236
|
-
*
|
|
3237
|
-
*
|
|
3238
|
-
*
|
|
3239
|
-
*
|
|
3240
|
-
*
|
|
3322
|
+
* Plan iter-pool-activate-queue, "Spike outcome" — the contract this method
|
|
3323
|
+
* implements. The slow half (read the row's grant, validate it, capture what
|
|
3324
|
+
* the route serves now) runs outside the mutation queue, so a slow credential
|
|
3325
|
+
* store does not delay unrelated mutations. The mutation queue turn owns every
|
|
3326
|
+
* write:
|
|
3327
|
+
*
|
|
3328
|
+
* 1. admission checks in queue order, which produce this method's error
|
|
3329
|
+
* surface (`Provider "…" is not managed`, `Account "…" not found on …`,
|
|
3330
|
+
* `Key "…" is not an OAuth account`, `The credential store does not support
|
|
3331
|
+
* records`) — plus a re-check that the row still names the generation the
|
|
3332
|
+
* read used;
|
|
3333
|
+
* 2. the projection write as a compare-and-write under the seam's per-key
|
|
3334
|
+
* lock (credentials/src/index.ts:236-250): it lands only while the live
|
|
3335
|
+
* record still equals the captured baseline, "both absent" included. A
|
|
3336
|
+
* rotation, a removal or any foreign writer that replaced the projection
|
|
3337
|
+
* meanwhile makes the compare fail, so this call refuses with the retryable
|
|
3338
|
+
* `could not be projected` message and leaves the newer projection
|
|
3339
|
+
* untouched — there is nothing to delete, because nothing was written;
|
|
3340
|
+
* 3. the owner claim, the row writes and the returned `buildState`.
|
|
3341
|
+
*
|
|
3342
|
+
* There is deliberately no shutdown check after the write: `close()` awaits
|
|
3343
|
+
* this queue before it closes the store, so a turn that started in time always
|
|
3344
|
+
* finishes (measured in the spike that produced this contract). A check there
|
|
3345
|
+
* is what used to leave an orphan projection behind.
|
|
3241
3346
|
*
|
|
3242
|
-
* The
|
|
3243
|
-
*
|
|
3244
|
-
* The account whose payload lands becomes the pool's active account and is
|
|
3245
|
-
* cascade-selectable again.
|
|
3347
|
+
* The one write the turn cannot make durable by itself is a rejected state
|
|
3348
|
+
* flush; see {@link compensateActivationFlush} for that compensation.
|
|
3246
3349
|
*/
|
|
3247
|
-
activateAccount(provider, accountId) {
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3350
|
+
async activateAccount(provider, accountId) {
|
|
3351
|
+
const records = this.credentialRecords();
|
|
3352
|
+
// Locator only: it keeps the row's grantRef reachable for the read below.
|
|
3353
|
+
// The turn re-reads the store and decides; this lookup decides nothing.
|
|
3354
|
+
const located = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
|
|
3355
|
+
// The refusals `run` applies (1683, 1685-1687) are hoisted ahead of the
|
|
3356
|
+
// credential read: a refused call should not pay for it at all. They are
|
|
3357
|
+
// not the guarantee — the turn's own admission checks are.
|
|
3358
|
+
if (this.closing)
|
|
3359
|
+
throw new Error('Keys Manager is shutting down');
|
|
3360
|
+
if (this.store.readOnly) {
|
|
3361
|
+
throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
|
|
3362
|
+
}
|
|
3363
|
+
// The slow half, outside the queue: read the grant this activation would
|
|
3364
|
+
// project and capture what the route serves now as the compare baseline.
|
|
3365
|
+
// Nothing here decides — admission order belongs to the turn.
|
|
3366
|
+
const prepared = located !== undefined
|
|
3367
|
+
&& located.kind === 'oauth'
|
|
3368
|
+
&& located.oauth !== null
|
|
3369
|
+
&& records !== undefined
|
|
3370
|
+
? await this.prepareActivation(provider, located, records)
|
|
3371
|
+
: undefined;
|
|
3372
|
+
if (prepared !== undefined && records !== undefined) {
|
|
3373
|
+
// Same-`grantRef` rotation: pi-ai can refresh the ACCESS token while
|
|
3374
|
+
// keeping the refresh token, so the reference never moves, both `grantRef`
|
|
3375
|
+
// fences pass, and the projection can already carry the fresh payload
|
|
3376
|
+
// while the plugin-scope record still holds the stale one. Projecting
|
|
3377
|
+
// `prepared.payload` then writes an older credential over a fresher
|
|
3378
|
+
// projection. Re-read the source as late as possible and refuse unless it
|
|
3379
|
+
// still says what this activation prepared.
|
|
3380
|
+
//
|
|
3381
|
+
// This read sits OUTSIDE the queue, exactly where `prepareActivation` does
|
|
3382
|
+
// its credential work: a read inside the turn would put credential I/O
|
|
3383
|
+
// back on the global mutation queue, which is the cross-provider stall —
|
|
3384
|
+
// and the shutdown wait — the activation split exists to remove.
|
|
3385
|
+
//
|
|
3386
|
+
// Residual, named: reading outside the turn only narrows the same-ref
|
|
3387
|
+
// rotation window to this activation's QUEUE WAIT instead of closing it.
|
|
3388
|
+
// Closing it fully needs a seam that lets one read and one write share a
|
|
3389
|
+
// single lock, which `CredentialRecordsLike` (host-types.ts:136-143) does
|
|
3390
|
+
// not promise. What still covers the rest: the in-lock baseline compare on
|
|
3391
|
+
// the projection side, and the post-write `grantRef` recheck for reference
|
|
3392
|
+
// changes. A same-ref rotation that lands while the activation waits in
|
|
3393
|
+
// the queue is the uncovered case.
|
|
3394
|
+
const source = await records.readRecord(prepared.grantRef);
|
|
3395
|
+
if (source === undefined
|
|
3396
|
+
|| source.kind !== 'grant'
|
|
3397
|
+
|| JSON.stringify(source.payload) !== prepared.written) {
|
|
3398
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
let undo;
|
|
3402
|
+
try {
|
|
3403
|
+
await this.run(async () => {
|
|
3404
|
+
const providerRecord = this.store.provider(provider);
|
|
3405
|
+
if (providerRecord === undefined)
|
|
3406
|
+
throw new Error(`Provider "${provider}" is not managed`);
|
|
3407
|
+
const row = providerRecord.keys.find(candidate => candidate.id === accountId);
|
|
3408
|
+
if (row === undefined)
|
|
3409
|
+
throw new Error(`Account "${accountId}" not found on ${provider}`);
|
|
3410
|
+
if (row.kind !== 'oauth' || row.oauth === null)
|
|
3411
|
+
throw new Error(`Key "${accountId}" is not an OAuth account`);
|
|
3412
|
+
if (records === undefined) {
|
|
3413
|
+
throw new Error('The credential store does not support records');
|
|
3267
3414
|
}
|
|
3268
|
-
|
|
3269
|
-
|
|
3415
|
+
if (prepared === undefined) {
|
|
3416
|
+
// The locator only hints; the turn decides. A row that was not an
|
|
3417
|
+
// OAuth account when it was located (created, re-added, or repointed
|
|
3418
|
+
// in between) has nothing prepared for it — that is a stale locator,
|
|
3419
|
+
// not a store without record support.
|
|
3420
|
+
throw new Error(`The account "${accountId}" on ${provider} changed before it could be activated; activate it again`);
|
|
3421
|
+
}
|
|
3422
|
+
// A remount replaced the credential backend while the read ran; the
|
|
3423
|
+
// payload came from records this manager no longer serves.
|
|
3424
|
+
//
|
|
3425
|
+
// The row must still name the generation this activation read. A
|
|
3426
|
+
// rotation echo or a removal + re-add that repointed it meanwhile
|
|
3427
|
+
// belongs to a different grant, and projecting the old payload would
|
|
3428
|
+
// either overwrite a newer generation or report a success the row does
|
|
3429
|
+
// not serve. Refusing keeps the record untouched; the caller retries.
|
|
3430
|
+
if (this.credentialRecords() !== records || row.oauth.grantRef !== prepared.grantRef) {
|
|
3431
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3432
|
+
}
|
|
3433
|
+
// The projection write is a compare-and-write under the seam's per-key
|
|
3434
|
+
// lock: it lands only while the route still serves the baseline read
|
|
3435
|
+
// above. A removal or a foreign writer that got there first makes the
|
|
3436
|
+
// compare fail, and this activation refuses without touching the
|
|
3437
|
+
// record — there is nothing to compensate afterwards.
|
|
3438
|
+
const written = await this.compareAndWriteProjection(records, provider, prepared);
|
|
3439
|
+
if (written === undefined) {
|
|
3440
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3441
|
+
}
|
|
3442
|
+
// No post-write shutdown check: `close()` awaits this queue before it
|
|
3443
|
+
// closes the store (`1758-1762`), so a run that starts in time always
|
|
3444
|
+
// finishes before the store goes away. Refusing here after the write
|
|
3445
|
+
// is what used to manufacture an orphan.
|
|
3446
|
+
undo = { valueMissing: row.valueMissing, cooldownUntil: row.cooldownUntil, generation: 0 };
|
|
3270
3447
|
this.claimProjectionOwner(provider, row.id);
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
//
|
|
3274
|
-
|
|
3448
|
+
undo.generation = this.ownershipGenerations.get(provider) ?? 0;
|
|
3449
|
+
row.valueMissing = false;
|
|
3450
|
+
// Round-5 finding M1: manual recovery ends the cooling window too, the
|
|
3451
|
+
// same reset a successful re-authentication applies. A demoted account
|
|
3452
|
+
// is re-activated while its stale cooldown still sits on the row, which
|
|
3453
|
+
// would render the freshly projected grant as cooling and keep it out
|
|
3454
|
+
// of the healthy counts until the timer expires. Clearing the window
|
|
3455
|
+
// also resets escalation: `applyFailureCooldown` derives the next level
|
|
3456
|
+
// from `cooldownUntil` only (core.ts:168-174).
|
|
3457
|
+
row.cooldownUntil = 0;
|
|
3458
|
+
this.store.touch();
|
|
3459
|
+
// The pre-write check closed the read window, but a rotation commit can
|
|
3460
|
+
// land inside the seam's compare-and-write await: it repoints the row
|
|
3461
|
+
// (a different record key) while the projection write is in flight, and
|
|
3462
|
+
// the in-lock compare cannot see the row. Projecting then leaves the
|
|
3463
|
+
// route serving a generation the row has already superseded, so the
|
|
3464
|
+
// activation refuses and the flush compensation above undoes both — the
|
|
3465
|
+
// claim is set, so the undo path owns it.
|
|
3466
|
+
if (row.oauth.grantRef !== prepared.grantRef) {
|
|
3467
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3468
|
+
}
|
|
3469
|
+
}, async () => {
|
|
3470
|
+
// A rejected flush lands here AFTER the turn resolved its work: `run`
|
|
3471
|
+
// does not roll the mutation back, so the row is still cleared in memory
|
|
3472
|
+
// and the projection still serves the route. Undo both once, by
|
|
3473
|
+
// identity, inside THIS turn's window — a later turn would lose the race
|
|
3474
|
+
// against every mutation already queued behind the activation (a removal
|
|
3475
|
+
// of the account deletes the half-committed projection first, and the
|
|
3476
|
+
// captured baseline could then no longer be restored). Failures before
|
|
3477
|
+
// the write leave `undo` unset: nothing to undo.
|
|
3478
|
+
//
|
|
3479
|
+
// Residual (I-000090 territory): `undo` is assigned only once the write
|
|
3480
|
+
// resolved, so a `modifyRecord` that applies its mutation and THEN
|
|
3481
|
+
// rejects refuses with the raw seam error and keeps the projection. What
|
|
3482
|
+
// compensation could run is undecidable from this repo — the seam would
|
|
3483
|
+
// have to promise it never commits then rejects — so this shape is left
|
|
3484
|
+
// as the seam reports it rather than guessed at.
|
|
3485
|
+
if (undo === undefined)
|
|
3486
|
+
return;
|
|
3487
|
+
await this.compensateActivationFlush(provider, accountId, prepared, undo, records);
|
|
3275
3488
|
});
|
|
3276
|
-
|
|
3277
|
-
//
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
//
|
|
3287
|
-
//
|
|
3288
|
-
|
|
3489
|
+
// The re-activated grant is the serving credential again, so a broker
|
|
3490
|
+
// reference left by the key pool must go (see syncBrokerBinding). A
|
|
3491
|
+
// settings refusal does not roll back the committed projection.
|
|
3492
|
+
//
|
|
3493
|
+
// Both the sync and the returned snapshot run in a second queue turn:
|
|
3494
|
+
// the binding attempt must have happened before the view is built (a
|
|
3495
|
+
// refusal pushes its warning, and building the view inside the write turn
|
|
3496
|
+
// drained it, so the RPC reported a clean activation while requests kept
|
|
3497
|
+
// using the pool), and the snapshot itself is state: taken outside the
|
|
3498
|
+
// queue, another mutation could enter, mutate and be mid-flush while
|
|
3499
|
+
// `buildState` pruned and drained, exposing state that is not durable.
|
|
3500
|
+
// The turn waits for such a mutation to finish or fail first.
|
|
3501
|
+
if (this.closing) {
|
|
3502
|
+
// Shutdown opened while this activation was writing, so no new turn may
|
|
3503
|
+
// be appended: `closeOnce` captured `this.queue` when `closing` went up
|
|
3504
|
+
// and closes the store once THAT reference settles, so a turn submitted
|
|
3505
|
+
// here would run after close(). Instead, join the reference shutdown is
|
|
3506
|
+
// itself draining — mutations already queued behind this turn are still
|
|
3507
|
+
// running, and `buildState` prunes and drains, so the snapshot must come
|
|
3508
|
+
// after they settle rather than beside them.
|
|
3509
|
+
await this.queue;
|
|
3510
|
+
// The binding's last-point guard refuses the write once `closing` is
|
|
3511
|
+
// set and reports that refusal itself, so a route that needs converging
|
|
3512
|
+
// now says so in the returned state instead of reporting a clean
|
|
3513
|
+
// success; a route that is already converged needs no write at all. The
|
|
3514
|
+
// next start's repair pass owns the convergence either way.
|
|
3515
|
+
await this.syncBrokerBinding(provider);
|
|
3516
|
+
return this.buildState();
|
|
3517
|
+
}
|
|
3518
|
+
// The projection turn above already committed AND flushed, so the
|
|
3519
|
+
// activation is durable and serving. This second turn only converges the
|
|
3520
|
+
// binding and takes the snapshot; if its flush fails, that activation must
|
|
3521
|
+
// not be reported as failed — a caller would retry an activation that
|
|
3522
|
+
// already landed. `built` is the discriminator: `guarded` runs the body
|
|
3523
|
+
// before the flush, so a BODY failure leaves it unset and still
|
|
3524
|
+
// propagates (shutdown, a refusal), while a post-commit flush failure is
|
|
3525
|
+
// reported in the state the caller receives.
|
|
3526
|
+
let built;
|
|
3527
|
+
try {
|
|
3528
|
+
return await this.run(async () => {
|
|
3529
|
+
await this.syncBrokerBinding(provider);
|
|
3530
|
+
built = this.buildState();
|
|
3531
|
+
return built;
|
|
3532
|
+
});
|
|
3533
|
+
}
|
|
3534
|
+
catch (error) {
|
|
3535
|
+
if (built === undefined)
|
|
3536
|
+
throw error;
|
|
3537
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
3538
|
+
const warning = `The ${provider} activation is saved, but its binding and state snapshot could not be persisted (${reason}). The account is serving; the next successful change saves the rest.`;
|
|
3539
|
+
// One channel only: the returned snapshot. `buildState` has already
|
|
3540
|
+
// drained the warning queue, so ALSO queueing this copy would surface
|
|
3541
|
+
// the same failure twice (once in this payload, once on the next
|
|
3542
|
+
// refresh). It still reaches the operator log, which is what a caller
|
|
3543
|
+
// that ignores the returned state leaves visible.
|
|
3544
|
+
this.warn(`activation post-commit persistence failed for ${provider}: ${reason}`);
|
|
3545
|
+
// The snapshot was taken before this warning existed, and re-taking one
|
|
3546
|
+
// outside the queue is the race this turn exists to avoid, so the view
|
|
3547
|
+
// is annotated rather than rebuilt.
|
|
3548
|
+
return { ...built, warnings: [...built.warnings, warning] };
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
catch (error) {
|
|
3552
|
+
// Which path covers which case:
|
|
3553
|
+
// - `run` refuses the submission while `closing`, and `guarded` refuses a
|
|
3554
|
+
// read-only store before the operation runs: nothing was written, so
|
|
3555
|
+
// `undo` stayed unset and there is nothing to compensate.
|
|
3556
|
+
// - a failure INSIDE the turn (the flush, or the seam) runs the
|
|
3557
|
+
// compensation hook above, inside that turn's exclusive window, even
|
|
3558
|
+
// when closing began during it; that hook is the fallback the previous
|
|
3559
|
+
// independent turn used to be, and it needs no closing branch.
|
|
3560
|
+
// - the hook's own flush is best-effort: a still-unwritable store leaves
|
|
3561
|
+
// memory restored and the next successful flush persists it.
|
|
3562
|
+
throw error;
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
/**
|
|
3566
|
+
* The slow half of an activation, outside the queue: read the row's grant
|
|
3567
|
+
* generation, validate it, and capture the projection the route serves now.
|
|
3568
|
+
*/
|
|
3569
|
+
async prepareActivation(provider, row, records) {
|
|
3570
|
+
const grantRef = row.oauth.grantRef;
|
|
3571
|
+
const stored = await records.readRecord(grantRef);
|
|
3572
|
+
if (stored === undefined || stored.kind !== 'grant' || !isOAuthGrantPayload(stored.payload)) {
|
|
3573
|
+
throw new Error(`The grant payload at ${grantRef} is missing or unusable; sign in to this account again`);
|
|
3574
|
+
}
|
|
3575
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3576
|
+
const live = await records.readRecord(projectionKey);
|
|
3577
|
+
const payload = structuredClone(stored.payload);
|
|
3578
|
+
// F5: the restored record decides its owner, so the classification is
|
|
3579
|
+
// derived from the baseline payload through the shared matcher — the same
|
|
3580
|
+
// rule the reconciler applies — rather than from an owner id captured
|
|
3581
|
+
// earlier that the record may no longer belong to.
|
|
3582
|
+
const accounts = this.store.provider(provider)?.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null) ?? [];
|
|
3583
|
+
const baselineOwner = live !== undefined && live.kind === 'grant' && isOAuthGrantPayload(live.payload)
|
|
3584
|
+
? matchOwningAccount(provider, accounts, live.payload)
|
|
3585
|
+
: undefined;
|
|
3586
|
+
return {
|
|
3587
|
+
grantRef,
|
|
3588
|
+
payload,
|
|
3589
|
+
written: JSON.stringify(payload),
|
|
3590
|
+
baseline: live === undefined ? { state: 'absent' } : { state: 'present', record: structuredClone(live) },
|
|
3591
|
+
// Captured with the baseline, before the turn claims the route: the
|
|
3592
|
+
// compensation has to put back the classification that matches the
|
|
3593
|
+
// record it restores, and only this snapshot still knows it. Any present
|
|
3594
|
+
// record no row's payload matches is foreign (a native api-key
|
|
3595
|
+
// credential, an unrecognized payload, an unowned grant), so `none` is
|
|
3596
|
+
// only the absent-baseline case.
|
|
3597
|
+
classification: live === undefined
|
|
3598
|
+
? { state: 'none' }
|
|
3599
|
+
: baselineOwner !== undefined ? { state: 'owned', rowId: baselineOwner.id } : { state: 'foreign' },
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
/** Whether the projection still holds the value this activation read. */
|
|
3603
|
+
matchesActivationBaseline(current, baseline) {
|
|
3604
|
+
if (baseline.state === 'absent')
|
|
3605
|
+
return current === undefined;
|
|
3606
|
+
if (current === undefined)
|
|
3607
|
+
return false;
|
|
3608
|
+
// Compare the FIELDS, never the object shape: `CredentialRecordLike` makes
|
|
3609
|
+
// `key`/`env`/`payload` optional, and a seam whose read path and
|
|
3610
|
+
// `modifyRecord` path materialise different key sets (one omits them, one
|
|
3611
|
+
// sets them to `undefined`) would defeat a structural comparison and refuse
|
|
3612
|
+
// every activation over an already-serving route.
|
|
3613
|
+
//
|
|
3614
|
+
// An api-key projection keeps its secret in `key`/`env` with `payload`
|
|
3615
|
+
// absent, so every field is compared: payload plus the native fields.
|
|
3616
|
+
const baselineRecord = baseline.record;
|
|
3617
|
+
const sameSecret = current.key === baselineRecord.key;
|
|
3618
|
+
const sameEnv = JSON.stringify(current.env ?? null) === JSON.stringify(baselineRecord.env ?? null);
|
|
3619
|
+
const samePayload = JSON.stringify(current.payload ?? null) === JSON.stringify(baselineRecord.payload ?? null);
|
|
3620
|
+
return current.kind === baselineRecord.kind && sameSecret && sameEnv && samePayload;
|
|
3621
|
+
}
|
|
3622
|
+
/**
|
|
3623
|
+
* Write the projection only while the route still serves the baseline. The
|
|
3624
|
+
* seam's `modifyRecord` mutate body runs under the per-key lock, so this is
|
|
3625
|
+
* the one atomic primitive the activation has (I-000090: `deleteRecord` has
|
|
3626
|
+
* no expected-payload form). Returns the payload written, or `undefined` when
|
|
3627
|
+
* the compare failed and nothing was touched.
|
|
3628
|
+
*/
|
|
3629
|
+
async compareAndWriteProjection(records, provider, prepared) {
|
|
3630
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3631
|
+
let landed = false;
|
|
3632
|
+
await records.modifyRecord(projectionKey, async (current) => {
|
|
3633
|
+
if (!this.matchesActivationBaseline(current, prepared.baseline))
|
|
3634
|
+
return undefined;
|
|
3635
|
+
landed = true;
|
|
3636
|
+
// Only this write supersedes a pending own-removal marker: a projection
|
|
3637
|
+
// is about to serve again, so a removal echo still in flight cannot
|
|
3638
|
+
// describe the live record. A refused compare must leave the marker
|
|
3639
|
+
// armed for the removal it belongs to — clearing it there would let that
|
|
3640
|
+
// removal's echo read as an out-of-band deletion (a spurious "removed
|
|
3641
|
+
// outside Keys Manager" warning and a `valueMissing` row).
|
|
3642
|
+
this.intentionalProjectionRemovals.delete(provider);
|
|
3643
|
+
return { kind: 'grant', payload: prepared.payload };
|
|
3289
3644
|
});
|
|
3645
|
+
return landed ? prepared.written : undefined;
|
|
3646
|
+
}
|
|
3647
|
+
/**
|
|
3648
|
+
* Undo one activation whose state flush was rejected: the row this turn
|
|
3649
|
+
* cleared in memory, the owner claim it took, and the projection it wrote
|
|
3650
|
+
* (back to the captured baseline).
|
|
3651
|
+
*
|
|
3652
|
+
* The baseline decides the mechanism. A present baseline is restored with the
|
|
3653
|
+
* same compare-and-write the projection used; only an absent baseline has to
|
|
3654
|
+
* remove a record, and only while that record is still this activation's own
|
|
3655
|
+
* payload. A foreign writer's record is never deleted.
|
|
3656
|
+
*/
|
|
3657
|
+
async compensateActivationFlush(provider, accountId, prepared, undo, records) {
|
|
3658
|
+
const outcome = prepared.baseline.state === 'absent'
|
|
3659
|
+
? await this.deleteActivationProjection(records, provider, prepared.written, undo.generation)
|
|
3660
|
+
: await this.restoreActivationProjection(records, provider, prepared, undo.generation);
|
|
3661
|
+
// Both helpers awaited, so the classification below decides after the same
|
|
3662
|
+
// read the guard above made before it: a claim that landed inside that I/O
|
|
3663
|
+
// already settled what serves the route, and overwriting it would revert a
|
|
3664
|
+
// newer owner or mark its route foreign. No await separates this check from
|
|
3665
|
+
// the writes in the switch.
|
|
3666
|
+
if (this.ownershipSuperseded(provider, undo.generation))
|
|
3667
|
+
return;
|
|
3668
|
+
// The compensation is about to replace what the route serves (or which row
|
|
3669
|
+
// owns it), so every reconciler that captured the earlier generation read a
|
|
3670
|
+
// world this undoes and must not commit: its projection read may predate the
|
|
3671
|
+
// restore, and its row/binding writes would re-claim an account the restored
|
|
3672
|
+
// baseline does not serve. `ours` keeps the activation's projection and claim
|
|
3673
|
+
// in place — it changes nothing a reconciler could misread — so it needs no
|
|
3674
|
+
// bump.
|
|
3675
|
+
if (outcome !== 'ours')
|
|
3676
|
+
this.noteProjectionCommitted(provider);
|
|
3677
|
+
// P2-B: the row rollback belongs to the outcomes that actually changed what
|
|
3678
|
+
// serves the route. `ours` keeps the activation's projection and claim — the
|
|
3679
|
+
// account IS serving — so restoring the pre-activation demotion would render
|
|
3680
|
+
// a live account as missing/cooling, and a restart without an echo would
|
|
3681
|
+
// persist that lie.
|
|
3682
|
+
if (outcome !== 'ours') {
|
|
3683
|
+
const row = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
|
|
3684
|
+
if (row !== undefined) {
|
|
3685
|
+
row.valueMissing = undo.valueMissing;
|
|
3686
|
+
row.cooldownUntil = undo.cooldownUntil;
|
|
3687
|
+
this.store.touch();
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
switch (outcome) {
|
|
3691
|
+
case 'superseded':
|
|
3692
|
+
// The newer claim that moved the generation owns the route and has
|
|
3693
|
+
// already settled what serves it; this undo decides nothing.
|
|
3694
|
+
return;
|
|
3695
|
+
case 'ours':
|
|
3696
|
+
// The activation's projection is still the live record, so its claim
|
|
3697
|
+
// stays: deleting it would call the route pool-served while a record
|
|
3698
|
+
// serves it, and selection plus failure attribution would be wrong
|
|
3699
|
+
// until an echo reconciled the route (never, if the optional
|
|
3700
|
+
// `record-updated` subscription is unavailable).
|
|
3701
|
+
return;
|
|
3702
|
+
case 'baseline':
|
|
3703
|
+
this.applyActivationClassification(provider, prepared.classification);
|
|
3704
|
+
return;
|
|
3705
|
+
case 'foreign':
|
|
3706
|
+
this.activeAccounts.delete(provider);
|
|
3707
|
+
this.foreignProjections.add(provider);
|
|
3708
|
+
return;
|
|
3709
|
+
case 'gone':
|
|
3710
|
+
// Nothing serves the route any more, so the pool does.
|
|
3711
|
+
this.activeAccounts.delete(provider);
|
|
3712
|
+
this.foreignProjections.delete(provider);
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
/** Put the route's owner classification back to a captured snapshot. */
|
|
3717
|
+
applyActivationClassification(provider, classification) {
|
|
3718
|
+
if (classification.state === 'owned') {
|
|
3719
|
+
this.activeAccounts.set(provider, classification.rowId);
|
|
3720
|
+
this.foreignProjections.delete(provider);
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
this.activeAccounts.delete(provider);
|
|
3724
|
+
if (classification.state === 'foreign')
|
|
3725
|
+
this.foreignProjections.add(provider);
|
|
3726
|
+
else
|
|
3727
|
+
this.foreignProjections.delete(provider);
|
|
3728
|
+
}
|
|
3729
|
+
/** Put the projection back to the present baseline, if it is still ours. */
|
|
3730
|
+
async restoreActivationProjection(records, provider, prepared, generation) {
|
|
3731
|
+
const baseline = prepared.baseline;
|
|
3732
|
+
if (baseline.state !== 'present')
|
|
3733
|
+
return 'baseline';
|
|
3734
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3735
|
+
let outcome = 'ours';
|
|
3736
|
+
try {
|
|
3737
|
+
await records.modifyRecord(projectionKey, async (current) => {
|
|
3738
|
+
// The generation is decided HERE, under the seam's per-key lock and in
|
|
3739
|
+
// the same synchronous step as the payload compare: a claim that lands
|
|
3740
|
+
// between this compensation's entry and this callback would otherwise
|
|
3741
|
+
// see its own projection matched and get the baseline written back over
|
|
3742
|
+
// a route it owns, while the post-I/O check then exits early and leaves
|
|
3743
|
+
// that newer account claimed beside the restored credential.
|
|
3744
|
+
if (this.ownershipSuperseded(provider, generation)) {
|
|
3745
|
+
outcome = 'superseded';
|
|
3746
|
+
return undefined;
|
|
3747
|
+
}
|
|
3748
|
+
if (current === undefined) {
|
|
3749
|
+
outcome = 'gone';
|
|
3750
|
+
return undefined;
|
|
3751
|
+
}
|
|
3752
|
+
if (current.kind !== 'grant' || JSON.stringify(current.payload) !== prepared.written) {
|
|
3753
|
+
// A record this activation did not write serves the route now.
|
|
3754
|
+
outcome = 'foreign';
|
|
3755
|
+
return undefined;
|
|
3756
|
+
}
|
|
3757
|
+
// Return the captured record itself: rebuilding it from `kind` and
|
|
3758
|
+
// `payload` would drop the `key`/`env` an api-key projection carries and
|
|
3759
|
+
// leave the route unusable after a failed flush.
|
|
3760
|
+
outcome = 'baseline';
|
|
3761
|
+
return structuredClone(baseline.record);
|
|
3762
|
+
});
|
|
3763
|
+
}
|
|
3764
|
+
catch (error) {
|
|
3765
|
+
// An unreadable or refused seam proves nothing: the activation's
|
|
3766
|
+
// projection may still be live, so `ours` keeps its claim.
|
|
3767
|
+
this.warn(`activation projection restore failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3768
|
+
return 'ours';
|
|
3769
|
+
}
|
|
3770
|
+
return outcome;
|
|
3771
|
+
}
|
|
3772
|
+
/**
|
|
3773
|
+
* The delete authority's proof for an activation's own projection, as an
|
|
3774
|
+
* explicit three-valued answer (QC R2): only `matches` may authorize touching
|
|
3775
|
+
* the record. `unknown` is a store failure — a closed seam, an unreadable
|
|
3776
|
+
* record — and proves nothing, so it is never permission.
|
|
3777
|
+
*/
|
|
3778
|
+
async activationProjectionProof(records, provider, written, generation) {
|
|
3779
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3780
|
+
try {
|
|
3781
|
+
// Absent and "another writer's record" are different answers: absent
|
|
3782
|
+
// means nothing serves the route any more (our own demotion can remove
|
|
3783
|
+
// the projection without superseding the claim), while `differs` means a
|
|
3784
|
+
// replacement does. Folding them together marked a serviceless route
|
|
3785
|
+
// foreign, which makes `keyPoolServes` false and leaves no usable
|
|
3786
|
+
// credential at all.
|
|
3787
|
+
let state = 'absent';
|
|
3788
|
+
await records.modifyRecord(projectionKey, async (current) => {
|
|
3789
|
+
// The claim is decided under the same lock as the identity compare, for
|
|
3790
|
+
// the same reason as the restore: a newer owner must not have its
|
|
3791
|
+
// projection deleted by an undo that only checks the generation later.
|
|
3792
|
+
if (this.ownershipSuperseded(provider, generation)) {
|
|
3793
|
+
state = 'superseded';
|
|
3794
|
+
return undefined;
|
|
3795
|
+
}
|
|
3796
|
+
state = current === undefined
|
|
3797
|
+
? 'absent'
|
|
3798
|
+
: current.kind === 'grant' && JSON.stringify(current.payload) === written ? 'matches' : 'differs';
|
|
3799
|
+
return undefined;
|
|
3800
|
+
});
|
|
3801
|
+
return state;
|
|
3802
|
+
}
|
|
3803
|
+
catch (error) {
|
|
3804
|
+
this.warn(`activation projection proof failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3805
|
+
return 'unknown';
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
/**
|
|
3809
|
+
* Delete one projection an activation just proved it still owns, through the
|
|
3810
|
+
* same guarded shape the demotion path uses (MF2): the intentional-removal
|
|
3811
|
+
* marker is armed before the delete, so the deletion's own echo is read as
|
|
3812
|
+
* ours instead of as an out-of-band removal.
|
|
3813
|
+
*/
|
|
3814
|
+
async deleteActivationProjection(records, provider, written, generation) {
|
|
3815
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3816
|
+
// F2: the identity read and the delete cannot be atomic here — the seam has
|
|
3817
|
+
// no compare-and-delete, `deleteRecord(key)` takes no expected payload
|
|
3818
|
+
// (host-types.ts:142, tracked as I-000090). This read immediately before the
|
|
3819
|
+
// delete narrows the window to the seam call itself; a writer that replaces
|
|
3820
|
+
// the record inside it still loses its grant, and that residual needs the
|
|
3821
|
+
// seam CAS, not another read here. Anything but a proven match is left
|
|
3822
|
+
// alone.
|
|
3823
|
+
const proof = await this.activationProjectionProof(records, provider, written, generation);
|
|
3824
|
+
if (proof === 'superseded')
|
|
3825
|
+
return 'superseded';
|
|
3826
|
+
// `absent` needs no delete and leaves nothing serving (the pool does);
|
|
3827
|
+
// `differs` means another writer's record serves the route; `unknown`
|
|
3828
|
+
// proves nothing and must keep the activation's claim.
|
|
3829
|
+
if (proof === 'absent')
|
|
3830
|
+
return 'gone';
|
|
3831
|
+
if (proof !== 'matches')
|
|
3832
|
+
return proof === 'differs' ? 'foreign' : 'ours';
|
|
3833
|
+
const markedAt = Date.now();
|
|
3834
|
+
this.intentionalProjectionRemovals.set(provider, markedAt);
|
|
3835
|
+
try {
|
|
3836
|
+
await records.deleteRecord(projectionKey);
|
|
3837
|
+
}
|
|
3838
|
+
catch (error) {
|
|
3839
|
+
// A delete that threw removed nothing, so no own-removal echo is coming:
|
|
3840
|
+
// revoke this call's marker or it would mask a genuine one for the TTL.
|
|
3841
|
+
if (this.intentionalProjectionRemovals.get(provider) === markedAt) {
|
|
3842
|
+
this.intentionalProjectionRemovals.delete(provider);
|
|
3843
|
+
}
|
|
3844
|
+
this.warn(`cancelled activation projection cleanup failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3845
|
+
// R14-SF2 applies here too: a throwing delete may have removed the record
|
|
3846
|
+
// before throwing, so the outcome is re-proved rather than assumed —
|
|
3847
|
+
// guessing is what this file forbids. A second failure
|
|
3848
|
+
// proves nothing and keeps the claim (`ours`).
|
|
3849
|
+
const after = await this.activationProjectionProof(records, provider, written, generation);
|
|
3850
|
+
if (after === 'superseded')
|
|
3851
|
+
return 'superseded';
|
|
3852
|
+
if (after === 'absent')
|
|
3853
|
+
return 'gone';
|
|
3854
|
+
if (after === 'differs')
|
|
3855
|
+
return 'foreign';
|
|
3856
|
+
return 'ours';
|
|
3857
|
+
}
|
|
3858
|
+
return 'gone';
|
|
3290
3859
|
}
|
|
3291
3860
|
/**
|
|
3292
3861
|
* Remote: send one minimal request with the selected key or account row.
|
|
@@ -4597,9 +5166,11 @@ export class KeysManagerService {
|
|
|
4597
5166
|
...(typeof payload.expires === 'number' && Number.isFinite(payload.expires) ? { expiresAt: payload.expires } : {}),
|
|
4598
5167
|
};
|
|
4599
5168
|
if (previousRef !== rotatedRef) {
|
|
4600
|
-
//
|
|
4601
|
-
//
|
|
4602
|
-
//
|
|
5169
|
+
// Defence in depth, not a reachable window: no await separates the guard
|
|
5170
|
+
// above from the delete below, so neither a shutdown nor a takeover can
|
|
5171
|
+
// land in between. The re-check is kept because it is the condition the
|
|
5172
|
+
// delete depends on and a future await inserted here would otherwise
|
|
5173
|
+
// silently remove a record this attempt never owned.
|
|
4603
5174
|
if (this.persistenceRefused(capability) || (guard !== undefined && !guard())) {
|
|
4604
5175
|
this.store.touch();
|
|
4605
5176
|
return;
|
|
@@ -4608,6 +5179,11 @@ export class KeysManagerService {
|
|
|
4608
5179
|
try {
|
|
4609
5180
|
if (records === undefined)
|
|
4610
5181
|
throw new Error('The credential store does not support records');
|
|
5182
|
+
// Declared residual: this delete is an await with no expected-payload
|
|
5183
|
+
// CAS (`deleteRecord(key)` takes only the key), so a claim landing
|
|
5184
|
+
// inside it can still remove the same-ref record an activation just
|
|
5185
|
+
// wrote. Post-delete compensation is out of scope here; closing it
|
|
5186
|
+
// needs a seam-level compare-and-delete.
|
|
4611
5187
|
await records.deleteRecord(previousRef);
|
|
4612
5188
|
}
|
|
4613
5189
|
catch (error) {
|
|
@@ -5165,6 +5741,15 @@ export class KeysManagerService {
|
|
|
5165
5741
|
this.store.pruneBindings(now);
|
|
5166
5742
|
this.pruneAttempts(now);
|
|
5167
5743
|
}
|
|
5744
|
+
/**
|
|
5745
|
+
* Project one provider's unattributed-request count into its row. Additive
|
|
5746
|
+
* and optional (`I-000120`): a row with no count spreads nothing, so an
|
|
5747
|
+
* older snapshot and a healthy store render exactly as before.
|
|
5748
|
+
*/
|
|
5749
|
+
unattributedView(provider) {
|
|
5750
|
+
const count = this.unattributedRequests.get(provider) ?? 0;
|
|
5751
|
+
return count > 0 ? { unattributedRequests: count } : {};
|
|
5752
|
+
}
|
|
5168
5753
|
/** Build the complete UI state. */
|
|
5169
5754
|
buildState() {
|
|
5170
5755
|
const now = Date.now();
|
|
@@ -5181,6 +5766,11 @@ export class KeysManagerService {
|
|
|
5181
5766
|
const profile = namespace === undefined ? undefined : asProfile(getPath(namespace.value, entry.settingsPath));
|
|
5182
5767
|
const managed = this.store.provider(entry.provider);
|
|
5183
5768
|
const extra = EXTRA_PROVIDER_MAP.get(entry.provider);
|
|
5769
|
+
// A transport route names its own credential record and registers no
|
|
5770
|
+
// settings namespace, so the profile/capability read below can only
|
|
5771
|
+
// describe the route wrongly: it is neither a DSH route nor key-pool
|
|
5772
|
+
// capable, and it is never adoptable.
|
|
5773
|
+
const transport = entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE;
|
|
5184
5774
|
const oauthFlow = this.oauthFlowAvailable(entry.provider);
|
|
5185
5775
|
const caps = capabilityOf(entry, profile, oauthFlow);
|
|
5186
5776
|
const support = { supported: caps.keys, ...(caps.reason === undefined ? {} : { reason: caps.reason }) };
|
|
@@ -5198,14 +5788,17 @@ export class KeysManagerService {
|
|
|
5198
5788
|
provider: entry.provider,
|
|
5199
5789
|
displayName: extra?.name ?? entry.displayName ?? entry.provider,
|
|
5200
5790
|
source: extra !== undefined ? 'extra' : 'dsh',
|
|
5201
|
-
origin: extra !== undefined ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
|
|
5791
|
+
origin: extra !== undefined || transport ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
|
|
5202
5792
|
managed: managed !== undefined,
|
|
5203
5793
|
createdProfile: managed?.createdProfile ?? false,
|
|
5204
5794
|
configured: active.has(entry.provider) || profile !== undefined,
|
|
5205
5795
|
active: active.has(entry.provider),
|
|
5206
|
-
supported: support.supported,
|
|
5207
|
-
...(
|
|
5208
|
-
|
|
5796
|
+
supported: transport ? false : support.supported,
|
|
5797
|
+
...(transport
|
|
5798
|
+
? { reason: 'This route is served by a transport plugin; manage its account from this provider card.' }
|
|
5799
|
+
: support.reason === undefined ? {} : { reason: support.reason }),
|
|
5800
|
+
accountSupported: transport ? false : accountSupport.supported,
|
|
5801
|
+
...(transport ? { transport: true } : {}),
|
|
5209
5802
|
settingsNs: entry.settingsNs,
|
|
5210
5803
|
settingsPath: [...entry.settingsPath],
|
|
5211
5804
|
apiKeyEnv,
|
|
@@ -5224,6 +5817,7 @@ export class KeysManagerService {
|
|
|
5224
5817
|
status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
|
|
5225
5818
|
keys,
|
|
5226
5819
|
...(warning === undefined ? {} : { warning }),
|
|
5820
|
+
...this.unattributedView(entry.provider),
|
|
5227
5821
|
});
|
|
5228
5822
|
seen.add(entry.provider);
|
|
5229
5823
|
}
|
|
@@ -5264,6 +5858,7 @@ export class KeysManagerService {
|
|
|
5264
5858
|
status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
|
|
5265
5859
|
keys,
|
|
5266
5860
|
...(managed === undefined ? {} : { warning: 'This extra provider is managed but its pi-ai route is not registered. Check the provider profile in Models.' }),
|
|
5861
|
+
...this.unattributedView(extra.id),
|
|
5267
5862
|
});
|
|
5268
5863
|
seen.add(extra.id);
|
|
5269
5864
|
}
|
|
@@ -5302,6 +5897,7 @@ export class KeysManagerService {
|
|
|
5302
5897
|
status: statusOfProvider(managed.keys, now),
|
|
5303
5898
|
keys,
|
|
5304
5899
|
warning: 'This provider route is not currently registered. Check its profile in Models.',
|
|
5900
|
+
...this.unattributedView(managed.provider),
|
|
5305
5901
|
});
|
|
5306
5902
|
seen.add(managed.provider);
|
|
5307
5903
|
}
|
|
@@ -5325,6 +5921,8 @@ export class KeysManagerService {
|
|
|
5325
5921
|
writable: descriptor.writable && !this.store.readOnly,
|
|
5326
5922
|
settings: { ...normalizeSettings(this.store.settings) },
|
|
5327
5923
|
providers: views,
|
|
5924
|
+
// Managed only: a transport route renders as a card but stays out of
|
|
5925
|
+
// this published count, the key counts and every key-pool write path.
|
|
5328
5926
|
managedCount: views.filter(view => view.managed).length,
|
|
5329
5927
|
keyCount: allKeys.length,
|
|
5330
5928
|
healthyKeyCount: allKeys.filter(key => key.status === 'active').length,
|
|
@@ -5494,14 +6092,38 @@ export class KeysManagerService {
|
|
|
5494
6092
|
// PR review round 35: the last point before the settings write. Only
|
|
5495
6093
|
// `closing` is read: shutdown's own pre-close restoration writes here by
|
|
5496
6094
|
// design and runs before that flag goes up.
|
|
5497
|
-
|
|
6095
|
+
//
|
|
6096
|
+
// Reaching this point means the route genuinely needs a write: `current ===
|
|
6097
|
+
// desired` already returned true above, and the preserved / releasing /
|
|
6098
|
+
// foreign-value branches declined for their own reasons. So shutdown is
|
|
6099
|
+
// what stops it, and this is the one place that can say so — the callers
|
|
6100
|
+
// cannot infer it (`pushWarning` caps its list, and a silent `false` has
|
|
6101
|
+
// several non-refusal causes upstream).
|
|
6102
|
+
if (this.closing) {
|
|
6103
|
+
this.bindingWriteRefused(provider, 'Keys Manager is shutting down');
|
|
6104
|
+
return false;
|
|
6105
|
+
}
|
|
6106
|
+
// A caller that stamped the ownership generation it entered with forbids
|
|
6107
|
+
// this write once a newer claim landed: the binding it would commit was
|
|
6108
|
+
// derived from the classification that claim replaced.
|
|
6109
|
+
if (options.ownershipGeneration !== undefined
|
|
6110
|
+
&& this.ownershipSuperseded(provider, options.ownershipGeneration))
|
|
5498
6111
|
return false;
|
|
5499
6112
|
try {
|
|
5500
|
-
// The entry gate above read a snapshot
|
|
5501
|
-
// this write re-reads its descriptor (PR review round 25). Binding
|
|
5502
|
-
// guess then, so the gate closes again at the last point before the
|
|
6113
|
+
// The entry gate above read a snapshot, and ownership can turn unknown
|
|
6114
|
+
// while this write re-reads its descriptor (PR review round 25). Binding
|
|
6115
|
+
// is a guess then, so the gate closes again at the last point before the
|
|
5503
6116
|
// mutation. Unbinding stays allowed.
|
|
5504
|
-
|
|
6117
|
+
// That last point is `mutateSettings`' `stillWrites` predicate: it is
|
|
6118
|
+
// evaluated synchronously between the descriptor re-read and
|
|
6119
|
+
// `settings.mutate` (no await between them), and the mutate itself passes
|
|
6120
|
+
// the descriptor's revision as an expected-revision CAS, so a takeover
|
|
6121
|
+
// that changed the namespace in between makes the write fail instead of
|
|
6122
|
+
// landing stale state. A claim the predicate sees must not leave this
|
|
6123
|
+
// binding behind — it describes the classification that claim replaced.
|
|
6124
|
+
const stamp = options.ownershipGeneration;
|
|
6125
|
+
const written = await this.mutateSettings(record.settingsNs, [desired === undefined ? { op: 'unset', path } : { op: 'set', path, value: desired }], () => (desired === undefined || this.projectionOwnershipKnown(provider))
|
|
6126
|
+
&& (stamp === undefined || !this.ownershipSuperseded(provider, stamp)));
|
|
5505
6127
|
if (!written)
|
|
5506
6128
|
return false;
|
|
5507
6129
|
}
|
|
@@ -5575,8 +6197,15 @@ export class KeysManagerService {
|
|
|
5575
6197
|
const takeover = record?.takeoverPending === true
|
|
5576
6198
|
&& !this.foreignProjections.has(provider)
|
|
5577
6199
|
&& this.oauthOwnerFor(provider) === null;
|
|
5578
|
-
|
|
5579
|
-
|
|
6200
|
+
// The awaited sync can be superseded (an activation claims the route while
|
|
6201
|
+
// it runs). It would then commit a binding derived from the pre-claim
|
|
6202
|
+
// classification, and settling the debt would record a takeover reconciled
|
|
6203
|
+
// for a generation that no longer owns the route. The generation is
|
|
6204
|
+
// captured here, passed to the sync's own last-mile gate, and re-read
|
|
6205
|
+
// immediately before the clear.
|
|
6206
|
+
const generation = this.ownershipGenerations.get(provider) ?? 0;
|
|
6207
|
+
const bound = await this.syncBrokerBinding(provider, { write: false, takeover, ownershipGeneration: generation });
|
|
6208
|
+
if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
|
|
5580
6209
|
record.takeoverPending = false;
|
|
5581
6210
|
this.store.touch();
|
|
5582
6211
|
}
|
|
@@ -5599,6 +6228,14 @@ export class KeysManagerService {
|
|
|
5599
6228
|
// is persistence, so shutdown refuses it.
|
|
5600
6229
|
if (this.closing)
|
|
5601
6230
|
return;
|
|
6231
|
+
// The own-generation stamp: this sync must not commit a binding derived
|
|
6232
|
+
// from a classification a newer claim replaced. It travels into
|
|
6233
|
+
// `syncBrokerBinding`, whose `stillWrites` predicate is the last point
|
|
6234
|
+
// before the settings write and runs synchronously right before a
|
|
6235
|
+
// revision-CAS'd mutate; the only window left is that write call itself,
|
|
6236
|
+
// which fails on a revision mismatch rather than committing stale state.
|
|
6237
|
+
// Every caller is covered here rather than at the call sites.
|
|
6238
|
+
const generation = this.ownershipGenerations.get(provider) ?? 0;
|
|
5602
6239
|
try {
|
|
5603
6240
|
// PR review round 40: adoption keeps the pre-adoption reference while a
|
|
5604
6241
|
// foreign projection serves the route and records that it still owes the
|
|
@@ -5607,8 +6244,8 @@ export class KeysManagerService {
|
|
|
5607
6244
|
// once the bind lands; `originalApiKeyEnv` alone proves nothing.
|
|
5608
6245
|
const record = this.store.provider(provider);
|
|
5609
6246
|
const takeover = record?.takeoverPending === true;
|
|
5610
|
-
const bound = await this.syncBrokerBinding(provider, { takeover });
|
|
5611
|
-
if (takeover && bound && record !== undefined) {
|
|
6247
|
+
const bound = await this.syncBrokerBinding(provider, { takeover, ownershipGeneration: generation });
|
|
6248
|
+
if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
|
|
5612
6249
|
record.takeoverPending = false;
|
|
5613
6250
|
this.store.touch();
|
|
5614
6251
|
}
|
|
@@ -5690,6 +6327,12 @@ export class KeysManagerService {
|
|
|
5690
6327
|
throw new Error('This extra provider needs a key; add it from the extra-provider section');
|
|
5691
6328
|
throw new Error(`Provider "${provider}" is not registered by any adapter`);
|
|
5692
6329
|
}
|
|
6330
|
+
// A transport route's credential is the record its own adapter reads, so
|
|
6331
|
+
// adopting it would bind a profile nothing serves (and its namespace is
|
|
6332
|
+
// not even mounted). Refuse before any settings read or write.
|
|
6333
|
+
if (entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE) {
|
|
6334
|
+
throw new Error(`Provider "${provider}" is served by a transport plugin; manage its account from its provider card`);
|
|
6335
|
+
}
|
|
5693
6336
|
const descriptor = this.describeSettings();
|
|
5694
6337
|
const namespace = this.namespace(descriptor, entry.settingsNs);
|
|
5695
6338
|
if (namespace === undefined)
|
|
@@ -5848,6 +6491,7 @@ export class KeysManagerService {
|
|
|
5848
6491
|
return 'superseded';
|
|
5849
6492
|
}
|
|
5850
6493
|
this.ownershipReadFailures.delete(provider);
|
|
6494
|
+
this.unattributedRequests.delete(provider);
|
|
5851
6495
|
this.noteProviderHydrated(provider);
|
|
5852
6496
|
if (record === undefined)
|
|
5853
6497
|
this.foreignProjections.delete(provider);
|
|
@@ -6810,6 +7454,12 @@ export class KeysManagerService {
|
|
|
6810
7454
|
// branches, so it is constructed after the isolation wait — a mismatch
|
|
6811
7455
|
// profile still resolves pi-ai's stored grant, so the borrowed
|
|
6812
7456
|
// projection reaches it too.
|
|
7457
|
+
//
|
|
7458
|
+
// No ownership read and no unattributed count here: the store already
|
|
7459
|
+
// said this route's profile disagrees with its binding, so this manager
|
|
7460
|
+
// cannot describe what serves it and hands the request over untouched.
|
|
7461
|
+
// The counter is defined over routes whose ownership it did try to read
|
|
7462
|
+
// (see `unattributedRequests`).
|
|
6813
7463
|
return this.isolateFromTest(providerId, () => next());
|
|
6814
7464
|
}
|
|
6815
7465
|
// An ordinary request must never be served by the grant a row test has
|
|
@@ -7046,6 +7696,17 @@ export class KeysManagerService {
|
|
|
7046
7696
|
});
|
|
7047
7697
|
}
|
|
7048
7698
|
iterator = await service.als.run(store, () => next()[Symbol.asyncIterator]());
|
|
7699
|
+
// I-000120 / D8 + I-000201: this request is served without an
|
|
7700
|
+
// attribution, and this is the only site that counts one — but
|
|
7701
|
+
// only once downstream actually has an iterator. Both causes (the
|
|
7702
|
+
// bounded wait expired, the read had failed) still arrive here as
|
|
7703
|
+
// the same `false`, and the hydration pass never counts. A routing
|
|
7704
|
+
// or adapter failure that throws before dispatch served nothing, so
|
|
7705
|
+
// it must not inflate the count; `started` keeps one attempt to one
|
|
7706
|
+
// increment, and a later attempt that does dispatch counts then.
|
|
7707
|
+
if (!hydrated) {
|
|
7708
|
+
service.unattributedRequests.set(providerId, (service.unattributedRequests.get(providerId) ?? 0) + 1);
|
|
7709
|
+
}
|
|
7049
7710
|
}
|
|
7050
7711
|
if (iterator === undefined)
|
|
7051
7712
|
throw new Error('managed stream has no iterator');
|