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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The monthly statement as a spreadsheet.
|
|
3
|
+
*
|
|
4
|
+
* Built from the same payload the Statement page renders, so the file and the
|
|
5
|
+
* screen cannot disagree. The four claims stay four sections: each row names
|
|
6
|
+
* its claim, its basis and its own dates, and there is no total row, because
|
|
7
|
+
* metered usage, provider-billed cost, allocated cost and realized value are
|
|
8
|
+
* different questions and adding them answers none of them.
|
|
9
|
+
*/
|
|
10
|
+
import { toCsv } from './csv.js';
|
|
11
|
+
export const STATEMENT_CSV_COLUMNS = [
|
|
12
|
+
'claim', 'item', 'amountUsd', 'amountMicros', 'requests', 'basis',
|
|
13
|
+
'periodStartUtc', 'periodEndUtc', 'coversMonth', 'recordedAtUtc', 'runId', 'note',
|
|
14
|
+
];
|
|
15
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
16
|
+
const microsUsd = (micros) => {
|
|
17
|
+
const sign = micros < 0 ? '-' : '';
|
|
18
|
+
const abs = Math.abs(micros);
|
|
19
|
+
return `${sign}${Math.trunc(abs / 1_000_000)}.${String(abs % 1_000_000).padStart(6, '0')}`;
|
|
20
|
+
};
|
|
21
|
+
function billedSource(kind) {
|
|
22
|
+
if (kind === 'operator_supplied_export')
|
|
23
|
+
return 'provider figure from an export the operator supplied';
|
|
24
|
+
if (kind === 'provider_api_pull')
|
|
25
|
+
return 'provider figure read from the provider';
|
|
26
|
+
return 'provider figure, source not recorded';
|
|
27
|
+
}
|
|
28
|
+
function billedNote(r) {
|
|
29
|
+
const notes = [];
|
|
30
|
+
if (r.snapshotStability === 'changed_across_observations')
|
|
31
|
+
notes.push('conflicted: the provider reported different figures for the same days');
|
|
32
|
+
else if (r.snapshotStability === null)
|
|
33
|
+
notes.push('snapshot stability not recorded');
|
|
34
|
+
if (r.offPathBound === 'none_local_estimate_exceeds_provider')
|
|
35
|
+
notes.push('local estimate exceeds the provider total, so the remainder bounds nothing');
|
|
36
|
+
else if (r.offPathBound === 'unknown_local_total_truncated_by_retention')
|
|
37
|
+
notes.push('retention deleted local rows, so the remainder bounds nothing');
|
|
38
|
+
else if (r.offPathBound === null)
|
|
39
|
+
notes.push('remainder bound not recorded');
|
|
40
|
+
if (!r.coversWholeMonth)
|
|
41
|
+
notes.push('figures are for this run\'s own period, not prorated to the month');
|
|
42
|
+
if (r.localQuantization && r.localQuantization.daysRounded > 0) {
|
|
43
|
+
notes.push(`local side summed exactly per day; ${r.localQuantization.daysRounded} day total(s) rounded half to even to the microdollar`);
|
|
44
|
+
}
|
|
45
|
+
return notes.join('; ');
|
|
46
|
+
}
|
|
47
|
+
/** One calendar month, four claims, never summed. */
|
|
48
|
+
export function statementToCsv(s) {
|
|
49
|
+
const month = [iso(s.period.startMs), iso(s.period.endMs)];
|
|
50
|
+
const rows = [];
|
|
51
|
+
rows.push(['statement', s.period.label, '', '', '', s.demo ? 'sample data, not measured' : 'local ledger',
|
|
52
|
+
...month, '', s.generatedAt, '',
|
|
53
|
+
'Four separate claims. Do not add the sections together. The month runs from local midnight; times are shown in UTC.' + (s.period.complete ? '' : ' Month in progress: figures so far.')]);
|
|
54
|
+
const m = s.metered;
|
|
55
|
+
const exact = m.basis.exactShare === null ? '' : `; ${Math.round(m.basis.exactShare * 100)}% at each model's own list rate`;
|
|
56
|
+
const basis = `local estimate: ${m.basis.headlineLabel.toLowerCase()}${exact}`;
|
|
57
|
+
const retention = m.retention.truncated ? `retention deleted ${m.retention.rowsRemoved} row(s) in this period; covers what survived` : '';
|
|
58
|
+
rows.push(['metered', 'total', m.costUsd.toFixed(6), '', m.requests, basis, ...month, 'yes', '', '', retention]);
|
|
59
|
+
for (const p of m.byProject)
|
|
60
|
+
rows.push(['metered', `project: ${p.label}`, p.costUsd.toFixed(6), '', p.requests, 'local estimate', ...month, 'yes', '', '', '']);
|
|
61
|
+
for (const x of m.byModel)
|
|
62
|
+
rows.push(['metered', `model: ${x.provider ? `${x.provider}/` : ''}${x.label}`, x.costUsd.toFixed(6), '', x.requests, 'local estimate', ...month, 'yes', '', '', '']);
|
|
63
|
+
const b = s.billed;
|
|
64
|
+
if (b.runs.length === 0) {
|
|
65
|
+
rows.push(['billed', 'not established', '', '', '', '', ...month, 'no', '', '',
|
|
66
|
+
b.scanComplete ? 'no comparison with a provider bill covers this month' : 'none among the most recent comparisons; an older one may exist']);
|
|
67
|
+
}
|
|
68
|
+
for (const r of b.runs) {
|
|
69
|
+
const own = [iso(r.periodStartMs), iso(r.periodEndMs), r.coversWholeMonth ? 'yes' : 'no', iso(r.computedAtMs), r.reconciliationRunId];
|
|
70
|
+
const project = r.providerProjectRef ? ` (${r.providerProjectRef})` : '';
|
|
71
|
+
const note = billedNote(r);
|
|
72
|
+
rows.push(['billed', `provider reported${project}`, microsUsd(r.providerReportedMicros), r.providerReportedMicros, '', billedSource(r.providerSourceKind), ...own, note]);
|
|
73
|
+
rows.push(['billed', `measured locally for comparison${project}`, microsUsd(r.localCapturedMicros), r.localCapturedMicros, '', 'local estimate, same scope and days', ...own, note]);
|
|
74
|
+
rows.push(['billed', `not explained${project}`, microsUsd(r.unexplainedVarianceMicros), r.unexplainedVarianceMicros, '', 'provider reported minus measured', ...own, note]);
|
|
75
|
+
}
|
|
76
|
+
const a = s.allocated;
|
|
77
|
+
const names = new Map(a.costCentres.map((c) => [c.costCentreId, c.name]));
|
|
78
|
+
if (a.runs.length === 0) {
|
|
79
|
+
rows.push(['allocated', 'not established', '', '', '', '', ...month, 'no', '', '',
|
|
80
|
+
a.scanComplete ? 'no recorded split covers this month' : 'none among the most recent splits; an older one may exist']);
|
|
81
|
+
}
|
|
82
|
+
for (const r of a.runs) {
|
|
83
|
+
const own = [iso(r.periodStartMs), iso(r.periodEndMs), r.coversWholeMonth ? 'yes' : 'no', iso(r.computedAtMs), r.allocationRunId];
|
|
84
|
+
const note = r.coversWholeMonth ? '' : 'figures are for this run\'s own period, not prorated to the month';
|
|
85
|
+
for (const c of r.byCostCentre) {
|
|
86
|
+
rows.push(['allocated', `cost centre: ${names.get(c.costCentreId) ?? c.costCentreId}`, microsUsd(c.allocatedMicros), c.allocatedMicros, c.requestCount, 'showback split of the local estimate', ...own, note]);
|
|
87
|
+
}
|
|
88
|
+
if (r.unallocatedMicros !== 0)
|
|
89
|
+
rows.push(['allocated', 'not allocated', microsUsd(r.unallocatedMicros), r.unallocatedMicros, '', 'showback split of the local estimate', ...own, note]);
|
|
90
|
+
}
|
|
91
|
+
const kept = (item, c) => {
|
|
92
|
+
// No amount: the realized section never carries a dollar a reader could sum
|
|
93
|
+
// as value. The count is in the requests column; the spend behind it is named
|
|
94
|
+
// in the note as spend.
|
|
95
|
+
rows.push(['realized', item, '', '', c.units, 'pieces of work judged in the month (count in the requests column); not value', ...month, 'yes', s.realized.measuredAt ?? '', '', `list cost behind this work: $${c.costUsd.toFixed(2)} (spend, not value). ${s.realized.reason}`]);
|
|
96
|
+
};
|
|
97
|
+
kept('kept', s.realized.kept);
|
|
98
|
+
kept('not kept', s.realized.notKept);
|
|
99
|
+
kept('results disagree', s.realized.contested);
|
|
100
|
+
kept('no signal', s.realized.unknown);
|
|
101
|
+
kept('too early to tell', s.realized.tooEarly);
|
|
102
|
+
rows.push(['close', s.close.status, '', '', '', 'economic period close', ...month, '', '', s.close.activeFinalizationId ?? '', '']);
|
|
103
|
+
return toCsv([...STATEMENT_CSV_COLUMNS], rows);
|
|
104
|
+
}
|
package/dist/git/correlate.js
CHANGED
|
@@ -65,6 +65,17 @@ export async function repoToplevel(dir) {
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
/** Commits reachable from HEAD made in the last `days` days (0 when unreadable). */
|
|
69
|
+
export async function commitCountSince(repoPath, days) {
|
|
70
|
+
try {
|
|
71
|
+
const out = await git(repoPath, ['rev-list', '--count', `--since=${days}.days`, 'HEAD']);
|
|
72
|
+
const n = Number(out.trim());
|
|
73
|
+
return Number.isSafeInteger(n) && n > 0 ? n : 0;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
68
79
|
export async function projectName(repoPath) {
|
|
69
80
|
try {
|
|
70
81
|
const top = (await git(repoPath, ['rev-parse', '--show-toplevel'])).trim();
|
|
@@ -146,29 +157,95 @@ export async function attributeCommits(store, repoPath, opts = {}) {
|
|
|
146
157
|
const limit = opts.limit ?? 20;
|
|
147
158
|
const maxLookbackMs = (opts.maxLookbackHours ?? 8) * 60 * 60 * 1000;
|
|
148
159
|
const project = await projectName(repoPath);
|
|
149
|
-
|
|
160
|
+
// Newest first BY TIME, not in log order. `git log` follows the graph, and
|
|
161
|
+
// after a rebase, cherry-pick or merge the author times along it are not
|
|
162
|
+
// monotonic; windows built from log neighbours then overlapped and booked the
|
|
163
|
+
// same request to two commits. On one real repository 40 windows overlapped
|
|
164
|
+
// and the Kept answer counted $99 of spend twice. Sorted, the windows tile
|
|
165
|
+
// time and every request lands in at most one of them.
|
|
166
|
+
const commits = (await readCommits(repoPath, limit + 1))
|
|
167
|
+
.map((c, i) => ({ c, i }))
|
|
168
|
+
.sort((a, b) => b.c.tsEpochMs - a.c.tsEpochMs || a.i - b.i)
|
|
169
|
+
.map((x) => x.c);
|
|
150
170
|
const results = [];
|
|
151
|
-
|
|
171
|
+
const n = Math.min(limit, commits.length);
|
|
172
|
+
const windowOf = (i) => {
|
|
152
173
|
const commit = commits[i];
|
|
153
174
|
const prev = commits[i + 1];
|
|
154
175
|
const naturalStart = prev ? prev.tsEpochMs : commit.tsEpochMs - maxLookbackMs;
|
|
155
|
-
|
|
156
|
-
|
|
176
|
+
return [Math.max(naturalStart, commit.tsEpochMs - maxLookbackMs), commit.tsEpochMs];
|
|
177
|
+
};
|
|
178
|
+
// With an evidence scope, read the scoped rows ONCE for the whole span and
|
|
179
|
+
// slice each window out of them. Three queries per commit took 10.5 s for
|
|
180
|
+
// 1,018 commits on a real repository; the slices give the same rows, in the
|
|
181
|
+
// same [start, end) bounds and order, because the read is ordered by time.
|
|
182
|
+
let scoped = null;
|
|
183
|
+
let scopedTs = [];
|
|
184
|
+
if (opts.scope && n > 0) {
|
|
185
|
+
let lo = Infinity;
|
|
186
|
+
let hi = -Infinity;
|
|
187
|
+
for (let i = 0; i < n; i++) {
|
|
188
|
+
const [a, b] = windowOf(i);
|
|
189
|
+
if (a < lo)
|
|
190
|
+
lo = a;
|
|
191
|
+
if (b > hi)
|
|
192
|
+
hi = b;
|
|
193
|
+
}
|
|
194
|
+
scoped = store.requestsInRange(lo, hi).filter((r) => opts.scope.matches(r));
|
|
195
|
+
scopedTs = scoped.map((r) => r.tsEpochMs);
|
|
196
|
+
}
|
|
197
|
+
const firstAtOrAfter = (t) => {
|
|
198
|
+
let a = 0;
|
|
199
|
+
let b = scopedTs.length;
|
|
200
|
+
while (a < b) {
|
|
201
|
+
const m = (a + b) >> 1;
|
|
202
|
+
if (scopedTs[m] < t)
|
|
203
|
+
a = m + 1;
|
|
204
|
+
else
|
|
205
|
+
b = m;
|
|
206
|
+
}
|
|
207
|
+
return a;
|
|
208
|
+
};
|
|
209
|
+
// The retention floor is one fact for the whole pass; read it once.
|
|
210
|
+
const floor = store.windowCoverage(Number.POSITIVE_INFINITY);
|
|
211
|
+
for (let i = 0; i < n; i++) {
|
|
212
|
+
const commit = commits[i];
|
|
213
|
+
const [windowStartMs, windowEndMs] = windowOf(i);
|
|
157
214
|
// Scope the window's spend to this repo's project when the caller asks — so a
|
|
158
215
|
// commit absorbs only its own project's native/imported spend, not every
|
|
159
216
|
// project's concurrent traffic. Undefined scope = the project-blind window sum
|
|
160
217
|
// (proxy default), preserving the original behavior.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
218
|
+
let spend;
|
|
219
|
+
let economicRows;
|
|
220
|
+
if (opts.scope) {
|
|
221
|
+
// The repository's label PLUS the folders and sessions its commits prove
|
|
222
|
+
// belong to it (a moved checkout, a worktree, a session started elsewhere).
|
|
223
|
+
const rows = windowEndMs > windowStartMs
|
|
224
|
+
? scoped.slice(firstAtOrAfter(windowStartMs), firstAtOrAfter(windowEndMs))
|
|
225
|
+
: [];
|
|
226
|
+
spend = {
|
|
227
|
+
costUsd: rows.reduce((s, r) => s + r.costUsd, 0),
|
|
228
|
+
requests: rows.length,
|
|
229
|
+
outputTokens: rows.reduce((s, r) => s + r.outputTokens, 0),
|
|
230
|
+
};
|
|
231
|
+
economicRows = store.economicRowsOf(rows);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
spend = store.summary(windowStartMs, windowEndMs, opts.scopeProject);
|
|
235
|
+
economicRows = store.economicRequestRowsInRange(windowStartMs, windowEndMs, {
|
|
236
|
+
project: opts.scopeProject,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
165
239
|
const economic = economicAttributionFromRows(economicRows);
|
|
166
240
|
const totalLines = commit.linesAdded + commit.linesDeleted;
|
|
167
241
|
const attributedCostUsd = economicAttributionNumber(economic, spend.costUsd);
|
|
168
242
|
// Did retention delete rows from inside this window? Strictly before the
|
|
169
243
|
// boundary, because `prune` removes rows older than it; a null boundary is
|
|
170
244
|
// "no prune on record" and licenses nothing either way (D-170, D-176).
|
|
171
|
-
const coverage =
|
|
245
|
+
const coverage = {
|
|
246
|
+
truncated: floor.prunedBeforeMs !== null && windowStartMs < floor.prunedBeforeMs,
|
|
247
|
+
prunedBeforeMs: floor.prunedBeforeMs,
|
|
248
|
+
};
|
|
172
249
|
const costPerHundredLines = totalLines > 0 && !coverage.truncated
|
|
173
250
|
? (attributedCostUsd / totalLines) * 100
|
|
174
251
|
: null;
|
package/dist/git/quality.js
CHANGED
|
@@ -16,6 +16,7 @@ import { promisify } from 'node:util';
|
|
|
16
16
|
import { attributeCommits } from './correlate.js';
|
|
17
17
|
import { measurementModel } from '../measurement/model.js';
|
|
18
18
|
import { scope } from '../epistemic/scope.js';
|
|
19
|
+
import { storedCommitFiles, rememberCommitFiles, reusableBlame, rememberBlame, flushSurvivalStore } from './survivalStore.js';
|
|
19
20
|
const run = promisify(execFile);
|
|
20
21
|
/** The construct measured by Git line retention. */
|
|
21
22
|
export const ARTIFACT_PERSISTENCE_CONSTRUCT = 'artifact_persistence';
|
|
@@ -84,6 +85,19 @@ async function git(repoPath, args) {
|
|
|
84
85
|
const { stdout } = await run('git', ['-C', repoPath, ...args], { maxBuffer: 64 * 1024 * 1024 });
|
|
85
86
|
return stdout;
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* How many survival reads actually started git, since the process began or the
|
|
90
|
+
* last reset. For tests and diagnostics: a cache that answers shows up here as
|
|
91
|
+
* work not done.
|
|
92
|
+
*/
|
|
93
|
+
const gitRuns = { blame: 0, numstat: 0 };
|
|
94
|
+
export function survivalGitRuns() {
|
|
95
|
+
return { ...gitRuns };
|
|
96
|
+
}
|
|
97
|
+
export function resetSurvivalGitRuns() {
|
|
98
|
+
gitRuns.blame = 0;
|
|
99
|
+
gitRuns.numstat = 0;
|
|
100
|
+
}
|
|
87
101
|
/** A commit's numstat never changes, so it is read once per process. */
|
|
88
102
|
const commitFilesCache = new Map();
|
|
89
103
|
/** Files a commit touched, with lines added per file (numstat). */
|
|
@@ -93,7 +107,11 @@ function commitFiles(repoPath, hash) {
|
|
|
93
107
|
if (pending === undefined) {
|
|
94
108
|
if (commitFilesCache.size >= BLAME_CACHE_MAX)
|
|
95
109
|
commitFilesCache.clear();
|
|
96
|
-
|
|
110
|
+
// A commit's file list is fixed by its hash, so one read in any earlier run stands.
|
|
111
|
+
const stored = storedCommitFiles(repoPath, hash);
|
|
112
|
+
pending = stored !== undefined
|
|
113
|
+
? Promise.resolve(stored)
|
|
114
|
+
: (gitRuns.numstat += 1, readCommitFiles(repoPath, hash).then((files) => { rememberCommitFiles(repoPath, hash, files); return files; }));
|
|
97
115
|
// A failed read is not cached: the next caller retries rather than inheriting it.
|
|
98
116
|
pending.catch(() => commitFilesCache.delete(key));
|
|
99
117
|
commitFilesCache.set(key, pending);
|
|
@@ -101,15 +119,35 @@ function commitFiles(repoPath, hash) {
|
|
|
101
119
|
return pending;
|
|
102
120
|
}
|
|
103
121
|
async function readCommitFiles(repoPath, hash) {
|
|
104
|
-
// --format= suppresses the commit header;
|
|
105
|
-
|
|
122
|
+
// --format= suppresses the commit header; -z keeps paths exact (see parseNumstatZ).
|
|
123
|
+
return parseNumstatZ(await git(repoPath, ['show', '--numstat', '-z', '--format=', hash]));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse `git show --numstat -z`. Each file is `added<TAB>deleted<TAB>path<NUL>`,
|
|
127
|
+
* except a rename or copy, which is `added<TAB>deleted<TAB><NUL>old<NUL>new<NUL>`.
|
|
128
|
+
*
|
|
129
|
+
* The paths returned are blamed at HEAD, so a rename has to yield its NEW path.
|
|
130
|
+
* Without -z, git prints a renamed file as `src/{old.ts => new.ts}`, which is not
|
|
131
|
+
* a path: blame refused it ("no such path in HEAD"), the file read as deleted,
|
|
132
|
+
* and every line the commit added there counted as not retained. -z also keeps
|
|
133
|
+
* a path containing spaces, tabs, quotes or " => " exactly as it is.
|
|
134
|
+
*/
|
|
135
|
+
export function parseNumstatZ(out) {
|
|
136
|
+
const fields = out.split('\0');
|
|
106
137
|
const files = [];
|
|
107
|
-
for (
|
|
108
|
-
const m =
|
|
138
|
+
for (let i = 0; i < fields.length; i++) {
|
|
139
|
+
const m = /^\n?(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(fields[i]);
|
|
109
140
|
if (!m)
|
|
110
141
|
continue;
|
|
111
|
-
|
|
112
|
-
|
|
142
|
+
let path = m[3];
|
|
143
|
+
if (path === '') {
|
|
144
|
+
// A rename or copy: the old path, then the new one, as their own fields.
|
|
145
|
+
path = fields[i + 2] ?? '';
|
|
146
|
+
i += 2;
|
|
147
|
+
}
|
|
148
|
+
if (path === '')
|
|
149
|
+
continue;
|
|
150
|
+
files.push({ path, added: m[1] === '-' ? 0 : Number(m[1]) });
|
|
113
151
|
}
|
|
114
152
|
return files;
|
|
115
153
|
}
|
|
@@ -141,7 +179,11 @@ export function clearBlameCache() {
|
|
|
141
179
|
* caller's own deadline check, so a prefetch can never turn an unmeasured
|
|
142
180
|
* commit into a measured one.
|
|
143
181
|
*/
|
|
144
|
-
export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency = 8
|
|
182
|
+
export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency = 8,
|
|
183
|
+
/** The same history bound the measuring pass will use, so the caches meet. */
|
|
184
|
+
sinceSec,
|
|
185
|
+
/** The pass's HEAD (survivalHead); read here when not given. */
|
|
186
|
+
passHead) {
|
|
145
187
|
const expired = () => deadlineMs !== undefined && Date.now() >= deadlineMs;
|
|
146
188
|
const pool = async (items, work) => {
|
|
147
189
|
let next = 0;
|
|
@@ -156,7 +198,7 @@ export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency
|
|
|
156
198
|
});
|
|
157
199
|
await Promise.all(lanes);
|
|
158
200
|
};
|
|
159
|
-
const head = await
|
|
201
|
+
const head = passHead === undefined ? await survivalHead(repoPath) : passHead;
|
|
160
202
|
if (head === null)
|
|
161
203
|
return;
|
|
162
204
|
const paths = new Set();
|
|
@@ -165,9 +207,17 @@ export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency
|
|
|
165
207
|
if (f.added > 0)
|
|
166
208
|
paths.add(f.path);
|
|
167
209
|
});
|
|
168
|
-
await pool([...paths], (path) => blameAt(repoPath, head, path));
|
|
210
|
+
await pool([...paths], (path) => blameAt(repoPath, head, path, sinceSec));
|
|
211
|
+
flushSurvivalStore();
|
|
169
212
|
}
|
|
170
|
-
|
|
213
|
+
/**
|
|
214
|
+
* The commit HEAD names now, or null for an unborn branch. A measuring pass
|
|
215
|
+
* reads it once and hands it to `prefetchSurvival` and `survivingLines`: asking
|
|
216
|
+
* per commit started one `git rev-parse` each, which was 3.6 s of a 3.9 s pass
|
|
217
|
+
* over 80 commits once the blames were cached. A caller that passes no HEAD
|
|
218
|
+
* still gets a fresh read per call, so a commit made a moment ago is seen.
|
|
219
|
+
*/
|
|
220
|
+
export async function survivalHead(repoPath) {
|
|
171
221
|
try {
|
|
172
222
|
const out = (await git(repoPath, ['rev-parse', 'HEAD'])).trim();
|
|
173
223
|
return /^[0-9a-f]{40,64}$/.test(out) ? out : null;
|
|
@@ -192,13 +242,32 @@ export function blameCountsFromPorcelain(out) {
|
|
|
192
242
|
}
|
|
193
243
|
return counts;
|
|
194
244
|
}
|
|
195
|
-
|
|
196
|
-
|
|
245
|
+
/**
|
|
246
|
+
* `sinceSec` bounds blame's walk back through history. Every commit newer than
|
|
247
|
+
* it keeps its exact line count; lines from older history are credited to a
|
|
248
|
+
* boundary commit instead of being traced to their origin, which is where
|
|
249
|
+
* almost all of blame's time goes on a long-lived file. Callers pass a bound
|
|
250
|
+
* older than every commit they measure, so their counts are unchanged.
|
|
251
|
+
*/
|
|
252
|
+
function blameAt(repoPath, head, path, sinceSec) {
|
|
253
|
+
const key = `${repoPath}\0${head}\0${path}\0${sinceSec ?? ''}`;
|
|
197
254
|
let pending = blameCache.get(key);
|
|
198
255
|
if (pending === undefined) {
|
|
199
256
|
if (blameCache.size >= BLAME_CACHE_MAX)
|
|
200
257
|
blameCache.clear();
|
|
201
|
-
|
|
258
|
+
const bound = sinceSec === undefined ? [] : [`--since=${sinceSec}`];
|
|
259
|
+
pending = (async () => {
|
|
260
|
+
// An earlier run's blame of this file, when it is provably the same
|
|
261
|
+
// observation (survivalStore.ts says when that is).
|
|
262
|
+
const reused = await reusableBlame(repoPath, head, path, sinceSec);
|
|
263
|
+
if (reused !== undefined)
|
|
264
|
+
return reused;
|
|
265
|
+
gitRuns.blame += 1;
|
|
266
|
+
const counts = await git(repoPath, ['blame', '--porcelain', ...bound, head, '--', path]).then(blameCountsFromPorcelain, () => null);
|
|
267
|
+
if (counts !== null)
|
|
268
|
+
rememberBlame(repoPath, head, path, sinceSec, counts);
|
|
269
|
+
return counts;
|
|
270
|
+
})();
|
|
202
271
|
blameCache.set(key, pending);
|
|
203
272
|
}
|
|
204
273
|
return pending;
|
|
@@ -208,8 +277,8 @@ function blameAt(repoPath, head, path) {
|
|
|
208
277
|
* `hash` by git blame. This is the retained-line count for `hash`'s introduced
|
|
209
278
|
* artifact lines; it is not a quality score.
|
|
210
279
|
*/
|
|
211
|
-
async function survivingLinesInFile(repoPath, head, hash, path) {
|
|
212
|
-
const counts = await blameAt(repoPath, head, path);
|
|
280
|
+
async function survivingLinesInFile(repoPath, head, hash, path, sinceSec) {
|
|
281
|
+
const counts = await blameAt(repoPath, head, path, sinceSec);
|
|
213
282
|
if (counts === null)
|
|
214
283
|
return 0; // file deleted/renamed at HEAD → none of its lines survived as-is
|
|
215
284
|
const exact = counts.get(hash);
|
|
@@ -231,23 +300,29 @@ async function survivingLinesInFile(repoPath, head, hash, path) {
|
|
|
231
300
|
* between files: a single `git blame` already running is allowed to finish,
|
|
232
301
|
* because killing it would cost the work without producing an answer.
|
|
233
302
|
*/
|
|
234
|
-
export async function survivingLines(repoPath, hash, deadlineMs
|
|
235
|
-
|
|
303
|
+
export async function survivingLines(repoPath, hash, deadlineMs,
|
|
304
|
+
/** Optional history bound for blame; must be older than `hash` (see blameAt). */
|
|
305
|
+
sinceSec,
|
|
306
|
+
/** The pass's HEAD (survivalHead); read here when not given. */
|
|
307
|
+
passHead) {
|
|
308
|
+
const [files, head] = await Promise.all([commitFiles(repoPath, hash), passHead === undefined ? survivalHead(repoPath) : passHead]);
|
|
236
309
|
let added = 0;
|
|
237
310
|
let surviving = 0;
|
|
238
311
|
let measured = true;
|
|
312
|
+
let filesWithAdditions = 0;
|
|
239
313
|
for (const f of files) {
|
|
240
314
|
added += f.added;
|
|
241
315
|
if (f.added === 0)
|
|
242
316
|
continue;
|
|
317
|
+
filesWithAdditions += 1;
|
|
243
318
|
if (head === null || (deadlineMs !== undefined && Date.now() >= deadlineMs)) {
|
|
244
319
|
// No HEAD to blame against (an unborn branch) is unmeasured, never zero.
|
|
245
320
|
measured = false;
|
|
246
321
|
continue;
|
|
247
322
|
}
|
|
248
|
-
surviving += await survivingLinesInFile(repoPath, head, hash, f.path);
|
|
323
|
+
surviving += await survivingLinesInFile(repoPath, head, hash, f.path, sinceSec);
|
|
249
324
|
}
|
|
250
|
-
return { added, surviving, measured };
|
|
325
|
+
return { added, surviving, measured, filesWithAdditions };
|
|
251
326
|
}
|
|
252
327
|
/**
|
|
253
328
|
* Scan recent history for reverts, and report the boundary of what was read.
|
|
@@ -304,8 +379,9 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
|
|
|
304
379
|
const attributions = await attributeCommits(store, repoPath, { limit, persist: opts.persist });
|
|
305
380
|
const reverted = await revertedHashes(repoPath, limit);
|
|
306
381
|
const commits = [];
|
|
382
|
+
const passHead = await survivalHead(repoPath);
|
|
307
383
|
for (const a of attributions) {
|
|
308
|
-
const survival = await survivingLines(repoPath, a.hash);
|
|
384
|
+
const survival = await survivingLines(repoPath, a.hash, undefined, undefined, passHead);
|
|
309
385
|
const { added, surviving } = survival;
|
|
310
386
|
const retainedArtifact = artifactPersistence({
|
|
311
387
|
introducedLines: added,
|
|
@@ -316,6 +392,9 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
|
|
|
316
392
|
const ageDays = (now - a.tsEpochMs) / (24 * 60 * 60 * 1000);
|
|
317
393
|
const maturing = now - a.tsEpochMs < windowMs;
|
|
318
394
|
const cost = a.attributedCostUsd;
|
|
395
|
+
// A window retention emptied reports the surviving spend, which is not the
|
|
396
|
+
// cost of the work, so no per-dollar figure is derived from it.
|
|
397
|
+
const spendKnown = a.spendWindowTruncated === false;
|
|
319
398
|
const isReverted = reverted.has(a.hash.slice(0, 7));
|
|
320
399
|
commits.push({
|
|
321
400
|
...a,
|
|
@@ -327,8 +406,8 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
|
|
|
327
406
|
reverted: isReverted,
|
|
328
407
|
ageDays,
|
|
329
408
|
maturing,
|
|
330
|
-
aiYield: cost > 0 ? surviving / cost : null,
|
|
331
|
-
costPerSurvivingLine: surviving > 0 ? cost / surviving : null,
|
|
409
|
+
aiYield: spendKnown && cost > 0 ? surviving / cost : null,
|
|
410
|
+
costPerSurvivingLine: spendKnown && surviving > 0 ? cost / surviving : null,
|
|
332
411
|
});
|
|
333
412
|
}
|
|
334
413
|
// Retention/churn/revert are artifact observations over all matured commits.
|
|
@@ -345,7 +424,10 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
|
|
|
345
424
|
const revertCount = mature.filter((c) => c.reverted).length;
|
|
346
425
|
// Legacy AI Yield/effective-spend projections only count commits with AI cost,
|
|
347
426
|
// so pure-human commits do not pad the retained-lines-per-dollar numerator.
|
|
348
|
-
|
|
427
|
+
// Commits whose spend window retention pruned are withheld from every
|
|
428
|
+
// cost-based figure: their surviving cost would understate the denominator.
|
|
429
|
+
const spendTruncated = mature.filter((c) => c.spendWindowTruncated !== false);
|
|
430
|
+
const aiCommits = mature.filter((c) => c.spendWindowTruncated === false && c.attributedCostUsd > 0);
|
|
349
431
|
const aiCost = aiCommits.reduce((s, c) => s + c.attributedCostUsd, 0);
|
|
350
432
|
const aiSurviving = aiCommits.reduce((s, c) => s + c.survivingLines, 0);
|
|
351
433
|
const costOnDurable = aiCommits
|
|
@@ -371,6 +453,7 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
|
|
|
371
453
|
aiYield: aiCost > 0 ? aiSurviving / aiCost : null,
|
|
372
454
|
costPerSurvivingLine: aiSurviving > 0 ? aiCost / aiSurviving : null,
|
|
373
455
|
effectiveSpendRatio: aiCost > 0 ? costOnDurable / aiCost : null,
|
|
456
|
+
spendWindowTruncatedCommits: spendTruncated.length,
|
|
374
457
|
},
|
|
375
458
|
};
|
|
376
459
|
}
|