-
Notifications
You must be signed in to change notification settings - Fork 15
feat(feed): publish the Anthropic account uuid on every entry; reap stale leases #198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -49,6 +49,7 @@ export type AccountBase = { | |||||||||||
| export type OAuthAccount = AccountBase & { | ||||||||||||
| type: 'oauth' | ||||||||||||
| authLineageId?: string | ||||||||||||
| anthropicAccountUuid?: string | ||||||||||||
| claustrumHandle?: string | ||||||||||||
| access?: string | ||||||||||||
| refresh: string | ||||||||||||
|
|
@@ -556,6 +557,11 @@ function normalizeAccount(value: unknown): FallbackAccount | null { | |||||||||||
| typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim() | ||||||||||||
| ? value.claustrumHandle.trim() | ||||||||||||
| : undefined, | ||||||||||||
| anthropicAccountUuid: | ||||||||||||
| typeof value.anthropicAccountUuid === 'string' && | ||||||||||||
| value.anthropicAccountUuid.trim() | ||||||||||||
| ? value.anthropicAccountUuid.trim() | ||||||||||||
| : undefined, | ||||||||||||
| access: typeof value.access === 'string' ? value.access : undefined, | ||||||||||||
| refresh: value.refresh, | ||||||||||||
| expires: typeof value.expires === 'number' ? value.expires : undefined, | ||||||||||||
|
|
@@ -1197,6 +1203,7 @@ function accountRuntimeState(account: FallbackAccount) { | |||||||||||
| } | ||||||||||||
| return objectWithDefinedEntries({ | ||||||||||||
| authLineageId: account.authLineageId, | ||||||||||||
| anthropicAccountUuid: account.anthropicAccountUuid, | ||||||||||||
| claustrumHandle: account.claustrumHandle, | ||||||||||||
| access: account.access, | ||||||||||||
| refresh: account.refresh, | ||||||||||||
|
|
@@ -3714,10 +3721,16 @@ export function upsertAccount( | |||||||||||
| (account.label && candidate.label === account.label), | ||||||||||||
| ) | ||||||||||||
| if (index >= 0) { | ||||||||||||
| storage.accounts[index] = { | ||||||||||||
| ...storage.accounts[index], | ||||||||||||
| const existing = storage.accounts[index] | ||||||||||||
| if (!existing) return | ||||||||||||
| const lineageChanged = | ||||||||||||
| existing.type === 'oauth' && | ||||||||||||
| account.type === 'oauth' && | ||||||||||||
| existing.authLineageId !== account.authLineageId | ||||||||||||
| const updated: FallbackAccount = { | ||||||||||||
| ...existing, | ||||||||||||
| ...account, | ||||||||||||
| addedAt: storage.accounts[index]?.addedAt ?? account.addedAt, | ||||||||||||
| addedAt: existing.addedAt ?? account.addedAt, | ||||||||||||
| ...(account.type === 'oauth' && { | ||||||||||||
| quota: account.quota, | ||||||||||||
| profile: account.profile, | ||||||||||||
|
|
@@ -3726,11 +3739,69 @@ export function upsertAccount( | |||||||||||
| lastQuotaRefreshError: account.lastQuotaRefreshError, | ||||||||||||
| }), | ||||||||||||
| } | ||||||||||||
| if (lineageChanged && updated.type === 'oauth') { | ||||||||||||
| delete updated.anthropicAccountUuid | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+3743
to
+3744
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When an existing OAuth account has no Prompt for AI agents
Suggested change
|
||||||||||||
| storage.accounts[index] = updated | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| storage.accounts.push(account) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function persistFallbackQuotaHeaderPersistent( | ||||||||||||
| input: { | ||||||||||||
| accountId: string | ||||||||||||
| authLineageId?: string | ||||||||||||
| quota: OAuthQuotaSnapshot | ||||||||||||
| anthropicAccountUuid?: string | ||||||||||||
| }, | ||||||||||||
| path = getAccountStoragePath(), | ||||||||||||
| ): Promise<boolean> { | ||||||||||||
| return enqueueSave(async () => { | ||||||||||||
| const configLock = await acquireAccountConfigWriteLock(path) | ||||||||||||
| try { | ||||||||||||
| const stateLock = await acquireAccountStateWriteLock(path) | ||||||||||||
| try { | ||||||||||||
| const storage = await loadAccounts(path) | ||||||||||||
| const account = storage?.accounts.find( | ||||||||||||
| (candidate): candidate is OAuthAccount => | ||||||||||||
| candidate.id === input.accountId && isOAuthAccount(candidate), | ||||||||||||
| ) | ||||||||||||
| if ( | ||||||||||||
| !storage || | ||||||||||||
| !account || | ||||||||||||
| account.authLineageId !== input.authLineageId | ||||||||||||
| ) { | ||||||||||||
| return false | ||||||||||||
| } | ||||||||||||
| account.quota = { | ||||||||||||
| ...input.quota, | ||||||||||||
| accountIdentity: account.id, | ||||||||||||
| } | ||||||||||||
| if (input.anthropicAccountUuid !== undefined) { | ||||||||||||
| account.anthropicAccountUuid = input.anthropicAccountUuid | ||||||||||||
| } | ||||||||||||
| await saveAccountStateUnlocked(storage, path, { | ||||||||||||
| accounts: [input.accountId], | ||||||||||||
| }) | ||||||||||||
| return true | ||||||||||||
| } finally { | ||||||||||||
| await stateLock.release() | ||||||||||||
| } | ||||||||||||
| } finally { | ||||||||||||
| await configLock.release() | ||||||||||||
| } | ||||||||||||
| }) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function fallbackAccountUuidForLineage( | ||||||||||||
| account: OAuthAccount | undefined, | ||||||||||||
| authLineageId?: string, | ||||||||||||
| ): string | null { | ||||||||||||
| if (!account || account.authLineageId !== authLineageId) return null | ||||||||||||
| return account.anthropicAccountUuid ?? null | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function removeAccount(storage: AccountStorage, id: string): boolean { | ||||||||||||
| const index = storage.accounts.findIndex((c) => c.id === id) | ||||||||||||
| if (index < 0) return false | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,13 @@ | ||
| import { randomUUID } from 'node:crypto' | ||
| import { | ||
| chmod, | ||
| lstat, | ||
| mkdir, | ||
| readdir, | ||
| readFile, | ||
| rename, | ||
| rm, | ||
| stat, | ||
| writeFile, | ||
| } from 'node:fs/promises' | ||
| import { tmpdir } from 'node:os' | ||
|
|
@@ -28,6 +30,13 @@ import { | |
| export const QUOTA_HEADER_FEED_SCHEMA_VERSION = 3 | ||
| export const QUOTA_HEADER_FEED_LEASE_MS = 180_000 | ||
|
|
||
| /** | ||
| * Lease files are per process; each carries only the accounts whose response | ||
| * headers THAT process harvested. Consumers MUST union entries across all files | ||
| * inside `lease_horizon_ms`, deduplicating by account; "newest file wins" is wrong. | ||
| * `anthropic_account_uuid` is always present: null is unresolvable, while absence | ||
| * identifies an old producer. A fallback `account_ref` is store-local. | ||
| */ | ||
| export type QuotaHeaderFeedIdentity = | ||
| | { identity_source: 'credential_id'; credential_id: string } | ||
| | { identity_source: 'account_ref'; account_ref: string } | ||
|
|
@@ -59,11 +68,14 @@ type QuotaHeaderFeedMetadata = { | |
|
|
||
| export type QuotaHeaderFeedEntry = QuotaHeaderFeedIdentity & | ||
| QuotaHeaderFeedMetadata & { | ||
| /** Always present: null means UUID resolution failed; absence denotes an old producer. */ | ||
| anthropic_account_uuid: string | null | ||
| quota: QuotaHeaderFeedQuota | ||
| } | ||
|
|
||
| export type QuotaHeaderFeedPublishEntry = QuotaHeaderFeedIdentity & | ||
| QuotaHeaderFeedMetadata & { | ||
| anthropic_account_uuid: string | null | ||
| quota: Omit<QuotaHeaderFeedQuota, 'provenance'> & { | ||
| fieldSources?: QuotaFieldSources | ||
| } | ||
|
|
@@ -72,6 +84,7 @@ export type QuotaHeaderFeedPublishEntry = QuotaHeaderFeedIdentity & | |
|
|
||
| type FeedRecord = { | ||
| version: typeof QUOTA_HEADER_FEED_SCHEMA_VERSION | ||
| lease_horizon_ms: number | ||
| entries: Record<string, QuotaHeaderFeedEntry> | ||
| } | ||
|
|
||
|
|
@@ -105,6 +118,12 @@ function validIdentity( | |
| ) | ||
| return false | ||
| if (!entry.quota || typeof entry.quota !== 'object') return false | ||
| if ( | ||
| !Object.hasOwn(entry, 'anthropic_account_uuid') || | ||
| (entry.anthropic_account_uuid !== null && | ||
| typeof entry.anthropic_account_uuid !== 'string') | ||
| ) | ||
| return false | ||
| if (entry.identity_source === 'none') { | ||
| return !('credential_id' in entry) && !('account_ref' in entry) | ||
| } | ||
|
|
@@ -292,6 +311,8 @@ export class QuotaHeaderFeedRegistry { | |
| now?: () => number | ||
| leaseMs?: number | ||
| instanceId?: string | ||
| removeFile?: (path: string) => Promise<void> | ||
| beforeRemoveFile?: (path: string) => Promise<void> | ||
| } = {}, | ||
| ) { | ||
| const instanceId = options.instanceId ?? `${process.pid}-${randomUUID()}` | ||
|
|
@@ -302,16 +323,33 @@ export class QuotaHeaderFeedRegistry { | |
| } | ||
|
|
||
| publish(entry: QuotaHeaderFeedPublishEntry): Promise<void> { | ||
| const { accountKey, quota, ...entryWithoutQuota } = entry | ||
| const cleanEntry = { | ||
| ...entryWithoutQuota, | ||
| quota: projectQuota(quota), | ||
| } as QuotaHeaderFeedEntry | ||
| const { accountKey, quota } = entry | ||
| try { | ||
| validatePublishEntry(cleanEntry) | ||
| validatePublishEntry(entry) | ||
| } catch (error) { | ||
| return Promise.reject(error) | ||
| } | ||
| const identity = | ||
| entry.identity_source === 'credential_id' | ||
| ? { | ||
| identity_source: 'credential_id' as const, | ||
| credential_id: entry.credential_id, | ||
| } | ||
| : entry.identity_source === 'account_ref' | ||
| ? { | ||
| identity_source: 'account_ref' as const, | ||
| account_ref: entry.account_ref, | ||
| } | ||
| : { identity_source: 'none' as const } | ||
| const cleanEntry: QuotaHeaderFeedEntry = { | ||
| ...identity, | ||
| schema_version: entry.schema_version, | ||
| provider: entry.provider, | ||
| configured_account_count: entry.configured_account_count, | ||
| observed_at_ms: entry.observed_at_ms, | ||
| anthropic_account_uuid: entry.anthropic_account_uuid, | ||
| quota: projectQuota(quota), | ||
| } | ||
| if (!accountKey) | ||
| return Promise.reject(new Error('Invalid quota header feed account key')) | ||
| this.writeChain = this.writeChain | ||
|
|
@@ -321,6 +359,7 @@ export class QuotaHeaderFeedRegistry { | |
| this.options.directory ?? getDefaultQuotaHeaderFeedDirectory() | ||
| await mkdir(directory, { recursive: true, mode: 0o700 }) | ||
| await chmod(directory, 0o700) | ||
| await this.reapStaleSiblingLeases(directory) | ||
| let entries: Record<string, QuotaHeaderFeedEntry> = {} | ||
| try { | ||
| const record = JSON.parse( | ||
|
|
@@ -338,7 +377,7 @@ export class QuotaHeaderFeedRegistry { | |
| try { | ||
| await writeFile( | ||
| tempPath, | ||
| `${JSON.stringify({ version: QUOTA_HEADER_FEED_SCHEMA_VERSION, entries })}\n`, | ||
| `${JSON.stringify({ version: QUOTA_HEADER_FEED_SCHEMA_VERSION, lease_horizon_ms: this.options.leaseMs ?? QUOTA_HEADER_FEED_LEASE_MS, entries })}\n`, | ||
| { mode: 0o600 }, | ||
| ) | ||
| await chmod(tempPath, 0o600) | ||
|
|
@@ -350,6 +389,36 @@ export class QuotaHeaderFeedRegistry { | |
| return this.writeChain | ||
| } | ||
|
|
||
| private async reapStaleSiblingLeases(directory: string): Promise<void> { | ||
| const now = this.options.now?.() ?? Date.now() | ||
| const leaseMs = this.options.leaseMs ?? QUOTA_HEADER_FEED_LEASE_MS | ||
| let names: string[] | ||
| try { | ||
| names = await readdir(directory) | ||
| } catch { | ||
| return | ||
| } | ||
| await Promise.all( | ||
| names | ||
| .filter((name) => /^\d+-[0-9a-f-]+\.json$/i.test(name)) | ||
| .map(async (name) => { | ||
| const path = join(directory, name) | ||
| if (path === this.filePath) return | ||
| try { | ||
| const file = await stat(path) | ||
| if (file.mtimeMs > now || now - file.mtimeMs < leaseMs) return | ||
| await this.options.beforeRemoveFile?.(path) | ||
| const current = await lstat(path) | ||
| if (current.ino !== file.ino || current.mtimeMs !== file.mtimeMs) | ||
| return | ||
| await (this.options.removeFile ?? ((target) => rm(target)))(path) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When another process publishes concurrently, this Prompt for AI agents |
||
| } catch { | ||
| // A missed cleanup must not prevent this process from refreshing its lease. | ||
| } | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| async list(): Promise<QuotaHeaderFeedEntry[]> { | ||
| await this.writeChain.catch(() => {}) | ||
| const directory = | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.