@stage5/lumine 0.3.54 → 0.3.58
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/lib/admin-daily.js +76 -3
- package/lib/admin-duties.js +9 -1
- package/lib/admin-owner-trace.js +23 -4
- package/lib/admin-owner-viewport.js +113 -0
- package/lib/admin-spending.js +472 -0
- package/lib/admin.js +46 -7
- package/lib/commands.js +14 -1
- package/lib/sdk.js +2 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +13 -5
- package/sdk/LUMINE_ADMIN.md +174 -74
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const round = (value) => {
|
|
6
|
+
if (!Number.isFinite(value))
|
|
7
|
+
throw new Error("Invalid computed spending amount.");
|
|
8
|
+
return Number(value.toFixed(8));
|
|
9
|
+
};
|
|
10
|
+
const USAGE =
|
|
11
|
+
"lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--json]";
|
|
12
|
+
|
|
13
|
+
function invalid(message) {
|
|
14
|
+
return Object.assign(new Error(message), {
|
|
15
|
+
code: "CLI_ADMIN_CLI_VALIDATION",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildSpendingOperation(options) {
|
|
20
|
+
if (
|
|
21
|
+
(options.positional || []).length !== 1 ||
|
|
22
|
+
options.adminDays ||
|
|
23
|
+
options.adminDate ||
|
|
24
|
+
options.adminAll ||
|
|
25
|
+
options.adminResume
|
|
26
|
+
) {
|
|
27
|
+
throw invalid(`Usage: ${USAGE}.`);
|
|
28
|
+
}
|
|
29
|
+
const query = new URLSearchParams();
|
|
30
|
+
const values = {
|
|
31
|
+
month: options.adminSpendingMonth,
|
|
32
|
+
from: options.adminSpendingFrom,
|
|
33
|
+
to: options.adminSpendingTo,
|
|
34
|
+
};
|
|
35
|
+
for (const [key, value] of Object.entries(values)) {
|
|
36
|
+
if (value === undefined) continue;
|
|
37
|
+
if (typeof value !== "string" || !/^20\d{2}-(0[1-9]|1[0-2])$/.test(value))
|
|
38
|
+
throw invalid(`--${key} must use YYYY-MM format.`);
|
|
39
|
+
query.set(key, value);
|
|
40
|
+
}
|
|
41
|
+
if (query.has("month") && (query.has("from") || query.has("to")))
|
|
42
|
+
throw invalid("Use --month or --from/--to, not both.");
|
|
43
|
+
if (query.has("from") !== query.has("to"))
|
|
44
|
+
throw invalid("Provide both --from and --to.");
|
|
45
|
+
if (query.has("from") && query.get("from") > query.get("to"))
|
|
46
|
+
throw invalid("--from must not follow --to.");
|
|
47
|
+
const qs = query.toString();
|
|
48
|
+
return {
|
|
49
|
+
name: "spending.report",
|
|
50
|
+
method: "GET",
|
|
51
|
+
path: `/cli/admin/spending${qs ? `?${qs}` : ""}`,
|
|
52
|
+
mutates: false,
|
|
53
|
+
requiresRun: false,
|
|
54
|
+
timeoutMs: 60000,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runAws(args, { profile, region, timeoutMs }) {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execFileAsync(
|
|
61
|
+
"aws",
|
|
62
|
+
[
|
|
63
|
+
...args,
|
|
64
|
+
"--profile",
|
|
65
|
+
profile,
|
|
66
|
+
"--region",
|
|
67
|
+
region,
|
|
68
|
+
"--output",
|
|
69
|
+
"json",
|
|
70
|
+
"--no-cli-pager",
|
|
71
|
+
],
|
|
72
|
+
{
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
timeout: timeoutMs,
|
|
75
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
76
|
+
env: { ...process.env, AWS_PAGER: "", AWS_CLI_AUTO_PROMPT: "off" },
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
return JSON.parse(stdout);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Do not expose subprocess stdout, credentials or arbitrary provider error
|
|
82
|
+
// payloads in a saved financial report.
|
|
83
|
+
throw new Error(
|
|
84
|
+
error.code === "ENOENT"
|
|
85
|
+
? "AWS CLI is not installed. Install it and configure the requested profile."
|
|
86
|
+
: `AWS billing could not be read with profile ${profile}; check its authentication and Cost Explorer access.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function amount(metric) {
|
|
92
|
+
if (
|
|
93
|
+
metric?.Unit !== "USD" ||
|
|
94
|
+
metric.Amount === "" ||
|
|
95
|
+
metric.Amount == null ||
|
|
96
|
+
!Number.isFinite(Number(metric.Amount))
|
|
97
|
+
) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"AWS returned an invalid amount or a currency other than USD.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return Number(metric.Amount);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function normalizeAwsSpending(pages, months) {
|
|
106
|
+
const expected = new Map(
|
|
107
|
+
months
|
|
108
|
+
.filter((m) => m.startDayKey < m.endDayKeyExclusive)
|
|
109
|
+
.map((m) => [m.startDayKey, m]),
|
|
110
|
+
);
|
|
111
|
+
const found = new Map();
|
|
112
|
+
for (const page of pages) {
|
|
113
|
+
if (!Array.isArray(page?.ResultsByTime))
|
|
114
|
+
throw new Error("AWS returned no billing periods.");
|
|
115
|
+
for (const period of page.ResultsByTime) {
|
|
116
|
+
const start = period.TimePeriod?.Start;
|
|
117
|
+
const requested = expected.get(start);
|
|
118
|
+
if (
|
|
119
|
+
!requested ||
|
|
120
|
+
period.TimePeriod?.End !== requested.endDayKeyExclusive ||
|
|
121
|
+
typeof period.Estimated !== "boolean"
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"AWS billing periods do not match the requested UTC months.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
let target = found.get(start);
|
|
128
|
+
if (!target) {
|
|
129
|
+
target = {
|
|
130
|
+
status: "available",
|
|
131
|
+
costUsd: 0,
|
|
132
|
+
estimated: period.Estimated,
|
|
133
|
+
services: [],
|
|
134
|
+
startDayKey: start,
|
|
135
|
+
endDayKeyExclusive: period.TimePeriod.End,
|
|
136
|
+
};
|
|
137
|
+
found.set(start, target);
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
target.estimated !== period.Estimated ||
|
|
141
|
+
!Array.isArray(period.Groups)
|
|
142
|
+
)
|
|
143
|
+
throw new Error("Inconsistent AWS billing page.");
|
|
144
|
+
if (
|
|
145
|
+
period.Groups.length === 0 &&
|
|
146
|
+
period.Total?.UnblendedCost &&
|
|
147
|
+
amount(period.Total.UnblendedCost) !== 0
|
|
148
|
+
) {
|
|
149
|
+
throw new Error("AWS omitted the requested service breakdown.");
|
|
150
|
+
}
|
|
151
|
+
for (const group of period.Groups) {
|
|
152
|
+
if (
|
|
153
|
+
!Array.isArray(group.Keys) ||
|
|
154
|
+
group.Keys.length !== 1 ||
|
|
155
|
+
typeof group.Keys[0] !== "string" ||
|
|
156
|
+
target.services.some((s) => s.service === group.Keys[0])
|
|
157
|
+
)
|
|
158
|
+
throw new Error("Duplicate or invalid AWS service row.");
|
|
159
|
+
const costUsd = amount(group.Metrics?.UnblendedCost);
|
|
160
|
+
target.services.push({ service: group.Keys[0], costUsd });
|
|
161
|
+
target.costUsd += costUsd;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (found.size !== expected.size)
|
|
166
|
+
throw new Error("AWS billing is missing a requested month.");
|
|
167
|
+
return months.map((month) => {
|
|
168
|
+
if (month.startDayKey === month.endDayKeyExclusive)
|
|
169
|
+
return {
|
|
170
|
+
status: "no_completed_days",
|
|
171
|
+
costUsd: 0,
|
|
172
|
+
estimated: true,
|
|
173
|
+
services: [],
|
|
174
|
+
startDayKey: month.startDayKey,
|
|
175
|
+
endDayKeyExclusive: month.endDayKeyExclusive,
|
|
176
|
+
};
|
|
177
|
+
const value = found.get(month.startDayKey);
|
|
178
|
+
return {
|
|
179
|
+
...value,
|
|
180
|
+
costUsd: round(value.costUsd),
|
|
181
|
+
services: value.services.sort((a, b) => b.costUsd - a.costUsd),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateReport(report) {
|
|
187
|
+
if (
|
|
188
|
+
report?.schemaVersion !== 1 ||
|
|
189
|
+
report.currency !== "USD" ||
|
|
190
|
+
report.timezone !== "UTC" ||
|
|
191
|
+
!Array.isArray(report.months) ||
|
|
192
|
+
!report.months.length ||
|
|
193
|
+
report.months.length > 12 ||
|
|
194
|
+
!report.sources?.aws ||
|
|
195
|
+
report.range?.includesInProgressDay !== false
|
|
196
|
+
) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"The API did not return a supported spending report; deploy the matching API release.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
let expectedStart = report.range.startDayKey;
|
|
202
|
+
for (const month of report.months) {
|
|
203
|
+
const nextMonth = new Date(
|
|
204
|
+
Date.UTC(
|
|
205
|
+
Number(month.monthKey?.slice(0, 4)),
|
|
206
|
+
Number(month.monthKey?.slice(5, 7)),
|
|
207
|
+
1,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
.toISOString()
|
|
211
|
+
.slice(0, 10);
|
|
212
|
+
if (
|
|
213
|
+
!/^20\d{2}-(0[1-9]|1[0-2])$/.test(month.monthKey) ||
|
|
214
|
+
month.startDayKey !== `${month.monthKey}-01` ||
|
|
215
|
+
month.startDayKey !== expectedStart ||
|
|
216
|
+
!/^20\d{2}-\d{2}-\d{2}$/.test(month.endDayKeyExclusive) ||
|
|
217
|
+
month.endDayKeyExclusive < month.startDayKey ||
|
|
218
|
+
month.endDayKeyExclusive > nextMonth ||
|
|
219
|
+
(month.status === "closed" && month.endDayKeyExclusive !== nextMonth) ||
|
|
220
|
+
(month !== report.months.at(-1) &&
|
|
221
|
+
month.endDayKeyExclusive !== nextMonth) ||
|
|
222
|
+
new Date(`${month.endDayKeyExclusive}T00:00:00Z`)
|
|
223
|
+
.toISOString()
|
|
224
|
+
.slice(0, 10) !== month.endDayKeyExclusive
|
|
225
|
+
) {
|
|
226
|
+
throw new Error("The API spending report has invalid month boundaries.");
|
|
227
|
+
}
|
|
228
|
+
expectedStart = nextMonth;
|
|
229
|
+
// An available source must supply its amounts. In particular, null paid
|
|
230
|
+
// totals must never be coerced to zero when combined with operating costs.
|
|
231
|
+
const required = [
|
|
232
|
+
...(month.ai ? [month.ai.grossUsd] : []),
|
|
233
|
+
...(month.reimbursements
|
|
234
|
+
? [
|
|
235
|
+
month.reimbursements.aiServiceUsd,
|
|
236
|
+
month.reimbursements.infrastructureUsd,
|
|
237
|
+
month.reimbursements.totalUsd,
|
|
238
|
+
]
|
|
239
|
+
: []),
|
|
240
|
+
];
|
|
241
|
+
if (
|
|
242
|
+
required.some(
|
|
243
|
+
(value) => typeof value !== "number" || !Number.isFinite(value),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
throw new Error("The API spending report has incomplete source amounts.");
|
|
247
|
+
for (const value of [
|
|
248
|
+
month.ai?.grossUsd,
|
|
249
|
+
month.ai?.creditUsd,
|
|
250
|
+
month.ai?.estimatedCashUsd,
|
|
251
|
+
month.reimbursements?.aiServiceUsd,
|
|
252
|
+
month.reimbursements?.infrastructureUsd,
|
|
253
|
+
month.reimbursements?.totalUsd,
|
|
254
|
+
]) {
|
|
255
|
+
if (
|
|
256
|
+
value != null &&
|
|
257
|
+
(typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
258
|
+
)
|
|
259
|
+
throw new Error("Invalid spending source amount.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
report.months.at(-1).endDayKeyExclusive !== report.range.endDayKeyExclusive
|
|
264
|
+
)
|
|
265
|
+
throw new Error("The API spending report has incomplete month coverage.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function completeSpendingReport(receipt, options, aws = runAws) {
|
|
269
|
+
if (receipt?.ok !== true) return receipt;
|
|
270
|
+
const report = receipt.data?.spending;
|
|
271
|
+
validateReport(report);
|
|
272
|
+
const warnings = [...(report.warnings || [])];
|
|
273
|
+
const source = report.sources.aws;
|
|
274
|
+
const profile = options.adminAwsProfile || source.defaultProfile;
|
|
275
|
+
if (typeof profile !== "string" || !profile.trim())
|
|
276
|
+
throw invalid("--aws-profile must name an AWS CLI profile.");
|
|
277
|
+
let awsMonths = null;
|
|
278
|
+
let awsError = null;
|
|
279
|
+
try {
|
|
280
|
+
const settings = {
|
|
281
|
+
profile,
|
|
282
|
+
region: source.region,
|
|
283
|
+
timeoutMs: Math.max(
|
|
284
|
+
1000,
|
|
285
|
+
Math.min(60000, Number(options.timeoutMs) || 30000),
|
|
286
|
+
),
|
|
287
|
+
};
|
|
288
|
+
const hasCompletedDays =
|
|
289
|
+
report.range.startDayKey < report.range.endDayKeyExclusive;
|
|
290
|
+
const pages = [];
|
|
291
|
+
if (hasCompletedDays) {
|
|
292
|
+
const identity = await aws(["sts", "get-caller-identity"], settings);
|
|
293
|
+
if (
|
|
294
|
+
identity?.Account !== source.accountId ||
|
|
295
|
+
identity?.Arn !== source.principalArn
|
|
296
|
+
) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"AWS identity does not match the Twinkle billing principal. No Cost Explorer query was sent.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
let token;
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
do {
|
|
304
|
+
if (pages.length >= 100)
|
|
305
|
+
throw new Error("AWS billing exceeded the pagination limit.");
|
|
306
|
+
const page = await aws(
|
|
307
|
+
[
|
|
308
|
+
"ce",
|
|
309
|
+
"get-cost-and-usage",
|
|
310
|
+
"--time-period",
|
|
311
|
+
`Start=${report.range.startDayKey},End=${report.range.endDayKeyExclusive}`,
|
|
312
|
+
"--granularity",
|
|
313
|
+
"MONTHLY",
|
|
314
|
+
"--metrics",
|
|
315
|
+
"UnblendedCost",
|
|
316
|
+
"--group-by",
|
|
317
|
+
"Type=DIMENSION,Key=SERVICE",
|
|
318
|
+
"--no-paginate",
|
|
319
|
+
...(token ? ["--next-page-token", token] : []),
|
|
320
|
+
],
|
|
321
|
+
settings,
|
|
322
|
+
);
|
|
323
|
+
pages.push(page);
|
|
324
|
+
token = page.NextPageToken;
|
|
325
|
+
if (token && (typeof token !== "string" || seen.has(token)))
|
|
326
|
+
throw new Error("AWS billing pagination did not advance.");
|
|
327
|
+
if (token) seen.add(token);
|
|
328
|
+
} while (token);
|
|
329
|
+
}
|
|
330
|
+
awsMonths = normalizeAwsSpending(pages, report.months);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
awsError = error.message;
|
|
333
|
+
warnings.push(awsError);
|
|
334
|
+
}
|
|
335
|
+
const months = report.months.map((month, i) => {
|
|
336
|
+
const billing = awsMonths?.[i] ?? null;
|
|
337
|
+
const overlap = month.ai?.awsOverlapDetected === true;
|
|
338
|
+
if (overlap)
|
|
339
|
+
warnings.push(
|
|
340
|
+
`${month.monthKey}: AWS-billed AI detected; combined totals require overlap reconciliation.`,
|
|
341
|
+
);
|
|
342
|
+
const operating =
|
|
343
|
+
month.ai?.estimatedCashUsd != null && billing && !overlap
|
|
344
|
+
? round(month.ai.estimatedCashUsd + billing.costUsd)
|
|
345
|
+
: null;
|
|
346
|
+
return {
|
|
347
|
+
...month,
|
|
348
|
+
aws: billing,
|
|
349
|
+
providerOperatingCostUsd: operating,
|
|
350
|
+
totalIncludingReimbursementsUsd:
|
|
351
|
+
operating !== null && month.reimbursements
|
|
352
|
+
? round(operating + month.reimbursements.totalUsd)
|
|
353
|
+
: null,
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
const complete = months.every(
|
|
357
|
+
(m) => m.totalIncludingReimbursementsUsd !== null,
|
|
358
|
+
);
|
|
359
|
+
const sum = (select) =>
|
|
360
|
+
months.every((m) => select(m) != null)
|
|
361
|
+
? round(months.reduce((n, m) => n + select(m), 0))
|
|
362
|
+
: null;
|
|
363
|
+
return {
|
|
364
|
+
...receipt,
|
|
365
|
+
status: complete ? "success" : "partial",
|
|
366
|
+
data: {
|
|
367
|
+
...receipt.data,
|
|
368
|
+
spending: {
|
|
369
|
+
...report,
|
|
370
|
+
complete,
|
|
371
|
+
months,
|
|
372
|
+
warnings,
|
|
373
|
+
sources: {
|
|
374
|
+
...report.sources,
|
|
375
|
+
aws: {
|
|
376
|
+
...source,
|
|
377
|
+
profile,
|
|
378
|
+
status: awsMonths ? "available" : "unavailable",
|
|
379
|
+
error: awsError,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
totals: {
|
|
383
|
+
aiEstimatedCashUsd: sum((m) => m.ai?.estimatedCashUsd),
|
|
384
|
+
awsUsd: sum((m) => m.aws?.costUsd),
|
|
385
|
+
reimbursementsUsd: sum((m) => m.reimbursements?.totalUsd),
|
|
386
|
+
totalIncludingReimbursementsUsd: sum(
|
|
387
|
+
(m) => m.totalIncludingReimbursementsUsd,
|
|
388
|
+
),
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function formatSpending(report) {
|
|
396
|
+
const money = (value) =>
|
|
397
|
+
value == null
|
|
398
|
+
? "Unavailable"
|
|
399
|
+
: `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
400
|
+
const lines = [
|
|
401
|
+
`Twinkle spending · USD · UTC · ${report.complete ? "complete sources" : "INCOMPLETE SOURCES"}`,
|
|
402
|
+
];
|
|
403
|
+
const rows = [
|
|
404
|
+
["Category", ...report.months.map((m) => m.monthKey)],
|
|
405
|
+
[
|
|
406
|
+
"AI after credits",
|
|
407
|
+
...report.months.map((m) => money(m.ai?.estimatedCashUsd)),
|
|
408
|
+
],
|
|
409
|
+
["AWS (including tax)", ...report.months.map((m) => money(m.aws?.costUsd))],
|
|
410
|
+
[
|
|
411
|
+
"Reimbursements",
|
|
412
|
+
...report.months.map((m) => money(m.reimbursements?.totalUsd)),
|
|
413
|
+
],
|
|
414
|
+
[
|
|
415
|
+
"TOTAL incl. reimbursements",
|
|
416
|
+
...report.months.map((m) => money(m.totalIncludingReimbursementsUsd)),
|
|
417
|
+
],
|
|
418
|
+
["", ...report.months.map(() => "")],
|
|
419
|
+
["AI before credits", ...report.months.map((m) => money(m.ai?.grossUsd))],
|
|
420
|
+
["Promotional credit", ...report.months.map((m) => money(m.ai?.creditUsd))],
|
|
421
|
+
[
|
|
422
|
+
"Reimbursement: AI service",
|
|
423
|
+
...report.months.map((m) => money(m.reimbursements?.aiServiceUsd)),
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
"Reimbursement: infrastructure",
|
|
427
|
+
...report.months.map((m) => money(m.reimbursements?.infrastructureUsd)),
|
|
428
|
+
],
|
|
429
|
+
];
|
|
430
|
+
const widths = rows[0].map((_, i) =>
|
|
431
|
+
Math.max(...rows.map((row) => row[i].length)),
|
|
432
|
+
);
|
|
433
|
+
lines.push(
|
|
434
|
+
...rows.map((row) =>
|
|
435
|
+
row
|
|
436
|
+
.map((cell, i) =>
|
|
437
|
+
i ? cell.padStart(widths[i]) : cell.padEnd(widths[i]),
|
|
438
|
+
)
|
|
439
|
+
.join(" | "),
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
for (const month of report.months) {
|
|
443
|
+
if (month.status === "in_progress")
|
|
444
|
+
lines.push(
|
|
445
|
+
`${month.monthKey} AI/AWS: ${month.throughDayKey ? `through ${month.throughDayKey} UTC` : "no completed UTC days"}; current usage day excluded.`,
|
|
446
|
+
);
|
|
447
|
+
if (month.aws?.estimated)
|
|
448
|
+
lines.push(
|
|
449
|
+
`${month.monthKey} AWS is provisional and may change with billing updates.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
lines.push(
|
|
453
|
+
`Selected-period total: ${money(report.totals.totalIncludingReimbursementsUsd)}`,
|
|
454
|
+
);
|
|
455
|
+
lines.push(
|
|
456
|
+
"AI is a usage-based estimate after credits. AWS estimated flags and service details are in JSON.",
|
|
457
|
+
);
|
|
458
|
+
lines.push(
|
|
459
|
+
"Reimbursements are gross paid amounts by payment date, before refunds/fees; included once in the total above.",
|
|
460
|
+
);
|
|
461
|
+
if (report.sources.reimbursements?.includesCurrentDay)
|
|
462
|
+
lines.push(
|
|
463
|
+
`Reimbursements include today's settled payments through ${new Date((report.sources.reimbursements.endAtExclusive - 1) * 1000).toISOString()}.`,
|
|
464
|
+
);
|
|
465
|
+
if (report.inProgressDay)
|
|
466
|
+
lines.push(
|
|
467
|
+
`${report.inProgressDay.dayKey} partial AI usage: ${money(report.inProgressDay.ai?.grossUsd)} before credits, excluded.`,
|
|
468
|
+
);
|
|
469
|
+
for (const warning of report.warnings || [])
|
|
470
|
+
lines.push(`Unavailable: ${warning}`);
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
package/lib/admin.js
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
printOwnerTrace,
|
|
45
45
|
} from "./admin-owner-trace.js";
|
|
46
46
|
import { buildRolesOperation, printRoles } from "./admin-roles.js";
|
|
47
|
+
import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
|
|
47
48
|
import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
|
|
48
49
|
import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
|
|
49
50
|
import {
|
|
@@ -380,7 +381,7 @@ function buildApprovalOperation({ action, target, options }) {
|
|
|
380
381
|
"approvals.decide",
|
|
381
382
|
"POST",
|
|
382
383
|
`/cli/admin/approvals/${id}`,
|
|
383
|
-
{ decision: action, reason },
|
|
384
|
+
{ decision: action, reason, ...(options.adminFingerprint ? { fingerprint: String(options.adminFingerprint) } : {}) },
|
|
384
385
|
{ requiresRun: false },
|
|
385
386
|
);
|
|
386
387
|
}
|
|
@@ -1545,7 +1546,10 @@ export async function adminCommand(options) {
|
|
|
1545
1546
|
recommendationContentTypes,
|
|
1546
1547
|
viewFilter,
|
|
1547
1548
|
});
|
|
1548
|
-
if (operation.
|
|
1549
|
+
if (operation.name === "spending.report") {
|
|
1550
|
+
result = await completeSpendingReport(await fetchOperation(), options);
|
|
1551
|
+
if (result.status === "partial") process.exitCode = 1;
|
|
1552
|
+
} else if (operation.featuredWorkflow) {
|
|
1549
1553
|
result = await runFeaturedWorkflow({
|
|
1550
1554
|
options,
|
|
1551
1555
|
operation,
|
|
@@ -1624,6 +1628,11 @@ export async function adminCommand(options) {
|
|
|
1624
1628
|
// private evidence is printed, never kept in the receipt file
|
|
1625
1629
|
mutationIntent?.confirm(receiptToKeep(operation, result));
|
|
1626
1630
|
} catch (error) {
|
|
1631
|
+
if (operation.name === "spending.report" && error.status === 404) {
|
|
1632
|
+
error.message = "The spending report API is not deployed yet. Deploy the matching API before using this CLI command.";
|
|
1633
|
+
error.code = "CLI_ADMIN_SPENDING_NOT_DEPLOYED";
|
|
1634
|
+
error.data = { ok: false, status: "unavailable", error: { code: error.code, message: error.message, details: null } };
|
|
1635
|
+
}
|
|
1627
1636
|
if (operation.name === "runtime.evidence" && error.status === 404) {
|
|
1628
1637
|
error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
|
|
1629
1638
|
error.message =
|
|
@@ -3077,11 +3086,11 @@ export function parseAdminOperation(options) {
|
|
|
3077
3086
|
}
|
|
3078
3087
|
|
|
3079
3088
|
if (MEETUP_NAMESPACES.has(namespace)) {
|
|
3080
|
-
// Bridge Builder crews (achievement type 'meetup'): the same
|
|
3081
|
-
// the website.
|
|
3082
|
-
//
|
|
3089
|
+
// Bridge Builder crews (achievement type 'meetup'): the same owner tools
|
|
3090
|
+
// as the website. Check-ins provide evidence; every meetup completion
|
|
3091
|
+
// waits for Mikey's decision (2026-10-22).
|
|
3083
3092
|
const usage =
|
|
3084
|
-
"Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch <name> [--note <text>] | branches [list|show|approve|reject|merge] | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | story ... | announce-checkins [--send] [--as zero|ciel].";
|
|
3093
|
+
"Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | review-completion <crewId> --decision approve|reject --fingerprint <review fingerprint> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch <name> [--note <text>] | branches [list|show|approve|reject|merge] | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | story ... | announce-checkins [--send] [--as zero|ciel].";
|
|
3085
3094
|
const note = String(options.note || "").trim();
|
|
3086
3095
|
if (note.length > 1000) {
|
|
3087
3096
|
throw cliValidationError("--note must be at most 1000 characters.");
|
|
@@ -3233,6 +3242,14 @@ export function parseAdminOperation(options) {
|
|
|
3233
3242
|
{ requiresRun: false },
|
|
3234
3243
|
);
|
|
3235
3244
|
}
|
|
3245
|
+
if (action === "review-completion") {
|
|
3246
|
+
const crewId = parseRequiredInteger(target, "Crew ID", 1);
|
|
3247
|
+
const decision = parseChoice(options.adminDecision || "", "--decision", ["approve", "reject"]);
|
|
3248
|
+
const fingerprint = String(options.adminFingerprint || "");
|
|
3249
|
+
if (!/^[0-9a-f]{64}$/.test(fingerprint)) throw cliValidationError("Use --fingerprint from meetup show --json after reviewing every participant's evidence.");
|
|
3250
|
+
return writeOperation("meetup.review-completion", "POST", `/cli/admin/meetup-quest/crews/${crewId}/checkin-review`,
|
|
3251
|
+
{ isApproved: decision === "approve", fingerprint }, { requiresRun: false });
|
|
3252
|
+
}
|
|
3236
3253
|
if (action === "info") {
|
|
3237
3254
|
// Staff's "who are you" check on a member (ruled 2026-10-07): the member
|
|
3238
3255
|
// gives their teacher's name and class name (or how they know the crew)
|
|
@@ -3629,6 +3646,17 @@ export function parseAdminOperation(options) {
|
|
|
3629
3646
|
throw cliValidationError(usage);
|
|
3630
3647
|
}
|
|
3631
3648
|
|
|
3649
|
+
if (namespace === "reward-diagnostics") {
|
|
3650
|
+
const buildId = options.buildIdFlag ? parseRequiredInteger(options.buildIdFlag, "--build", 1) : 0;
|
|
3651
|
+
const userId = options.adminUser ? parseRequiredInteger(options.adminUser, "--user", 1) : 0;
|
|
3652
|
+
const days = options.adminDays ? parseRequiredInteger(options.adminDays, "--days", 1) : 1;
|
|
3653
|
+
if (days > 30 || userId && !buildId) throw cliValidationError("Use --build with --user; --days must be 1–30.");
|
|
3654
|
+
return readOperation("reward-diagnostics.read", withQuery("/cli/admin/reward-diagnostics", {
|
|
3655
|
+
buildId: buildId || undefined, userId: userId || undefined, days,
|
|
3656
|
+
before: options.adminCursor ? parseRequiredInteger(options.adminCursor, "--cursor", 1) : undefined
|
|
3657
|
+
}), { requiresRun: false });
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3632
3660
|
if (namespace === "reward-activity" || namespace === "reward-telemetry") {
|
|
3633
3661
|
// Read-only claim telemetry for the daily run: no run lease, no mutation.
|
|
3634
3662
|
const date = options.adminDate
|
|
@@ -4474,6 +4502,8 @@ export function parseAdminOperation(options) {
|
|
|
4474
4502
|
);
|
|
4475
4503
|
}
|
|
4476
4504
|
|
|
4505
|
+
if (namespace === "spending") return buildSpendingOperation(options);
|
|
4506
|
+
|
|
4477
4507
|
if (namespace === "ai-costs") {
|
|
4478
4508
|
if (action === "monthly" && !target && !extra) {
|
|
4479
4509
|
if (options.adminDays) {
|
|
@@ -6620,8 +6650,10 @@ function printMeetupResult({ operation, data, result }) {
|
|
|
6620
6650
|
}
|
|
6621
6651
|
if (operation.name === "meetup.show") {
|
|
6622
6652
|
printMeetupCrew(data.crew || {});
|
|
6653
|
+
if (data.meetupEvidence) console.log(JSON.stringify(data.meetupEvidence, null, 2));
|
|
6623
6654
|
return;
|
|
6624
6655
|
}
|
|
6656
|
+
if (operation.name === "meetup.review-completion") console.log(`Meetup completion ${data.status}.`);
|
|
6625
6657
|
if (data.crew) printMeetupCrew(data.crew);
|
|
6626
6658
|
}
|
|
6627
6659
|
|
|
@@ -6933,6 +6965,7 @@ function printApprovalResult({ operation, data }) {
|
|
|
6933
6965
|
`${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId})`,
|
|
6934
6966
|
);
|
|
6935
6967
|
printEvidence(data.evidence);
|
|
6968
|
+
if (data.meetupEvidence) console.log(JSON.stringify(data.meetupEvidence, null, 2));
|
|
6936
6969
|
return;
|
|
6937
6970
|
}
|
|
6938
6971
|
if (operation.name === "approvals.decide") {
|
|
@@ -7360,10 +7393,16 @@ function printAiBucketRuleResult({ operation, data }) {
|
|
|
7360
7393
|
|
|
7361
7394
|
function printAdminResult({ operation, result }) {
|
|
7362
7395
|
const data = result?.data || {};
|
|
7396
|
+
if (operation.name === "spending.report" && data.spending) {
|
|
7397
|
+
console.log(formatSpending(data.spending));
|
|
7398
|
+
return;
|
|
7399
|
+
}
|
|
7363
7400
|
if (operation.name === "math-lab.queue") {
|
|
7364
7401
|
console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
|
|
7365
7402
|
for (const grade of data.grades || []) console.log(grade.available
|
|
7366
|
-
?
|
|
7403
|
+
? grade.progression === "collection"
|
|
7404
|
+
? `${grade.ruleId}: ${grade.sets} in collection; ${grade.remaining} fresh choices minimum (${grade.activeLearners} active learners; median ${grade.medianRemaining})${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
|
|
7405
|
+
: `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
|
|
7367
7406
|
: `${grade.ruleId}: queue unavailable`);
|
|
7368
7407
|
if (!data.complete) console.log("Queue coverage is incomplete; unavailable grades are not empty queues.");
|
|
7369
7408
|
return;
|
package/lib/commands.js
CHANGED
|
@@ -2562,6 +2562,13 @@ export function parseArgs(args) {
|
|
|
2562
2562
|
);
|
|
2563
2563
|
}
|
|
2564
2564
|
}
|
|
2565
|
+
if (command === "admin" && positional[0] === "spending") {
|
|
2566
|
+
const allowed = new Set(["help", "month", "from", "to", "awsProfile", "output", "json",
|
|
2567
|
+
"apiUrl", "siteUrl", "authFile", "authToken", "clientName", "timeoutMs", "noUpdateCheck"]);
|
|
2568
|
+
for (const key of Object.keys(raw)) {
|
|
2569
|
+
if (!allowed.has(key)) throw new Error(`Unsupported spending option --${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}.`);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2565
2572
|
const suggestionInboxActions = new Set([
|
|
2566
2573
|
"list",
|
|
2567
2574
|
"merge",
|
|
@@ -2805,6 +2812,10 @@ export function parseArgs(args) {
|
|
|
2805
2812
|
adminActions: raw.actions ? String(raw.actions) : "",
|
|
2806
2813
|
adminDate: raw.date ? String(raw.date) : "",
|
|
2807
2814
|
adminDays: raw.days ? String(raw.days) : "",
|
|
2815
|
+
adminSpendingMonth: raw.month,
|
|
2816
|
+
adminSpendingFrom: raw.from,
|
|
2817
|
+
adminSpendingTo: raw.to,
|
|
2818
|
+
adminAwsProfile: raw.awsProfile,
|
|
2808
2819
|
adminMaxStep: raw.maxStep ? String(raw.maxStep) : "",
|
|
2809
2820
|
adminBuild: raw.build ? String(raw.build) : "",
|
|
2810
2821
|
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
@@ -3269,6 +3280,7 @@ export function printHelp() {
|
|
|
3269
3280
|
lumine admin meetup list [--status review|active|completed|all] | show <crewId> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch "<name>" [--note <text>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | announce-checkins [--send] [--as zero|ciel] (Bridge Builder crews and check-ins)
|
|
3270
3281
|
lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text> (Bridge Builder story pages: publishes only what every tagged member's parent allowed)
|
|
3271
3282
|
lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
|
|
3283
|
+
lumine admin reward-diagnostics [--build <id> --user <id>] [--days <1..30>] [--cursor <id>] [--json]
|
|
3272
3284
|
lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
|
|
3273
3285
|
lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
|
|
3274
3286
|
lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
|
|
@@ -3315,7 +3327,7 @@ export function printHelp() {
|
|
|
3315
3327
|
lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
|
|
3316
3328
|
lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
|
|
3317
3329
|
lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
|
|
3318
|
-
lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--wake] [--path <substring>] [--limit 300] [--json] (--wake:
|
|
3330
|
+
lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--wake] [--path <substring>] [--limit 300] [--json] (--wake: sessions, 20 s wake profiles, bounded viewport geometry and dropped-event counts after loads or returns; network tags identify the upload network; what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
|
|
3319
3331
|
lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
|
|
3320
3332
|
lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
|
|
3321
3333
|
lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
|
|
@@ -3325,6 +3337,7 @@ export function printHelp() {
|
|
|
3325
3337
|
(with both a comment session and a daily run active, comment draft/reply/post/edit need --via session|run)
|
|
3326
3338
|
lumine admin comment edit <comment-id> --file <comment.md> [--review-receipt <review.json> | --reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--json]
|
|
3327
3339
|
lumine admin brief [--days <1..30>] [--json]
|
|
3340
|
+
lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--output <file.json>] [--json] (owner spending: AI, AWS, reimbursements; no active run required)
|
|
3328
3341
|
lumine admin ai-costs monthly [--output <file.json>] [--json] (no active run required)
|
|
3329
3342
|
lumine admin ai-costs day <YYYY-MM-DD> [--json]
|
|
3330
3343
|
lumine admin media-costs monthly [--json]
|
package/lib/sdk.js
CHANGED
|
@@ -303,7 +303,7 @@ export const SDK_CLI_METHODS = {
|
|
|
303
303
|
path: "api/rewards/related-archive", special: "rewards", operation: "related-archive",
|
|
304
304
|
scopes: ["rewards:claim"], readOnly: true,
|
|
305
305
|
mapArgs: (args) =>
|
|
306
|
-
args.receiptId !== undefined ? { receiptId: args.receiptId } : { ruleId: args.ruleId },
|
|
306
|
+
args.receiptId !== undefined ? { receiptId: args.receiptId } : { ruleId: args.ruleId, ...(args.setKey !== undefined ? { setKey: args.setKey } : {}) },
|
|
307
307
|
},
|
|
308
308
|
"rewards.listExplanations": {
|
|
309
309
|
path: "api/rewards/explanations", special: "rewards", operation: "explanations",
|
|
@@ -349,7 +349,7 @@ export const SDK_CLI_METHODS = {
|
|
|
349
349
|
operation: "start",
|
|
350
350
|
scopes: ["rewards:claim"],
|
|
351
351
|
write: true,
|
|
352
|
-
mapArgs: (args) => ({ ruleId: args.ruleId }),
|
|
352
|
+
mapArgs: (args) => ({ ruleId: args.ruleId, ...(args.setKey !== undefined ? { setKey: args.setKey } : {}) }),
|
|
353
353
|
},
|
|
354
354
|
"rewards.claim": {
|
|
355
355
|
path: "api/rewards/claim",
|