@cortexkit/antigravity-auth-core 3.0.9 → 3.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +3 -1
- package/dist/common-auth-embedded/claustrum/consumer.js +25 -21
- package/dist/common-auth-embedded/claustrum/enrollment.d.ts +5 -0
- package/dist/common-auth-embedded/claustrum/enrollment.js +27 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +1 -0
- package/dist/common-auth-embedded/claustrum/index.js +1 -0
- package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +18 -0
- package/dist/common-auth-embedded/claustrum/quota-writer.js +253 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +18 -2
- package/dist/common-auth-embedded/claustrum/roster.js +100 -43
- package/dist/common-auth-embedded/fs/private-files.d.ts +2 -0
- package/dist/common-auth-embedded/fs/private-files.js +49 -3
- package/dist/common-auth-embedded/fs/with-lock.d.ts +1 -0
- package/dist/common-auth-embedded/fs/with-lock.js +2 -0
- package/dist/common-auth-embedded/quota/format.d.ts +19 -0
- package/dist/common-auth-embedded/quota/format.js +50 -5
- package/dist/common-auth-embedded/quota/index.d.ts +3 -3
- package/dist/common-auth-embedded/quota/index.js +2 -2
- package/dist/common-auth-embedded/quota/map.d.ts +24 -1
- package/dist/common-auth-embedded/quota/map.js +31 -6
- package/dist/common-auth-embedded/quota/merge.d.ts +6 -1
- package/dist/common-auth-embedded/quota/merge.js +17 -3
- package/dist/common-auth-embedded/quota/projection.d.ts +3 -1
- package/dist/common-auth-embedded/quota/projection.js +3 -1
- package/dist/common-auth-embedded/source-output.json +89 -73
- package/dist/pre-response-deadline.d.ts.map +1 -1
- package/dist/pre-response-deadline.js +5 -1
- package/dist/pre-response-deadline.js.map +1 -1
- package/dist/quota-manager.d.ts +3 -3
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +59 -36
- package/dist/quota-manager.js.map +1 -1
- package/dist/transform/gemini.d.ts.map +1 -1
- package/dist/transform/gemini.js +12 -0
- package/dist/transform/gemini.js.map +1 -1
- package/dist/vault-enrollment-authority.d.ts +2 -0
- package/dist/vault-enrollment-authority.d.ts.map +1 -1
- package/dist/vault-enrollment-authority.js +2 -0
- package/dist/vault-enrollment-authority.js.map +1 -1
- package/package.json +1 -1
|
@@ -169,8 +169,10 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
|
|
|
169
169
|
* a served credential (an insula reading from `cachedInsulaQuotaForRoute`).
|
|
170
170
|
* It lands only while the route is still bound to that known account, so no
|
|
171
171
|
* credential fetch is needed and a replaced account's reading is dropped.
|
|
172
|
-
*
|
|
172
|
+
* Memory updates immediately; durable publication is detached and coalesced.
|
|
173
173
|
*/
|
|
174
174
|
recordAccountQuota(routeId: string, accountIdentity: string, observation: QuotaObservation): Promise<boolean>;
|
|
175
175
|
close(): void;
|
|
176
|
+
/** Close immediately; the last consumer also waits for the writer's bounded drain. */
|
|
177
|
+
dispose(): Promise<void>;
|
|
176
178
|
}
|
|
@@ -4,7 +4,8 @@ import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from './custody.js
|
|
|
4
4
|
import { readClaustrumEnrollmentToken } from './enrollment.js';
|
|
5
5
|
import { ClaustrumConsumerError } from './errors.js';
|
|
6
6
|
import { acquireInsulaQuotaSource, } from './insula.js';
|
|
7
|
-
import {
|
|
7
|
+
import { pendingVaultQuota, retainVaultQuotaWriter } from './quota-writer.js';
|
|
8
|
+
import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
|
|
8
9
|
import { SAFETY_POLL_MS } from './watch.js';
|
|
9
10
|
/**
|
|
10
11
|
* One plugin host's view of its vault accounts. It turns vault credentials
|
|
@@ -35,9 +36,13 @@ export class ClaustrumConsumer {
|
|
|
35
36
|
#insulaSkips = new Map();
|
|
36
37
|
/** When this instance last completed a discovery, by `now`. */
|
|
37
38
|
#discoveredAt;
|
|
39
|
+
#releaseQuotaWriter;
|
|
38
40
|
constructor(options) {
|
|
39
41
|
this.#options = options;
|
|
40
42
|
this.#logger = options.logger ?? createLogger('claustrum');
|
|
43
|
+
this.#releaseQuotaWriter = retainVaultQuotaWriter(options.rosterPath, (publication) => {
|
|
44
|
+
this.#roster = applyVaultQuota(this.#roster, publication).roster;
|
|
45
|
+
});
|
|
41
46
|
}
|
|
42
47
|
#assertOpen(signal) {
|
|
43
48
|
this.#shutdown.signal.throwIfAborted();
|
|
@@ -176,11 +181,9 @@ export class ClaustrumConsumer {
|
|
|
176
181
|
let discovered;
|
|
177
182
|
const roster = await refreshVaultRoster({
|
|
178
183
|
path: this.#options.rosterPath,
|
|
179
|
-
custody
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return discovered;
|
|
183
|
-
},
|
|
184
|
+
custody,
|
|
185
|
+
onInventory: (inventory) => {
|
|
186
|
+
discovered = inventory;
|
|
184
187
|
},
|
|
185
188
|
isActive: () => this.#active(),
|
|
186
189
|
signal: this.#shutdown.signal,
|
|
@@ -193,7 +196,7 @@ export class ClaustrumConsumer {
|
|
|
193
196
|
});
|
|
194
197
|
this.#assertOpen();
|
|
195
198
|
const changed = roster?.view !== this.#roster?.view;
|
|
196
|
-
this.#roster = roster;
|
|
199
|
+
this.#roster = pendingVaultQuota(this.#options.rosterPath, roster);
|
|
197
200
|
custody.retain(roster?.rows ?? []);
|
|
198
201
|
// Each discovery replaces the previous value, including with unknown. A
|
|
199
202
|
// refresh that served a peer's roster discovered nothing and leaves it;
|
|
@@ -349,7 +352,7 @@ export class ClaustrumConsumer {
|
|
|
349
352
|
return undefined;
|
|
350
353
|
try {
|
|
351
354
|
await readClaustrumEnrollmentToken(this.#options.tokenPath);
|
|
352
|
-
const roster = await readVaultRoster(this.#options.rosterPath);
|
|
355
|
+
const roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
353
356
|
const row = roster?.rows.find((entry) => entry.routeId === routeId);
|
|
354
357
|
return this.#normalizeInsulaQuota(row?.accountIdentity);
|
|
355
358
|
}
|
|
@@ -364,7 +367,7 @@ export class ClaustrumConsumer {
|
|
|
364
367
|
return { miss: 'unreachable' };
|
|
365
368
|
try {
|
|
366
369
|
await readClaustrumEnrollmentToken(this.#options.tokenPath);
|
|
367
|
-
const roster = await readVaultRoster(this.#options.rosterPath);
|
|
370
|
+
const roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
368
371
|
const account = roster?.rows.find((row) => row.routeId === routeId)?.accountIdentity;
|
|
369
372
|
if (!account || !this.#insula)
|
|
370
373
|
return { miss: 'unreachable' };
|
|
@@ -419,7 +422,7 @@ export class ClaustrumConsumer {
|
|
|
419
422
|
this.#insula = undefined;
|
|
420
423
|
throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
421
424
|
}
|
|
422
|
-
const current = await readVaultRoster(this.#options.rosterPath);
|
|
425
|
+
const current = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
423
426
|
this.#assertOpen(signal);
|
|
424
427
|
const observed = roster.rows.find((row) => row.routeId === routeId);
|
|
425
428
|
const configured = current?.rows.find((row) => row.routeId === routeId);
|
|
@@ -511,13 +514,13 @@ export class ClaustrumConsumer {
|
|
|
511
514
|
async decline(routeId) {
|
|
512
515
|
this.#assertOpen();
|
|
513
516
|
await declineVaultRoute(this.#options.rosterPath, routeId);
|
|
514
|
-
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
517
|
+
this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
515
518
|
this.#custody?.retain(this.#roster?.rows ?? []);
|
|
516
519
|
}
|
|
517
520
|
async accept(routeId) {
|
|
518
521
|
this.#assertOpen();
|
|
519
522
|
await acceptVaultRoute(this.#options.rosterPath, routeId);
|
|
520
|
-
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
523
|
+
this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
521
524
|
}
|
|
522
525
|
/**
|
|
523
526
|
* Store a quota or profile observation for a vault route. The receipt the
|
|
@@ -527,7 +530,7 @@ export class ClaustrumConsumer {
|
|
|
527
530
|
*/
|
|
528
531
|
async recordQuota(routeId, observation, attempt) {
|
|
529
532
|
this.#assertOpen();
|
|
530
|
-
|
|
533
|
+
return recordVaultQuota(this.#options.rosterPath, {
|
|
531
534
|
routeId,
|
|
532
535
|
observation,
|
|
533
536
|
credentialId: attempt.credentialId,
|
|
@@ -539,27 +542,21 @@ export class ClaustrumConsumer {
|
|
|
539
542
|
expectedAccountIdentity: attempt.expectedAccountIdentity,
|
|
540
543
|
}),
|
|
541
544
|
});
|
|
542
|
-
if (kept)
|
|
543
|
-
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
544
|
-
return kept;
|
|
545
545
|
}
|
|
546
546
|
/**
|
|
547
547
|
* Store a background observation that belongs to an account rather than to
|
|
548
548
|
* a served credential (an insula reading from `cachedInsulaQuotaForRoute`).
|
|
549
549
|
* It lands only while the route is still bound to that known account, so no
|
|
550
550
|
* credential fetch is needed and a replaced account's reading is dropped.
|
|
551
|
-
*
|
|
551
|
+
* Memory updates immediately; durable publication is detached and coalesced.
|
|
552
552
|
*/
|
|
553
553
|
async recordAccountQuota(routeId, accountIdentity, observation) {
|
|
554
554
|
this.#assertOpen();
|
|
555
|
-
|
|
555
|
+
return recordVaultAccountQuota(this.#options.rosterPath, {
|
|
556
556
|
routeId,
|
|
557
557
|
accountIdentity,
|
|
558
558
|
observation,
|
|
559
559
|
});
|
|
560
|
-
if (kept)
|
|
561
|
-
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
562
|
-
return kept;
|
|
563
560
|
}
|
|
564
561
|
close() {
|
|
565
562
|
if (this.#shutdown.signal.aborted)
|
|
@@ -574,5 +571,12 @@ export class ClaustrumConsumer {
|
|
|
574
571
|
this.#insula = undefined;
|
|
575
572
|
this.#roster = undefined;
|
|
576
573
|
this.#grantReach = undefined;
|
|
574
|
+
void this.#releaseQuotaWriter();
|
|
575
|
+
forgetPendingVaultDiscovery(this.#options.rosterPath);
|
|
576
|
+
}
|
|
577
|
+
/** Close immediately; the last consumer also waits for the writer's bounded drain. */
|
|
578
|
+
async dispose() {
|
|
579
|
+
this.close();
|
|
580
|
+
await this.#releaseQuotaWriter();
|
|
577
581
|
}
|
|
578
582
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { type ClaustrumClientOptions as ClaustrumEnrollmentClientOptions, type EnrollmentPollOutcome, type EnrollmentTokenFile, writeEnrollmentTokenFile } from '@cortexkit/claustrum-client';
|
|
2
|
+
/**
|
|
3
|
+
* The code a vault without the enrollment ceremony answers a proposal with.
|
|
4
|
+
* Setup reports it as "this vault cannot connect plugins" instead of waiting.
|
|
5
|
+
*/
|
|
6
|
+
export declare const UNSUPPORTED_ENROLLMENT_CODE = "unknown_method";
|
|
2
7
|
/**
|
|
3
8
|
* Codes the vault's closed enrollment-refusal vocabulary marks permanent. The
|
|
4
9
|
* client labels some module error frames transient/retry regardless, so the
|
|
@@ -10,12 +10,22 @@ const ENROLLMENT_SCHEMA = 1;
|
|
|
10
10
|
const ENROLLMENT_FILE_MAX_BYTES = 16 * 1024;
|
|
11
11
|
const ENROLLMENT_LOCK_TTL_MS = 30_000;
|
|
12
12
|
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
13
|
+
/**
|
|
14
|
+
* The code a vault without the enrollment ceremony answers a proposal with.
|
|
15
|
+
* Setup reports it as "this vault cannot connect plugins" instead of waiting.
|
|
16
|
+
*/
|
|
17
|
+
export const UNSUPPORTED_ENROLLMENT_CODE = 'unknown_method';
|
|
13
18
|
/**
|
|
14
19
|
* Codes the vault's closed enrollment-refusal vocabulary marks permanent. The
|
|
15
20
|
* client labels some module error frames transient/retry regardless, so the
|
|
16
21
|
* producer's code takes precedence over the client's action for these.
|
|
17
22
|
*/
|
|
18
23
|
export const TERMINAL_ENROLLMENT_CODES = new Set([
|
|
24
|
+
// The daemon's answer to an operation it does not implement: a vault built
|
|
25
|
+
// before the enrollment ceremony. It arrives as a transport Error frame with
|
|
26
|
+
// no class, which claustrum-client before 0.13.0 labels transient/retry, but
|
|
27
|
+
// asking again can never succeed.
|
|
28
|
+
UNSUPPORTED_ENROLLMENT_CODE,
|
|
19
29
|
'invalid_params',
|
|
20
30
|
'pending_exists',
|
|
21
31
|
'not_found',
|
|
@@ -638,6 +648,23 @@ export class ClaustrumEnrollmentManager {
|
|
|
638
648
|
retryCode: refusal.code,
|
|
639
649
|
};
|
|
640
650
|
}
|
|
651
|
+
// A vault without the ceremony cannot hold a request for this
|
|
652
|
+
// proposal, now or from an earlier run (the state has no request
|
|
653
|
+
// id). So nothing is blocked and nothing is in progress: remove the
|
|
654
|
+
// pending state, still under the ceremony lock, so status reads as
|
|
655
|
+
// "not enrolled" and a reset or a later run starts afresh.
|
|
656
|
+
if (refusal.code === UNSUPPORTED_ENROLLMENT_CODE) {
|
|
657
|
+
await lock.assertOwned();
|
|
658
|
+
await unlink(this.#paths.statePath).catch((cause) => {
|
|
659
|
+
if (cause.code !== 'ENOENT')
|
|
660
|
+
throw cause;
|
|
661
|
+
});
|
|
662
|
+
return {
|
|
663
|
+
state: 'unavailable',
|
|
664
|
+
proposedName: state.proposedName,
|
|
665
|
+
code: refusal.code,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
641
668
|
return this.#block(state, refusal.code);
|
|
642
669
|
}
|
|
643
670
|
}
|
|
@@ -9,6 +9,7 @@ export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type Claustr
|
|
|
9
9
|
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
10
10
|
export { acquireInsulaQuotaSource, type InsulaQuotaContext, type InsulaQuotaLookup, type InsulaQuotaMiss, type InsulaQuotaOptions, type InsulaQuotaReading, type InsulaQuotaResult, type InsulaQuotaSource, type InsulaUsageEntry, type InsulaUsageTransport, type InsulaUsageWatch, type InsulaUsageWindow, type InsulaWatchOptions, } from './insula.js';
|
|
11
11
|
export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
12
|
+
export { drainVaultQuota } from './quota-writer.js';
|
|
12
13
|
export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, type MutateVaultRosterOptions, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
|
|
13
14
|
export { type ChangeNotice, ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, type ScopedRosterWatchOptions, type ScopedWatchEnd, type ScopedWatchHandle, type ScopedWatchNoticeShape, type ScopedWatchSource, WATCH_RETRY_MS, type WatchMode, } from './watch.js';
|
|
14
15
|
/**
|
|
@@ -14,6 +14,7 @@ export { ClaustrumConsumerError, } from './errors.js';
|
|
|
14
14
|
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
15
15
|
export { acquireInsulaQuotaSource, } from './insula.js';
|
|
16
16
|
export { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
17
|
+
export { drainVaultQuota } from './quota-writer.js';
|
|
17
18
|
export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from './roster.js';
|
|
18
19
|
export { ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, WATCH_RETRY_MS, } from './watch.js';
|
|
19
20
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type QuotaObservation } from '../quota/index.js';
|
|
2
|
+
import { type QuotaReceipt, type VaultRosterFile } from './roster.js';
|
|
3
|
+
export type VaultQuotaPublication = {
|
|
4
|
+
routeId: string;
|
|
5
|
+
accountIdentity?: string;
|
|
6
|
+
receipt?: QuotaReceipt;
|
|
7
|
+
observation: QuotaObservation;
|
|
8
|
+
};
|
|
9
|
+
type Listener = (publication: VaultQuotaPublication) => void;
|
|
10
|
+
/** Retain the shared writer; the last consumer's close drains it. */
|
|
11
|
+
export declare function retainVaultQuotaWriter(path: string, listener: Listener): () => Promise<void>;
|
|
12
|
+
/** Enqueue without filesystem work; each quota field retains its own receipt fence. */
|
|
13
|
+
export declare function enqueueVaultQuota(path: string, publication: VaultQuotaPublication): Promise<boolean>;
|
|
14
|
+
/** Overlay uncommitted observations when a consumer reloads the durable roster. */
|
|
15
|
+
export declare function pendingVaultQuota(path: string, roster: VaultRosterFile | undefined): VaultRosterFile | undefined;
|
|
16
|
+
/** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
|
|
17
|
+
export declare function drainVaultQuota(path: string, timeoutMs?: number): Promise<void>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { createLogger } from '../logger/index.js';
|
|
3
|
+
import { isQuotaObservation } from '../quota/index.js';
|
|
4
|
+
import { applyVaultQuota, mutateVaultRoster, } from './roster.js';
|
|
5
|
+
// Every consumer of the same file shares one mailbox and one lock waiter.
|
|
6
|
+
const writers = new Map();
|
|
7
|
+
const logger = createLogger('claustrum');
|
|
8
|
+
const COALESCE_MS = 100;
|
|
9
|
+
function writerFor(path) {
|
|
10
|
+
const key = resolve(path);
|
|
11
|
+
let writer = writers.get(key);
|
|
12
|
+
if (!writer) {
|
|
13
|
+
writer = {
|
|
14
|
+
path: key,
|
|
15
|
+
pending: new Map(),
|
|
16
|
+
listeners: new Set(),
|
|
17
|
+
wake: new Set(),
|
|
18
|
+
abort: new AbortController(),
|
|
19
|
+
failures: 0,
|
|
20
|
+
draining: false,
|
|
21
|
+
order: 0,
|
|
22
|
+
};
|
|
23
|
+
writers.set(key, writer);
|
|
24
|
+
}
|
|
25
|
+
return writer;
|
|
26
|
+
}
|
|
27
|
+
/** Retain the shared writer; the last consumer's close drains it. */
|
|
28
|
+
export function retainVaultQuotaWriter(path, listener) {
|
|
29
|
+
const writer = writerFor(path);
|
|
30
|
+
writer.listeners.add(listener);
|
|
31
|
+
let released;
|
|
32
|
+
return () => (released ??= (() => {
|
|
33
|
+
writer.listeners.delete(listener);
|
|
34
|
+
return writer.listeners.size ? Promise.resolve() : drainVaultQuota(path);
|
|
35
|
+
})());
|
|
36
|
+
}
|
|
37
|
+
// Split one observation into its independently dated parts: each rate-limit
|
|
38
|
+
// window (scope and label), each covered-but-unread window, the budget, each
|
|
39
|
+
// counter and the credits record. The mailbox keeps the newest of each part on
|
|
40
|
+
// its own, so a later observation that carries only some parts can't erase an
|
|
41
|
+
// older but still newest reading of another part, which is how
|
|
42
|
+
// mergeQuotaObservation treats them on disk. Each key also carries the
|
|
43
|
+
// receipt's identity, so the identity fence still applies to every part.
|
|
44
|
+
function fields(publication) {
|
|
45
|
+
const result = new Map();
|
|
46
|
+
const { observation } = publication;
|
|
47
|
+
const witness = JSON.stringify([
|
|
48
|
+
publication.routeId,
|
|
49
|
+
publication.receipt?.credentialId,
|
|
50
|
+
publication.receipt?.accountIdentitySource,
|
|
51
|
+
publication.receipt?.expectedAccountIdentity,
|
|
52
|
+
]);
|
|
53
|
+
const add = (key, at, part) => result.set(`${witness}:${key}`, {
|
|
54
|
+
at,
|
|
55
|
+
order: 0,
|
|
56
|
+
publication: { ...publication, observation: part },
|
|
57
|
+
});
|
|
58
|
+
const keyFor = (scope, label) => JSON.stringify([scope ?? 'all', label]);
|
|
59
|
+
const read = new Set();
|
|
60
|
+
for (const reading of observation.readings ?? []) {
|
|
61
|
+
const key = keyFor(reading.scope, reading.label);
|
|
62
|
+
read.add(key);
|
|
63
|
+
add(key, observation.checkedAt, {
|
|
64
|
+
checkedAt: observation.checkedAt,
|
|
65
|
+
readings: [{ ...reading }],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
for (const pair of observation.coverage ?? []) {
|
|
69
|
+
const key = keyFor(pair.scope, pair.label);
|
|
70
|
+
if (!read.has(key))
|
|
71
|
+
add(key, observation.checkedAt, {
|
|
72
|
+
checkedAt: observation.checkedAt,
|
|
73
|
+
coverage: [{ ...pair }],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (observation.budget)
|
|
77
|
+
add('budget', observation.checkedAt, {
|
|
78
|
+
checkedAt: observation.checkedAt,
|
|
79
|
+
budget: { ...observation.budget },
|
|
80
|
+
});
|
|
81
|
+
for (const [name, counter] of Object.entries(observation.counters ?? {})) {
|
|
82
|
+
add(`counter:${name}`, counter.checkedAt, {
|
|
83
|
+
checkedAt: observation.checkedAt,
|
|
84
|
+
counters: { [name]: { ...counter } },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (observation.credits)
|
|
88
|
+
add('credits', observation.credits.checkedAt, {
|
|
89
|
+
checkedAt: observation.checkedAt,
|
|
90
|
+
credits: { ...observation.credits },
|
|
91
|
+
});
|
|
92
|
+
// Even an empty observation can bind an account proved by its receipt.
|
|
93
|
+
if (!result.size)
|
|
94
|
+
add('empty', observation.checkedAt, { checkedAt: observation.checkedAt });
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/** Enqueue without filesystem work; each quota field retains its own receipt fence. */
|
|
98
|
+
export function enqueueVaultQuota(path, publication) {
|
|
99
|
+
if (!isQuotaObservation(publication.observation))
|
|
100
|
+
return Promise.resolve(false);
|
|
101
|
+
const writer = writerFor(path);
|
|
102
|
+
const account = JSON.stringify([
|
|
103
|
+
publication.accountIdentity ?? null,
|
|
104
|
+
publication.accountIdentity === undefined ? publication.routeId : null,
|
|
105
|
+
]);
|
|
106
|
+
const pending = writer.pending.get(account) ?? new Map();
|
|
107
|
+
for (const [key, field] of fields(publication)) {
|
|
108
|
+
const previous = pending.get(key);
|
|
109
|
+
if (!previous || field.at >= previous.at) {
|
|
110
|
+
field.order = ++writer.order;
|
|
111
|
+
// A covered disappearance after an unflushed reading is a retirement,
|
|
112
|
+
// not an absence. Retain just enough history to preserve that distinction.
|
|
113
|
+
if (field.publication.observation.coverage)
|
|
114
|
+
field.priorReading = previous?.publication.observation.readings
|
|
115
|
+
? previous.publication
|
|
116
|
+
: previous?.priorReading;
|
|
117
|
+
pending.set(key, field);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (pending.size)
|
|
121
|
+
writer.pending.set(account, pending);
|
|
122
|
+
for (const listener of writer.listeners)
|
|
123
|
+
listener(publication);
|
|
124
|
+
schedule(writer, COALESCE_MS);
|
|
125
|
+
return Promise.resolve(true);
|
|
126
|
+
}
|
|
127
|
+
function orderedFields(pending) {
|
|
128
|
+
// Equal observation times are last-enqueue-wins even across different
|
|
129
|
+
// credentials or account-only readings. Map insertion order cannot prove it.
|
|
130
|
+
return [...pending.values()]
|
|
131
|
+
.flatMap((account) => [...account.values()])
|
|
132
|
+
.sort((a, b) => a.order - b.order);
|
|
133
|
+
}
|
|
134
|
+
/** Overlay uncommitted observations when a consumer reloads the durable roster. */
|
|
135
|
+
export function pendingVaultQuota(path, roster) {
|
|
136
|
+
const writer = writers.get(resolve(path));
|
|
137
|
+
if (writer) {
|
|
138
|
+
for (const field of orderedFields(writer.pending)) {
|
|
139
|
+
if (field.priorReading)
|
|
140
|
+
roster = applyVaultQuota(roster, field.priorReading).roster;
|
|
141
|
+
roster = applyVaultQuota(roster, field.publication).roster;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return roster;
|
|
145
|
+
}
|
|
146
|
+
function schedule(writer, delay) {
|
|
147
|
+
if (writer.running ||
|
|
148
|
+
writer.timer ||
|
|
149
|
+
!writer.pending.size ||
|
|
150
|
+
writer.abort.signal.aborted)
|
|
151
|
+
return;
|
|
152
|
+
writer.timer = setTimeout(() => {
|
|
153
|
+
writer.timer = undefined;
|
|
154
|
+
flush(writer);
|
|
155
|
+
}, delay);
|
|
156
|
+
if (!writer.draining)
|
|
157
|
+
writer.timer.unref?.();
|
|
158
|
+
}
|
|
159
|
+
function flush(writer) {
|
|
160
|
+
if (writer.running || writer.abort.signal.aborted)
|
|
161
|
+
return;
|
|
162
|
+
const batch = new Map([...writer.pending].map(([account, pending]) => [
|
|
163
|
+
account,
|
|
164
|
+
new Map(pending),
|
|
165
|
+
]));
|
|
166
|
+
writer.running = (async () => {
|
|
167
|
+
try {
|
|
168
|
+
await mutateVaultRoster(writer.path, (current) => {
|
|
169
|
+
let next = current;
|
|
170
|
+
for (const field of orderedFields(batch)) {
|
|
171
|
+
if (field.priorReading)
|
|
172
|
+
next = applyVaultQuota(next, field.priorReading).roster;
|
|
173
|
+
next = applyVaultQuota(next, field.publication).roster;
|
|
174
|
+
}
|
|
175
|
+
return { ...(next !== current && { next }), result: undefined };
|
|
176
|
+
}, { signal: writer.abort.signal });
|
|
177
|
+
for (const [account, fields] of batch) {
|
|
178
|
+
const pending = writer.pending.get(account);
|
|
179
|
+
for (const [key, field] of fields) {
|
|
180
|
+
// A newer enqueue must survive completion of an older flush.
|
|
181
|
+
if (pending?.get(key) === field)
|
|
182
|
+
pending.delete(key);
|
|
183
|
+
}
|
|
184
|
+
if (!pending?.size)
|
|
185
|
+
writer.pending.delete(account);
|
|
186
|
+
}
|
|
187
|
+
writer.failures = 0;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (!writer.abort.signal.aborted && writer.failures++ === 0) {
|
|
191
|
+
logger.warn('vault quota publication delayed; retrying', {
|
|
192
|
+
error: error instanceof Error ? error.message : String(error),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Keep the batch in the mailbox, including any fresher fields enqueued meanwhile.
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
writer.running = undefined;
|
|
199
|
+
for (const wake of writer.wake)
|
|
200
|
+
wake();
|
|
201
|
+
const backoff = writer.failures
|
|
202
|
+
? Math.min(5_000, 100 * 2 ** Math.min(writer.failures, 6)) *
|
|
203
|
+
(0.5 + Math.random())
|
|
204
|
+
: COALESCE_MS;
|
|
205
|
+
schedule(writer, backoff);
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
208
|
+
}
|
|
209
|
+
/** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
|
|
210
|
+
export async function drainVaultQuota(path, timeoutMs = 1_000) {
|
|
211
|
+
const key = resolve(path);
|
|
212
|
+
const writer = writers.get(key);
|
|
213
|
+
if (!writer)
|
|
214
|
+
return;
|
|
215
|
+
writer.draining = true;
|
|
216
|
+
clearTimeout(writer.timer);
|
|
217
|
+
writer.timer = undefined;
|
|
218
|
+
let deadline;
|
|
219
|
+
let wake;
|
|
220
|
+
const drained = new Promise((done) => {
|
|
221
|
+
wake = () => {
|
|
222
|
+
if (!writer.pending.size || writer.abort.signal.aborted)
|
|
223
|
+
done();
|
|
224
|
+
};
|
|
225
|
+
writer.wake.add(wake);
|
|
226
|
+
wake();
|
|
227
|
+
});
|
|
228
|
+
try {
|
|
229
|
+
if (writer.pending.size)
|
|
230
|
+
flush(writer);
|
|
231
|
+
await Promise.race([
|
|
232
|
+
drained,
|
|
233
|
+
new Promise((done) => {
|
|
234
|
+
deadline = setTimeout(done, Math.max(0, timeoutMs));
|
|
235
|
+
}),
|
|
236
|
+
]);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
clearTimeout(deadline);
|
|
240
|
+
if (wake)
|
|
241
|
+
writer.wake.delete(wake);
|
|
242
|
+
writer.draining = false;
|
|
243
|
+
// A still-open consumer owns the mailbox, so another consumer's disposal
|
|
244
|
+
// must not discard its observations or stop its retries.
|
|
245
|
+
if (!writer.listeners.size) {
|
|
246
|
+
writer.abort.abort();
|
|
247
|
+
clearTimeout(writer.timer);
|
|
248
|
+
writer.pending.clear();
|
|
249
|
+
if (writers.get(key) === writer)
|
|
250
|
+
writers.delete(key);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -2,6 +2,8 @@ import { type QuotaMap, type QuotaObservation } from '../quota/index.js';
|
|
|
2
2
|
import type { RoutingRow } from '../routing/index.js';
|
|
3
3
|
import type { ClaustrumScopedAttempt, ClaustrumScopedCustody, SkippedVaultRecord, VaultCredential, VaultCredentialType, VaultInventory } from './custody.js';
|
|
4
4
|
import { type DeclinedAccount } from './interlock.js';
|
|
5
|
+
import { type VaultQuotaPublication } from './quota-writer.js';
|
|
6
|
+
export { drainVaultQuota } from './quota-writer.js';
|
|
5
7
|
/**
|
|
6
8
|
* One vault account as the pool sees it. It carries no bearer material: the
|
|
7
9
|
* access token is fetched from the vault for every send. Several vault
|
|
@@ -91,6 +93,7 @@ export interface VaultRosterLockContext {
|
|
|
91
93
|
assertOwned: () => Promise<void>;
|
|
92
94
|
}
|
|
93
95
|
export interface MutateVaultRosterOptions {
|
|
96
|
+
signal?: AbortSignal;
|
|
94
97
|
/**
|
|
95
98
|
* Runs when the new roster is fully staged on disk, immediately before the
|
|
96
99
|
* rename that publishes it, so a caller can confirm a lease of its own at
|
|
@@ -127,7 +130,10 @@ export type QuotaReceipt = Pick<ClaustrumScopedAttempt, 'credentialId' | 'accoun
|
|
|
127
130
|
* write is fenced on the receipt the reading was taken with: it lands only
|
|
128
131
|
* while the row still holds that receipt's credential (as representative or
|
|
129
132
|
* alias) and account, so a slow read for a replaced account can never land on
|
|
130
|
-
* its successor.
|
|
133
|
+
* its successor. Resolves false when the fence refuses the reading against the
|
|
134
|
+
* roster as it stands now (on disk, plus readings this process has not
|
|
135
|
+
* written yet), and true once the reading is queued for the next write. The
|
|
136
|
+
* write applies the fence again, so a rebind between the two still refuses.
|
|
131
137
|
*/
|
|
132
138
|
export declare function recordVaultQuota(path: string, input: QuotaReceipt & {
|
|
133
139
|
routeId: string;
|
|
@@ -139,19 +145,28 @@ export declare function recordVaultQuota(path: string, input: QuotaReceipt & {
|
|
|
139
145
|
* lands only while the row is still bound to that exact known account, so a
|
|
140
146
|
* reading taken before a replacement never reaches the successor. Unlike
|
|
141
147
|
* `recordVaultQuota` it never binds an account: a row without a known account
|
|
142
|
-
* is refused.
|
|
148
|
+
* is refused. Resolves false when the row is not bound to that account now,
|
|
149
|
+
* and true once the reading is queued, as `recordVaultQuota` does.
|
|
143
150
|
*/
|
|
144
151
|
export declare function recordVaultAccountQuota(path: string, input: {
|
|
145
152
|
routeId: string;
|
|
146
153
|
accountIdentity: string;
|
|
147
154
|
observation: QuotaObservation;
|
|
148
155
|
}): Promise<boolean>;
|
|
156
|
+
/** Apply the same identity fence to memory overlays and durable quota batches. */
|
|
157
|
+
export declare function applyVaultQuota(current: VaultRosterFile | undefined, input: VaultQuotaPublication): {
|
|
158
|
+
roster: VaultRosterFile | undefined;
|
|
159
|
+
kept: boolean;
|
|
160
|
+
};
|
|
161
|
+
export declare function forgetPendingVaultDiscovery(path: string): void;
|
|
149
162
|
/**
|
|
150
163
|
* Discover and commit the roster. The lease covers the list call and the
|
|
151
164
|
* commit, not just the write: an opaque view cannot tell a delayed old reply
|
|
152
165
|
* from a newer one, so a run that lost its lease is refused at commit. A
|
|
153
166
|
* failed list, or custody switching off mid-run, never writes anything; a
|
|
154
167
|
* peer holding the lease means its last committed roster is served instead.
|
|
168
|
+
* A write-lock timeout keeps the discovered inventory for the next poll,
|
|
169
|
+
* unless a peer has superseded its base view in the meantime.
|
|
155
170
|
*/
|
|
156
171
|
export declare function refreshVaultRoster(options: {
|
|
157
172
|
path: string;
|
|
@@ -159,6 +174,7 @@ export declare function refreshVaultRoster(options: {
|
|
|
159
174
|
isActive?: () => boolean | Promise<boolean>;
|
|
160
175
|
signal?: AbortSignal;
|
|
161
176
|
projection?: ProjectionOptions | (() => ProjectionOptions);
|
|
177
|
+
onInventory?: (inventory: VaultInventory) => void;
|
|
162
178
|
}): Promise<VaultRosterFile | undefined>;
|
|
163
179
|
/**
|
|
164
180
|
* A host-owned main account as last verified from the vault: the plugin's
|