agent-dealer 1.2.1 → 1.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/bundle/server/dist/adapters/linear-inbox.js +9 -5
- package/bundle/server/dist/capacity/adapter.js +2 -0
- package/bundle/server/dist/capacity/claude-events.js +45 -10
- package/bundle/server/dist/capacity/claude-events.test.js +7 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +788 -0
- package/bundle/server/dist/capacity/claude-local-cache.test.js +520 -0
- package/bundle/server/dist/capacity/codex-app-server.js +100 -10
- package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +425 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +369 -0
- package/bundle/server/dist/capacity/cursor-individual.js +1037 -0
- package/bundle/server/dist/capacity/cursor-individual.test.js +1081 -0
- package/bundle/server/dist/capacity/muse-host.js +748 -0
- package/bundle/server/dist/capacity/muse-host.test.js +480 -0
- package/bundle/server/dist/capacity/muse-lifecycle.test.js +97 -0
- package/bundle/server/dist/capacity/muse.js +389 -91
- package/bundle/server/dist/capacity/muse.test.js +100 -61
- package/bundle/server/dist/capacity/service.js +1 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
- package/bundle/server/dist/db/index.js +30 -0
- package/bundle/server/dist/db/schema.sql +27 -0
- package/bundle/server/dist/index.js +17 -2
- package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
- package/bundle/server/dist/repository/repository-mappings.js +46 -0
- package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
- package/bundle/server/dist/repository/runtime-capacity.js +8 -3
- package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
- package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
- package/bundle/server/dist/routes/index.js +58 -9
- package/bundle/server/dist/routes/intake-linear.test.js +55 -1
- package/bundle/server/dist/routes/repository-mappings.js +22 -0
- package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
- package/bundle/server/dist/routes/runtime-capacity.test.js +84 -8
- package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
- package/bundle/server/dist/runners/muse-serve-session.js +313 -0
- package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
- package/bundle/server/static-ui/assets/index-CYqRh_-S.css +1 -0
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
- package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +6 -6
- package/bundle/shared/dist/linear-intake.d.ts +71 -0
- package/bundle/shared/dist/linear-intake.js +90 -0
- package/bundle/shared/dist/linear-intake.test.js +80 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
- package/bundle/shared/dist/runtime-capacity.js +60 -0
- package/bundle/shared/dist/runtime-capacity.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +57 -0
- package/dist/doctor.js +211 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +246 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-ChnI0F02.css +0 -1
- package/bundle/server/static-ui/assets/index-CyB1573Q.js +0 -60
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
// packages/server/src/capacity/cursor-individual.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-250: Cursor Individual capacity over an opt-in EXPERIMENTAL dashboard
|
|
4
|
+
// adapter.
|
|
5
|
+
//
|
|
6
|
+
// Cursor documents monthly individual usage and exposes it in the Spending
|
|
7
|
+
// dashboard, but the supported CLI exposes no usage surface. Community tools
|
|
8
|
+
// commonly call authenticated dashboard endpoints (usage-summary /
|
|
9
|
+
// current-period routes) using the existing local Cursor login. Those
|
|
10
|
+
// endpoints and credential formats are NOT a supported public contract and
|
|
11
|
+
// may change without notice — so this adapter is:
|
|
12
|
+
//
|
|
13
|
+
// - DISABLED BY DEFAULT behind the explicit opt-in
|
|
14
|
+
// `AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY=experimental`. Any other value
|
|
15
|
+
// (including unset) short-circuits BEFORE any credential file is read and
|
|
16
|
+
// before any HTTP is attempted, and serves `enabled: false` N/A.
|
|
17
|
+
// - Labeled `experimental_api` everywhere it surfaces (API source,
|
|
18
|
+
// diagnostics, UI badge, docs).
|
|
19
|
+
// - Minimal-credential: the raw token lives only in one in-memory auth
|
|
20
|
+
// header for the single dashboard call. It is never persisted (see
|
|
21
|
+
// repository/cursor-individual-billing.ts), never returned to the browser,
|
|
22
|
+
// and never logged — tests pin all three.
|
|
23
|
+
// - Bounded: strict origin/path allowlisting, manual redirect handling that
|
|
24
|
+
// rejects unknown origins, per-request timeout, response-size cap, and
|
|
25
|
+
// response-schema validation. A broken endpoint degrades to N/A with an
|
|
26
|
+
// explicit reason; it never throws out of read(), never touches
|
|
27
|
+
// `runtime_availability` (NOT-111 health), and never breaks job execution.
|
|
28
|
+
// - Forward-compatible: `supportedReader` is checked FIRST — if Cursor ships
|
|
29
|
+
// a supported Individual usage surface, wiring its reader as the default
|
|
30
|
+
// below makes the dashboard path the fallback automatically, with no
|
|
31
|
+
// operator action and no opt-in change.
|
|
32
|
+
//
|
|
33
|
+
// Billing-cycle values only (label/start/end, reported usage, remaining
|
|
34
|
+
// percent): no 5H/1W-style windows are invented from monthly data, and money
|
|
35
|
+
// is never rendered as a token percentage.
|
|
36
|
+
import { remainingPercentFromFraction, remainingPercentFromUsedPercent } from "@agent-dealer/shared";
|
|
37
|
+
import { DEFAULT_EXPIRES_AFTER_MS, DEFAULT_STALE_AFTER_MS } from "./adapter.js";
|
|
38
|
+
import { cursorIndividualCredentialStatus, loadCursorIndividualCredential, } from "./cursor-individual-credentials.js";
|
|
39
|
+
import { normalizeCursorApiDate } from "./cursor-team.js";
|
|
40
|
+
export const CURSOR_INDIVIDUAL_RUNTIME = "cursor_local";
|
|
41
|
+
/** Explicit opt-in env: the ONLY value that enables the dashboard read. */
|
|
42
|
+
export const CURSOR_INDIVIDUAL_OPT_IN_ENV = "AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY";
|
|
43
|
+
/** The opt-in value. Anything else (including unset) means disabled. */
|
|
44
|
+
export const CURSOR_INDIVIDUAL_OPT_IN_VALUE = "experimental";
|
|
45
|
+
/** Base URL override for tests (mock HTTP servers). Never set in production. */
|
|
46
|
+
export const CURSOR_INDIVIDUAL_BASE_URL_ENV = "CURSOR_INDIVIDUAL_API_BASE_URL";
|
|
47
|
+
/** Per-request timeout override (mirrors the Team adapter). */
|
|
48
|
+
export const CURSOR_INDIVIDUAL_TIMEOUT_ENV = "AGENT_DEALER_CURSOR_INDIVIDUAL_TIMEOUT_MS";
|
|
49
|
+
/** Opt-out for the on-demand stale refresh (mirrors the Team/Codex adapters). */
|
|
50
|
+
export const CURSOR_INDIVIDUAL_REFRESH_ENV = "AGENT_DEALER_CURSOR_INDIVIDUAL_REFRESH";
|
|
51
|
+
/**
|
|
52
|
+
* Undocumented dashboard origins the adapter may talk to. `www.cursor.com`
|
|
53
|
+
* canonicalizes to `cursor.com` (a same-site redirect), so the bare domain
|
|
54
|
+
* must be allowlisted too or that redirect itself reads `unsafe-redirect`.
|
|
55
|
+
* Community tools target the Cursor web dashboard for these routes; the
|
|
56
|
+
* allowlist is exactly these origins — redirects or config pointing
|
|
57
|
+
* anywhere else are rejected before any credential is sent.
|
|
58
|
+
*/
|
|
59
|
+
export const CURSOR_INDIVIDUAL_ALLOWED_ORIGINS = [
|
|
60
|
+
"https://cursor.com",
|
|
61
|
+
"https://www.cursor.com",
|
|
62
|
+
"https://api.cursor.com",
|
|
63
|
+
];
|
|
64
|
+
/** Default dashboard origin (community-observed; undocumented, may drift). */
|
|
65
|
+
export const CURSOR_INDIVIDUAL_DEFAULT_ORIGIN = "https://cursor.com";
|
|
66
|
+
/**
|
|
67
|
+
* Candidate dashboard usage routes, tried in order. The first 2xx carrying
|
|
68
|
+
* a usable billing value wins; a 404 moves to the next candidate (endpoint
|
|
69
|
+
* drift); auth/transport failures dominate immediately. Both entries name
|
|
70
|
+
* the community-observed usage-summary/current-period shape from the brief —
|
|
71
|
+
* neither is a supported contract.
|
|
72
|
+
*/
|
|
73
|
+
export const CURSOR_INDIVIDUAL_USAGE_PATHS = [
|
|
74
|
+
"/api/usage-summary/current-period",
|
|
75
|
+
"/api/usage-summary",
|
|
76
|
+
];
|
|
77
|
+
/** Cap on a dashboard response body (larger bodies read `malformed`). */
|
|
78
|
+
export const CURSOR_INDIVIDUAL_MAX_BODY_BYTES = 512 * 1024;
|
|
79
|
+
/** Redirect hops followed per read before giving up (allowlisted origins only). */
|
|
80
|
+
export const CURSOR_INDIVIDUAL_MAX_REDIRECTS = 3;
|
|
81
|
+
/**
|
|
82
|
+
* Cooldown after a failed poll before `refreshCursorIndividualBillingIfStale()`
|
|
83
|
+
* polls again (mirrors the Team adapter's 429/5xx backoff).
|
|
84
|
+
*/
|
|
85
|
+
export const CURSOR_INDIVIDUAL_FAILURE_BACKOFF_MS = 60_000;
|
|
86
|
+
/** Server-side evidence pointers only — never credentials or raw payloads. */
|
|
87
|
+
export const CURSOR_INDIVIDUAL_EVIDENCE = "cursor-dashboard:usage-summary/current-period";
|
|
88
|
+
/** The billing-cycle window identity in `runtime_capacity_snapshots`. */
|
|
89
|
+
export const CURSOR_INDIVIDUAL_WINDOW_KEY = "billing_cycle";
|
|
90
|
+
export const CURSOR_INDIVIDUAL_PROVIDER_BUCKET = "individual";
|
|
91
|
+
export const CURSOR_INDIVIDUAL_PROVIDER_LABEL = "billing cycle";
|
|
92
|
+
/** True only with the explicit experimental opt-in. No silent opt-in, ever. */
|
|
93
|
+
export function isCursorIndividualExperimentalEnabled() {
|
|
94
|
+
return process.env[CURSOR_INDIVIDUAL_OPT_IN_ENV] === CURSOR_INDIVIDUAL_OPT_IN_VALUE;
|
|
95
|
+
}
|
|
96
|
+
export function cursorIndividualBaseUrl() {
|
|
97
|
+
const raw = process.env[CURSOR_INDIVIDUAL_BASE_URL_ENV]?.trim();
|
|
98
|
+
const base = raw ? raw : CURSOR_INDIVIDUAL_DEFAULT_ORIGIN;
|
|
99
|
+
return base.replace(/\/+$/, "");
|
|
100
|
+
}
|
|
101
|
+
/** Per-request timeout (each dashboard fetch gets this budget). */
|
|
102
|
+
export function cursorIndividualTimeoutMs() {
|
|
103
|
+
const raw = process.env[CURSOR_INDIVIDUAL_TIMEOUT_ENV];
|
|
104
|
+
if (raw !== undefined && raw !== "") {
|
|
105
|
+
const n = Number(raw);
|
|
106
|
+
if (Number.isFinite(n) && n > 0)
|
|
107
|
+
return n;
|
|
108
|
+
}
|
|
109
|
+
return 10_000;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Operator-safe failure log: the static kind only, never tokens or payloads.
|
|
113
|
+
* A rejected login (401/403) means the local Cursor session the adapter
|
|
114
|
+
* borrowed is expired or revoked — the operator must refresh the Cursor
|
|
115
|
+
* login itself (desktop app or `cursor agent login`); Dealer never mutates
|
|
116
|
+
* Cursor auth, so the hint is static text, not a credential action.
|
|
117
|
+
*/
|
|
118
|
+
function logFailure(kind) {
|
|
119
|
+
const hint = kind === "forbidden" ? " (refresh the Cursor login and retry — Dealer never touches Cursor auth)" : "";
|
|
120
|
+
console.error(`[cursor-individual-capacity] dashboard read failed: ${kind}${hint}`);
|
|
121
|
+
}
|
|
122
|
+
/** Default reader: no supported surface exists yet — always null. */
|
|
123
|
+
export function defaultSupportedUsageReader() {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Undocumented-shape parsing: only the fields below are recognized —
|
|
128
|
+
// billing-cycle label/start/end, a reported usage value/unit, and a usable
|
|
129
|
+
// remaining/used scale. Anything else yields nulls (the caller maps "no
|
|
130
|
+
// usable value" to `unparsable`), never guesses. In particular: no durations
|
|
131
|
+
// are invented (monthly data never becomes a 5H/1W window) and no cycle
|
|
132
|
+
// label is synthesized when the dashboard reports none.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
function asFiniteNumber(value) {
|
|
135
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
136
|
+
}
|
|
137
|
+
function pickNumber(obj, keys) {
|
|
138
|
+
for (const k of keys) {
|
|
139
|
+
const n = asFiniteNumber(obj[k]);
|
|
140
|
+
if (n !== null)
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
function pickString(obj, keys) {
|
|
146
|
+
for (const k of keys) {
|
|
147
|
+
const v = obj[k];
|
|
148
|
+
if (typeof v === "string" && v.trim())
|
|
149
|
+
return v.trim();
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
function pickRaw(obj, keys) {
|
|
154
|
+
for (const k of keys) {
|
|
155
|
+
if (obj[k] !== undefined && obj[k] !== null)
|
|
156
|
+
return obj[k];
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const CYCLE_LABEL_KEYS = [
|
|
161
|
+
"cycleLabel",
|
|
162
|
+
"cycle_label",
|
|
163
|
+
"billingPeriod",
|
|
164
|
+
"billing_period",
|
|
165
|
+
"period",
|
|
166
|
+
"periodLabel",
|
|
167
|
+
"period_label",
|
|
168
|
+
"month",
|
|
169
|
+
];
|
|
170
|
+
const CYCLE_START_KEYS = [
|
|
171
|
+
"cycleStart",
|
|
172
|
+
"cycle_start",
|
|
173
|
+
"billingCycleStart",
|
|
174
|
+
"billing_cycle_start",
|
|
175
|
+
"periodStart",
|
|
176
|
+
"period_start",
|
|
177
|
+
"startDate",
|
|
178
|
+
"start_date",
|
|
179
|
+
"currentPeriodStart",
|
|
180
|
+
"current_period_start",
|
|
181
|
+
];
|
|
182
|
+
const CYCLE_END_KEYS = [
|
|
183
|
+
"cycleEnd",
|
|
184
|
+
"cycle_end",
|
|
185
|
+
"billingCycleEnd",
|
|
186
|
+
"billing_cycle_end",
|
|
187
|
+
"periodEnd",
|
|
188
|
+
"period_end",
|
|
189
|
+
"endDate",
|
|
190
|
+
"end_date",
|
|
191
|
+
"resetAt",
|
|
192
|
+
"reset_at",
|
|
193
|
+
"resetDate",
|
|
194
|
+
"reset_date",
|
|
195
|
+
"currentPeriodEnd",
|
|
196
|
+
"current_period_end",
|
|
197
|
+
];
|
|
198
|
+
const USAGE_VALUE_KEYS = [
|
|
199
|
+
"usageValue",
|
|
200
|
+
"usage_value",
|
|
201
|
+
"usage",
|
|
202
|
+
"used",
|
|
203
|
+
"spent",
|
|
204
|
+
"totalUsage",
|
|
205
|
+
"total_usage",
|
|
206
|
+
"spendValue",
|
|
207
|
+
"spend_value",
|
|
208
|
+
"costInCents",
|
|
209
|
+
"cost_in_cents",
|
|
210
|
+
];
|
|
211
|
+
const USAGE_UNIT_KEYS = ["usageUnit", "usage_unit", "unit", "currency"];
|
|
212
|
+
const USED_PERCENT_KEYS = [
|
|
213
|
+
"usedPercent",
|
|
214
|
+
"used_percent",
|
|
215
|
+
"usagePercent",
|
|
216
|
+
"usage_percent",
|
|
217
|
+
"totalPercentUsed",
|
|
218
|
+
"total_percent_used",
|
|
219
|
+
];
|
|
220
|
+
// NOTE (NOT-267): `apiPercentUsed` is deliberately NOT listed anywhere here.
|
|
221
|
+
// The live payload reports per-pool scales (e.g. an API-model pool) next to
|
|
222
|
+
// the account-total `individualUsage.plan.totalPercentUsed` — substituting a
|
|
223
|
+
// pool scale for the account total would misreport personal capacity, so a
|
|
224
|
+
// payload carrying ONLY a pool scale reads as no usable scale (unparsable),
|
|
225
|
+
// never as the account total.
|
|
226
|
+
const USED_FRACTION_KEYS = ["usedFraction", "used_fraction", "usageFraction", "usage_fraction"];
|
|
227
|
+
const REMAINING_PERCENT_KEYS = [
|
|
228
|
+
"remainingPercent",
|
|
229
|
+
"remaining_percent",
|
|
230
|
+
"percentRemaining",
|
|
231
|
+
"percent_remaining",
|
|
232
|
+
];
|
|
233
|
+
const REMAINING_FRACTION_KEYS = ["remainingFraction", "remaining_fraction"];
|
|
234
|
+
/**
|
|
235
|
+
* Last-resort scale: the plan's explicit used-vs-limit pair (e.g. spend vs
|
|
236
|
+
* budget), read ONLY when no percent/fraction/remaining scale is present.
|
|
237
|
+
* Money-vs-money and count-vs-count both reduce to the same share — but
|
|
238
|
+
* this never mixes with the percent scales above, and never with a pool
|
|
239
|
+
* scale: only the `individualUsage.plan` pair counts, never merged
|
|
240
|
+
* top-level fields.
|
|
241
|
+
*/
|
|
242
|
+
const USED_AMOUNT_KEYS = [
|
|
243
|
+
"used",
|
|
244
|
+
"usage",
|
|
245
|
+
"usedCredits",
|
|
246
|
+
"used_credits",
|
|
247
|
+
"consumed",
|
|
248
|
+
"consumedCredits",
|
|
249
|
+
"consumed_credits",
|
|
250
|
+
];
|
|
251
|
+
const LIMIT_AMOUNT_KEYS = [
|
|
252
|
+
"limit",
|
|
253
|
+
"quota",
|
|
254
|
+
"allowance",
|
|
255
|
+
"totalLimit",
|
|
256
|
+
"total_limit",
|
|
257
|
+
"limitValue",
|
|
258
|
+
"limit_value",
|
|
259
|
+
"monthlyLimit",
|
|
260
|
+
"monthly_limit",
|
|
261
|
+
];
|
|
262
|
+
function clampPercent(n) {
|
|
263
|
+
return Math.min(100, Math.max(0, n));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Normalize one dashboard usage-summary payload. Returns null when the
|
|
267
|
+
* payload is not an object at all, or when it carries no usable billing
|
|
268
|
+
* value (no cycle label/start/end AND no usage/remaining scale) — the caller
|
|
269
|
+
* maps that to `unparsable`.
|
|
270
|
+
*/
|
|
271
|
+
function individualUsagePlan(p) {
|
|
272
|
+
const usage = p.individualUsage;
|
|
273
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage))
|
|
274
|
+
return null;
|
|
275
|
+
const plan = usage.plan;
|
|
276
|
+
return plan && typeof plan === "object" && !Array.isArray(plan) ? plan : null;
|
|
277
|
+
}
|
|
278
|
+
export function cursorIndividualPayloadToReadings(payload) {
|
|
279
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
280
|
+
return null;
|
|
281
|
+
const top = payload;
|
|
282
|
+
// The live payload nests its usage scale under `individualUsage.plan`
|
|
283
|
+
// (e.g. `totalPercentUsed`) while the cycle dates stay top-level — merge
|
|
284
|
+
// the plan in (top-level wins on a name clash) so both shapes read the
|
|
285
|
+
// same fields below.
|
|
286
|
+
const plan = individualUsagePlan(top);
|
|
287
|
+
const p = plan ? { ...plan, ...top } : top;
|
|
288
|
+
const cycleLabel = pickString(p, CYCLE_LABEL_KEYS);
|
|
289
|
+
const cycleStart = normalizeCursorApiDate(pickRaw(p, CYCLE_START_KEYS));
|
|
290
|
+
const cycleEnd = normalizeCursorApiDate(pickRaw(p, CYCLE_END_KEYS));
|
|
291
|
+
const usageValue = pickNumber(p, USAGE_VALUE_KEYS);
|
|
292
|
+
const usageUnit = pickString(p, USAGE_UNIT_KEYS);
|
|
293
|
+
let remainingPercent = null;
|
|
294
|
+
const usedPercent = pickNumber(p, USED_PERCENT_KEYS);
|
|
295
|
+
const usedFraction = pickNumber(p, USED_FRACTION_KEYS);
|
|
296
|
+
const remainingDirect = pickNumber(p, REMAINING_PERCENT_KEYS);
|
|
297
|
+
const remainingFraction = pickNumber(p, REMAINING_FRACTION_KEYS);
|
|
298
|
+
if (usedPercent !== null) {
|
|
299
|
+
remainingPercent = remainingPercentFromUsedPercent(usedPercent);
|
|
300
|
+
}
|
|
301
|
+
else if (usedFraction !== null) {
|
|
302
|
+
remainingPercent = remainingPercentFromFraction(usedFraction);
|
|
303
|
+
}
|
|
304
|
+
else if (remainingDirect !== null) {
|
|
305
|
+
remainingPercent = clampPercent(remainingDirect);
|
|
306
|
+
}
|
|
307
|
+
else if (remainingFraction !== null) {
|
|
308
|
+
remainingPercent = clampPercent(remainingFraction * 100);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
// Last resort: the plan's explicit used/limit pair, read from the plan
|
|
312
|
+
// object ONLY — never from the merged top level, where an unrelated
|
|
313
|
+
// numeric field (e.g. a top-level `usage` value next to a `limit`) could
|
|
314
|
+
// otherwise be misread as the plan's scale. A non-positive limit (or a
|
|
315
|
+
// negative used amount) is not a scale — it reads as no usable value,
|
|
316
|
+
// never a division artifact.
|
|
317
|
+
const usedAmount = plan ? pickNumber(plan, USED_AMOUNT_KEYS) : null;
|
|
318
|
+
const limitAmount = plan ? pickNumber(plan, LIMIT_AMOUNT_KEYS) : null;
|
|
319
|
+
if (usedAmount !== null && limitAmount !== null && limitAmount > 0 && usedAmount >= 0) {
|
|
320
|
+
remainingPercent = clampPercent(100 - (usedAmount / limitAmount) * 100);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const hasCycle = cycleLabel !== null || cycleStart !== null || cycleEnd !== null;
|
|
324
|
+
const hasUsage = usageValue !== null || remainingPercent !== null;
|
|
325
|
+
if (!hasCycle && !hasUsage)
|
|
326
|
+
return null;
|
|
327
|
+
return { cycleLabel, cycleStart, cycleEnd, usageValue, usageUnit, remainingPercent };
|
|
328
|
+
}
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Bounded HTTP read (injectable fetch so tests never touch the live dashboard)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
function isAllowedOrigin(url) {
|
|
333
|
+
let origin;
|
|
334
|
+
try {
|
|
335
|
+
origin = new URL(url).origin;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
return CURSOR_INDIVIDUAL_ALLOWED_ORIGINS.includes(origin);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Read a response body up to `CURSOR_INDIVIDUAL_MAX_BODY_BYTES`, aborting
|
|
344
|
+
* the read (never buffering the full body first) the moment that cap is
|
|
345
|
+
* crossed — `res.text()` has no such bound, so a large/streaming response
|
|
346
|
+
* would otherwise sit fully in memory before the size check ever ran.
|
|
347
|
+
* Shares the caller's `signal`, so a hop's timeout aborts a stalled body
|
|
348
|
+
* read the same way it aborts a stalled header response.
|
|
349
|
+
*/
|
|
350
|
+
export async function readBoundedText(res, signal) {
|
|
351
|
+
if (!res.body)
|
|
352
|
+
return res.text();
|
|
353
|
+
const reader = res.body.getReader();
|
|
354
|
+
const chunks = [];
|
|
355
|
+
let total = 0;
|
|
356
|
+
try {
|
|
357
|
+
for (;;) {
|
|
358
|
+
if (signal.aborted)
|
|
359
|
+
throw new Error("aborted");
|
|
360
|
+
const { done, value } = await reader.read();
|
|
361
|
+
if (done)
|
|
362
|
+
break;
|
|
363
|
+
if (!value)
|
|
364
|
+
continue;
|
|
365
|
+
total += value.byteLength;
|
|
366
|
+
if (total > CURSOR_INDIVIDUAL_MAX_BODY_BYTES) {
|
|
367
|
+
await reader.cancel().catch(() => undefined);
|
|
368
|
+
throw new Error("response exceeds the size cap");
|
|
369
|
+
}
|
|
370
|
+
chunks.push(value);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
reader.releaseLock();
|
|
375
|
+
}
|
|
376
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
|
|
377
|
+
}
|
|
378
|
+
function defaultFetch() {
|
|
379
|
+
const impl = globalThis.fetch;
|
|
380
|
+
return (url, init) => impl(url, { ...init, redirect: "manual" }).then((res) => {
|
|
381
|
+
const headers = {};
|
|
382
|
+
res.headers.forEach((value, key) => {
|
|
383
|
+
headers[key.toLowerCase()] = value;
|
|
384
|
+
});
|
|
385
|
+
return { status: res.status, headers, text: () => readBoundedText(res, init.signal) };
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
function isRedirect(status) {
|
|
389
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* One bounded GET with manual redirect handling: redirects are followed only
|
|
393
|
+
* to allowlisted https origins (up to CURSOR_INDIVIDUAL_MAX_REDIRECTS hops).
|
|
394
|
+
* A redirect to an unknown origin — or a Location that does not parse —
|
|
395
|
+
* fails as `unsafe-redirect` BEFORE any further request, so the credential
|
|
396
|
+
* never travels off the allowlist.
|
|
397
|
+
*/
|
|
398
|
+
async function fetchJson(fetchImpl, url, authHeader, timeoutMs) {
|
|
399
|
+
if (!isAllowedOrigin(url))
|
|
400
|
+
return { failure: "unsafe-redirect" };
|
|
401
|
+
let current = url;
|
|
402
|
+
for (let hop = 0; hop <= CURSOR_INDIVIDUAL_MAX_REDIRECTS; hop += 1) {
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
// A manual, ref'd setTimeout (not AbortSignal.timeout(), whose internal
|
|
405
|
+
// timer is unref'd) — otherwise, once nothing else in the process holds
|
|
406
|
+
// the event loop open, Node can conclude the run before this timer ever
|
|
407
|
+
// fires, surfacing as "Promise resolution is still pending but the event
|
|
408
|
+
// loop has already resolved" instead of an actual abort. The timer stays
|
|
409
|
+
// live for the WHOLE hop (headers + body read below), cleared only once
|
|
410
|
+
// this hop is fully settled — a server that sends headers and then
|
|
411
|
+
// stalls the body must abort too, not just a slow header response.
|
|
412
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
413
|
+
try {
|
|
414
|
+
let res;
|
|
415
|
+
try {
|
|
416
|
+
res = await fetchImpl(current, {
|
|
417
|
+
method: "GET",
|
|
418
|
+
headers: { Cookie: authHeader, Accept: "application/json" },
|
|
419
|
+
signal: controller.signal,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return { failure: "unavailable" };
|
|
424
|
+
}
|
|
425
|
+
if (isRedirect(res.status)) {
|
|
426
|
+
const location = res.headers["location"];
|
|
427
|
+
if (!location)
|
|
428
|
+
return { failure: "unavailable" };
|
|
429
|
+
let next;
|
|
430
|
+
try {
|
|
431
|
+
next = new URL(location, current).toString();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return { failure: "unsafe-redirect" };
|
|
435
|
+
}
|
|
436
|
+
if (!next.toLowerCase().startsWith("https://") || !isAllowedOrigin(next)) {
|
|
437
|
+
return { failure: "unsafe-redirect" };
|
|
438
|
+
}
|
|
439
|
+
current = next;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (res.status === 401 || res.status === 403)
|
|
443
|
+
return { failure: "forbidden" };
|
|
444
|
+
if (res.status === 429)
|
|
445
|
+
return { failure: "rate-limited" };
|
|
446
|
+
// 404 on an undocumented path means the endpoint moved or never existed
|
|
447
|
+
// for this account — the caller tries the next candidate path.
|
|
448
|
+
if (res.status === 404 || res.status === 405)
|
|
449
|
+
return { absent: true };
|
|
450
|
+
if (res.status < 200 || res.status >= 300)
|
|
451
|
+
return { failure: "unavailable" };
|
|
452
|
+
let text;
|
|
453
|
+
try {
|
|
454
|
+
text = await res.text();
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// An abort mid-body-read is a timeout (`unavailable`), never a parse
|
|
458
|
+
// verdict on data that never fully arrived.
|
|
459
|
+
return { failure: controller.signal.aborted ? "unavailable" : "malformed" };
|
|
460
|
+
}
|
|
461
|
+
if (text.length > CURSOR_INDIVIDUAL_MAX_BODY_BYTES)
|
|
462
|
+
return { failure: "malformed" };
|
|
463
|
+
try {
|
|
464
|
+
return { status: res.status, payload: JSON.parse(text) };
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return { failure: "malformed" };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { failure: "unavailable" };
|
|
475
|
+
}
|
|
476
|
+
function emptyBilling(observedAt) {
|
|
477
|
+
return {
|
|
478
|
+
cycleLabel: null,
|
|
479
|
+
cycleStart: null,
|
|
480
|
+
cycleEnd: null,
|
|
481
|
+
usageValue: null,
|
|
482
|
+
usageUnit: null,
|
|
483
|
+
remainingPercent: null,
|
|
484
|
+
source: "unavailable",
|
|
485
|
+
unavailableReason: "missing",
|
|
486
|
+
observedAt,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function readingsToBilling(readings, observedAt, viaSupportedApi) {
|
|
490
|
+
return {
|
|
491
|
+
cycleLabel: readings.cycleLabel,
|
|
492
|
+
cycleStart: readings.cycleStart,
|
|
493
|
+
cycleEnd: readings.cycleEnd,
|
|
494
|
+
usageValue: readings.usageValue,
|
|
495
|
+
usageUnit: readings.usageUnit,
|
|
496
|
+
remainingPercent: readings.remainingPercent,
|
|
497
|
+
source: viaSupportedApi ? "supported_protocol" : "experimental_api",
|
|
498
|
+
unavailableReason: null,
|
|
499
|
+
observedAt,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function supportedToReadings(supported) {
|
|
503
|
+
const normalized = {
|
|
504
|
+
cycleLabel: supported.cycleLabel ?? null,
|
|
505
|
+
cycleStart: supported.cycleStart ?? null,
|
|
506
|
+
cycleEnd: supported.cycleEnd ?? null,
|
|
507
|
+
usageValue: supported.usageValue ?? null,
|
|
508
|
+
usageUnit: supported.usageUnit ?? null,
|
|
509
|
+
};
|
|
510
|
+
if (supported.usedPercent !== undefined && supported.usedPercent !== null) {
|
|
511
|
+
normalized.usedPercent = supported.usedPercent;
|
|
512
|
+
}
|
|
513
|
+
else if (supported.usedFraction !== undefined && supported.usedFraction !== null) {
|
|
514
|
+
normalized.usedFraction = supported.usedFraction;
|
|
515
|
+
}
|
|
516
|
+
else if (supported.remainingPercent !== undefined && supported.remainingPercent !== null) {
|
|
517
|
+
normalized.remainingPercent = supported.remainingPercent;
|
|
518
|
+
}
|
|
519
|
+
else if (supported.remainingFraction !== undefined && supported.remainingFraction !== null) {
|
|
520
|
+
normalized.remainingFraction = supported.remainingFraction;
|
|
521
|
+
}
|
|
522
|
+
return cursorIndividualPayloadToReadings(normalized);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* One bounded experimental dashboard read. Never throws for provider-side
|
|
526
|
+
* outcomes — those come back as `failure.kind` — never touches
|
|
527
|
+
* `runtime_availability`, and never exposes the credential: the auth header
|
|
528
|
+
* value stays in this function's scope and never appears in the observation,
|
|
529
|
+
* logs, or evidence refs.
|
|
530
|
+
*
|
|
531
|
+
* Order: explicit opt-in → supported surface (preferred, skips the
|
|
532
|
+
* dashboard entirely) → local credential → allowlisted dashboard GETs.
|
|
533
|
+
*/
|
|
534
|
+
export async function readCursorIndividualBilling(opts = {}) {
|
|
535
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
536
|
+
const observedAt = new Date(nowMs).toISOString();
|
|
537
|
+
if (!isCursorIndividualExperimentalEnabled()) {
|
|
538
|
+
return {
|
|
539
|
+
billing: emptyBilling(observedAt),
|
|
540
|
+
enabled: false,
|
|
541
|
+
configured: false,
|
|
542
|
+
viaSupportedApi: false,
|
|
543
|
+
failure: { kind: "disabled" },
|
|
544
|
+
evidenceRef: null,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const supportedReader = opts.supportedReader ?? defaultSupportedUsageReader;
|
|
548
|
+
let supported = null;
|
|
549
|
+
try {
|
|
550
|
+
supported = await supportedReader();
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
supported = null;
|
|
554
|
+
}
|
|
555
|
+
if (supported) {
|
|
556
|
+
const readings = supportedToReadings(supported);
|
|
557
|
+
if (readings) {
|
|
558
|
+
return {
|
|
559
|
+
billing: readingsToBilling(readings, observedAt, true),
|
|
560
|
+
enabled: true,
|
|
561
|
+
configured: true,
|
|
562
|
+
viaSupportedApi: true,
|
|
563
|
+
failure: null,
|
|
564
|
+
evidenceRef: null,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
// A supported surface answered but carried nothing usable: fall through
|
|
568
|
+
// to the dashboard rather than discarding the read.
|
|
569
|
+
}
|
|
570
|
+
const loadCredential = opts.loadCredential ?? (() => loadCursorIndividualCredential(opts.readFile));
|
|
571
|
+
const credential = loadCredential();
|
|
572
|
+
if (credential.status === "absent") {
|
|
573
|
+
logFailure("absent-credential");
|
|
574
|
+
return {
|
|
575
|
+
billing: emptyBilling(observedAt),
|
|
576
|
+
enabled: true,
|
|
577
|
+
configured: false,
|
|
578
|
+
viaSupportedApi: false,
|
|
579
|
+
failure: { kind: "absent-credential" },
|
|
580
|
+
evidenceRef: null,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (credential.status === "unparsable" || !credential.authHeader) {
|
|
584
|
+
logFailure("bad-credential");
|
|
585
|
+
return {
|
|
586
|
+
billing: { ...emptyBilling(observedAt), unavailableReason: "unparsable" },
|
|
587
|
+
enabled: true,
|
|
588
|
+
configured: false,
|
|
589
|
+
viaSupportedApi: false,
|
|
590
|
+
failure: { kind: "bad-credential" },
|
|
591
|
+
evidenceRef: null,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
const baseUrl = (opts.baseUrl ?? cursorIndividualBaseUrl()).replace(/\/+$/, "");
|
|
595
|
+
const timeoutMs = opts.timeoutMs ?? cursorIndividualTimeoutMs();
|
|
596
|
+
const fetchImpl = opts.fetchImpl ?? defaultFetch();
|
|
597
|
+
const authHeader = credential.authHeader;
|
|
598
|
+
let fatal = null;
|
|
599
|
+
let readings = null;
|
|
600
|
+
// A 200 response this module cannot parse as JSON (`malformed`) is kept
|
|
601
|
+
// distinct from a genuine auth/rate-limit/transport/redirect failure: an
|
|
602
|
+
// undocumented path can serve an HTML SPA shell with a 200 status instead
|
|
603
|
+
// of a 404 for an account it doesn't apply to, so `malformed` is treated
|
|
604
|
+
// like `absent` and the next candidate still gets a try. The first such
|
|
605
|
+
// verdict is remembered as the diagnostic in case no candidate parses.
|
|
606
|
+
let malformedFallback = null;
|
|
607
|
+
for (const candidatePath of CURSOR_INDIVIDUAL_USAGE_PATHS) {
|
|
608
|
+
const outcome = await fetchJson(fetchImpl, `${baseUrl}${candidatePath}`, authHeader, timeoutMs);
|
|
609
|
+
if ("failure" in outcome) {
|
|
610
|
+
if (outcome.failure === "malformed") {
|
|
611
|
+
malformedFallback = malformedFallback ?? outcome.failure;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// Auth, rate-limit, transport, redirect, and oversize failures dominate
|
|
615
|
+
// over remaining candidates: retrying another undocumented path cannot
|
|
616
|
+
// fix a rejected credential or an unsafe redirect.
|
|
617
|
+
fatal = outcome.failure;
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
if ("absent" in outcome)
|
|
621
|
+
continue;
|
|
622
|
+
const parsed = cursorIndividualPayloadToReadings(outcome.payload);
|
|
623
|
+
if (parsed === null) {
|
|
624
|
+
fatal = "malformed";
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
readings = parsed;
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
if (!fatal && !readings)
|
|
631
|
+
fatal = malformedFallback;
|
|
632
|
+
if (fatal) {
|
|
633
|
+
logFailure(fatal);
|
|
634
|
+
const reason = fatal === "malformed" ? "unparsable" : "missing";
|
|
635
|
+
return {
|
|
636
|
+
billing: { ...emptyBilling(observedAt), unavailableReason: reason },
|
|
637
|
+
enabled: true,
|
|
638
|
+
configured: true,
|
|
639
|
+
viaSupportedApi: false,
|
|
640
|
+
failure: { kind: fatal },
|
|
641
|
+
evidenceRef: null,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (readings === null) {
|
|
645
|
+
// Every candidate path 404/405'd (or nothing answered): the undocumented
|
|
646
|
+
// endpoint drifted or never existed for this account — `missing`, never
|
|
647
|
+
// a parse verdict on data that never arrived.
|
|
648
|
+
logFailure("unavailable");
|
|
649
|
+
return {
|
|
650
|
+
billing: emptyBilling(observedAt),
|
|
651
|
+
enabled: true,
|
|
652
|
+
configured: true,
|
|
653
|
+
viaSupportedApi: false,
|
|
654
|
+
failure: { kind: "unavailable" },
|
|
655
|
+
evidenceRef: null,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
billing: readingsToBilling(readings, observedAt, false),
|
|
660
|
+
enabled: true,
|
|
661
|
+
configured: true,
|
|
662
|
+
viaSupportedApi: false,
|
|
663
|
+
failure: null,
|
|
664
|
+
evidenceRef: CURSOR_INDIVIDUAL_EVIDENCE,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Failure kind → stored N/A reason (shared enum only). Transport/auth/
|
|
669
|
+
* rate-limit/redirect failures record `missing` (supported-shaped N/A, no
|
|
670
|
+
* snapshot without stored data); malformed payloads and unreadable
|
|
671
|
+
* credentials record `unparsable`. Disabled and absent-credential never
|
|
672
|
+
* reach storage — they short-circuit at serve time.
|
|
673
|
+
*/
|
|
674
|
+
export function cursorIndividualFailureReason(kind) {
|
|
675
|
+
return kind === "malformed" || kind === "bad-credential" ? "unparsable" : "missing";
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Persist one observation as the normalized individual snapshot. Transient
|
|
679
|
+
* failures do NOT overwrite a stored snapshot — the last-known values keep
|
|
680
|
+
* serving under read-time freshness instead. Successful and malformed
|
|
681
|
+
* observations do overwrite (malformed is the current verdict on arrived
|
|
682
|
+
* data). Disabled/absent-credential observations persist nothing. Imported
|
|
683
|
+
* lazily to keep this module free of DB binds.
|
|
684
|
+
*/
|
|
685
|
+
export async function ingestCursorIndividualObservation(observation, nowMs = Date.now()) {
|
|
686
|
+
if (!observation.enabled)
|
|
687
|
+
return;
|
|
688
|
+
if (observation.failure &&
|
|
689
|
+
observation.failure.kind !== "malformed" &&
|
|
690
|
+
observation.failure.kind !== "bad-credential") {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const { writeCursorIndividualBillingRow } = await import("../repository/cursor-individual-billing.js");
|
|
694
|
+
const b = observation.billing;
|
|
695
|
+
const observedAt = b.observedAt ?? new Date(nowMs).toISOString();
|
|
696
|
+
const observedMs = Date.parse(observedAt);
|
|
697
|
+
const base = Number.isFinite(observedMs) ? observedMs : nowMs;
|
|
698
|
+
const unavailable = observation.failure !== null;
|
|
699
|
+
writeCursorIndividualBillingRow({
|
|
700
|
+
cycleLabel: b.cycleLabel,
|
|
701
|
+
cycleStart: b.cycleStart,
|
|
702
|
+
cycleEnd: b.cycleEnd,
|
|
703
|
+
usageValue: b.usageValue,
|
|
704
|
+
usageUnit: b.usageUnit,
|
|
705
|
+
remainingPercent: b.remainingPercent,
|
|
706
|
+
source: b.source,
|
|
707
|
+
unavailableReason: b.unavailableReason,
|
|
708
|
+
observedAt,
|
|
709
|
+
freshUntil: unavailable ? null : new Date(base + DEFAULT_STALE_AFTER_MS).toISOString(),
|
|
710
|
+
expiresAt: unavailable ? null : new Date(base + DEFAULT_EXPIRES_AFTER_MS).toISOString(),
|
|
711
|
+
evidenceRef: observation.evidenceRef,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function observationToUnavailable(observedAt, reason) {
|
|
715
|
+
return { ...emptyBilling(observedAt), unavailableReason: reason };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Read model for `GET /api/cursor-individual-billing`: the stored normalized
|
|
719
|
+
* snapshot with freshness applied, or an explicit N/A. Disabled reads
|
|
720
|
+
* `enabled: false` without touching credentials, HTTP, or the database. A
|
|
721
|
+
* `cycleEnd` in the past reads `expired` — a finished cycle is never
|
|
722
|
+
* presented as current capacity (mirrors "a past reset is never current").
|
|
723
|
+
*/
|
|
724
|
+
export async function getCursorIndividualBillingSnapshot(nowMs = Date.now()) {
|
|
725
|
+
const generatedAt = new Date(nowMs).toISOString();
|
|
726
|
+
const disabled = {
|
|
727
|
+
enabled: false,
|
|
728
|
+
configured: false,
|
|
729
|
+
cycleLabel: null,
|
|
730
|
+
cycleStart: null,
|
|
731
|
+
cycleEnd: null,
|
|
732
|
+
usageValue: null,
|
|
733
|
+
usageUnit: null,
|
|
734
|
+
remainingPercent: null,
|
|
735
|
+
source: "unavailable",
|
|
736
|
+
unavailableReason: "missing",
|
|
737
|
+
observedAt: null,
|
|
738
|
+
generatedAt,
|
|
739
|
+
};
|
|
740
|
+
if (!isCursorIndividualExperimentalEnabled())
|
|
741
|
+
return disabled;
|
|
742
|
+
const status = cursorIndividualCredentialStatusSafe();
|
|
743
|
+
if (!status.present) {
|
|
744
|
+
return {
|
|
745
|
+
...disabled,
|
|
746
|
+
enabled: true,
|
|
747
|
+
unavailableReason: status.format === null && status.path !== null ? "unparsable" : "missing",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const { readCursorIndividualBillingRow } = await import("../repository/cursor-individual-billing.js");
|
|
751
|
+
const row = readCursorIndividualBillingRow();
|
|
752
|
+
if (!row) {
|
|
753
|
+
return {
|
|
754
|
+
...disabled,
|
|
755
|
+
enabled: true,
|
|
756
|
+
configured: true,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
const hasValues = row.usageValue !== null || row.remainingPercent !== null;
|
|
760
|
+
let reason = row.unavailableReason;
|
|
761
|
+
let nulled = false;
|
|
762
|
+
if (hasValues && reason === null) {
|
|
763
|
+
const cycleMs = row.cycleEnd !== null ? Date.parse(row.cycleEnd) : NaN;
|
|
764
|
+
if (Number.isFinite(cycleMs) && cycleMs <= nowMs) {
|
|
765
|
+
reason = "expired";
|
|
766
|
+
nulled = true;
|
|
767
|
+
}
|
|
768
|
+
else if (row.expiresAt !== null && Date.parse(row.expiresAt) <= nowMs) {
|
|
769
|
+
reason = "expired";
|
|
770
|
+
nulled = true;
|
|
771
|
+
}
|
|
772
|
+
else if (row.freshUntil !== null && Date.parse(row.freshUntil) <= nowMs) {
|
|
773
|
+
reason = "stale";
|
|
774
|
+
nulled = true;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
enabled: true,
|
|
779
|
+
configured: true,
|
|
780
|
+
cycleLabel: nulled ? null : row.cycleLabel,
|
|
781
|
+
cycleStart: nulled ? null : row.cycleStart,
|
|
782
|
+
cycleEnd: nulled ? null : row.cycleEnd,
|
|
783
|
+
usageValue: nulled ? null : row.usageValue,
|
|
784
|
+
usageUnit: nulled ? null : row.usageUnit,
|
|
785
|
+
remainingPercent: nulled ? null : row.remainingPercent,
|
|
786
|
+
source: nulled || reason !== null ? "unavailable" : row.source,
|
|
787
|
+
unavailableReason: reason,
|
|
788
|
+
observedAt: row.observedAt,
|
|
789
|
+
generatedAt,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function cursorIndividualCredentialStatusSafe() {
|
|
793
|
+
try {
|
|
794
|
+
return cursorIndividualCredentialStatus();
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return { present: false, path: null, format: null };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Bounded refresh: run one dashboard read and ingest it. Used by the stale
|
|
802
|
+
* path below; failures persist as N/A rows only for malformed/bad-credential
|
|
803
|
+
* observations — transient failures keep last-known values. Never throws,
|
|
804
|
+
* never touches runtime health, never logs the credential.
|
|
805
|
+
*/
|
|
806
|
+
export async function refreshCursorIndividualBilling(opts = {}) {
|
|
807
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
808
|
+
let observation;
|
|
809
|
+
try {
|
|
810
|
+
observation = await readCursorIndividualBilling({ ...opts, nowMs });
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
logFailure("unavailable");
|
|
814
|
+
observation = {
|
|
815
|
+
billing: observationToUnavailable(new Date(nowMs).toISOString(), "missing"),
|
|
816
|
+
enabled: isCursorIndividualExperimentalEnabled(),
|
|
817
|
+
configured: false,
|
|
818
|
+
viaSupportedApi: false,
|
|
819
|
+
failure: { kind: "unavailable" },
|
|
820
|
+
evidenceRef: null,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
await ingestCursorIndividualObservation(observation, nowMs);
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
// Storage failure must not break the read below.
|
|
828
|
+
}
|
|
829
|
+
return getCursorIndividualBillingSnapshot(nowMs);
|
|
830
|
+
}
|
|
831
|
+
let cursorIndividualSharedPollInFlight = null;
|
|
832
|
+
let cursorIndividualLastFailedPollMs = null;
|
|
833
|
+
/** Test helper — clear the failure backoff (and any in-flight refresh). */
|
|
834
|
+
export function resetCursorIndividualPollStateForTests() {
|
|
835
|
+
cursorIndividualLastFailedPollMs = null;
|
|
836
|
+
cursorIndividualSharedPollInFlight = null;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* One bounded dashboard poll, shared by BOTH on-demand refresh paths below.
|
|
840
|
+
* The billing card (`/api/cursor-individual-billing`) and the capacity strip
|
|
841
|
+
* (`/api/runtime-capacity`) each read a different stored table, so each
|
|
842
|
+
* deciding independently that its own table is stale — as when both mount
|
|
843
|
+
* concurrently against an empty database — would otherwise fire two
|
|
844
|
+
* separate dashboard polls. This single-flights across BOTH callers (not
|
|
845
|
+
* just concurrent calls to the same one) and ingests the one observation
|
|
846
|
+
* into both stores, so satisfying either caller's staleness also refreshes
|
|
847
|
+
* the other's. Failures back off for `CURSOR_INDIVIDUAL_FAILURE_BACKOFF_MS`
|
|
848
|
+
* (shared by both paths) before polling again. Never throws.
|
|
849
|
+
*/
|
|
850
|
+
async function pollCursorIndividualShared(nowMs, opts) {
|
|
851
|
+
if (cursorIndividualLastFailedPollMs !== null &&
|
|
852
|
+
nowMs - cursorIndividualLastFailedPollMs < CURSOR_INDIVIDUAL_FAILURE_BACKOFF_MS) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (cursorIndividualSharedPollInFlight) {
|
|
856
|
+
await cursorIndividualSharedPollInFlight;
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
cursorIndividualSharedPollInFlight = (async () => {
|
|
860
|
+
try {
|
|
861
|
+
const observation = await readCursorIndividualBilling({ ...opts, nowMs });
|
|
862
|
+
// Transient (auth/transport/rate-limit/redirect) failures must not
|
|
863
|
+
// overwrite either store's last-known value — only a genuine verdict
|
|
864
|
+
// on data that arrived (success, or malformed/bad-credential) does.
|
|
865
|
+
// `ingestCursorIndividualObservation` already self-gates this for the
|
|
866
|
+
// billing table; `ingestAdapterResult` has no such guard, so the call
|
|
867
|
+
// site below gates it too — otherwise a transient failure would keep
|
|
868
|
+
// the billing card's last-known value while silently replacing the
|
|
869
|
+
// capacity strip's with `missing`, the two surfaces disagreeing after
|
|
870
|
+
// the same poll.
|
|
871
|
+
const transient = observation.failure !== null &&
|
|
872
|
+
observation.failure.kind !== "malformed" &&
|
|
873
|
+
observation.failure.kind !== "bad-credential";
|
|
874
|
+
if (transient) {
|
|
875
|
+
cursorIndividualLastFailedPollMs = nowMs;
|
|
876
|
+
}
|
|
877
|
+
try {
|
|
878
|
+
await ingestCursorIndividualObservation(observation, nowMs);
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
// Storage failure must not break the read below.
|
|
882
|
+
}
|
|
883
|
+
if (!transient) {
|
|
884
|
+
try {
|
|
885
|
+
const { ingestAdapterResult } = await import("./service.js");
|
|
886
|
+
await ingestAdapterResult(cursorIndividualObservationToAdapterResult(observation, nowMs));
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
// Storage failure must not break the read below.
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
cursorIndividualLastFailedPollMs = nowMs;
|
|
895
|
+
}
|
|
896
|
+
finally {
|
|
897
|
+
cursorIndividualSharedPollInFlight = null;
|
|
898
|
+
}
|
|
899
|
+
})();
|
|
900
|
+
await cursorIndividualSharedPollInFlight;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* On-demand bounded refresh: when explicitly opted in and the stored
|
|
904
|
+
* snapshot is missing or older than the stale window, run one bounded poll
|
|
905
|
+
* (shared with the capacity-strip path below) and ingest it, then return.
|
|
906
|
+
* Disabled, fresh snapshots, and recent failed polls short-circuit with no
|
|
907
|
+
* credential access and no HTTP (single-flight across concurrent requests,
|
|
908
|
+
* including the other refresh path). This helper never throws, never
|
|
909
|
+
* touches runtime health, and never sends the credential off the allowlist.
|
|
910
|
+
* Set `AGENT_DEALER_CURSOR_INDIVIDUAL_REFRESH=off` to disable.
|
|
911
|
+
*/
|
|
912
|
+
export async function refreshCursorIndividualBillingIfStale(nowMs = Date.now(), opts = {}) {
|
|
913
|
+
if (process.env[CURSOR_INDIVIDUAL_REFRESH_ENV] === "off")
|
|
914
|
+
return;
|
|
915
|
+
if (!isCursorIndividualExperimentalEnabled())
|
|
916
|
+
return;
|
|
917
|
+
const { readCursorIndividualBillingRow } = await import("../repository/cursor-individual-billing.js");
|
|
918
|
+
const row = readCursorIndividualBillingRow();
|
|
919
|
+
const newestObserved = row ? Date.parse(row.observedAt) : NaN;
|
|
920
|
+
if (Number.isFinite(newestObserved) && newestObserved + DEFAULT_STALE_AFTER_MS > nowMs) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
await pollCursorIndividualShared(nowMs, opts);
|
|
924
|
+
}
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
// CapacityAdapter: billing-cycle window for `cursor_local` (NOT-243 strip)
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
/**
|
|
929
|
+
* Map one observation to an adapter read result: a single `billing_cycle`
|
|
930
|
+
* window (no duration — monthly data never becomes a 5H/1W window) carrying
|
|
931
|
+
* the reported cycle reset and remaining usage, or an explicit unavailable
|
|
932
|
+
* reading. The label/reset/remaining come verbatim from the dashboard; no
|
|
933
|
+
* unsupported windows are guessed.
|
|
934
|
+
*/
|
|
935
|
+
export function cursorIndividualObservationToAdapterResult(observation, nowMs = Date.now()) {
|
|
936
|
+
const observedAt = observation.billing.observedAt ?? new Date(nowMs).toISOString();
|
|
937
|
+
if (observation.failure || observation.billing.unavailableReason !== null) {
|
|
938
|
+
const reason = observation.billing.unavailableReason ??
|
|
939
|
+
cursorIndividualFailureReason(observation.failure?.kind ?? "unavailable");
|
|
940
|
+
return {
|
|
941
|
+
runtime: CURSOR_INDIVIDUAL_RUNTIME,
|
|
942
|
+
windows: [],
|
|
943
|
+
unavailable: [
|
|
944
|
+
{
|
|
945
|
+
windowKey: CURSOR_INDIVIDUAL_WINDOW_KEY,
|
|
946
|
+
providerBucket: CURSOR_INDIVIDUAL_PROVIDER_BUCKET,
|
|
947
|
+
providerLabel: CURSOR_INDIVIDUAL_PROVIDER_LABEL,
|
|
948
|
+
reason,
|
|
949
|
+
observedAt,
|
|
950
|
+
},
|
|
951
|
+
],
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
const b = observation.billing;
|
|
955
|
+
if (b.remainingPercent === null) {
|
|
956
|
+
return {
|
|
957
|
+
runtime: CURSOR_INDIVIDUAL_RUNTIME,
|
|
958
|
+
windows: [],
|
|
959
|
+
unavailable: [
|
|
960
|
+
{
|
|
961
|
+
windowKey: CURSOR_INDIVIDUAL_WINDOW_KEY,
|
|
962
|
+
providerBucket: CURSOR_INDIVIDUAL_PROVIDER_BUCKET,
|
|
963
|
+
providerLabel: CURSOR_INDIVIDUAL_PROVIDER_LABEL,
|
|
964
|
+
reason: "unparsable",
|
|
965
|
+
observedAt,
|
|
966
|
+
},
|
|
967
|
+
],
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
runtime: CURSOR_INDIVIDUAL_RUNTIME,
|
|
972
|
+
windows: [
|
|
973
|
+
{
|
|
974
|
+
windowKey: CURSOR_INDIVIDUAL_WINDOW_KEY,
|
|
975
|
+
providerBucket: CURSOR_INDIVIDUAL_PROVIDER_BUCKET,
|
|
976
|
+
durationMinutes: null,
|
|
977
|
+
providerLabel: CURSOR_INDIVIDUAL_PROVIDER_LABEL,
|
|
978
|
+
usedValue: b.usageValue,
|
|
979
|
+
usedUnit: b.usageUnit,
|
|
980
|
+
// Round-trip through the shared used scale: 100 - remaining is exact
|
|
981
|
+
// for the clamped 0–100 range, so normalization recovers `remaining`.
|
|
982
|
+
usedPercent: 100 - b.remainingPercent,
|
|
983
|
+
resetAt: b.cycleEnd,
|
|
984
|
+
observedAt,
|
|
985
|
+
source: b.source === "supported_protocol" ? "supported_protocol" : "experimental_api",
|
|
986
|
+
},
|
|
987
|
+
],
|
|
988
|
+
unavailable: [],
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Experimental CapacityAdapter for `cursor_local`: disabled reads N/A
|
|
993
|
+
* (`missing`) with no credential or endpoint access; opted-in reads run one
|
|
994
|
+
* bounded dashboard poll. Failures never throw — they normalize to
|
|
995
|
+
* unavailable windows downstream.
|
|
996
|
+
*/
|
|
997
|
+
export function cursorIndividualCapacityAdapter(opts = {}) {
|
|
998
|
+
return {
|
|
999
|
+
runtime: CURSOR_INDIVIDUAL_RUNTIME,
|
|
1000
|
+
source: "experimental_api",
|
|
1001
|
+
read: async (nowMs = Date.now()) => {
|
|
1002
|
+
const observation = await readCursorIndividualBilling({ ...opts, nowMs });
|
|
1003
|
+
return cursorIndividualObservationToAdapterResult(observation, nowMs);
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* On-demand capacity-strip refresh: when the stored `cursor_local` window in
|
|
1009
|
+
* `runtime_capacity_snapshots` is missing or stale, run the poll shared with
|
|
1010
|
+
* the billing-card path above (see `pollCursorIndividualShared`) so the
|
|
1011
|
+
* Agents-page strip shows the billing-cycle window (or its explicit N/A).
|
|
1012
|
+
* Disabled is a strict no-op — no credential, no HTTP. Mirrors the
|
|
1013
|
+
* Codex/Team stale-check pattern: a fresh stored window (younger than
|
|
1014
|
+
* `DEFAULT_STALE_AFTER_MS`) short-circuits with no HTTP. `GET
|
|
1015
|
+
* /api/runtime-capacity` calls this on every poll, so without the check (and
|
|
1016
|
+
* the single-flight shared with the billing-card path) it would hit the
|
|
1017
|
+
* dashboard on every request. Never throws, never touches runtime health.
|
|
1018
|
+
* Imported lazily to keep this module free of DB binds.
|
|
1019
|
+
*/
|
|
1020
|
+
export async function refreshCursorIndividualCapacityIfStale(nowMs = Date.now(), opts = {}) {
|
|
1021
|
+
if (process.env[CURSOR_INDIVIDUAL_REFRESH_ENV] === "off")
|
|
1022
|
+
return;
|
|
1023
|
+
if (!isCursorIndividualExperimentalEnabled())
|
|
1024
|
+
return;
|
|
1025
|
+
const { listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
|
|
1026
|
+
const rows = listCapacitySnapshots(CURSOR_INDIVIDUAL_RUNTIME);
|
|
1027
|
+
const newestObserved = rows.reduce((max, row) => {
|
|
1028
|
+
const ms = Date.parse(row.observedAt);
|
|
1029
|
+
if (!Number.isFinite(ms))
|
|
1030
|
+
return max;
|
|
1031
|
+
return max === null || ms > max ? ms : max;
|
|
1032
|
+
}, null);
|
|
1033
|
+
if (newestObserved !== null && newestObserved + DEFAULT_STALE_AFTER_MS > nowMs) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
await pollCursorIndividualShared(nowMs, opts);
|
|
1037
|
+
}
|