@cortexkit/antigravity-auth-core 3.0.8 → 3.0.10
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/auth-menu/login.d.ts +6 -10
- package/dist/common-auth-embedded/auth-menu/login.js +14 -11
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +8 -2
- package/dist/common-auth-embedded/claustrum/consumer.js +64 -21
- package/dist/common-auth-embedded/claustrum/index.d.ts +2 -1
- package/dist/common-auth-embedded/claustrum/index.js +1 -0
- package/dist/common-auth-embedded/claustrum/insula.d.ts +23 -0
- package/dist/common-auth-embedded/claustrum/insula.js +64 -2
- 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/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/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -0
- package/dist/constants.js.map +1 -1
- 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/vault-account-source.d.ts +14 -2
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +38 -0
- package/dist/vault-account-source.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { acquireRefreshFileLock, LockContentionError, withLock, writeJsonAtomic, } from '../fs/index.js';
|
|
3
4
|
import { readPrivateText, windowsPrivateFiles } from '../fs/private-files.js';
|
|
4
|
-
import { decodeQuotaMap, mergeQuotaObservation, } from '../quota/index.js';
|
|
5
|
+
import { decodeQuotaMap, isQuotaObservation, mergeQuotaObservation, } from '../quota/index.js';
|
|
5
6
|
import { ClaustrumConsumerError } from './errors.js';
|
|
6
7
|
import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
8
|
+
import { enqueueVaultQuota, pendingVaultQuota, } from './quota-writer.js';
|
|
9
|
+
export { drainVaultQuota } from './quota-writer.js';
|
|
7
10
|
export const DEFAULT_ROUTE_PREFIX = 'vault:';
|
|
8
11
|
function hash(value) {
|
|
9
12
|
return createHash('sha256').update(value).digest('hex');
|
|
@@ -313,6 +316,7 @@ const WRITE_LOCK = {
|
|
|
313
316
|
name: 'claustrum-roster-write',
|
|
314
317
|
ttlMs: 10_000,
|
|
315
318
|
timeoutMs: 5_000,
|
|
319
|
+
renew: true,
|
|
316
320
|
};
|
|
317
321
|
const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
|
|
318
322
|
/**
|
|
@@ -321,7 +325,7 @@ const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
|
|
|
321
325
|
* before any rename performed inside `change`; if it throws, do not rename.
|
|
322
326
|
*/
|
|
323
327
|
export async function mutateVaultRoster(path, change, options = {}) {
|
|
324
|
-
return withLock(path, WRITE_LOCK, async (lock) => {
|
|
328
|
+
return withLock(path, { ...WRITE_LOCK, signal: options.signal }, async (lock) => {
|
|
325
329
|
const current = await readVaultRoster(path);
|
|
326
330
|
const { next, result } = await change(current, {
|
|
327
331
|
assertOwned: () => lock.assertOwned(),
|
|
@@ -333,6 +337,7 @@ export async function mutateVaultRoster(path, change, options = {}) {
|
|
|
333
337
|
await writeJsonAtomic(path, next, {
|
|
334
338
|
beforeRename: async () => {
|
|
335
339
|
await options.beforePublish?.();
|
|
340
|
+
options.signal?.throwIfAborted();
|
|
336
341
|
await lock.assertOwned();
|
|
337
342
|
},
|
|
338
343
|
});
|
|
@@ -415,55 +420,73 @@ function receiptFence(row, receipt) {
|
|
|
415
420
|
* write is fenced on the receipt the reading was taken with: it lands only
|
|
416
421
|
* while the row still holds that receipt's credential (as representative or
|
|
417
422
|
* alias) and account, so a slow read for a replaced account can never land on
|
|
418
|
-
* its successor.
|
|
423
|
+
* its successor. Resolves false when the fence refuses the reading against the
|
|
424
|
+
* roster as it stands now (on disk, plus readings this process has not
|
|
425
|
+
* written yet), and true once the reading is queued for the next write. The
|
|
426
|
+
* write applies the fence again, so a rebind between the two still refuses.
|
|
419
427
|
*/
|
|
420
|
-
export function recordVaultQuota(path, input) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// A bound account is `unclaimed`: the vault's list has not named it, and
|
|
428
|
-
// the next projection keeps it as the account this credential last
|
|
429
|
-
// logged into until the vault names one.
|
|
430
|
-
const bound = fence === 'bind'
|
|
431
|
-
? { accountIdentity: input.accountIdentity, unclaimed: true }
|
|
432
|
-
: {};
|
|
433
|
-
return {
|
|
434
|
-
next: {
|
|
435
|
-
...current,
|
|
436
|
-
rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
|
|
437
|
-
},
|
|
438
|
-
result: true,
|
|
439
|
-
};
|
|
428
|
+
export async function recordVaultQuota(path, input) {
|
|
429
|
+
const { routeId, observation, ...receipt } = input;
|
|
430
|
+
return enqueueFencedVaultQuota(path, {
|
|
431
|
+
routeId,
|
|
432
|
+
observation,
|
|
433
|
+
accountIdentity: receipt.accountIdentity,
|
|
434
|
+
receipt,
|
|
440
435
|
});
|
|
441
436
|
}
|
|
437
|
+
// The answer callers rely on (false for a replaced account) comes from the
|
|
438
|
+
// fence, so it runs here against the current roster before anything is
|
|
439
|
+
// queued. The read takes no lock: the roster file is replaced atomically.
|
|
440
|
+
async function enqueueFencedVaultQuota(path, publication) {
|
|
441
|
+
if (!isQuotaObservation(publication.observation))
|
|
442
|
+
return false;
|
|
443
|
+
const current = pendingVaultQuota(path, await readVaultRoster(path));
|
|
444
|
+
if (!applyVaultQuota(current, publication).kept)
|
|
445
|
+
return false;
|
|
446
|
+
return enqueueVaultQuota(path, publication);
|
|
447
|
+
}
|
|
442
448
|
/**
|
|
443
449
|
* Merge a quota observation that belongs to an account rather than to a
|
|
444
450
|
* served credential, such as a background reading from insula. The write
|
|
445
451
|
* lands only while the row is still bound to that exact known account, so a
|
|
446
452
|
* reading taken before a replacement never reaches the successor. Unlike
|
|
447
453
|
* `recordVaultQuota` it never binds an account: a row without a known account
|
|
448
|
-
* is refused.
|
|
454
|
+
* is refused. Resolves false when the row is not bound to that account now,
|
|
455
|
+
* and true once the reading is queued, as `recordVaultQuota` does.
|
|
449
456
|
*/
|
|
450
457
|
export function recordVaultAccountQuota(path, input) {
|
|
451
|
-
return
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
};
|
|
466
|
-
|
|
458
|
+
return enqueueFencedVaultQuota(path, input);
|
|
459
|
+
}
|
|
460
|
+
/** Apply the same identity fence to memory overlays and durable quota batches. */
|
|
461
|
+
export function applyVaultQuota(current, input) {
|
|
462
|
+
const row = current?.rows.find((entry) => entry.routeId === input.routeId);
|
|
463
|
+
const fence = !row
|
|
464
|
+
? 'refuse'
|
|
465
|
+
: input.receipt
|
|
466
|
+
? receiptFence(row, input.receipt)
|
|
467
|
+
: row.accountIdentity !== undefined &&
|
|
468
|
+
row.accountIdentity === input.accountIdentity
|
|
469
|
+
? 'hold'
|
|
470
|
+
: 'refuse';
|
|
471
|
+
if (!current || !row || fence === 'refuse')
|
|
472
|
+
return { roster: current, kept: false };
|
|
473
|
+
const quota = mergeQuotaObservation(row.quota, input.observation);
|
|
474
|
+
// Binding remembers which account this credential proved, without claiming
|
|
475
|
+
// that the vault's discovery has named that account yet.
|
|
476
|
+
const bound = fence === 'bind'
|
|
477
|
+
? { accountIdentity: input.accountIdentity, unclaimed: true }
|
|
478
|
+
: {};
|
|
479
|
+
return {
|
|
480
|
+
roster: {
|
|
481
|
+
...current,
|
|
482
|
+
rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
|
|
483
|
+
},
|
|
484
|
+
kept: true,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const pendingDiscoveries = new Map();
|
|
488
|
+
export function forgetPendingVaultDiscovery(path) {
|
|
489
|
+
pendingDiscoveries.delete(resolve(path));
|
|
467
490
|
}
|
|
468
491
|
/**
|
|
469
492
|
* Discover and commit the roster. The lease covers the list call and the
|
|
@@ -471,12 +494,16 @@ export function recordVaultAccountQuota(path, input) {
|
|
|
471
494
|
* from a newer one, so a run that lost its lease is refused at commit. A
|
|
472
495
|
* failed list, or custody switching off mid-run, never writes anything; a
|
|
473
496
|
* peer holding the lease means its last committed roster is served instead.
|
|
497
|
+
* A write-lock timeout keeps the discovered inventory for the next poll,
|
|
498
|
+
* unless a peer has superseded its base view in the meantime.
|
|
474
499
|
*/
|
|
475
500
|
export async function refreshVaultRoster(options) {
|
|
476
501
|
options.signal?.throwIfAborted();
|
|
477
502
|
const active = async () => (await options.isActive?.()) ?? true;
|
|
478
|
-
if (!(await active()))
|
|
503
|
+
if (!(await active())) {
|
|
504
|
+
forgetPendingVaultDiscovery(options.path);
|
|
479
505
|
return undefined;
|
|
506
|
+
}
|
|
480
507
|
const lease = await acquireRefreshFileLock({
|
|
481
508
|
...DISCOVERY_LEASE,
|
|
482
509
|
path: options.path,
|
|
@@ -489,8 +516,25 @@ export async function refreshVaultRoster(options) {
|
|
|
489
516
|
throw new ClaustrumConsumerError('roster-busy', 'Claustrum account discovery is already in progress');
|
|
490
517
|
return persisted;
|
|
491
518
|
}
|
|
519
|
+
const key = resolve(options.path);
|
|
520
|
+
let discovered;
|
|
492
521
|
try {
|
|
493
|
-
const
|
|
522
|
+
const previous = await readVaultRoster(options.path);
|
|
523
|
+
const pending = pendingDiscoveries.get(key);
|
|
524
|
+
// Opaque view cursors cannot be ordered. Reuse a discovery only while no
|
|
525
|
+
// peer has committed a different view since it was read.
|
|
526
|
+
discovered =
|
|
527
|
+
pending &&
|
|
528
|
+
(pending.previousView === previous?.view ||
|
|
529
|
+
pending.inventory.view === previous?.view)
|
|
530
|
+
? pending
|
|
531
|
+
: {
|
|
532
|
+
inventory: await options.custody.discover(options.signal),
|
|
533
|
+
previousView: previous?.view,
|
|
534
|
+
};
|
|
535
|
+
pendingDiscoveries.delete(key);
|
|
536
|
+
const { inventory } = discovered;
|
|
537
|
+
options.onInventory?.(inventory);
|
|
494
538
|
options.signal?.throwIfAborted();
|
|
495
539
|
return await mutateVaultRoster(options.path, async (current) => {
|
|
496
540
|
if (!(await active()))
|
|
@@ -503,8 +547,21 @@ export async function refreshVaultRoster(options) {
|
|
|
503
547
|
const next = projectVaultRoster(current, inventory, projection);
|
|
504
548
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
505
549
|
return { ...(changed && { next }), result: next };
|
|
550
|
+
}, {
|
|
551
|
+
beforePublish: async () => {
|
|
552
|
+
options.signal?.throwIfAborted();
|
|
553
|
+
await lease.assertOwned();
|
|
554
|
+
},
|
|
506
555
|
});
|
|
507
556
|
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
if (error instanceof LockContentionError &&
|
|
559
|
+
discovered &&
|
|
560
|
+
!options.signal?.aborted &&
|
|
561
|
+
(await active()))
|
|
562
|
+
pendingDiscoveries.set(key, discovered);
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
508
565
|
finally {
|
|
509
566
|
await lease.release();
|
|
510
567
|
}
|
|
@@ -28,6 +28,7 @@ export interface LockOptions {
|
|
|
28
28
|
ttlMs: number;
|
|
29
29
|
timeoutMs: number;
|
|
30
30
|
renew?: boolean;
|
|
31
|
+
signal?: AbortSignal;
|
|
31
32
|
onStep?: Parameters<typeof acquireRefreshFileLock>[0]['onStep'];
|
|
32
33
|
onRenewalTimer?: Parameters<typeof acquireRefreshFileLock>[0]['onRenewalTimer'];
|
|
33
34
|
}
|
|
@@ -21,6 +21,7 @@ export class LockOwnershipError extends Error {
|
|
|
21
21
|
export async function withLock(target, options, fn) {
|
|
22
22
|
const started = performance.now();
|
|
23
23
|
for (;;) {
|
|
24
|
+
options.signal?.throwIfAborted();
|
|
24
25
|
const lock = await acquireRefreshFileLock({
|
|
25
26
|
path: target,
|
|
26
27
|
name: options.name,
|
|
@@ -33,6 +34,7 @@ export async function withLock(target, options, fn) {
|
|
|
33
34
|
});
|
|
34
35
|
if (lock) {
|
|
35
36
|
try {
|
|
37
|
+
options.signal?.throwIfAborted();
|
|
36
38
|
return await fn(lock);
|
|
37
39
|
}
|
|
38
40
|
finally {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type QuotaCredits } from './map.js';
|
|
1
2
|
import type { ProjectedLimit, ProjectedQuota } from './projection.js';
|
|
2
3
|
/** A reading older than this is marked with its age. */
|
|
3
4
|
export declare const QUOTA_STALE_AFTER_MS: number;
|
|
@@ -31,6 +32,13 @@ export interface QuotaWindowPart {
|
|
|
31
32
|
kind: 'window' | 'budget';
|
|
32
33
|
/** The display name of a quota window, or `credits` for the budget. */
|
|
33
34
|
name: string;
|
|
35
|
+
/**
|
|
36
|
+
* The label the narrow line gives this item: an account-wide window keeps
|
|
37
|
+
* its name (`5h`), a model-scoped window reads as its model's short name
|
|
38
|
+
* (`Fa` for Fable, extended until names in the same reading differ), and
|
|
39
|
+
* the budget reads `cr`. For layouts too narrow for the full name.
|
|
40
|
+
*/
|
|
41
|
+
short: string;
|
|
34
42
|
/** The shared figure wording, including `credits` for budget figures. */
|
|
35
43
|
figure: string;
|
|
36
44
|
/** The unrounded remaining percentage, clamped to 0–100, when known. */
|
|
@@ -84,3 +92,14 @@ export declare function quotaTextParts(quota: ProjectedQuota | undefined, option
|
|
|
84
92
|
* provider answered without a limit for any window.
|
|
85
93
|
*/
|
|
86
94
|
export declare function formatQuota(quota: ProjectedQuota | undefined, options: QuotaTextOptions): string;
|
|
95
|
+
/**
|
|
96
|
+
* Prepaid credits as one short value, worded as the official Codex client's
|
|
97
|
+
* status view words them: `Unlimited` for unlimited credits, `<n> credits`
|
|
98
|
+
* for a finite positive balance (rounded to a whole number), `Available` when
|
|
99
|
+
* the account has credits but no usable balance was reported, and undefined
|
|
100
|
+
* when the account has no credits (or `credits` is undefined), so a caller
|
|
101
|
+
* shows no credits row at all. Unlimited wins even when `hasCredits` is false.
|
|
102
|
+
* No other formatter adds this text; a caller that wants a credits row asks
|
|
103
|
+
* for it.
|
|
104
|
+
*/
|
|
105
|
+
export declare function quotaCreditsText(credits: Pick<QuotaCredits, 'hasCredits' | 'unlimited' | 'balance'> | undefined): string | undefined;
|