segreant 0.1.0 → 0.2.0
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/LICENSING.md +5 -3
- package/README.md +57 -19
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +19 -8
- package/dist/alerts/detect.js +13 -0
- package/dist/billing/epistemic.js +4 -0
- package/dist/billing/importer.js +29 -10
- package/dist/billing/reconcile.js +122 -32
- package/dist/billing/scope.js +7 -0
- package/dist/budget/guard.js +33 -4
- package/dist/cli/allocCmd.js +73 -4
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +2 -1
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +68 -9
- package/dist/cli/capitalCmd.js +2 -1
- package/dist/cli/connectCmd.js +50 -9
- package/dist/cli/egressCmd.js +15 -3
- package/dist/cli/evidenceCmd.js +6 -3
- package/dist/cli/flags.js +68 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +210 -54
- package/dist/cli/launchCmd.js +2 -1
- package/dist/cli/opsCmd.js +49 -2
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/runCmd.js +203 -51
- package/dist/cli/showCmd.js +37 -11
- package/dist/cli/teamCmd.js +5 -5
- package/dist/cli/ui.js +24 -8
- package/dist/cli/valueCmd.js +189 -14
- package/dist/cli.js +102 -21
- package/dist/config.js +95 -6
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +43 -3
- package/dist/connect/codex.js +38 -1
- package/dist/connect/importShared.js +58 -5
- package/dist/connect/opencode.js +37 -9
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +4 -1
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +228 -15
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +11 -8
- package/dist/dashboard/contracts.js +23 -3
- package/dist/dashboard/routes.js +980 -112
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +2 -1
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +6 -5
- package/dist/dashboard/web/app/components/drawer.js +105 -35
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +54 -24
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +734 -23
- package/dist/dashboard/web/app/core/api.js +37 -1
- package/dist/dashboard/web/app/core/chain.js +11 -4
- package/dist/dashboard/web/app/core/claimLayers.js +179 -51
- package/dist/dashboard/web/app/core/claimTypes.js +30 -0
- package/dist/dashboard/web/app/core/fmt.js +19 -2
- package/dist/dashboard/web/app/core/generated-contract.js +23 -3
- package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
- package/dist/dashboard/web/app/core/generated-types.js +1 -1
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +79 -46
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +197 -15
- package/dist/dashboard/web/app/views/allocation.js +249 -61
- package/dist/dashboard/web/app/views/control.js +57 -16
- package/dist/dashboard/web/app/views/data.js +4 -4
- package/dist/dashboard/web/app/views/evidence.js +336 -87
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +30 -15
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +44 -4
- package/dist/dashboard/web/app/views/value.js +105 -59
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +16 -4
- package/dist/dashboard/web/index.html +1 -1
- package/dist/dashboard/web/styles/app.css +536 -23
- package/dist/demo/seed.js +153 -14
- package/dist/economics/ledger.js +1 -1
- package/dist/egress/policy.js +129 -5
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +6 -2
- package/dist/export/csv.js +10 -2
- package/dist/export/economic.js +6 -2
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/correlate.js +86 -9
- package/dist/git/quality.js +107 -24
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/guide.js +9 -4
- package/dist/plans/detect.js +147 -0
- package/dist/proxy/server.js +134 -14
- package/dist/proxy/usage.js +9 -4
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/scan/knownApps.js +6 -2
- package/dist/scan/scan.js +9 -0
- package/dist/store/allocation.js +14 -0
- package/dist/store/db.js +259 -34
- package/dist/store/realization.js +78 -14
- package/dist/store/schema.js +59 -1
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/time.js +7 -0
- package/dist/value/realization.js +321 -30
- package/dist/value/report.js +28 -8
- package/dist/value/selfReported.js +10 -9
- package/docs/ARCHITECTURE.md +8 -4
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/DATA-BOUNDARIES.md +66 -6
- package/docs/FAQ.md +43 -9
- package/docs/GETTING-STARTED.md +19 -12
- package/docs/GUIDE.md +81 -18
- package/docs/INTEGRATIONS.md +9 -9
- package/docs/METHODOLOGY.md +3 -2
- package/docs/NEUTRALITY.md +1 -1
- package/docs/PRIVACY.md +51 -0
- package/docs/README.md +1 -0
- package/docs/RELEASE-GATE.md +52 -7
- package/docs/THREAT-MODEL.md +2 -1
- package/package.json +3 -2
- package/pricing/models.json +1366 -60
package/dist/dashboard/routes.js
CHANGED
|
@@ -17,37 +17,51 @@
|
|
|
17
17
|
import { reconciliationReadiness } from '../billing/readiness.js';
|
|
18
18
|
import { CLAIM_USES } from '../epistemic/claim-uses.js';
|
|
19
19
|
import { EXACT_ALLOCATION_EXCLUDED_FLOOR } from '../alloc/exact.js';
|
|
20
|
+
import { ALLOCATION_METHODS, MATCH_FIELDS } from '../alloc/rules.js';
|
|
20
21
|
import { existsSync, statSync } from 'node:fs';
|
|
22
|
+
import { posix, win32 } from 'node:path';
|
|
21
23
|
import { allocatedClaimSupport, billedClaimSupport, meteredClaimSupport, realizedClaimSupport, } from './claim-support.js';
|
|
22
|
-
import { isDemo } from '../config.js';
|
|
24
|
+
import { isDemo, loadConfig, DemoReadOnlyError, DemoWriteBlockedError } from '../config.js';
|
|
25
|
+
import { MAX_BILLING_IMPORT_BYTES, parseBillingImportText } from '../billing/importer.js';
|
|
26
|
+
import { newOpenAiScopeDeclaration } from '../billing/scope.js';
|
|
27
|
+
import { reconciliationCountermodels } from '../billing/countermodels.js';
|
|
28
|
+
import { assessAssumptionFragility } from '../epistemic/countermodel.js';
|
|
29
|
+
import { moneyToJson } from '../economics/money.js';
|
|
23
30
|
import { probeProxyState } from '../egress/proxyHealth.js';
|
|
24
31
|
import { buildSettingsSnapshot, applySettingsPatch, SettingsValidationError } from './settings.js';
|
|
25
32
|
import { serveHtml } from './static.js';
|
|
26
|
-
import { resolveEnforcedSpend, startOfLocalDay } from '../budget/guard.js';
|
|
33
|
+
import { capBasisSpend, resolveEnforcedSpend, startOfLocalDay } from '../budget/guard.js';
|
|
27
34
|
import { driftDetectability } from '../value/drift.js';
|
|
28
|
-
import { loadRealization, realizeDiscoveredProjects } from '../value/realization.js';
|
|
35
|
+
import { loadRealization, realizeDiscoveredProjects, realizationFromStore, keptSummary } from '../value/realization.js';
|
|
29
36
|
// The one composition of the value primitives, shared with the CLI — see the
|
|
30
37
|
// '/api/value' handler below and src/value/report.ts for why it is not inline.
|
|
31
38
|
import { valueReport } from '../value/report.js';
|
|
39
|
+
import { buildSessions } from './sessions.js';
|
|
32
40
|
import { recordReportedOutcome } from '../value/selfReported.js';
|
|
33
41
|
import { buildMarketReport, loadMarket } from '../market/market.js';
|
|
34
|
-
import { projectName } from '../git/correlate.js';
|
|
42
|
+
import { projectName, resolveCommit } from '../git/correlate.js';
|
|
43
|
+
import { ValueReadCache, valueCacheKey } from './value-cache.js';
|
|
35
44
|
import { scanWithDiff, saveScan } from '../scan/scan.js';
|
|
36
45
|
import { describeSourceDepth } from '../value/sourceDepth.js';
|
|
37
46
|
import { buildGuide } from '../guide.js';
|
|
38
47
|
import { computeAlertCoverage, computeAlerts } from '../alerts/detect.js';
|
|
39
48
|
import { requestsToCsv } from '../export/csv.js';
|
|
49
|
+
import { statementToCsv } from '../export/statementCsv.js';
|
|
50
|
+
import { allocationRunToCsv } from '../export/allocationCsv.js';
|
|
40
51
|
import { economicRequestsToCsv } from '../export/economic.js';
|
|
41
52
|
import { instant } from '../epistemic/time.js';
|
|
42
53
|
import { DIMENSIONS } from '../value/characterization.js';
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import { importOpencode, defaultOpencodeDbPath } from '../connect/opencode.js';
|
|
46
|
-
import { importCodex, defaultCodexRoot } from '../connect/codex.js';
|
|
54
|
+
import { emptyImportSummary } from '../connect/importShared.js';
|
|
55
|
+
import { IMPORT_REGISTRY } from '../connect/registry.js';
|
|
47
56
|
import { judgeSessionFromStore } from '../judge/orchestrate.js';
|
|
48
57
|
import { resolveJudgeTier, hasHostedJudgeApiKey } from '../judge/tier.js';
|
|
49
58
|
import { pricingStatus } from '../cost/pricing.js';
|
|
59
|
+
import { declarePrice, unknownModels } from '../cost/declarations.js';
|
|
60
|
+
import { previewReprice, applyReprice } from '../cost/reprice.js';
|
|
61
|
+
import { modelNameError, removePriceOverride } from '../cost/overrides.js';
|
|
62
|
+
import { summarizeBasis } from '../cost/basis.js';
|
|
50
63
|
import { pricingCoverage } from '../cost/coverage.js';
|
|
64
|
+
import { activePriceOverrides } from '../cost/overrides.js';
|
|
51
65
|
import { RESOURCE_LIMITS } from '../util/resource-limits.js';
|
|
52
66
|
import { buildEconomicReport } from '../cli/economicCmd.js';
|
|
53
67
|
import { verifyBlockedAssignmentPlan } from '../causal/assignment.js';
|
|
@@ -116,6 +130,7 @@ export function buildOverview(store, config, range) {
|
|
|
116
130
|
: todaySpend;
|
|
117
131
|
const summary = store.summary(startMs, endMs);
|
|
118
132
|
const pricingWindow = store.healthStats(startMs, endMs);
|
|
133
|
+
const provenance = store.pricingEvidenceByModel(startMs, endMs);
|
|
119
134
|
// What retention deleted from inside this window. Read from the ledger's own
|
|
120
135
|
// record, because an absence of rows is exactly what cannot distinguish a
|
|
121
136
|
// quiet window from a pruned one (D-175).
|
|
@@ -128,7 +143,8 @@ export function buildOverview(store, config, range) {
|
|
|
128
143
|
// in the browser, which answered `complete` for a window with no spend in it.
|
|
129
144
|
claimSupport: meteredClaimSupport({
|
|
130
145
|
totalCostUsd: pricingWindow.totalCostUsd,
|
|
131
|
-
|
|
146
|
+
// Prices you set are not list prices, so they never count as list support.
|
|
147
|
+
estimatedCostUsd: pricingWindow.estimatedCostUsd + pricingWindow.declaredCostUsd,
|
|
132
148
|
retention,
|
|
133
149
|
}),
|
|
134
150
|
retention,
|
|
@@ -156,7 +172,8 @@ export function buildOverview(store, config, range) {
|
|
|
156
172
|
// Per-row provenance, grouped without mixing cards or match paths. This
|
|
157
173
|
// is deliberately separate from the active card above: a refresh never
|
|
158
174
|
// rewrites the evidence captured for historical requests.
|
|
159
|
-
provenance
|
|
175
|
+
provenance,
|
|
176
|
+
basis: summarizeBasis(provenance),
|
|
160
177
|
},
|
|
161
178
|
byModel: store.byModel(startMs, endMs),
|
|
162
179
|
byProject: store.byProject(startMs, endMs),
|
|
@@ -185,23 +202,11 @@ export function buildOverview(store, config, range) {
|
|
|
185
202
|
alertCoverage: computeAlertCoverage(store, config, { now }),
|
|
186
203
|
};
|
|
187
204
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
},
|
|
194
|
-
{
|
|
195
|
-
...IMPORTERS.find((i) => i.id === 'opencode'),
|
|
196
|
-
locate: () => defaultOpencodeDbPath(),
|
|
197
|
-
run: (store, opts) => importOpencode(store, opts),
|
|
198
|
-
},
|
|
199
|
-
{
|
|
200
|
-
...IMPORTERS.find((i) => i.id === 'codex'),
|
|
201
|
-
locate: () => defaultCodexRoot(),
|
|
202
|
-
run: (store, opts) => importCodex(store, opts),
|
|
203
|
-
},
|
|
204
|
-
];
|
|
205
|
+
// The same registry the CLI and `scan` use (src/connect/registry.ts), so the
|
|
206
|
+
// dashboard can never again skip a tool the terminal reads.
|
|
207
|
+
const DASH_IMPORTERS = IMPORT_REGISTRY.map((e) => ({
|
|
208
|
+
id: e.id, label: e.label, blurb: e.blurb, locate: e.locate, run: e.run,
|
|
209
|
+
}));
|
|
205
210
|
// ---------------------------------------------------------------------------
|
|
206
211
|
// Handlers
|
|
207
212
|
// ---------------------------------------------------------------------------
|
|
@@ -220,6 +225,14 @@ export function handleImporters({ res }) {
|
|
|
220
225
|
}),
|
|
221
226
|
});
|
|
222
227
|
}
|
|
228
|
+
/** Demo mode writes nothing real: refuse with a conflict and drain the body. */
|
|
229
|
+
function refuseDemoImport(req, res, what) {
|
|
230
|
+
if (!isDemo())
|
|
231
|
+
return false;
|
|
232
|
+
req.resume();
|
|
233
|
+
json(res, 409, { error: new DemoWriteBlockedError(what).message });
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
223
236
|
/**
|
|
224
237
|
* Trigger a native import from the dashboard. POST-only + a custom header the
|
|
225
238
|
* browser only sets same-origin (declared in the route table): a cross-site form
|
|
@@ -227,6 +240,8 @@ export function handleImporters({ res }) {
|
|
|
227
240
|
* mutating the local DB.
|
|
228
241
|
*/
|
|
229
242
|
export function handleImport({ req, res, url, store }) {
|
|
243
|
+
if (refuseDemoImport(req, res, 'the import'))
|
|
244
|
+
return;
|
|
230
245
|
const tool = url.searchParams.get('tool') ?? 'all';
|
|
231
246
|
const targets = tool === 'all' ? DASH_IMPORTERS : DASH_IMPORTERS.filter((i) => i.id === tool);
|
|
232
247
|
if (targets.length === 0)
|
|
@@ -286,6 +301,8 @@ export function handleDiscover({ req, res, url, store }) {
|
|
|
286
301
|
export function handleScan({ req, res, url, store }) {
|
|
287
302
|
const path = url.searchParams.get('path') || undefined;
|
|
288
303
|
if (req.method === 'POST') {
|
|
304
|
+
if (refuseDemoImport(req, res, 'the import and setup'))
|
|
305
|
+
return;
|
|
289
306
|
req.resume();
|
|
290
307
|
void (async () => {
|
|
291
308
|
try {
|
|
@@ -345,6 +362,219 @@ export function handleOverview({ res, url, store, config }) {
|
|
|
345
362
|
return json(res, 500, { error: String(err) });
|
|
346
363
|
}
|
|
347
364
|
}
|
|
365
|
+
export function handleSessions({ res, url, store }) {
|
|
366
|
+
const range = url.searchParams.get('range') ?? '7d';
|
|
367
|
+
const valid = ['today', '7d', '30d', 'all'];
|
|
368
|
+
const safe = valid.includes(range) ? range : '7d';
|
|
369
|
+
try {
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
return json(res, 200, buildSessions(store, safe, resolveRange(safe, now), { now }));
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
return json(res, 500, { error: String(err) });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const STATEMENT_MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
378
|
+
const STATEMENT_TOP = 10;
|
|
379
|
+
const STATEMENT_RUN_LIMIT = 50;
|
|
380
|
+
/**
|
|
381
|
+
* Local-time calendar month, half-open: local midnight on the 1st to local
|
|
382
|
+
* midnight on the 1st of the next month. Local time matches startOfLocalDay,
|
|
383
|
+
* which the day figures already use. Only the rolling `segreant month` window
|
|
384
|
+
* exists in the CLI, so there is no calendar helper to reuse.
|
|
385
|
+
*/
|
|
386
|
+
export function calendarMonthBounds(year, month) {
|
|
387
|
+
return { startMs: new Date(year, month - 1, 1).getTime(), endMs: new Date(year, month, 1).getTime() };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* How far a month's overlapping runs reach. Never a sum: a run that covers only
|
|
391
|
+
* part of the month says so, and no run is prorated into a month figure.
|
|
392
|
+
*/
|
|
393
|
+
function monthCoverageOf(runs) {
|
|
394
|
+
if (runs.length === 0)
|
|
395
|
+
return 'not_established';
|
|
396
|
+
return runs.some((r) => r.coversWholeMonth) ? 'whole' : 'partial';
|
|
397
|
+
}
|
|
398
|
+
export function handleStatement({ res, url, store }) {
|
|
399
|
+
const r = buildStatement(store, url.searchParams.get('month'), STATEMENT_TOP);
|
|
400
|
+
return r.status === 200 ? json(res, 200, r.payload) : json(res, r.status, { error: r.error });
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The same month as a spreadsheet: every project and model rather than the
|
|
404
|
+
* page's top ten, and the four claims kept as four sections with no total.
|
|
405
|
+
*/
|
|
406
|
+
export function handleStatementCsv({ res, url, store }) {
|
|
407
|
+
const r = buildStatement(store, url.searchParams.get('month'), null);
|
|
408
|
+
if (r.status !== 200)
|
|
409
|
+
return json(res, r.status, { error: r.error });
|
|
410
|
+
res.writeHead(200, {
|
|
411
|
+
'content-type': 'text/csv; charset=utf-8',
|
|
412
|
+
'content-disposition': `attachment; filename="segreant-statement-${r.payload.month}${r.payload.demo ? '-sample' : ''}.csv"`,
|
|
413
|
+
'cache-control': 'no-store',
|
|
414
|
+
});
|
|
415
|
+
res.end(statementToCsv(r.payload));
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* F5. A unit is counted in the month its survival check falls (commit time plus
|
|
419
|
+
* the window), as last measured. Read from stored snapshots, so opening a month
|
|
420
|
+
* never runs git. Dollars are the list cost behind the work, not its value.
|
|
421
|
+
*/
|
|
422
|
+
export function realizedForMonth(store, startMs, endMs) {
|
|
423
|
+
const report = realizationFromStore(store);
|
|
424
|
+
// Each unit is placed by the window it was measured with. Units persisted
|
|
425
|
+
// before that was recorded fall back to the default, and the reason says so.
|
|
426
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
427
|
+
const due = (u) => u.tsEpochMs + (u.checkWindowDays ?? report.windowDays) * DAY_MS;
|
|
428
|
+
const judged = report.units.filter((u) => !u.maturing && due(u) >= startMs && due(u) < endMs);
|
|
429
|
+
const waiting = report.units.filter((u) => u.maturing && u.tsEpochMs < endMs && due(u) >= startMs);
|
|
430
|
+
const k = keptSummary({ units: judged, windowDays: report.windowDays });
|
|
431
|
+
const cost = (us) => us.reduce((s, u) => s + (Number.isFinite(u.attributedCostUsd) ? u.attributedCostUsd : 0), 0);
|
|
432
|
+
const measuredAt = report.units.length > 0 ? report.generatedAt : null;
|
|
433
|
+
const assumed = [...judged, ...waiting].filter((u) => u.checkWindowDays === undefined).length;
|
|
434
|
+
return {
|
|
435
|
+
standing: judged.length > 0 ? 'judged_in_month' : 'none_judged',
|
|
436
|
+
windowDays: report.windowDays,
|
|
437
|
+
assumedWindowUnits: assumed,
|
|
438
|
+
measuredAt,
|
|
439
|
+
kept: k.kept,
|
|
440
|
+
notKept: k.notKept,
|
|
441
|
+
contested: k.contested,
|
|
442
|
+
unknown: k.unknown,
|
|
443
|
+
tooEarly: { units: waiting.length, costUsd: cost(waiting), nextCheckMs: waiting.length > 0 ? Math.min(...waiting.map(due)) : null },
|
|
444
|
+
reason: measuredAt === null
|
|
445
|
+
? 'No outcomes have been measured on this machine yet. Run a value read from the Value page.'
|
|
446
|
+
: `Work is counted in the month its survival check falls, as last measured. The dollars are the list cost of that work, not its value; a value needs an hourly rate and is on the Value page.`
|
|
447
|
+
+ (assumed > 0 ? ` ${assumed} ${assumed === 1 ? 'piece was' : 'pieces were'} measured before Segreant recorded each check's window, so ${assumed === 1 ? 'it is' : 'they are'} placed by the ${report.windowDays}-day default.` : ''),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function buildStatement(store, raw, top) {
|
|
451
|
+
try {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
let year;
|
|
454
|
+
let month;
|
|
455
|
+
if (raw === null) {
|
|
456
|
+
const current = new Date(now);
|
|
457
|
+
year = current.getFullYear();
|
|
458
|
+
month = current.getMonth() + 1;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
const match = /^(\d{4})-(\d{2})$/.exec(raw);
|
|
462
|
+
if (!match)
|
|
463
|
+
return { status: 400, error: 'month must be YYYY-MM, for example 2026-07' };
|
|
464
|
+
year = Number(match[1]);
|
|
465
|
+
month = Number(match[2]);
|
|
466
|
+
if (year < 1970 || month < 1 || month > 12)
|
|
467
|
+
return { status: 400, error: 'month must be a calendar month from 1970 onward' };
|
|
468
|
+
}
|
|
469
|
+
const { startMs, endMs } = calendarMonthBounds(year, month);
|
|
470
|
+
if (startMs > now)
|
|
471
|
+
return { status: 400, error: `month ${year}-${String(month).padStart(2, '0')} is entirely in the future` };
|
|
472
|
+
const overlaps = (periodStartMs, periodEndMs) => periodStartMs < endMs && periodEndMs > startMs;
|
|
473
|
+
const covers = (periodStartMs, periodEndMs) => periodStartMs <= startMs && periodEndMs >= endMs;
|
|
474
|
+
// Metered: the same reads buildOverview makes, for this month's range.
|
|
475
|
+
const summary = store.summary(startMs, endMs);
|
|
476
|
+
const pricingWindow = store.healthStats(startMs, endMs);
|
|
477
|
+
const retention = store.windowCoverage(startMs);
|
|
478
|
+
const basis = summarizeBasis(store.pricingEvidenceByModel(startMs, endMs));
|
|
479
|
+
// Provider-billed: runs whose period overlaps the month. A partial run is
|
|
480
|
+
// reported as itself, with its own figures, never prorated into the month.
|
|
481
|
+
const reconciliation = store.reconciliationRuns(STATEMENT_RUN_LIMIT);
|
|
482
|
+
const billedRuns = reconciliation
|
|
483
|
+
.filter((r) => overlaps(r.result.periodStartMs, r.result.periodEndMs))
|
|
484
|
+
.map((r) => ({
|
|
485
|
+
reconciliationRunId: r.reconciliationRunId,
|
|
486
|
+
computedAtMs: r.computedAtMs,
|
|
487
|
+
periodStartMs: r.result.periodStartMs,
|
|
488
|
+
periodEndMs: r.result.periodEndMs,
|
|
489
|
+
providerReportedMicros: r.result.providerReportedMicros,
|
|
490
|
+
localCapturedMicros: r.result.localCapturedMicros,
|
|
491
|
+
unexplainedVarianceMicros: r.result.unexplainedVarianceMicros,
|
|
492
|
+
providerSourceKind: r.result.providerSourceKind,
|
|
493
|
+
offPathBound: r.result.offPathBound ?? null,
|
|
494
|
+
snapshotStability: r.result.snapshotStability ?? null,
|
|
495
|
+
localQuantization: r.result.localQuantization ?? null,
|
|
496
|
+
providerProjectRef: r.result.providerProjectRef ?? null,
|
|
497
|
+
coversWholeMonth: covers(r.result.periodStartMs, r.result.periodEndMs),
|
|
498
|
+
}));
|
|
499
|
+
// Allocated: the same rule over allocation runs.
|
|
500
|
+
const allocations = store.allocationRuns(STATEMENT_RUN_LIMIT);
|
|
501
|
+
const allocatedRuns = allocations
|
|
502
|
+
.filter((r) => overlaps(r.result.periodStartMs, r.result.periodEndMs))
|
|
503
|
+
.map((r) => ({
|
|
504
|
+
allocationRunId: r.allocationRunId,
|
|
505
|
+
computedAtMs: r.computedAtMs,
|
|
506
|
+
periodStartMs: r.result.periodStartMs,
|
|
507
|
+
periodEndMs: r.result.periodEndMs,
|
|
508
|
+
totalMicros: r.result.totalMicros,
|
|
509
|
+
allocatedMicros: r.result.allocatedMicros,
|
|
510
|
+
unallocatedMicros: r.result.unallocatedMicros,
|
|
511
|
+
conserves: r.result.conserves,
|
|
512
|
+
byCostCentre: r.result.byCostCentre.map((c) => ({
|
|
513
|
+
costCentreId: c.costCentreId,
|
|
514
|
+
allocatedMicros: c.allocatedMicros,
|
|
515
|
+
requestCount: c.requestCount,
|
|
516
|
+
sourceBasis: c.sourceBasis,
|
|
517
|
+
})),
|
|
518
|
+
coversWholeMonth: covers(r.result.periodStartMs, r.result.periodEndMs),
|
|
519
|
+
}));
|
|
520
|
+
const closeStatus = store.economicPeriodCloseStatus(startMs, endMs);
|
|
521
|
+
const byProject = store.byProject(startMs, endMs)
|
|
522
|
+
.slice(0, top ?? undefined)
|
|
523
|
+
.map((p) => ({ label: p.label, costUsd: p.costUsd, requests: p.requests }));
|
|
524
|
+
const byModel = store.byModel(startMs, endMs)
|
|
525
|
+
.slice(0, top ?? undefined)
|
|
526
|
+
.map((m) => ({ label: m.label, provider: m.provider, costUsd: m.costUsd, requests: m.requests }));
|
|
527
|
+
return { status: 200, payload: {
|
|
528
|
+
demo: isDemo(),
|
|
529
|
+
generatedAt: new Date(now).toISOString(),
|
|
530
|
+
month: `${year}-${String(month).padStart(2, '0')}`,
|
|
531
|
+
period: {
|
|
532
|
+
startMs,
|
|
533
|
+
endMs,
|
|
534
|
+
label: `${STATEMENT_MONTH_NAMES[month - 1]} ${year}`,
|
|
535
|
+
complete: endMs <= now,
|
|
536
|
+
},
|
|
537
|
+
metered: {
|
|
538
|
+
costUsd: summary.costUsd,
|
|
539
|
+
requests: summary.requests,
|
|
540
|
+
claimSupport: meteredClaimSupport({
|
|
541
|
+
totalCostUsd: pricingWindow.totalCostUsd,
|
|
542
|
+
estimatedCostUsd: pricingWindow.estimatedCostUsd + pricingWindow.declaredCostUsd,
|
|
543
|
+
retention,
|
|
544
|
+
}),
|
|
545
|
+
basis,
|
|
546
|
+
retention,
|
|
547
|
+
byProject,
|
|
548
|
+
byModel,
|
|
549
|
+
},
|
|
550
|
+
billed: {
|
|
551
|
+
monthCoverage: monthCoverageOf(billedRuns),
|
|
552
|
+
overlappingRuns: billedRuns.length,
|
|
553
|
+
// False when the store returned as many runs as it was asked for, so an
|
|
554
|
+
// older overlapping run may exist beyond the read. A "not established"
|
|
555
|
+
// with this false is a limit of the read, not a finding.
|
|
556
|
+
scanComplete: reconciliation.length < STATEMENT_RUN_LIMIT,
|
|
557
|
+
runs: billedRuns,
|
|
558
|
+
},
|
|
559
|
+
allocated: {
|
|
560
|
+
monthCoverage: monthCoverageOf(allocatedRuns),
|
|
561
|
+
overlappingRuns: allocatedRuns.length,
|
|
562
|
+
scanComplete: allocations.length < STATEMENT_RUN_LIMIT,
|
|
563
|
+
runs: allocatedRuns,
|
|
564
|
+
costCentres: store.costCentres().map((c) => ({ costCentreId: c.costCentreId, name: c.name })),
|
|
565
|
+
},
|
|
566
|
+
realized: realizedForMonth(store, startMs, endMs),
|
|
567
|
+
close: {
|
|
568
|
+
status: closeStatus.status,
|
|
569
|
+
activeFinalizationId: closeStatus.activeFinalizationId,
|
|
570
|
+
eventCount: closeStatus.eventCount,
|
|
571
|
+
},
|
|
572
|
+
} };
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
return { status: 500, error: error instanceof Error ? error.message : String(error) };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
348
578
|
/**
|
|
349
579
|
* Provider billing evidence has a different truth contract from the local
|
|
350
580
|
* request ledger: an operator supplied it, Segreant has not verified it with
|
|
@@ -373,6 +603,16 @@ export function handleBilling({ res, url, store }) {
|
|
|
373
603
|
// disagree the moment a run landed between the calls.
|
|
374
604
|
const runs = store.reconciliationRuns(10);
|
|
375
605
|
const summary = store.billingSummary();
|
|
606
|
+
// An adopted export is recorded as an observation whose one "page" digest
|
|
607
|
+
// is the file's SHA-256 (adoptOpenAiCostsFromImport). Only an adoption under
|
|
608
|
+
// the ACTIVE declaration puts a file in use: one adopted under an earlier
|
|
609
|
+
// declaration says nothing about the project declared now.
|
|
610
|
+
const scope = store.activeOpenAiScope();
|
|
611
|
+
const adoptions = scope === null ? [] : store.openAiCostsObservationRuns(200).filter((o) => o.sourceKind === 'operator_supplied_export' && o.resultState === 'succeeded' && o.declaredScopeId === scope.declarationId);
|
|
612
|
+
const imports = store.billingImportRuns(25).map((run) => {
|
|
613
|
+
const adopted = adoptions.find((o) => o.pageDigestChainSha256 === run.fileSha256);
|
|
614
|
+
return { ...run, inUse: adopted ? { observationRunId: adopted.observationRunId, adoptedAtMs: adopted.fetchedAtMs } : null };
|
|
615
|
+
});
|
|
376
616
|
return json(res, 200, {
|
|
377
617
|
demo: isDemo(),
|
|
378
618
|
/** The boundary the kernel claims answer at; `null` is a live read. */
|
|
@@ -404,7 +644,17 @@ export function handleBilling({ res, url, store }) {
|
|
|
404
644
|
],
|
|
405
645
|
},
|
|
406
646
|
summary,
|
|
407
|
-
|
|
647
|
+
// The declaration itself, so the page can name what is declared and what
|
|
648
|
+
// clearing it would end. An operator statement, never provider-verified.
|
|
649
|
+
activeScope: scope === null ? null : {
|
|
650
|
+
declarationId: scope.declarationId,
|
|
651
|
+
billingAccountRef: scope.billingAccountRef,
|
|
652
|
+
providerProjectRef: scope.providerProjectRef,
|
|
653
|
+
upstreamDisplay: scope.upstreamDisplay,
|
|
654
|
+
declaredAtMs: scope.declaredAtMs,
|
|
655
|
+
trust: scope.trust,
|
|
656
|
+
},
|
|
657
|
+
imports,
|
|
408
658
|
kernel: {
|
|
409
659
|
kind: 'trusted_epistemic_kernel_billing',
|
|
410
660
|
claims: store.billingKernelClaims(25, asOf),
|
|
@@ -473,6 +723,35 @@ export function handleBilling({ res, url, store }) {
|
|
|
473
723
|
* rather than derived money — the page needs them to name a centre and to
|
|
474
724
|
* show which rule version placed each line.
|
|
475
725
|
*/
|
|
726
|
+
/**
|
|
727
|
+
* One recorded allocation run as CSV, by id. Read from the stored run, never
|
|
728
|
+
* recomputed, so the file is the split someone reviewed. An id is required:
|
|
729
|
+
* "the latest run" would silently change meaning when a new one is recorded.
|
|
730
|
+
*/
|
|
731
|
+
export function handleAllocationRunCsv({ res, url, store }) {
|
|
732
|
+
const id = url.searchParams.get('id');
|
|
733
|
+
if (id === null || id.trim() === '')
|
|
734
|
+
return json(res, 400, { error: 'id is required: the allocation run to export' });
|
|
735
|
+
if (id.length > 200)
|
|
736
|
+
return json(res, 400, { error: 'id is too long to be an allocation run id' });
|
|
737
|
+
try {
|
|
738
|
+
const run = store.allocationRunById(id);
|
|
739
|
+
if (run === null)
|
|
740
|
+
return json(res, 404, { error: 'no recorded allocation run has that id' });
|
|
741
|
+
const names = new Map(store.costCentres().map((c) => [c.costCentreId, c.name]));
|
|
742
|
+
const demo = isDemo();
|
|
743
|
+
const day = new Date(run.result.periodStartMs).toISOString().slice(0, 10);
|
|
744
|
+
res.writeHead(200, {
|
|
745
|
+
'content-type': 'text/csv; charset=utf-8',
|
|
746
|
+
'content-disposition': `attachment; filename="segreant-allocation-${day}${demo ? '-sample' : ''}.csv"`,
|
|
747
|
+
'cache-control': 'no-store',
|
|
748
|
+
});
|
|
749
|
+
res.end(allocationRunToCsv(run, (costCentreId) => names.get(costCentreId) ?? costCentreId, demo));
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
return json(res, 500, { error: String(err) });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
476
755
|
export function handleAllocation({ res, store }) {
|
|
477
756
|
try {
|
|
478
757
|
const reconciliationRuns = store.reconciliationRuns(1);
|
|
@@ -606,6 +885,104 @@ export function handlePricing({ res, url, store, config }) {
|
|
|
606
885
|
return json(res, 500, { error: String(err) });
|
|
607
886
|
}
|
|
608
887
|
}
|
|
888
|
+
export function handlePricingUnknown({ res, store }) {
|
|
889
|
+
try {
|
|
890
|
+
const payload = {
|
|
891
|
+
models: unknownModels(store), demo: isDemo(),
|
|
892
|
+
// Sample data shows no real declarations (activePriceOverrides is empty in demo mode).
|
|
893
|
+
declared: Object.entries(activePriceOverrides()).sort(([a], [b]) => a.localeCompare(b)).map(([model, price]) => ({
|
|
894
|
+
model, kind: price.kind, declaredAt: price.declaredAt,
|
|
895
|
+
summary: price.kind === 'free' ? 'free (local model)'
|
|
896
|
+
: price.kind === 'like' ? `like ${price.like}`
|
|
897
|
+
: `input $${price.input}, output $${price.output}${price.cacheRead ? `, cache read $${price.cacheRead}` : ''}${price.cacheWrite ? `, cache write $${price.cacheWrite}` : ''}${price.cacheWrite1h ? `, 1-hour cache write $${price.cacheWrite1h}` : ''} per 1M tokens`,
|
|
898
|
+
...(price.kind === 'rate' ? { rate: {
|
|
899
|
+
input: price.input, output: price.output,
|
|
900
|
+
...(price.cacheRead === undefined ? {} : { cacheRead: price.cacheRead }),
|
|
901
|
+
...(price.cacheWrite === undefined ? {} : { cacheWrite: price.cacheWrite }),
|
|
902
|
+
...(price.cacheWrite1h === undefined ? {} : { cacheWrite1h: price.cacheWrite1h }),
|
|
903
|
+
} } : {}),
|
|
904
|
+
...(price.kind === 'like' ? { like: price.like } : {}),
|
|
905
|
+
})),
|
|
906
|
+
boundary: 'Recorded cost (estimated). Declaring a price leaves recorded requests unchanged until you re-price them.',
|
|
907
|
+
};
|
|
908
|
+
json(res, 200, payload);
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/** Small local pricing operations share a strict 16 KiB JSON boundary. */
|
|
915
|
+
async function pricingBody(req, allowed) {
|
|
916
|
+
const chunks = [];
|
|
917
|
+
let bytes = 0;
|
|
918
|
+
for await (const chunk of req) {
|
|
919
|
+
bytes += chunk.byteLength;
|
|
920
|
+
if (bytes > 16_384) {
|
|
921
|
+
req.resume();
|
|
922
|
+
throw new PricingBodyTooLarge();
|
|
923
|
+
}
|
|
924
|
+
chunks.push(chunk);
|
|
925
|
+
}
|
|
926
|
+
let body;
|
|
927
|
+
try {
|
|
928
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
throw new Error('request must contain valid JSON');
|
|
932
|
+
}
|
|
933
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
934
|
+
throw new Error('request must be an object');
|
|
935
|
+
if (Object.keys(body).some((key) => !allowed.includes(key)))
|
|
936
|
+
throw new Error('request has an unknown field');
|
|
937
|
+
return body;
|
|
938
|
+
}
|
|
939
|
+
class PricingBodyTooLarge extends Error {
|
|
940
|
+
}
|
|
941
|
+
function pricingWrite(context, allowed, action, readOnly = () => false) {
|
|
942
|
+
void (async () => {
|
|
943
|
+
try {
|
|
944
|
+
const body = await pricingBody(context.req, allowed);
|
|
945
|
+
// A preview writes nothing, so the sample data can show one (independent
|
|
946
|
+
// review, 2026-10-18); only a write is refused there.
|
|
947
|
+
if (isDemo() && !readOnly(body))
|
|
948
|
+
return json(context.res, 409, { error: new DemoWriteBlockedError('the pricing change').message });
|
|
949
|
+
json(context.res, 200, action(body));
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
json(context.res, error instanceof PricingBodyTooLarge ? 413 : 400, {
|
|
953
|
+
error: error instanceof PricingBodyTooLarge ? 'pricing request exceeds bounded dashboard limit' : error instanceof Error ? error.message : String(error),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
})();
|
|
957
|
+
}
|
|
958
|
+
export function handlePricingDeclare(context) {
|
|
959
|
+
pricingWrite(context, ['model', 'kind', 'input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h', 'like'], (body) => {
|
|
960
|
+
const { model, ...input } = body;
|
|
961
|
+
const error = modelNameError(model);
|
|
962
|
+
if (error)
|
|
963
|
+
throw new Error(error);
|
|
964
|
+
// setPriceOverride validates kind-specific fields and decimal strings; no coercion.
|
|
965
|
+
const price = declarePrice(model, input);
|
|
966
|
+
return { ok: true, model: model, price };
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
export function handlePricingUnset(context) {
|
|
970
|
+
pricingWrite(context, ['model'], (body) => {
|
|
971
|
+
const error = modelNameError(body.model);
|
|
972
|
+
if (error)
|
|
973
|
+
throw new Error(error);
|
|
974
|
+
const model = body.model;
|
|
975
|
+
return { ok: true, model, removed: removePriceOverride(model) };
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
export function handleReprice(context) {
|
|
979
|
+
pricingWrite(context, ['apply'], (body) => {
|
|
980
|
+
if (typeof body.apply !== 'boolean')
|
|
981
|
+
throw new Error('apply must be true or false');
|
|
982
|
+
const { updates: _updates, meta: _meta, priceEvidence: _legacyCliEvidence, ...report } = body.apply ? applyReprice(context.store) : previewReprice(context.store);
|
|
983
|
+
return { ...report, demo: isDemo() };
|
|
984
|
+
}, (body) => body.apply === false);
|
|
985
|
+
}
|
|
609
986
|
export function handleExportCsv({ res, url, store }) {
|
|
610
987
|
const range = url.searchParams.get('range') ?? '30d';
|
|
611
988
|
const valid = ['today', '7d', '30d', 'all'];
|
|
@@ -707,6 +1084,10 @@ export function handleGuide({ res, store, config }) {
|
|
|
707
1084
|
requestsAllTime: store.summary(0, now + 1000).requests,
|
|
708
1085
|
requestsRetention: store.retentionFloor(),
|
|
709
1086
|
spend30dUsd: store.summary(now - 30 * day, now + 1000).costUsd,
|
|
1087
|
+
...(() => {
|
|
1088
|
+
const capBasis = capBasisSpend(store, config.budget, now - 30 * day, now + 1000);
|
|
1089
|
+
return { capBasisSpend30dUsd: capBasis.usd, capBasis: capBasis.basis };
|
|
1090
|
+
})(),
|
|
710
1091
|
dailyCapUsd: config.budget.dailyUsd,
|
|
711
1092
|
outcomeSignals: store.countSignals(),
|
|
712
1093
|
realizationUnits: store.countRealizationUnits(),
|
|
@@ -769,103 +1150,136 @@ export function handleJudge({ req, res, store, config }) {
|
|
|
769
1150
|
}
|
|
770
1151
|
})();
|
|
771
1152
|
}
|
|
1153
|
+
const valueReadCaches = new WeakMap();
|
|
1154
|
+
/**
|
|
1155
|
+
* The value read cache of one Store, created on first use. Scoped per Store so
|
|
1156
|
+
* two dashboards (or two tests, each with a fresh Store) never share answers.
|
|
1157
|
+
* `opts` applies only when the cache is created; tests use it to inject a clock.
|
|
1158
|
+
*/
|
|
1159
|
+
export function valueReadCacheFor(store, opts) {
|
|
1160
|
+
let cache = valueReadCaches.get(store);
|
|
1161
|
+
if (cache === undefined) {
|
|
1162
|
+
cache = new ValueReadCache(opts);
|
|
1163
|
+
valueReadCaches.set(store, cache);
|
|
1164
|
+
}
|
|
1165
|
+
return cache;
|
|
1166
|
+
}
|
|
772
1167
|
export function handleValue({ res, url, store, config }) {
|
|
773
1168
|
const repo = safeRepo(url.searchParams.get('repo'));
|
|
774
1169
|
const windowDays = Number(url.searchParams.get('window') || '14') || 14;
|
|
1170
|
+
const explicitRepo = url.searchParams.has('repo');
|
|
775
1171
|
void (async () => {
|
|
776
1172
|
try {
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
// matched. Now there is one sequence, so they cannot drift apart.
|
|
783
|
-
//
|
|
784
|
-
// `repo` is never empty — `safeRepo` falls back to the dashboard's
|
|
785
|
-
// launch directory when no `?repo=` is given, the same
|
|
786
|
-
// `flags.repo ?? process.cwd()` convention every CLI command uses — so
|
|
787
|
-
// the baseline mines that directory's git history exactly as
|
|
788
|
-
// `segreant roi` (no `--repo`) would.
|
|
789
|
-
//
|
|
790
|
-
// `discloseLiftSource: false` keeps this payload's strings exactly as
|
|
791
|
-
// they have always been: the CLI names its Lift source in
|
|
792
|
-
// `roi.lenses.lift.how` and labels the demo's synthetic TSF in
|
|
793
|
-
// `roi.notes`; this endpoint never has. It is a disclosure difference
|
|
794
|
-
// only — every value, interval and dollar is computed identically.
|
|
795
|
-
const value = await valueReport(store, config, {
|
|
796
|
-
repo,
|
|
797
|
-
windowDays,
|
|
798
|
-
limit: 40,
|
|
799
|
-
persist: false,
|
|
800
|
-
discloseLiftSource: false,
|
|
801
|
-
});
|
|
802
|
-
const spine = value.spine;
|
|
803
|
-
const rep = spine?.loaded.report ?? null;
|
|
804
|
-
// The payload's realization slice is deliberately narrower than the
|
|
805
|
-
// full report: the fields the GUI declares, and no more.
|
|
806
|
-
const realization = rep
|
|
807
|
-
? {
|
|
808
|
-
matured: rep.matured,
|
|
809
|
-
firstPassAcceptance: rep.firstPassAcceptance,
|
|
810
|
-
proposalCoverage: rep.proposalCoverage,
|
|
811
|
-
projectScoped: rep.projectScoped,
|
|
812
|
-
costStaleUnits: rep.costStaleUnits,
|
|
813
|
-
units: rep.units,
|
|
814
|
-
}
|
|
815
|
-
: null;
|
|
816
|
-
// Raw frontier cells may be unlike tasks. The dashboard exposes only
|
|
817
|
-
// the separately gated, within-task model trials from `frontier`.
|
|
818
|
-
const allocation = null;
|
|
819
|
-
// Cross-project allocation is not comparable/reliable enough to
|
|
820
|
-
// recommend from raw RoI alone; per-project value stays descriptive.
|
|
821
|
-
const projectAllocation = null;
|
|
822
|
-
// The money claim is `returnRatio.manualEquivalentValueUsd`, and only when
|
|
823
|
-
// the payload's own `basis` says it is priced. A dollar figure is never
|
|
824
|
-
// invented from a bare ratio.
|
|
825
|
-
const ratio = spine?.roi?.returnRatio ?? null;
|
|
826
|
-
const roiCoverage = spine?.roi?.coverage;
|
|
827
|
-
return json(res, 200, {
|
|
828
|
-
demo: value.demo,
|
|
829
|
-
// Contradicted gate evidence lands on COVERAGE here rather than on the
|
|
830
|
-
// epistemic axis, because mature units are different propositions and a
|
|
831
|
-
// population of contradictions is not a contradiction in the aggregate.
|
|
832
|
-
// See `src/dashboard/claim-support.ts`.
|
|
833
|
-
claimSupport: realizedClaimSupport({
|
|
834
|
-
maturedUnits: rep?.matured?.units ?? 0,
|
|
835
|
-
realizedUnits: rep?.matured?.realizedUnits ?? 0,
|
|
836
|
-
gateConflicts: rep?.matured?.gateConflicts ?? null,
|
|
837
|
-
roiCoverage: typeof roiCoverage === 'number' ? roiCoverage : null,
|
|
838
|
-
valued: ratio?.basis === 'usd' && typeof ratio.manualEquivalentValueUsd === 'number',
|
|
839
|
-
}),
|
|
840
|
-
gitRepo: spine?.loaded.source === 'git',
|
|
841
|
-
valueSource: spine?.loaded.source ?? null,
|
|
842
|
-
// Whether the realized-value dollars came from spend SCOPED to this
|
|
843
|
-
// project (imports / tagged proxy) vs the project-blind window sum —
|
|
844
|
-
// disclosed so the number's basis is never silent.
|
|
845
|
-
projectScoped: rep?.projectScoped ?? null,
|
|
1173
|
+
// Answered from memory when nothing the report reads has changed (see
|
|
1174
|
+
// src/dashboard/value-cache.ts for the key). Nothing here persists.
|
|
1175
|
+
const head = await resolveCommit(repo, 'HEAD');
|
|
1176
|
+
const key = valueCacheKey({
|
|
1177
|
+
params: url.searchParams,
|
|
846
1178
|
repo,
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
frontier: spine?.frontier ?? null,
|
|
851
|
-
budget: value.budget,
|
|
852
|
-
allocation,
|
|
853
|
-
projects: value.projects,
|
|
854
|
-
projectAllocation,
|
|
855
|
-
usage: value.usage,
|
|
856
|
-
selfReported: value.selfReported,
|
|
857
|
-
team: value.team,
|
|
858
|
-
drift: spine?.drift === null || spine?.drift === undefined
|
|
859
|
-
? null
|
|
860
|
-
: { ...spine.drift, ...driftDetectability(spine.drift) },
|
|
861
|
-
reclaimed: spine?.reclaimed ?? null,
|
|
1179
|
+
head,
|
|
1180
|
+
config,
|
|
1181
|
+
marks: store.valueChangeMarks(),
|
|
862
1182
|
});
|
|
1183
|
+
const payload = await valueReadCacheFor(store).get(key, () => computeValuePayload(store, config, repo, windowDays, explicitRepo));
|
|
1184
|
+
return json(res, 200, payload);
|
|
863
1185
|
}
|
|
864
1186
|
catch (err) {
|
|
865
1187
|
return json(res, 500, { error: String(err) });
|
|
866
1188
|
}
|
|
867
1189
|
})();
|
|
868
1190
|
}
|
|
1191
|
+
async function computeValuePayload(store, config, repo, windowDays, explicitRepo) {
|
|
1192
|
+
// The whole composition — realization, baseline, Lift, money, RoI,
|
|
1193
|
+
// drift, time reclaimed, frontier, per-project value, usage, cohort and
|
|
1194
|
+
// budget advice — lives in src/value/report.ts, which the CLI's `roi` /
|
|
1195
|
+
// `saved` commands call too. The two surfaces used to sequence these
|
|
1196
|
+
// primitives independently, held in step only by comments claiming they
|
|
1197
|
+
// matched. Now there is one sequence, so they cannot drift apart.
|
|
1198
|
+
//
|
|
1199
|
+
// `repo` is never empty — `safeRepo` falls back to the dashboard's
|
|
1200
|
+
// launch directory when no `?repo=` is given, the same
|
|
1201
|
+
// `flags.repo ?? process.cwd()` convention every CLI command uses — so
|
|
1202
|
+
// the baseline mines that directory's git history exactly as
|
|
1203
|
+
// `segreant roi` (no `--repo`) would.
|
|
1204
|
+
//
|
|
1205
|
+
// `discloseLiftSource: false` keeps this payload's strings exactly as
|
|
1206
|
+
// they have always been: the CLI names its Lift source in
|
|
1207
|
+
// `roi.lenses.lift.how` and labels the demo's synthetic TSF in
|
|
1208
|
+
// `roi.notes`; this endpoint never has. It is a disclosure difference
|
|
1209
|
+
// only — every value, interval and dollar is computed identically.
|
|
1210
|
+
const value = await store.withReadSnapshot(() => valueReport(store, config, {
|
|
1211
|
+
repo,
|
|
1212
|
+
windowDays,
|
|
1213
|
+
limit: 40,
|
|
1214
|
+
persist: false,
|
|
1215
|
+
discloseLiftSource: false,
|
|
1216
|
+
// Without an explicit ?repo=, answer from what `scan` measured and
|
|
1217
|
+
// stored (every project, as the CLI home view does) rather than
|
|
1218
|
+
// re-reading the launch folder's git on every load.
|
|
1219
|
+
preferStored: !explicitRepo,
|
|
1220
|
+
}));
|
|
1221
|
+
const spine = value.spine;
|
|
1222
|
+
const rep = spine?.loaded.report ?? null;
|
|
1223
|
+
// The payload's realization slice is deliberately narrower than the
|
|
1224
|
+
// full report: the fields the GUI declares, and no more.
|
|
1225
|
+
const realization = rep
|
|
1226
|
+
? {
|
|
1227
|
+
matured: rep.matured,
|
|
1228
|
+
firstPassAcceptance: rep.firstPassAcceptance,
|
|
1229
|
+
proposalCoverage: rep.proposalCoverage,
|
|
1230
|
+
projectScoped: rep.projectScoped,
|
|
1231
|
+
costStaleUnits: rep.costStaleUnits,
|
|
1232
|
+
units: rep.units,
|
|
1233
|
+
}
|
|
1234
|
+
: null;
|
|
1235
|
+
// Raw frontier cells may be unlike tasks. The dashboard exposes only
|
|
1236
|
+
// the separately gated, within-task model trials from `frontier`.
|
|
1237
|
+
const allocation = null;
|
|
1238
|
+
// Cross-project allocation is not comparable/reliable enough to
|
|
1239
|
+
// recommend from raw RoI alone; per-project value stays descriptive.
|
|
1240
|
+
const projectAllocation = null;
|
|
1241
|
+
// The money claim is `returnRatio.manualEquivalentValueUsd`, and only when
|
|
1242
|
+
// the payload's own `basis` says it is priced. A dollar figure is never
|
|
1243
|
+
// invented from a bare ratio.
|
|
1244
|
+
const ratio = spine?.roi?.returnRatio ?? null;
|
|
1245
|
+
const roiCoverage = spine?.roi?.coverage;
|
|
1246
|
+
return {
|
|
1247
|
+
demo: value.demo,
|
|
1248
|
+
// Contradicted gate evidence lands on COVERAGE here rather than on the
|
|
1249
|
+
// epistemic axis, because mature units are different propositions and a
|
|
1250
|
+
// population of contradictions is not a contradiction in the aggregate.
|
|
1251
|
+
// See `src/dashboard/claim-support.ts`.
|
|
1252
|
+
claimSupport: realizedClaimSupport({
|
|
1253
|
+
maturedUnits: rep?.matured?.units ?? 0,
|
|
1254
|
+
realizedUnits: rep?.matured?.realizedUnits ?? 0,
|
|
1255
|
+
gateConflicts: rep?.matured?.gateConflicts ?? null,
|
|
1256
|
+
roiCoverage: typeof roiCoverage === 'number' ? roiCoverage : null,
|
|
1257
|
+
valued: ratio?.basis === 'usd' && typeof ratio.manualEquivalentValueUsd === 'number',
|
|
1258
|
+
}),
|
|
1259
|
+
gitRepo: spine?.loaded.source === 'git',
|
|
1260
|
+
valueSource: spine?.loaded.source ?? null,
|
|
1261
|
+
// Whether the realized-value dollars came from spend SCOPED to this
|
|
1262
|
+
// project (imports / tagged proxy) vs the project-blind window sum —
|
|
1263
|
+
// disclosed so the number's basis is never silent.
|
|
1264
|
+
projectScoped: rep?.projectScoped ?? null,
|
|
1265
|
+
repo,
|
|
1266
|
+
generatedAt: new Date(value.generatedAtMs).toISOString(),
|
|
1267
|
+
realization,
|
|
1268
|
+
roi: spine?.roi ?? null,
|
|
1269
|
+
frontier: spine?.frontier ?? null,
|
|
1270
|
+
budget: value.budget,
|
|
1271
|
+
allocation,
|
|
1272
|
+
projects: value.projects,
|
|
1273
|
+
projectAllocation,
|
|
1274
|
+
usage: value.usage,
|
|
1275
|
+
selfReported: value.selfReported,
|
|
1276
|
+
team: value.team,
|
|
1277
|
+
drift: spine?.drift === null || spine?.drift === undefined
|
|
1278
|
+
? null
|
|
1279
|
+
: { ...spine.drift, ...driftDetectability(spine.drift) },
|
|
1280
|
+
reclaimed: spine?.reclaimed ?? null,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
869
1283
|
/** Public market, read-only: the bundled snapshot plus any refresh already on disk. Never fetches. */
|
|
870
1284
|
export function handleMarket({ res, config }) {
|
|
871
1285
|
try {
|
|
@@ -875,6 +1289,442 @@ export function handleMarket({ res, config }) {
|
|
|
875
1289
|
return json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
876
1290
|
}
|
|
877
1291
|
}
|
|
1292
|
+
const ALLOCATION_WRITE_FIELDS = new Set(['op', 'apply', 'id', 'name', 'owner', 'method', 'targets', 'match', 'priority', 'from', 'to', 'until', 'note']);
|
|
1293
|
+
const ALLOCATION_WRITE_OPS = new Set(['centre', 'archive', 'rule', 'revoke', 'run']);
|
|
1294
|
+
function allocationId(value) {
|
|
1295
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 64)
|
|
1296
|
+
throw new Error('id must be a non-empty string of at most 64 characters');
|
|
1297
|
+
return value;
|
|
1298
|
+
}
|
|
1299
|
+
function allocationText(value, label, max) {
|
|
1300
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.length > max)
|
|
1301
|
+
throw new Error(`${label} must be a non-empty string of at most ${max} characters`);
|
|
1302
|
+
return value;
|
|
1303
|
+
}
|
|
1304
|
+
function allocationOptionalText(value, label) {
|
|
1305
|
+
if (value === undefined || value === null)
|
|
1306
|
+
return null;
|
|
1307
|
+
if (typeof value !== 'string')
|
|
1308
|
+
throw new Error(`${label} must be a string`);
|
|
1309
|
+
return value;
|
|
1310
|
+
}
|
|
1311
|
+
/** An ISO date or date-time, or null when absent. Mirrors parseTs in src/cli/allocCmd.ts. */
|
|
1312
|
+
function allocationTs(value, label) {
|
|
1313
|
+
if (value === undefined || value === null)
|
|
1314
|
+
return null;
|
|
1315
|
+
if (typeof value !== 'string')
|
|
1316
|
+
throw new Error(`${label} must be an ISO date or date-time`);
|
|
1317
|
+
if (!value.trim())
|
|
1318
|
+
return null;
|
|
1319
|
+
const ms = Date.parse(value.trim());
|
|
1320
|
+
if (!Number.isFinite(ms))
|
|
1321
|
+
throw new Error(`${label} must be an ISO date or date-time`);
|
|
1322
|
+
return ms;
|
|
1323
|
+
}
|
|
1324
|
+
/** Same rules as parseTargets in src/cli/allocCmd.ts; the store validates ids and ratio ranges. */
|
|
1325
|
+
function allocationTargets(value) {
|
|
1326
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
1327
|
+
throw new Error('targets must name at least one cost centre');
|
|
1328
|
+
return value.map((entry) => {
|
|
1329
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
|
1330
|
+
throw new Error('each target must be { costCentreId, ratio }');
|
|
1331
|
+
const target = entry;
|
|
1332
|
+
const costCentreId = allocationId(target.costCentreId);
|
|
1333
|
+
if (typeof target.ratio !== 'number' || !Number.isFinite(target.ratio))
|
|
1334
|
+
throw new Error(`target ${costCentreId}: ratio must be a number`);
|
|
1335
|
+
return { costCentreId, ratio: target.ratio };
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
/** Same rules as parseMatch in src/cli/allocCmd.ts: known fields only, empty values are a wildcard. */
|
|
1339
|
+
function allocationMatch(value) {
|
|
1340
|
+
const match = {};
|
|
1341
|
+
if (value === undefined || value === null)
|
|
1342
|
+
return match;
|
|
1343
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
1344
|
+
throw new Error('match must be an object');
|
|
1345
|
+
for (const [key, field] of Object.entries(value)) {
|
|
1346
|
+
if (!MATCH_FIELDS.includes(key))
|
|
1347
|
+
throw new Error(`match.${key} is not a match field`);
|
|
1348
|
+
if (typeof field !== 'string')
|
|
1349
|
+
throw new Error(`match.${key} must be a string`);
|
|
1350
|
+
if (field.trim())
|
|
1351
|
+
match[key] = field.trim();
|
|
1352
|
+
}
|
|
1353
|
+
return match;
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Allocation changes from the GUI: the same Store calls, validation and
|
|
1357
|
+
* semantics as `segreant alloc`. Preview never writes; `run` with apply:true
|
|
1358
|
+
* records the run. Demo mode refuses every write, and allows only the preview.
|
|
1359
|
+
*/
|
|
1360
|
+
export function handleAllocationWrite({ req, res, store }) {
|
|
1361
|
+
void (async () => {
|
|
1362
|
+
try {
|
|
1363
|
+
const chunks = [];
|
|
1364
|
+
let bytes = 0;
|
|
1365
|
+
for await (const part of req) {
|
|
1366
|
+
const chunk = part;
|
|
1367
|
+
bytes += chunk.byteLength;
|
|
1368
|
+
if (bytes > RESOURCE_LIMITS.dashboardRequestBytes) {
|
|
1369
|
+
req.resume();
|
|
1370
|
+
return json(res, 413, { error: 'allocation request exceeds bounded dashboard limit' });
|
|
1371
|
+
}
|
|
1372
|
+
chunks.push(chunk);
|
|
1373
|
+
}
|
|
1374
|
+
const raw = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
1375
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1376
|
+
throw new Error('allocation request must be an object');
|
|
1377
|
+
const body = raw;
|
|
1378
|
+
if (Object.keys(body).some((key) => !ALLOCATION_WRITE_FIELDS.has(key)))
|
|
1379
|
+
throw new Error('allocation request has an unknown field');
|
|
1380
|
+
if (typeof body.op !== 'string' || !ALLOCATION_WRITE_OPS.has(body.op))
|
|
1381
|
+
throw new Error('op must be one of centre, archive, rule, revoke, run');
|
|
1382
|
+
const op = body.op;
|
|
1383
|
+
if (body.apply !== undefined && typeof body.apply !== 'boolean')
|
|
1384
|
+
throw new Error('apply must be true or false');
|
|
1385
|
+
const apply = body.apply === true;
|
|
1386
|
+
// The one allocation request demo mode answers: a preview, which records nothing.
|
|
1387
|
+
if (isDemo() && !(op === 'run' && !apply)) {
|
|
1388
|
+
return json(res, 409, { error: new DemoWriteBlockedError('the allocation change').message });
|
|
1389
|
+
}
|
|
1390
|
+
if (op === 'centre') {
|
|
1391
|
+
const costCentre = store.upsertCostCentre({
|
|
1392
|
+
costCentreId: allocationId(body.id),
|
|
1393
|
+
name: allocationText(body.name, 'name', 120),
|
|
1394
|
+
owner: allocationOptionalText(body.owner, 'owner'),
|
|
1395
|
+
});
|
|
1396
|
+
return json(res, 200, { ok: true, op, applied: true, message: `Cost centre "${costCentre.costCentreId}" saved.`, costCentre });
|
|
1397
|
+
}
|
|
1398
|
+
if (op === 'archive') {
|
|
1399
|
+
const id = allocationId(body.id);
|
|
1400
|
+
if (!store.archiveCostCentre(id))
|
|
1401
|
+
return json(res, 404, { error: `No open cost centre "${id}".` });
|
|
1402
|
+
return json(res, 200, { ok: true, op, applied: true, message: `Archived "${id}". Past runs keep it; new spend will not reach it.` });
|
|
1403
|
+
}
|
|
1404
|
+
if (op === 'rule') {
|
|
1405
|
+
const method = body.method;
|
|
1406
|
+
if (typeof method !== 'string' || !ALLOCATION_METHODS.includes(method)) {
|
|
1407
|
+
throw new Error(`method must be one of ${ALLOCATION_METHODS.join(', ')}`);
|
|
1408
|
+
}
|
|
1409
|
+
const priority = body.priority === undefined ? 100 : body.priority;
|
|
1410
|
+
if (typeof priority !== 'number' || !Number.isFinite(priority))
|
|
1411
|
+
throw new Error('priority must be a number');
|
|
1412
|
+
const rule = store.saveAllocationRule({
|
|
1413
|
+
ruleId: allocationId(body.id),
|
|
1414
|
+
method: method,
|
|
1415
|
+
match: allocationMatch(body.match),
|
|
1416
|
+
targets: allocationTargets(body.targets),
|
|
1417
|
+
priority,
|
|
1418
|
+
// Default to the epoch so a first rule covers existing history, as the CLI does.
|
|
1419
|
+
effectiveFromMs: allocationTs(body.from, 'from') ?? 0,
|
|
1420
|
+
effectiveToMs: allocationTs(body.until, 'until'),
|
|
1421
|
+
revokedAtMs: null,
|
|
1422
|
+
owner: allocationOptionalText(body.owner, 'owner'),
|
|
1423
|
+
note: allocationOptionalText(body.note, 'note'),
|
|
1424
|
+
});
|
|
1425
|
+
const superseded = rule.version > 1
|
|
1426
|
+
? ` v${rule.version - 1} closed at ${new Date(rule.effectiveFromMs).toISOString()} and retained — recorded runs are unchanged; new previews use effective dates.`
|
|
1427
|
+
: '';
|
|
1428
|
+
return json(res, 200, { ok: true, op, applied: true, rule, message: `Rule "${rule.ruleId}" v${rule.version} (${rule.method}) saved.${superseded}` });
|
|
1429
|
+
}
|
|
1430
|
+
if (op === 'revoke') {
|
|
1431
|
+
const id = allocationId(body.id);
|
|
1432
|
+
const revoked = store.revokeAllocationRule(id);
|
|
1433
|
+
if (revoked === 0)
|
|
1434
|
+
return json(res, 404, { error: `No open rule "${id}".` });
|
|
1435
|
+
return json(res, 200, { ok: true, op, applied: true, revoked, message: `Revoked ${revoked} open version(s) of "${id}". Rows retained.` });
|
|
1436
|
+
}
|
|
1437
|
+
const from = allocationTs(body.from, 'from');
|
|
1438
|
+
const to = allocationTs(body.to, 'to');
|
|
1439
|
+
if (from === null || to === null)
|
|
1440
|
+
throw new Error('from and to are required ISO dates or date-times');
|
|
1441
|
+
if (to <= from)
|
|
1442
|
+
throw new Error('to must be after from');
|
|
1443
|
+
const result = store.allocatePeriod(from, to);
|
|
1444
|
+
if (!result.conserves)
|
|
1445
|
+
return json(res, 500, { error: 'Allocation did not conserve its input. Refusing to show or record it.' });
|
|
1446
|
+
const allocationRunId = apply ? store.saveAllocationRun(result) : null;
|
|
1447
|
+
return json(res, 200, {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
op,
|
|
1450
|
+
applied: apply,
|
|
1451
|
+
allocationRunId,
|
|
1452
|
+
message: apply ? 'Recorded as an immutable derived run.' : 'Not recorded. Apply to record this run.',
|
|
1453
|
+
result,
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
catch (error) {
|
|
1457
|
+
return json(res, 400, { error: error instanceof SyntaxError ? 'allocation request must contain valid JSON' : error instanceof Error ? error.message : String(error) });
|
|
1458
|
+
}
|
|
1459
|
+
})();
|
|
1460
|
+
}
|
|
1461
|
+
const BILLING_WRITE_FIELDS = new Set(['op', 'apply', 'fileName', 'contentBase64', 'materialityUsd', 'importId', 'accountRef', 'projectRef']);
|
|
1462
|
+
const BILLING_WRITE_OPS = new Set(['import', 'reconcile', 'adopt', 'scope', 'scope-clear']);
|
|
1463
|
+
/**
|
|
1464
|
+
* Import content travels as the file's raw bytes in base64, never as decoded
|
|
1465
|
+
* text. The CLI hashes the bytes on disk; a browser that sent `file.text()`
|
|
1466
|
+
* would have the server hash a UTF-8 re-encoding instead, so the same export
|
|
1467
|
+
* (a BOM, a non-UTF-8 byte) could get two digests and import twice, once per
|
|
1468
|
+
* surface. Base64 makes the digest identical and bounds the body exactly: the
|
|
1469
|
+
* import limit at 4/3, plus the envelope. The shared 16 KiB dashboard cap would
|
|
1470
|
+
* refuse every realistic export.
|
|
1471
|
+
*/
|
|
1472
|
+
const BILLING_WRITE_BODY_BYTES = Math.ceil(MAX_BILLING_IMPORT_BYTES / 3) * 4 + 64 * 1024;
|
|
1473
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
1474
|
+
/** Serialize before the status line is written, so a payload that cannot be JSON-encoded fails as a 400 rather than a broken response. */
|
|
1475
|
+
function billingJson(res, status, payload) {
|
|
1476
|
+
const text = stringifyJson(payload, 0);
|
|
1477
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
1478
|
+
res.end(text);
|
|
1479
|
+
}
|
|
1480
|
+
function billingText(value, label, max) {
|
|
1481
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.length > max)
|
|
1482
|
+
throw new Error(`${label} must be a non-empty string of at most ${max} characters`);
|
|
1483
|
+
return value;
|
|
1484
|
+
}
|
|
1485
|
+
function billingOptionalText(value, label) {
|
|
1486
|
+
if (value === undefined || value === null)
|
|
1487
|
+
return null;
|
|
1488
|
+
if (typeof value !== 'string')
|
|
1489
|
+
throw new Error(`${label} must be a string`);
|
|
1490
|
+
return value;
|
|
1491
|
+
}
|
|
1492
|
+
/** Same reconciliation mapping as cmdReconcile in src/cli/billingCmd.ts. */
|
|
1493
|
+
function billingReconcile(store, body, apply) {
|
|
1494
|
+
let materialityUsd;
|
|
1495
|
+
if (body.materialityUsd !== undefined) {
|
|
1496
|
+
if (typeof body.materialityUsd !== 'number' || body.materialityUsd < 0
|
|
1497
|
+
|| !Number.isSafeInteger(Math.round(body.materialityUsd * 1_000_000))) {
|
|
1498
|
+
throw new Error('materialityUsd must be a non-negative dollar amount within the safe integer microdollar range');
|
|
1499
|
+
}
|
|
1500
|
+
materialityUsd = body.materialityUsd;
|
|
1501
|
+
}
|
|
1502
|
+
const readiness = reconciliationReadiness(store);
|
|
1503
|
+
const notReady = {
|
|
1504
|
+
status: 409,
|
|
1505
|
+
payload: {
|
|
1506
|
+
ok: false, op: 'reconcile', applied: false, status: 'not_ready', readiness,
|
|
1507
|
+
message: 'Reconciliation is not possible yet. Complete the steps listed in readiness first.',
|
|
1508
|
+
error: 'Reconciliation is not possible yet. Complete the steps listed in readiness first.',
|
|
1509
|
+
},
|
|
1510
|
+
};
|
|
1511
|
+
if (!readiness.ready)
|
|
1512
|
+
return notReady;
|
|
1513
|
+
const result = store.reconcileOpenAiCosts({ materialityUsd });
|
|
1514
|
+
if (!result)
|
|
1515
|
+
return notReady;
|
|
1516
|
+
if (result.status === 'refused') {
|
|
1517
|
+
return {
|
|
1518
|
+
status: 409,
|
|
1519
|
+
payload: { ok: false, op: 'reconcile', applied: false, status: 'refused', refusal: result.refusal, detail: result.detail, error: result.detail, message: result.detail },
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const reconciliationRunId = apply ? store.saveReconciliationRun(result) : null;
|
|
1523
|
+
// A provider-day comparison crosses the kernel only after the immutable run is
|
|
1524
|
+
// recorded. Local-only periods have no provider Evidence and stay a non-claim.
|
|
1525
|
+
const kernel = apply && reconciliationRunId !== null && result.coverage.providerDays > 0
|
|
1526
|
+
? store.issueOpenAiReconciliationToKernel(reconciliationRunId)
|
|
1527
|
+
: null;
|
|
1528
|
+
return {
|
|
1529
|
+
status: 200,
|
|
1530
|
+
payload: {
|
|
1531
|
+
ok: true,
|
|
1532
|
+
op: 'reconcile',
|
|
1533
|
+
applied: apply,
|
|
1534
|
+
message: apply ? 'Recorded as an immutable derived run.' : 'Not recorded. Apply to record this run.',
|
|
1535
|
+
reconciliationRunId,
|
|
1536
|
+
result,
|
|
1537
|
+
kernel,
|
|
1538
|
+
fragility: assessAssumptionFragility([...result.conditions], reconciliationCountermodels(result)),
|
|
1539
|
+
},
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
/** Same adoption preconditions and refusals as cmdAdoptCosts in src/cli/billingCmd.ts. */
|
|
1543
|
+
function billingAdopt(store, body, apply) {
|
|
1544
|
+
const scope = store.activeOpenAiScope();
|
|
1545
|
+
if (!scope || !scope.providerProjectRef) {
|
|
1546
|
+
const error = 'Declare the route scope first, so the adopted lines are bound to a project: use op "scope" with accountRef and projectRef, then retry.';
|
|
1547
|
+
return { status: 409, payload: { ok: false, op: 'adopt', applied: false, error, message: error } };
|
|
1548
|
+
}
|
|
1549
|
+
const imports = store.billingImportRuns(50).filter((r) => r.provider === 'openai');
|
|
1550
|
+
if (imports.length === 0) {
|
|
1551
|
+
const error = 'No OpenAI billing export has been imported. Import one first with op "import".';
|
|
1552
|
+
return { status: 409, payload: { ok: false, op: 'adopt', applied: false, error, message: error } };
|
|
1553
|
+
}
|
|
1554
|
+
const importId = billingText(body.importId, 'importId', 200);
|
|
1555
|
+
const plan = store.planOpenAiCostsAdoption({
|
|
1556
|
+
importId,
|
|
1557
|
+
declaredScopeId: scope.declarationId,
|
|
1558
|
+
providerProjectRef: scope.providerProjectRef,
|
|
1559
|
+
});
|
|
1560
|
+
if (!plan.adoptable) {
|
|
1561
|
+
const error = `Cannot adopt: ${plan.refusal.replaceAll('_', ' ')}. ${plan.detail}`;
|
|
1562
|
+
return { status: 409, payload: { ok: false, op: 'adopt', applied: false, plan, error, message: error } };
|
|
1563
|
+
}
|
|
1564
|
+
const run = apply ? store.adoptOpenAiCostsFromImport(plan) : null;
|
|
1565
|
+
return {
|
|
1566
|
+
status: 200,
|
|
1567
|
+
payload: {
|
|
1568
|
+
ok: true,
|
|
1569
|
+
op: 'adopt',
|
|
1570
|
+
applied: apply,
|
|
1571
|
+
observationRunId: run?.observationRunId ?? null,
|
|
1572
|
+
plan,
|
|
1573
|
+
networkAttempted: false,
|
|
1574
|
+
credentialRead: false,
|
|
1575
|
+
message: apply
|
|
1576
|
+
? 'Recorded as an immutable observation. Stamped operator_supplied_export; reconciliations built on it carry a fifth condition.'
|
|
1577
|
+
: 'Not recorded. Apply to record this adoption.',
|
|
1578
|
+
},
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Same preview and effect as `billing scope clear` in src/cli/billingCmd.ts.
|
|
1583
|
+
* Clearing ends the declaration for future proxy traffic only: requests
|
|
1584
|
+
* already recorded keep the scope they were captured under, and recorded
|
|
1585
|
+
* comparisons stay as they were.
|
|
1586
|
+
*/
|
|
1587
|
+
function billingScopeClear(store, apply) {
|
|
1588
|
+
const active = store.activeOpenAiScope();
|
|
1589
|
+
if (!apply) {
|
|
1590
|
+
return {
|
|
1591
|
+
status: 200,
|
|
1592
|
+
payload: {
|
|
1593
|
+
ok: true, op: 'scope-clear', applied: false, active,
|
|
1594
|
+
message: active
|
|
1595
|
+
? 'Not cleared. Clearing stops matching future OpenAI requests to this project. Requests already recorded and recorded comparisons are unchanged.'
|
|
1596
|
+
: 'No project is declared, so there is nothing to clear.',
|
|
1597
|
+
},
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
const cleared = store.clearOpenAiScope();
|
|
1601
|
+
return {
|
|
1602
|
+
status: 200,
|
|
1603
|
+
payload: {
|
|
1604
|
+
ok: true, op: 'scope-clear', applied: true, cleared,
|
|
1605
|
+
message: cleared
|
|
1606
|
+
? 'Cleared. Future OpenAI requests through Segreant are no longer matched to a project. Requests already recorded and recorded comparisons are unchanged.'
|
|
1607
|
+
: 'No project was declared, so nothing changed.',
|
|
1608
|
+
},
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
/** Same declaration, preview and refusal as `billing scope set` in src/cli/billingCmd.ts. */
|
|
1612
|
+
function billingScope(store, body, apply) {
|
|
1613
|
+
const accountRef = billingText(body.accountRef, 'accountRef', 200);
|
|
1614
|
+
const projectRef = billingOptionalText(body.projectRef, 'projectRef');
|
|
1615
|
+
let upstreamBase;
|
|
1616
|
+
let preview;
|
|
1617
|
+
try {
|
|
1618
|
+
upstreamBase = loadConfig().upstreams.openai;
|
|
1619
|
+
preview = newOpenAiScopeDeclaration({ billingAccountRef: accountRef, providerProjectRef: projectRef, upstreamBase });
|
|
1620
|
+
}
|
|
1621
|
+
catch (error) {
|
|
1622
|
+
throw new Error(`Billing scope failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1623
|
+
}
|
|
1624
|
+
if (!apply) {
|
|
1625
|
+
return {
|
|
1626
|
+
status: 200,
|
|
1627
|
+
payload: {
|
|
1628
|
+
ok: true,
|
|
1629
|
+
op: 'scope',
|
|
1630
|
+
applied: false,
|
|
1631
|
+
message: 'No data written. Apply to declare this scope.',
|
|
1632
|
+
scopePreview: {
|
|
1633
|
+
...preview,
|
|
1634
|
+
capture: 'future OpenAI proxy rows only when the resolved configured endpoint exactly matches',
|
|
1635
|
+
trust: 'operator_declared_unverified',
|
|
1636
|
+
reconciliationStatus: 'not_reconciled',
|
|
1637
|
+
},
|
|
1638
|
+
},
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
const declaration = store.setOpenAiScope({ billingAccountRef: accountRef, providerProjectRef: projectRef, upstreamBase });
|
|
1642
|
+
return {
|
|
1643
|
+
status: 200,
|
|
1644
|
+
payload: {
|
|
1645
|
+
ok: true,
|
|
1646
|
+
op: 'scope',
|
|
1647
|
+
applied: true,
|
|
1648
|
+
declaration,
|
|
1649
|
+
reconciliationStatus: 'not_reconciled',
|
|
1650
|
+
message: `Active local OpenAI scope ${declaration.declarationId} declared. Future matching proxy rows are declared_unverified; historical and imported rows are unchanged.`,
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Billing evidence from the GUI: the same parsing, store calls and refusals as
|
|
1656
|
+
* `segreant billing import|reconcile|openai-costs adopt|scope set`. Preview
|
|
1657
|
+
* never writes; apply:true writes, and demo mode refuses every apply.
|
|
1658
|
+
*/
|
|
1659
|
+
export function handleBillingWrite({ req, res, store }) {
|
|
1660
|
+
void (async () => {
|
|
1661
|
+
try {
|
|
1662
|
+
const chunks = [];
|
|
1663
|
+
let bytes = 0;
|
|
1664
|
+
for await (const part of req) {
|
|
1665
|
+
const chunk = part;
|
|
1666
|
+
bytes += chunk.byteLength;
|
|
1667
|
+
if (bytes > BILLING_WRITE_BODY_BYTES) {
|
|
1668
|
+
req.resume();
|
|
1669
|
+
return billingJson(res, 413, { error: 'billing request exceeds bounded dashboard limit' });
|
|
1670
|
+
}
|
|
1671
|
+
chunks.push(chunk);
|
|
1672
|
+
}
|
|
1673
|
+
const raw = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
1674
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1675
|
+
throw new Error('billing request must be an object');
|
|
1676
|
+
const body = raw;
|
|
1677
|
+
if (Object.keys(body).some((key) => !BILLING_WRITE_FIELDS.has(key)))
|
|
1678
|
+
throw new Error('billing request has an unknown field');
|
|
1679
|
+
if (typeof body.op !== 'string' || !BILLING_WRITE_OPS.has(body.op))
|
|
1680
|
+
throw new Error('op must be one of import, reconcile, adopt, scope, scope-clear');
|
|
1681
|
+
const op = body.op;
|
|
1682
|
+
if (body.apply !== undefined && typeof body.apply !== 'boolean')
|
|
1683
|
+
throw new Error('apply must be true or false');
|
|
1684
|
+
const apply = body.apply === true;
|
|
1685
|
+
if (apply && isDemo()) {
|
|
1686
|
+
return billingJson(res, 409, { error: new DemoWriteBlockedError('the billing change').message });
|
|
1687
|
+
}
|
|
1688
|
+
if (op === 'import') {
|
|
1689
|
+
const fileName = billingText(body.fileName, 'fileName', 200);
|
|
1690
|
+
if (fileName === '.' || fileName === '..' || posix.basename(fileName) !== fileName || win32.basename(fileName) !== fileName) {
|
|
1691
|
+
throw new Error('fileName must be an ordinary basename, without directories');
|
|
1692
|
+
}
|
|
1693
|
+
const encoded = billingText(body.contentBase64, 'contentBase64', BILLING_WRITE_BODY_BYTES);
|
|
1694
|
+
if (encoded.length % 4 !== 0 || !BASE64.test(encoded))
|
|
1695
|
+
throw new Error('contentBase64 must be the file\'s bytes in standard base64');
|
|
1696
|
+
// The file's own bytes are what is hashed, exactly as the CLI hashes the file on disk.
|
|
1697
|
+
const parsed = parseBillingImportText(Buffer.from(encoded, 'base64'), fileName);
|
|
1698
|
+
if (!apply) {
|
|
1699
|
+
return billingJson(res, 200, {
|
|
1700
|
+
ok: true, op, applied: false, preview: parsed.preview,
|
|
1701
|
+
message: 'Not recorded. Apply to import this file.',
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
const result = store.applyBillingImport(parsed.input);
|
|
1705
|
+
// Canonical kernel issue after the legacy compatibility write, as in the CLI.
|
|
1706
|
+
const kernel = store.issueBillingImportToKernel(result.run.importId);
|
|
1707
|
+
// BigInt coefficient converted to its canonical string form for the envelope only.
|
|
1708
|
+
const kernelJson = { ...kernel, total: { ...moneyToJson(kernel.total) } };
|
|
1709
|
+
const message = result.duplicateFile
|
|
1710
|
+
? `Identical billing evidence was already imported as ${result.run.importId}; no duplicate provider records written.`
|
|
1711
|
+
: `Imported ${result.run.recordsInserted} provider-declared charge line(s); ${result.run.recordsDuplicate} already existed. Status remains not_reconciled. Provider-reported totals are stored separately from local metered estimates.`;
|
|
1712
|
+
return billingJson(res, 200, {
|
|
1713
|
+
ok: true, op, applied: true, message, preview: parsed.preview,
|
|
1714
|
+
duplicateFile: result.duplicateFile, run: result.run, kernel: kernelJson,
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
const outcome = op === 'reconcile' ? billingReconcile(store, body, apply)
|
|
1718
|
+
: op === 'adopt' ? billingAdopt(store, body, apply)
|
|
1719
|
+
: op === 'scope-clear' ? billingScopeClear(store, apply)
|
|
1720
|
+
: billingScope(store, body, apply);
|
|
1721
|
+
return billingJson(res, outcome.status, outcome.payload);
|
|
1722
|
+
}
|
|
1723
|
+
catch (error) {
|
|
1724
|
+
return billingJson(res, 400, { error: error instanceof SyntaxError ? 'billing request must contain valid JSON' : error instanceof Error ? error.message : String(error) });
|
|
1725
|
+
}
|
|
1726
|
+
})();
|
|
1727
|
+
}
|
|
878
1728
|
/** Same local-only POST guard as other mutations. A POST with apply:false is
|
|
879
1729
|
* the preview; only a second deliberate POST with apply:true appends evidence. */
|
|
880
1730
|
export function handleOutcomeRecord({ req, res, store, config }) {
|
|
@@ -1071,6 +1921,14 @@ export function handleSettingsUpdate({ req, res, store, config, version, configP
|
|
|
1071
1921
|
if (err instanceof SyntaxError) {
|
|
1072
1922
|
return json(res, 400, { error: { code: 'SETTINGS_INVALID_JSON', message: 'settings request body must be valid JSON' } });
|
|
1073
1923
|
}
|
|
1924
|
+
if (err instanceof DemoReadOnlyError || (err instanceof Error && err.name === 'DemoReadOnlyError')) {
|
|
1925
|
+
return json(res, 409, {
|
|
1926
|
+
error: {
|
|
1927
|
+
code: 'SETTINGS_DEMO_READ_ONLY',
|
|
1928
|
+
message: "This dashboard is showing sample data, so settings can't be changed here. Start Segreant without --demo to change your own settings.",
|
|
1929
|
+
},
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1074
1932
|
return json(res, 500, { error: { code: 'SETTINGS_UPDATE_FAILED', message: 'settings could not be persisted' } });
|
|
1075
1933
|
}
|
|
1076
1934
|
})();
|
|
@@ -1110,10 +1968,20 @@ export const ROUTES = [
|
|
|
1110
1968
|
// POST carries the CSRF gate.
|
|
1111
1969
|
apiRoute('scan', handleScan),
|
|
1112
1970
|
apiRoute('overview', handleOverview),
|
|
1971
|
+
apiRoute('sessions', handleSessions),
|
|
1113
1972
|
apiRoute('billing', handleBilling),
|
|
1973
|
+
apiRoute('statement', handleStatement),
|
|
1974
|
+
apiRoute('statement-csv', handleStatementCsv),
|
|
1114
1975
|
apiRoute('allocation', handleAllocation),
|
|
1976
|
+
apiRoute('allocation-write', handleAllocationWrite),
|
|
1977
|
+
apiRoute('allocation-run-csv', handleAllocationRunCsv),
|
|
1978
|
+
apiRoute('billing-write', handleBillingWrite),
|
|
1115
1979
|
apiRoute('economic', handleEconomic),
|
|
1116
1980
|
apiRoute('pricing', handlePricing),
|
|
1981
|
+
apiRoute('pricing-unknown', handlePricingUnknown),
|
|
1982
|
+
apiRoute('pricing-declare', handlePricingDeclare),
|
|
1983
|
+
apiRoute('pricing-unset', handlePricingUnset),
|
|
1984
|
+
apiRoute('reprice', handleReprice),
|
|
1117
1985
|
apiRoute('export-csv', handleExportCsv),
|
|
1118
1986
|
apiRoute('realization', handleRealization),
|
|
1119
1987
|
apiRoute('guide', handleGuide),
|