+1
-1
@@ -59,7 +59,7 @@ export default Plugin.define({
|
||||
await quotaRegistration?.events.emit("updated", {});
|
||||
});
|
||||
quotaRegistration = await context.rpc.register(QuotaRpc, {
|
||||
status: async ({ sessionID }) => (await quota.status(sessionID)) ?? null,
|
||||
status: async () => quota.status() ?? null,
|
||||
});
|
||||
await quota.init();
|
||||
// Subscribe before the first read: configuration can publish its models while
|
||||
|
||||
+1
-2
@@ -8,14 +8,13 @@ const window = z.object({
|
||||
used: z.number(),
|
||||
resetsAt: z.number(),
|
||||
remaining: z.number(),
|
||||
sessionConsumed: z.number(),
|
||||
});
|
||||
|
||||
export const QuotaRpc = Rpc.define({
|
||||
id: "stanp-opencode.quota",
|
||||
methods: {
|
||||
status: {
|
||||
input: z.object({ sessionID: z.string() }),
|
||||
input: z.object({}),
|
||||
output: z
|
||||
.object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) })
|
||||
.nullable(),
|
||||
|
||||
+30
-108
@@ -54,7 +54,7 @@ export interface QuotaSnapshot {
|
||||
export interface QuotaStatus {
|
||||
plan: string;
|
||||
fetchedAt: number;
|
||||
windows: Array<QuotaWindow & { remaining: number; sessionConsumed: number }>;
|
||||
windows: Array<QuotaWindow & { remaining: number }>;
|
||||
}
|
||||
|
||||
const storedState = z.object({
|
||||
@@ -74,14 +74,8 @@ const storedState = z.object({
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
sessions: z.record(
|
||||
z.string(),
|
||||
z.record(
|
||||
z.string(),
|
||||
z.object({ resetsAt: z.number(), lastUsed: z.number(), consumed: z.number() }),
|
||||
),
|
||||
),
|
||||
});
|
||||
const legacyStoredState = storedState.extend({ sessions: z.unknown() });
|
||||
type StoredState = z.infer<typeof storedState>;
|
||||
|
||||
function withoutExcludedState(state: StoredState): StoredState {
|
||||
@@ -89,20 +83,10 @@ function withoutExcludedState(state: StoredState): StoredState {
|
||||
return {
|
||||
...state,
|
||||
latest: state.latest && windows?.length ? { ...state.latest, windows } : undefined,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(state.sessions).map(([session, tracked]) => [
|
||||
session,
|
||||
Object.fromEntries(
|
||||
Object.entries(tracked).filter(
|
||||
([id]) => !isExcludedLimit(id.split(":", 1)[0], undefined),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
type QuotaContext = Pick<Plugin.Context, "integration" | "session" | "storage">;
|
||||
type QuotaContext = Pick<Plugin.Context, "integration" | "storage">;
|
||||
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const quotaWindow = z.object({
|
||||
@@ -239,7 +223,8 @@ export class QuotaTracker {
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
private request: Promise<QuotaSnapshot | undefined> | undefined;
|
||||
private readonly requests = new Set<Promise<QuotaSnapshot | undefined>>();
|
||||
private readonly active = new Map<string, Set<string>>();
|
||||
private readonly migrations = new Set<Promise<void>>();
|
||||
private readonly active = new Set<string>();
|
||||
private controller: AbortController | undefined;
|
||||
private generation = 0;
|
||||
private disposed = false;
|
||||
@@ -276,14 +261,22 @@ export class QuotaTracker {
|
||||
: (this.state?.account ?? (await fingerprint(`credential:${credential.refresh}`)));
|
||||
if (generation !== this.generation) return;
|
||||
if (this.state?.account !== account) {
|
||||
const parsed = storedState.safeParse(await this.context.storage.get(STORAGE_KEY));
|
||||
const value = await this.context.storage.get(STORAGE_KEY);
|
||||
const parsed = storedState.safeParse(value);
|
||||
if (generation !== this.generation) return;
|
||||
const stored = parsed.success ? parsed.data : undefined;
|
||||
if (generation !== this.generation) return;
|
||||
this.state =
|
||||
stored?.account === account
|
||||
? withoutExcludedState(stored)
|
||||
: { account, sessions: {} };
|
||||
this.state = stored?.account === account ? withoutExcludedState(stored) : { account };
|
||||
const legacy = legacyStoredState.safeParse(value);
|
||||
if (stored?.account === account && legacy.success) {
|
||||
const migration = this.persist();
|
||||
this.migrations.add(migration);
|
||||
try {
|
||||
await migration;
|
||||
} finally {
|
||||
this.migrations.delete(migration);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (generation !== this.generation) return;
|
||||
if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs);
|
||||
@@ -345,7 +338,7 @@ export class QuotaTracker {
|
||||
if (parsed.success && parsed.data.account_id) {
|
||||
const account = await fingerprint(`account:${parsed.data.account_id}`);
|
||||
if (generation !== this.generation || !this.state) return;
|
||||
if (account !== this.state.account) this.state = { account, sessions: {} };
|
||||
if (account !== this.state.account) this.state = { account };
|
||||
}
|
||||
const snapshot = normalizeQuota(payload);
|
||||
if (!snapshot) return;
|
||||
@@ -360,102 +353,30 @@ export class QuotaTracker {
|
||||
|
||||
private apply(snapshot: QuotaSnapshot) {
|
||||
if (!this.state) return;
|
||||
for (const root of this.active.keys()) {
|
||||
const tracked = (this.state.sessions[root] ??= {});
|
||||
for (const window of snapshot.windows) {
|
||||
const previous = tracked[window.id];
|
||||
if (!previous) {
|
||||
tracked[window.id] = {
|
||||
resetsAt: window.resetsAt,
|
||||
lastUsed: window.used,
|
||||
consumed: 0,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (previous.resetsAt !== window.resetsAt) {
|
||||
previous.resetsAt = window.resetsAt;
|
||||
previous.lastUsed = window.used;
|
||||
previous.consumed += window.used;
|
||||
} else if (window.used >= previous.lastUsed) {
|
||||
previous.consumed += window.used - previous.lastUsed;
|
||||
previous.lastUsed = window.used;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.latest = snapshot;
|
||||
}
|
||||
|
||||
private async root(sessionID: string) {
|
||||
let current = sessionID;
|
||||
const seen = new Set<string>();
|
||||
while (!seen.has(current)) {
|
||||
seen.add(current);
|
||||
const session = await this.context.session.get({ sessionID: current });
|
||||
if (!session.parentID) return current;
|
||||
current = session.parentID;
|
||||
}
|
||||
return sessionID;
|
||||
}
|
||||
|
||||
async start(sessionID: string) {
|
||||
if (this.active.has(sessionID)) return;
|
||||
const generation = this.generation;
|
||||
try {
|
||||
const root = await this.root(sessionID);
|
||||
if (generation !== this.generation) return;
|
||||
const family = this.active.get(root);
|
||||
if (family?.has(sessionID)) return;
|
||||
const snapshot = await this.refresh();
|
||||
if (!snapshot || !this.state || generation !== this.generation) return;
|
||||
if (!family) {
|
||||
const previous = this.state.sessions[root] ?? {};
|
||||
this.state.sessions[root] = Object.fromEntries(
|
||||
snapshot.windows.map((window) => [
|
||||
window.id,
|
||||
{
|
||||
resetsAt: window.resetsAt,
|
||||
lastUsed: window.used,
|
||||
consumed: previous[window.id]?.consumed ?? 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
await this.persist();
|
||||
if (generation !== this.generation) return;
|
||||
await this.changed();
|
||||
if (generation !== this.generation) return;
|
||||
const active = family ?? new Set<string>();
|
||||
active.add(sessionID);
|
||||
this.active.set(root, active);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const snapshot = await this.refresh();
|
||||
if (!snapshot || generation !== this.generation) return;
|
||||
this.active.add(sessionID);
|
||||
}
|
||||
|
||||
async complete(sessionID: string) {
|
||||
try {
|
||||
const root = await this.root(sessionID);
|
||||
const family = this.active.get(root);
|
||||
if (!family?.has(sessionID)) return;
|
||||
await this.refresh();
|
||||
family.delete(sessionID);
|
||||
if (family.size === 0) this.active.delete(root);
|
||||
await this.persist();
|
||||
await this.changed();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!this.active.delete(sessionID)) return;
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async status(sessionID: string): Promise<QuotaStatus | undefined> {
|
||||
status(): QuotaStatus | undefined {
|
||||
if (!this.state?.latest) return;
|
||||
const tracked = this.state.sessions[await this.root(sessionID)];
|
||||
return {
|
||||
plan: this.state.latest.plan,
|
||||
fetchedAt: this.state.latest.fetchedAt,
|
||||
windows: this.state.latest.windows.map((window) => ({
|
||||
...window,
|
||||
remaining: Math.max(0, 100 - window.used),
|
||||
sessionConsumed: tracked?.[window.id]?.consumed ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -465,10 +386,9 @@ export class QuotaTracker {
|
||||
const value: Schema.Json = this.state.latest
|
||||
? {
|
||||
account: this.state.account,
|
||||
sessions: this.state.sessions,
|
||||
latest: this.state.latest,
|
||||
}
|
||||
: { account: this.state.account, sessions: this.state.sessions };
|
||||
: { account: this.state.account };
|
||||
await this.context.storage.set(STORAGE_KEY, value);
|
||||
}
|
||||
|
||||
@@ -478,6 +398,8 @@ export class QuotaTracker {
|
||||
this.controller?.abort();
|
||||
this.controller = undefined;
|
||||
this.request = undefined;
|
||||
this.active.clear();
|
||||
await Promise.all(this.migrations);
|
||||
if (this.state) await this.clear();
|
||||
}
|
||||
await this.refresh();
|
||||
@@ -489,6 +411,6 @@ export class QuotaTracker {
|
||||
this.controller?.abort();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
await Promise.all(this.requests);
|
||||
await Promise.all([...this.requests, ...this.migrations]);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-6
@@ -33,14 +33,11 @@ function resetTiming(resetsAt: number, now: number) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export function formatQuotaWindow(
|
||||
window: QuotaWindow & { remaining: number; sessionConsumed: number },
|
||||
now = Date.now(),
|
||||
) {
|
||||
export function formatQuotaWindow(window: QuotaWindow & { remaining: number }, now = Date.now()) {
|
||||
const label = window.label
|
||||
? `${window.label} ${duration(window.seconds)}`
|
||||
: duration(window.seconds);
|
||||
return `${label}: ${percent(window.remaining)}% · ~${percent(window.sessionConsumed)}pp · ${resetTiming(window.resetsAt, now)}`;
|
||||
return `${label}: ${percent(window.remaining)}% · ${resetTiming(window.resetsAt, now)}`;
|
||||
}
|
||||
|
||||
export async function loadQuotaStatus(load: () => Promise<QuotaStatus | null>) {
|
||||
@@ -66,7 +63,7 @@ export default Plugin.define({
|
||||
const Slot = (props: { sessionID: string }) => {
|
||||
const [status] = createResource(
|
||||
() => `${props.sessionID}:${revision()}`,
|
||||
() => loadQuotaStatus(() => quota.status({ sessionID: props.sessionID })),
|
||||
() => loadQuotaStatus(() => quota.status({})),
|
||||
);
|
||||
return (
|
||||
<Show when={status()}>
|
||||
|
||||
Reference in New Issue
Block a user