omp-conductor 0.15.9 → 0.15.11
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/README.md +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
package/src/setup.ts
CHANGED
|
@@ -29,8 +29,10 @@ import {
|
|
|
29
29
|
POLICY_BRIEF_NAME,
|
|
30
30
|
composeOrchestrator,
|
|
31
31
|
policyPathForRoot,
|
|
32
|
+
readSharedPolicy,
|
|
32
33
|
refreshComposedBrief,
|
|
33
34
|
renderBriefTemplate,
|
|
35
|
+
sharedPolicyPath,
|
|
34
36
|
} from "./brief-upgrade.ts";
|
|
35
37
|
import {
|
|
36
38
|
clonePolicy,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
WEEKDAYS,
|
|
56
58
|
type BaseFreshness,
|
|
57
59
|
type BehindBaseAction,
|
|
60
|
+
type AuthorityHolder,
|
|
58
61
|
type Caps,
|
|
59
62
|
type ConductorConfig,
|
|
60
63
|
type DigestCadence,
|
|
@@ -411,6 +414,31 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
|
|
|
411
414
|
" one is a hard boundary, not a preference.",
|
|
412
415
|
};
|
|
413
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The Promotion paragraph, worded from `authority.promotion`.
|
|
419
|
+
*
|
|
420
|
+
* The queue label is a sign-off: adding it to an issue lets a worker claim it
|
|
421
|
+
* and starts spend. POLICY.md must state who may do that — never a blank the
|
|
422
|
+
* floor defers into — and an unstated owner reads operator-only, because a
|
|
423
|
+
* gate nobody claimed must not resolve to the session that wants to use it.
|
|
424
|
+
*
|
|
425
|
+
* The paragraph refers to "the queue label" rather than naming it: the label
|
|
426
|
+
* itself is rendered into the section heading from config, and a second copy
|
|
427
|
+
* here would be a label that can disagree with the one the daemon reads.
|
|
428
|
+
*/
|
|
429
|
+
export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = {
|
|
430
|
+
human:
|
|
431
|
+
"**Default: the operator promotes.** Adding the queue label to an issue is promotion, and\n" +
|
|
432
|
+
"promotion is the operator's: the orchestrator grooms, routes and proposes, but the label\n" +
|
|
433
|
+
"goes on only when the operator puts it there — it is the sign-off that starts spend. To\n" +
|
|
434
|
+
"delegate, re-run `omp-conductor setup` or replace this paragraph with one naming the\n" +
|
|
435
|
+
"orchestrator as promoter.",
|
|
436
|
+
orchestrator:
|
|
437
|
+
"**Delegated in setup: the orchestrator promotes.** Adding the queue label to an issue is\n" +
|
|
438
|
+
"promotion, and the orchestrator may do it after grooming the issue, one at a time. The\n" +
|
|
439
|
+
"operator can take it back by editing this paragraph.",
|
|
440
|
+
};
|
|
441
|
+
|
|
414
442
|
export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
|
|
415
443
|
|
|
416
444
|
/** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
|
|
@@ -427,12 +455,14 @@ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
|
|
|
427
455
|
const REQUIRED_SCOPES = ["repo", "project"] as const;
|
|
428
456
|
|
|
429
457
|
/** GitHub's own palette, so the tracker reads at a glance: green means queued,
|
|
430
|
-
* blue means moving, amber means waiting on you, red means it gave up
|
|
458
|
+
* blue means moving, amber means waiting on you, red means it gave up, and
|
|
459
|
+
* purple is routing — the operator's input, the one label the loop never writes. */
|
|
431
460
|
const LABEL_COLOURS = {
|
|
432
461
|
queue: "0e8a16",
|
|
433
462
|
inProgress: "1d76db",
|
|
434
463
|
blocked: "fbca04",
|
|
435
464
|
failed: "b60205",
|
|
465
|
+
routing: "6f42c1",
|
|
436
466
|
} as const;
|
|
437
467
|
|
|
438
468
|
interface GhResult {
|
|
@@ -514,11 +544,18 @@ export async function checkTokenScopes(): Promise<ScopeCheck> {
|
|
|
514
544
|
}
|
|
515
545
|
|
|
516
546
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
547
|
+
* Every label setup wants on the tracker, before existence is known: the queue
|
|
548
|
+
* and state labels the loop reads and writes, plus one routing label per routed
|
|
549
|
+
* repo. Pure — the prefix and the repo keys are already answered at this point
|
|
550
|
+
* in the interview — which is what lets a test pin the whole set without `gh`.
|
|
551
|
+
*
|
|
552
|
+
* Routing labels are provisioned, never applied: `route()` requires exactly one
|
|
553
|
+
* `<prefix><repo>` label per issue and treats zero or two as unroutable, so a
|
|
554
|
+
* tracker without them can queue nothing. Creating them queues nothing either —
|
|
555
|
+
* applying one, with the queue label, stays the operator's sign-off.
|
|
519
556
|
*/
|
|
520
|
-
export
|
|
521
|
-
|
|
557
|
+
export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
|
|
558
|
+
return [
|
|
522
559
|
{
|
|
523
560
|
name: a.queueLabel,
|
|
524
561
|
colour: LABEL_COLOURS.queue,
|
|
@@ -539,13 +576,28 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
|
|
|
539
576
|
colour: LABEL_COLOURS.failed,
|
|
540
577
|
description: "The conductor gave up on this issue after its retry budget",
|
|
541
578
|
},
|
|
579
|
+
...a.targetRepos.map((r) => ({
|
|
580
|
+
name: `${a.routingLabelPrefix}${r.name}`,
|
|
581
|
+
colour: LABEL_COLOURS.routing,
|
|
582
|
+
description: `Routes this issue to the ${r.name} checkout`,
|
|
583
|
+
})),
|
|
542
584
|
];
|
|
585
|
+
}
|
|
543
586
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
587
|
+
/**
|
|
588
|
+
* Turns wanted labels into the plan the operator is shown, deduped
|
|
589
|
+
* case-insensitively and marked against what already exists.
|
|
590
|
+
*
|
|
591
|
+
* GitHub label names are unique case-insensitively, so an operator who answers
|
|
592
|
+
* "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
|
|
593
|
+
* creation is pending that would then fail — and a multi-repo project whose
|
|
594
|
+
* names case-fold onto each other or onto a lifecycle label wants one creation,
|
|
595
|
+
* not two.
|
|
596
|
+
*/
|
|
597
|
+
export function planAgainstLabels(
|
|
598
|
+
wanted: readonly Omit<LabelPlan, "exists">[],
|
|
599
|
+
existing: ReadonlySet<string>,
|
|
600
|
+
): LabelPlan[] {
|
|
549
601
|
const seen = new Set<string>();
|
|
550
602
|
const plan: LabelPlan[] = [];
|
|
551
603
|
for (const w of wanted) {
|
|
@@ -557,6 +609,18 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
|
|
|
557
609
|
return plan;
|
|
558
610
|
}
|
|
559
611
|
|
|
612
|
+
/**
|
|
613
|
+
* The labels a tracker needs, and which already exist. Read-only: this is what
|
|
614
|
+
* the operator is shown before being asked to consent to creation.
|
|
615
|
+
*
|
|
616
|
+
* The queue and state labels are the ones the loop reads and writes; the
|
|
617
|
+
* routing labels (one per repo) are the operator's input that makes an issue
|
|
618
|
+
* routable, and setup provisions them only so there is something to apply.
|
|
619
|
+
*/
|
|
620
|
+
export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
|
|
621
|
+
return planAgainstLabels(wantedLabels(a), await listLabelNames(trackerRepo));
|
|
622
|
+
}
|
|
623
|
+
|
|
560
624
|
/** Lower-cased label names currently on the repo. Throws rather than guessing:
|
|
561
625
|
* a repo whose labels cannot be listed cannot be serviced either. */
|
|
562
626
|
async function listLabelNames(trackerRepo: string): Promise<Set<string>> {
|
|
@@ -1023,6 +1087,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
|
1023
1087
|
QUEUE_LABEL: p.queueLabel,
|
|
1024
1088
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
1025
1089
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
1090
|
+
PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion],
|
|
1026
1091
|
POLICY_SOURCE: policySourceLine(p),
|
|
1027
1092
|
};
|
|
1028
1093
|
}
|
|
@@ -1073,14 +1138,19 @@ export function renderPolicyForProject(p: ProjectConfig): string {
|
|
|
1073
1138
|
}
|
|
1074
1139
|
|
|
1075
1140
|
/**
|
|
1076
|
-
* Composed session brief: rendered floor +
|
|
1141
|
+
* Composed session brief: rendered floor + shared policy (when present) +
|
|
1142
|
+
* POLICY scaffold (or a caller's policy).
|
|
1077
1143
|
*
|
|
1078
1144
|
* Setup writes the policy half to `POLICY.md` and this compose to
|
|
1079
|
-
* `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md
|
|
1080
|
-
* floor updates apply without brief-upgrade.
|
|
1145
|
+
* `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md and the live
|
|
1146
|
+
* host-wide shared policy, so package floor updates apply without brief-upgrade.
|
|
1081
1147
|
*/
|
|
1082
1148
|
export function renderBriefForProject(p: ProjectConfig, policyText?: string): string {
|
|
1083
|
-
return composeOrchestrator(
|
|
1149
|
+
return composeOrchestrator(
|
|
1150
|
+
renderFloorForProject(p),
|
|
1151
|
+
policyText ?? renderPolicyForProject(p),
|
|
1152
|
+
readSharedPolicy(),
|
|
1153
|
+
);
|
|
1084
1154
|
}
|
|
1085
1155
|
|
|
1086
1156
|
/** Wizard-time path, via the project the answers describe. */
|
|
@@ -1094,7 +1164,7 @@ export function renderOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}
|
|
|
1094
1164
|
// would write" and "what setup writes" cannot diverge — the preview an operator
|
|
1095
1165
|
// consents to is the file they get.
|
|
1096
1166
|
const project = buildProject(a);
|
|
1097
|
-
return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose));
|
|
1167
|
+
return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose), readSharedPolicy());
|
|
1098
1168
|
}
|
|
1099
1169
|
|
|
1100
1170
|
/** `POLICY.md` for these answers: the project's own template plus the interview. */
|
|
@@ -1149,7 +1219,7 @@ export function writeOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {})
|
|
|
1149
1219
|
mkdirSync(dirname(orchestratorPath), { recursive: true });
|
|
1150
1220
|
const policy = renderPolicy(a, prose);
|
|
1151
1221
|
writeFileSync(policyPath, policy);
|
|
1152
|
-
writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
|
|
1222
|
+
writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy, readSharedPolicy()));
|
|
1153
1223
|
return orchestratorPath;
|
|
1154
1224
|
}
|
|
1155
1225
|
|
|
@@ -1246,7 +1316,8 @@ function releaseLines(j: OperatorJudgment): string[] {
|
|
|
1246
1316
|
}
|
|
1247
1317
|
|
|
1248
1318
|
/**
|
|
1249
|
-
* Recompose `ORCHESTRATOR.md` from the package floor +
|
|
1319
|
+
* Recompose `ORCHESTRATOR.md` from the package floor + host-wide shared policy
|
|
1320
|
+
* (when present) + live `POLICY.md`.
|
|
1250
1321
|
*
|
|
1251
1322
|
* Returns false when POLICY.md is missing (caller should migrate or set up).
|
|
1252
1323
|
*/
|
|
@@ -1255,6 +1326,7 @@ export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
|
|
|
1255
1326
|
policyPath: policyPathForProject(p),
|
|
1256
1327
|
orchestratorPath: briefPathForProject(p),
|
|
1257
1328
|
floor: renderFloorForProject(p),
|
|
1329
|
+
sharedPolicyPath: sharedPolicyPath(),
|
|
1258
1330
|
});
|
|
1259
1331
|
}
|
|
1260
1332
|
|
|
@@ -1355,6 +1427,15 @@ function describeAvailabilityDays(days: readonly Weekday[]): string {
|
|
|
1355
1427
|
: days.join(",");
|
|
1356
1428
|
}
|
|
1357
1429
|
|
|
1430
|
+
/**
|
|
1431
|
+
* The caps table's key column. Wide enough that the longest cap key,
|
|
1432
|
+
* `maxConcurrentWorkersPerRepo`, cannot fuse with its value — the very defect
|
|
1433
|
+
* that made #448's consent screen read `maxConcurrentWorkersPerRepo1` while
|
|
1434
|
+
* the daemon ran 3. Exported so tests pin the rendered table against the same
|
|
1435
|
+
* column the renderer uses.
|
|
1436
|
+
*/
|
|
1437
|
+
export const CAPS_PLAN_KEY_PAD = 28;
|
|
1438
|
+
|
|
1358
1439
|
/**
|
|
1359
1440
|
* Everything that would change, as plain text, with no side effects at all.
|
|
1360
1441
|
*
|
|
@@ -1368,11 +1449,15 @@ export function summarisePlan(
|
|
|
1368
1449
|
scopes: ScopeCheck,
|
|
1369
1450
|
labels: LabelPlan[],
|
|
1370
1451
|
tg: TelegramPresence,
|
|
1452
|
+
defaults: Caps = DEFAULT_CAPS,
|
|
1371
1453
|
): string {
|
|
1372
1454
|
const project = buildProject(a);
|
|
1373
|
-
// Caps are
|
|
1374
|
-
//
|
|
1375
|
-
|
|
1455
|
+
// Caps are resolved against the caller's defaults — the loaded config's
|
|
1456
|
+
// global block on a re-run, the shipped baseline on a first run, where the
|
|
1457
|
+
// two are the same object. That is the exact baseline the daemon resolves
|
|
1458
|
+
// against, so the consent screen and runtime cannot disagree (#372/#448).
|
|
1459
|
+
// The shipped baseline itself is never labelled "effective".
|
|
1460
|
+
const effective = resolveCaps(project, defaults);
|
|
1376
1461
|
const states = Object.values(a.stateLabels).join(", ");
|
|
1377
1462
|
|
|
1378
1463
|
const lines: string[] = [];
|
|
@@ -1441,10 +1526,10 @@ export function summarisePlan(
|
|
|
1441
1526
|
lines.push("", "caps (effective)");
|
|
1442
1527
|
for (const [key, value] of Object.entries(effective)) {
|
|
1443
1528
|
const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
|
|
1444
|
-
lines.push(` ${key.padEnd(
|
|
1529
|
+
lines.push(` ${key.padEnd(CAPS_PLAN_KEY_PAD)}${String(value)}${answered}`);
|
|
1445
1530
|
}
|
|
1446
1531
|
if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
|
|
1447
|
-
lines.push(` ${"worker model".padEnd(
|
|
1532
|
+
lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`);
|
|
1448
1533
|
}
|
|
1449
1534
|
|
|
1450
1535
|
lines.push("", "escalation");
|
|
@@ -1482,7 +1567,7 @@ export function summarisePlan(
|
|
|
1482
1567
|
const granted = RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] === "orchestrator");
|
|
1483
1568
|
lines.push(
|
|
1484
1569
|
"",
|
|
1485
|
-
`authority merge=${a.authority.merge} release=${a.authority.release}`,
|
|
1570
|
+
`authority merge=${a.authority.merge} release=${a.authority.release} promotion=${a.authority.promotion ?? DEFAULT_AUTHORITY.promotion}`,
|
|
1486
1571
|
`tool gate ${
|
|
1487
1572
|
granted.length === 0
|
|
1488
1573
|
? "every release/deploy shape blocked for workers and the orchestrator"
|
|
@@ -1602,13 +1687,16 @@ export type AmendAreaId = (typeof AMEND_AREA_IDS)[number];
|
|
|
1602
1687
|
* `describe` is the reason the pick-list is worth anything — an operator picking
|
|
1603
1688
|
* blind from eight nouns cannot tell which one holds the setting they came to
|
|
1604
1689
|
* change, so every row carries its own current value. It reads only the config,
|
|
1605
|
-
* so the whole menu can be rendered and reviewed without a terminal.
|
|
1690
|
+
* so the whole menu can be rendered and reviewed without a terminal. The caps
|
|
1691
|
+
* row resolves inherited values against the defaults the caller is amending —
|
|
1692
|
+
* the loaded config's global block on a re-run — so the menu and the daemon
|
|
1693
|
+
* agree on what a project actually runs (#448).
|
|
1606
1694
|
*/
|
|
1607
1695
|
export const AMEND_AREAS: {
|
|
1608
1696
|
readonly [K in AmendAreaId]: {
|
|
1609
1697
|
readonly name: string;
|
|
1610
1698
|
readonly asks: string;
|
|
1611
|
-
readonly describe: (p: ProjectConfig) => string;
|
|
1699
|
+
readonly describe: (p: ProjectConfig, defaults?: Caps) => string;
|
|
1612
1700
|
};
|
|
1613
1701
|
} = {
|
|
1614
1702
|
tracker: {
|
|
@@ -1636,8 +1724,8 @@ export const AMEND_AREAS: {
|
|
|
1636
1724
|
// an area no menu offers is a setting only a full re-interview can reach.
|
|
1637
1725
|
name: "caps & worker model",
|
|
1638
1726
|
asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
|
|
1639
|
-
describe: (p) => {
|
|
1640
|
-
const c = resolveCaps(p,
|
|
1727
|
+
describe: (p, defaults = DEFAULT_CAPS) => {
|
|
1728
|
+
const c = resolveCaps(p, defaults);
|
|
1641
1729
|
const answered = Object.keys(p.caps).length > 0;
|
|
1642
1730
|
const spend =
|
|
1643
1731
|
c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
|
|
@@ -1664,7 +1752,7 @@ export const AMEND_AREAS: {
|
|
|
1664
1752
|
},
|
|
1665
1753
|
authority: {
|
|
1666
1754
|
name: "authority",
|
|
1667
|
-
asks: "who lands green PRs, who cuts releases, then one question per release/deploy shape",
|
|
1755
|
+
asks: "who lands green PRs, who cuts releases, who promotes, then one question per release/deploy shape",
|
|
1668
1756
|
describe: (p) => {
|
|
1669
1757
|
const grants = resolveReleaseGrants(p);
|
|
1670
1758
|
const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
|
|
@@ -1673,7 +1761,8 @@ export const AMEND_AREAS: {
|
|
|
1673
1761
|
// set that mutates a running environment and the one #122 cost us. The full
|
|
1674
1762
|
// table is in `status` and in the amend summary.
|
|
1675
1763
|
return (
|
|
1676
|
-
`merge=${p.authority.merge}, release=${p.authority.release},
|
|
1764
|
+
`merge=${p.authority.merge}, release=${p.authority.release}, ` +
|
|
1765
|
+
`promote=${p.authority.promotion ?? DEFAULT_AUTHORITY.promotion}, tools: ` +
|
|
1677
1766
|
`${granted.length === 0 ? "all blocked" : `${granted.length}/${RELEASE_SHAPES.length} granted`}` +
|
|
1678
1767
|
`, deploy=${grants.deploy}`
|
|
1679
1768
|
);
|
|
@@ -1742,10 +1831,10 @@ const AMEND_LABEL_MAX = 96;
|
|
|
1742
1831
|
* its own text alone — and it is the value, not the noun, that tells them
|
|
1743
1832
|
* whether this is the row they came for.
|
|
1744
1833
|
*/
|
|
1745
|
-
export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string; description: string }[] {
|
|
1834
|
+
export function amendChoices(p: ProjectConfig, defaults: Caps = DEFAULT_CAPS): { id: AmendAreaId; label: string; description: string }[] {
|
|
1746
1835
|
return AMEND_AREA_IDS.map((id) => {
|
|
1747
1836
|
const area = AMEND_AREAS[id];
|
|
1748
|
-
const current = area.describe(p);
|
|
1837
|
+
const current = area.describe(p, defaults);
|
|
1749
1838
|
return {
|
|
1750
1839
|
id,
|
|
1751
1840
|
label: `${area.name} — ${current.length > AMEND_LABEL_MAX ? `${current.slice(0, AMEND_LABEL_MAX - 1).trimEnd()}…` : current}`,
|
|
@@ -1762,11 +1851,18 @@ export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string
|
|
|
1762
1851
|
* mutation it authorises — creating labels, writing the config, replacing a
|
|
1763
1852
|
* brief — and a delta alone names none of them. What this adds is the sentence
|
|
1764
1853
|
* the operator is actually looking for: one area changed, everything else came
|
|
1765
|
-
* back off disk.
|
|
1854
|
+
* back off disk. Inherited caps on both sides of the "was"/"now" compare
|
|
1855
|
+
* against the same `defaults`, so a changed per-repo limit reads as a change
|
|
1856
|
+
* and a carried value reads as no change (#448).
|
|
1766
1857
|
*/
|
|
1767
|
-
export function summariseAmend(
|
|
1858
|
+
export function summariseAmend(
|
|
1859
|
+
area: AmendAreaId,
|
|
1860
|
+
before: ProjectConfig,
|
|
1861
|
+
a: SetupAnswers,
|
|
1862
|
+
defaults: Caps = DEFAULT_CAPS,
|
|
1863
|
+
): string {
|
|
1768
1864
|
const it = AMEND_AREAS[area];
|
|
1769
|
-
const was = it.describe(before);
|
|
1865
|
+
const was = it.describe(before, defaults);
|
|
1770
1866
|
// The brief is a decision, not a config key, so its "after" is what the wizard
|
|
1771
1867
|
// is about to do rather than what a rebuilt project would say.
|
|
1772
1868
|
const now =
|
|
@@ -1774,7 +1870,7 @@ export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: Setu
|
|
|
1774
1870
|
? a.writeOrchestratorBrief
|
|
1775
1871
|
? `would ${existsSync(orchestratorBriefPath(a)) ? "OVERWRITE" : "write"} ${orchestratorBriefPath(a)}`
|
|
1776
1872
|
: "not written — left exactly as it is"
|
|
1777
|
-
: it.describe(buildProject(a));
|
|
1873
|
+
: it.describe(buildProject(a), defaults);
|
|
1778
1874
|
|
|
1779
1875
|
const others = AMEND_AREA_IDS.filter((o) => o !== area).map((o) => AMEND_AREAS[o].name);
|
|
1780
1876
|
const lines = [`amending ${it.name} — project ${before.name}`];
|
package/src/stats.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `stats` — what the fleet accomplished and at what cost, over a window
|
|
3
|
+
* (#282, legibility 5/5).
|
|
4
|
+
*
|
|
5
|
+
* Pure by construction: every fact arrives as injected run rows, a window and
|
|
6
|
+
* a gh-call count, so the aggregation is testable without a daemon or a store
|
|
7
|
+
* (the same seam split `doctor` uses). The CLI command in ./commands/stats.ts
|
|
8
|
+
* supplies the rows through read-only store queries, then renders the same
|
|
9
|
+
* numbers the --json shape carries.
|
|
10
|
+
*
|
|
11
|
+
* The unit of outcome is the **issue journey**, never the run row: an issue
|
|
12
|
+
* that took six attempts is one merged outcome with six runs and one lead
|
|
13
|
+
* time. Counting rows would make a stranded issue look like productive
|
|
14
|
+
* throughput, which is the exact misreading this surface exists to prevent.
|
|
15
|
+
*
|
|
16
|
+
* Spend semantics follow the store's own convention (README Limitations, and
|
|
17
|
+
* the doctor `spend-telemetry` finding): a run whose `spendUsd` reads 0.00
|
|
18
|
+
* means harness telemetry was absent, not that the run was free. Such runs are
|
|
19
|
+
* counted separately as **unmetered** and never averaged into a per-issue cost.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { RunRecord } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
/** The bounded window a report answers over. Day keys are UTC (`YYYY-MM-DD`). */
|
|
25
|
+
export interface StatsWindow {
|
|
26
|
+
sinceDay: string;
|
|
27
|
+
untilDay: string;
|
|
28
|
+
sinceEpochMs: number;
|
|
29
|
+
untilEpochMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One aggregate, per repo and for the whole selection. */
|
|
33
|
+
export interface RepoStats {
|
|
34
|
+
/** The repo name; `"(all)"` on the totals row. */
|
|
35
|
+
repo: string;
|
|
36
|
+
/** Distinct issues whose PR merged and settled inside the window. */
|
|
37
|
+
merged: number;
|
|
38
|
+
/** `merged` plus issues whose terminal state inside the window did not merge. */
|
|
39
|
+
settled: number;
|
|
40
|
+
/** `merged / settled`; `null` when nothing settled (never a fake 0). */
|
|
41
|
+
mergeRate: number | null;
|
|
42
|
+
/** Mean attempts per merged issue, over the whole continuation chain. */
|
|
43
|
+
runsPerMerged: number | null;
|
|
44
|
+
/** First queue-label claim → merge settlement, over merged issues, in ms. */
|
|
45
|
+
leadTimeMedianMs: number | null;
|
|
46
|
+
leadTimeP90Ms: number | null;
|
|
47
|
+
/** Metered (spendUsd > 0) spend across merged chains, rounded to cents. */
|
|
48
|
+
spendUsd: number;
|
|
49
|
+
/** `spendUsd / merged issues with ≥1 metered run`; null when none. */
|
|
50
|
+
spendPerMerged: number | null;
|
|
51
|
+
/** Runs examined with `spendUsd === 0` — telemetry absent, not free. */
|
|
52
|
+
unmeteredRuns: number;
|
|
53
|
+
/** Merged issues whose whole chain metered nothing — cost unknown, not $0. */
|
|
54
|
+
unmeteredMerged: number;
|
|
55
|
+
/** Terminal non-merged runs in the window, bucketed by failure class. */
|
|
56
|
+
failureClasses: Record<string, number>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The stable report shape `--json` prints; the human renderer reads it too. */
|
|
60
|
+
export interface StatsReport {
|
|
61
|
+
project: string;
|
|
62
|
+
window: StatsWindow;
|
|
63
|
+
/** Tracked GitHub API calls over the window's UTC days (#198). */
|
|
64
|
+
ghCalls: number;
|
|
65
|
+
/**
|
|
66
|
+
* True when nothing settled in the window: a fresh store or an idle fleet.
|
|
67
|
+
* A consumer must render this as "no measurements", never as zero outcomes.
|
|
68
|
+
*/
|
|
69
|
+
empty: boolean;
|
|
70
|
+
/** The whole selection (`--project NAME`, or the sole configured project). */
|
|
71
|
+
total: RepoStats;
|
|
72
|
+
/** Per-repo aggregates of the same shape, busiest (by merges) first. */
|
|
73
|
+
repos: RepoStats[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Terminal non-merged run states — everything the failure histogram counts. */
|
|
77
|
+
const FAILED_STATES: ReadonlySet<RunRecord["state"]> = new Set([
|
|
78
|
+
"failed",
|
|
79
|
+
"blocked",
|
|
80
|
+
"killed",
|
|
81
|
+
"orphaned",
|
|
82
|
+
"stopped",
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/** Runs with no classified failure fall into this explicit bucket. */
|
|
86
|
+
export const UNCLASSIFIED = "unclassified";
|
|
87
|
+
|
|
88
|
+
/** The timestamp a run is placed on the window timeline. Mirrors the store's
|
|
89
|
+
* `COALESCE(endedAt, startedAt)` convention everywhere else in this package. */
|
|
90
|
+
const WINDOW_KEY = (run: RunRecord): number => run.endedAt ?? run.startedAt;
|
|
91
|
+
|
|
92
|
+
/** One issue's settled outcome, reduced from its full attempt chain. */
|
|
93
|
+
interface IssueOutcome {
|
|
94
|
+
repo: string;
|
|
95
|
+
merged: boolean;
|
|
96
|
+
/** Attempts in the whole chain (continuations collapse into one journey). */
|
|
97
|
+
attempts: number;
|
|
98
|
+
/** First claim → merge settlement, in ms. Present iff `merged`. */
|
|
99
|
+
leadTimeMs?: number;
|
|
100
|
+
/** Sum of metered (spendUsd > 0) spend across the chain, in USD. */
|
|
101
|
+
meteredSpend: number;
|
|
102
|
+
/** Runs in the chain whose spend actually metered. */
|
|
103
|
+
meteredRuns: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function median(sorted: readonly number[]): number {
|
|
107
|
+
if (sorted.length === 0) return Number.NaN;
|
|
108
|
+
const lo = sorted[Math.floor((sorted.length - 1) / 2)]!;
|
|
109
|
+
const hi = sorted[Math.ceil((sorted.length - 1) / 2)]!;
|
|
110
|
+
return (lo + hi) / 2;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Nearest-rank p90: at least 90% of the samples are at or below the result. */
|
|
114
|
+
function p90(sorted: readonly number[]): number {
|
|
115
|
+
if (sorted.length === 0) return Number.NaN;
|
|
116
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(0.9 * sorted.length) - 1)]!;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function cents(usd: number): number {
|
|
120
|
+
return Math.round(usd * 100) / 100;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function emptyRepo(repo: string): RepoStats {
|
|
124
|
+
return {
|
|
125
|
+
repo,
|
|
126
|
+
merged: 0,
|
|
127
|
+
settled: 0,
|
|
128
|
+
mergeRate: null,
|
|
129
|
+
runsPerMerged: null,
|
|
130
|
+
leadTimeMedianMs: null,
|
|
131
|
+
leadTimeP90Ms: null,
|
|
132
|
+
spendUsd: 0,
|
|
133
|
+
spendPerMerged: null,
|
|
134
|
+
unmeteredRuns: 0,
|
|
135
|
+
unmeteredMerged: 0,
|
|
136
|
+
failureClasses: {},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Aggregate one repo's settled issues and failure runs into {@link RepoStats}.
|
|
142
|
+
* Lead times and per-issue cost are reported only where a merged issue exists;
|
|
143
|
+
* `null` is "not measured", never a zero that could read as a measurement.
|
|
144
|
+
*/
|
|
145
|
+
function summarize(args: {
|
|
146
|
+
repo: string;
|
|
147
|
+
outcomes: readonly IssueOutcome[];
|
|
148
|
+
failureRuns: readonly { repo: string; cls: string }[];
|
|
149
|
+
unmeteredRuns: number;
|
|
150
|
+
}): RepoStats {
|
|
151
|
+
const out = emptyRepo(args.repo);
|
|
152
|
+
const merged = args.outcomes.filter((o) => o.merged);
|
|
153
|
+
const leadTimes = merged
|
|
154
|
+
.map((o) => o.leadTimeMs!)
|
|
155
|
+
.sort((a, b) => a - b);
|
|
156
|
+
const meteredMerged = merged.filter((o) => o.meteredRuns > 0);
|
|
157
|
+
const meteredSpend = merged.reduce((sum, o) => sum + o.meteredSpend, 0);
|
|
158
|
+
|
|
159
|
+
out.merged = merged.length;
|
|
160
|
+
out.settled = args.outcomes.length;
|
|
161
|
+
out.mergeRate = args.outcomes.length === 0 ? null : merged.length / args.outcomes.length;
|
|
162
|
+
if (merged.length > 0) {
|
|
163
|
+
out.runsPerMerged = merged.reduce((sum, o) => sum + o.attempts, 0) / merged.length;
|
|
164
|
+
out.leadTimeMedianMs = Math.round(median(leadTimes));
|
|
165
|
+
out.leadTimeP90Ms = Math.round(p90(leadTimes));
|
|
166
|
+
}
|
|
167
|
+
out.spendUsd = cents(meteredSpend);
|
|
168
|
+
out.spendPerMerged = meteredMerged.length === 0 ? null : cents(meteredSpend / meteredMerged.length);
|
|
169
|
+
out.unmeteredMerged = merged.filter((o) => o.meteredRuns === 0).length;
|
|
170
|
+
out.unmeteredRuns = args.unmeteredRuns;
|
|
171
|
+
for (const run of args.failureRuns) {
|
|
172
|
+
out.failureClasses[run.cls] = (out.failureClasses[run.cls] ?? 0) + 1;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compute the report from store rows. `runs` is whatever the store's
|
|
179
|
+
* stats-window query returned for the project: rows settling at or after
|
|
180
|
+
* `window.sinceEpochMs`, plus the full chain of every issue whose merge
|
|
181
|
+
* settled there. Rows within an issue must be ordered by `startedAt`, so the
|
|
182
|
+
* first row of a chain is its first queue-label claim.
|
|
183
|
+
*/
|
|
184
|
+
export function computeStats(args: {
|
|
185
|
+
project: string;
|
|
186
|
+
window: StatsWindow;
|
|
187
|
+
ghCalls: number;
|
|
188
|
+
runs: readonly RunRecord[];
|
|
189
|
+
}): StatsReport {
|
|
190
|
+
const chains = new Map<number, RunRecord[]>();
|
|
191
|
+
for (const run of args.runs) {
|
|
192
|
+
const chain = chains.get(run.issue);
|
|
193
|
+
if (chain === undefined) chains.set(run.issue, [run]);
|
|
194
|
+
else chain.push(run);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const outcomes: IssueOutcome[] = [];
|
|
198
|
+
const byRepo = new Map<string, IssueOutcome[]>();
|
|
199
|
+
const failureRuns: { repo: string; cls: string }[] = [];
|
|
200
|
+
let unmeteredRuns = 0;
|
|
201
|
+
const unmeteredByRepo = new Map<string, number>();
|
|
202
|
+
|
|
203
|
+
for (const chain of chains.values()) {
|
|
204
|
+
// One issue merges once; if a weird history ever carried two merged rows,
|
|
205
|
+
// the newest settlement wins.
|
|
206
|
+
let merged: RunRecord | undefined;
|
|
207
|
+
for (const run of chain) {
|
|
208
|
+
if (
|
|
209
|
+
run.state === "merged" &&
|
|
210
|
+
(merged === undefined || WINDOW_KEY(run) > WINDOW_KEY(merged))
|
|
211
|
+
) {
|
|
212
|
+
merged = run;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const terminatedInWindow = chain.some(
|
|
217
|
+
(run) => FAILED_STATES.has(run.state) && WINDOW_KEY(run) >= args.window.sinceEpochMs,
|
|
218
|
+
);
|
|
219
|
+
// A chain appears in the result for two reasons: its merge settled in the
|
|
220
|
+
// window, or an individual run ended there. Without a merged row and
|
|
221
|
+
// without a terminal row in the window it is still in flight — news, not
|
|
222
|
+
// an outcome.
|
|
223
|
+
if (merged === undefined && !terminatedInWindow) continue;
|
|
224
|
+
|
|
225
|
+
for (const run of chain) {
|
|
226
|
+
if (run.spendUsd === 0) {
|
|
227
|
+
unmeteredRuns += 1;
|
|
228
|
+
unmeteredByRepo.set(run.repo, (unmeteredByRepo.get(run.repo) ?? 0) + 1);
|
|
229
|
+
}
|
|
230
|
+
if (merged === undefined && FAILED_STATES.has(run.state) && WINDOW_KEY(run) >= args.window.sinceEpochMs) {
|
|
231
|
+
failureRuns.push({ repo: run.repo, cls: run.failureClass ?? UNCLASSIFIED });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const claimAt = chain[0]!.startedAt;
|
|
236
|
+
const mergedAt = merged === undefined ? undefined : WINDOW_KEY(merged);
|
|
237
|
+
const repo =
|
|
238
|
+
merged?.repo ??
|
|
239
|
+
chain.find((r) => FAILED_STATES.has(r.state) && WINDOW_KEY(r) >= args.window.sinceEpochMs)!.repo;
|
|
240
|
+
const outcome: IssueOutcome = {
|
|
241
|
+
repo,
|
|
242
|
+
merged: merged !== undefined,
|
|
243
|
+
attempts: chain.length,
|
|
244
|
+
leadTimeMs: mergedAt === undefined ? undefined : Math.max(0, mergedAt - claimAt),
|
|
245
|
+
meteredSpend: chain.reduce((sum, run) => sum + (run.spendUsd > 0 ? run.spendUsd : 0), 0),
|
|
246
|
+
meteredRuns: chain.filter((run) => run.spendUsd > 0).length,
|
|
247
|
+
};
|
|
248
|
+
outcomes.push(outcome);
|
|
249
|
+
const bucket = byRepo.get(repo);
|
|
250
|
+
if (bucket === undefined) byRepo.set(repo, [outcome]);
|
|
251
|
+
else bucket.push(outcome);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const repos = [...byRepo.entries()]
|
|
255
|
+
.map(([repo, list]) =>
|
|
256
|
+
summarize({
|
|
257
|
+
repo,
|
|
258
|
+
outcomes: list,
|
|
259
|
+
failureRuns: failureRuns.filter((r) => r.repo === repo),
|
|
260
|
+
unmeteredRuns: unmeteredByRepo.get(repo) ?? 0,
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
.sort((a, b) => b.merged - a.merged || b.settled - a.settled || a.repo.localeCompare(b.repo));
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
project: args.project,
|
|
267
|
+
window: args.window,
|
|
268
|
+
ghCalls: args.ghCalls,
|
|
269
|
+
empty: outcomes.length === 0,
|
|
270
|
+
total: summarize({ repo: "(all)", outcomes, failureRuns, unmeteredRuns }),
|
|
271
|
+
repos,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `N` and its unit, for a count line that must not print "0" as a measure. */
|
|
276
|
+
function countLine(n: number, noun: string, plural: string): string {
|
|
277
|
+
return `${n} ${n === 1 ? noun : plural}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function humanDuration(ms: number): string {
|
|
281
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
282
|
+
if (s < 60) return `${s}s`;
|
|
283
|
+
const m = Math.floor(s / 60);
|
|
284
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
285
|
+
const h = Math.floor(m / 60);
|
|
286
|
+
if (h < 24) return `${h}h ${String(m % 60).padStart(2, "0")}m`;
|
|
287
|
+
return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function renderRepo(repo: RepoStats, indent: string): string {
|
|
291
|
+
const lines: string[] = [];
|
|
292
|
+
const leadTimeMedian = repo.leadTimeMedianMs;
|
|
293
|
+
const leadTimeP90 = repo.leadTimeP90Ms;
|
|
294
|
+
const lead =
|
|
295
|
+
leadTimeMedian === null || leadTimeP90 === null
|
|
296
|
+
? "—"
|
|
297
|
+
: `median ${humanDuration(leadTimeMedian)} · p90 ${humanDuration(leadTimeP90)}`;
|
|
298
|
+
const cost =
|
|
299
|
+
repo.spendPerMerged === null
|
|
300
|
+
? `$${repo.spendUsd.toFixed(2)} metered on merged work (no metered merge to divide by)`
|
|
301
|
+
: `$${repo.spendUsd.toFixed(2)} metered ($${repo.spendPerMerged.toFixed(2)}/merged)`;
|
|
302
|
+
const unmetered: string[] = [];
|
|
303
|
+
if (repo.unmeteredRuns > 0) unmetered.push(`${countLine(repo.unmeteredRuns, "run", "runs")} unmetered`);
|
|
304
|
+
if (repo.unmeteredMerged > 0) unmetered.push(`${countLine(repo.unmeteredMerged, "merged issue", "merged issues")} never metered`);
|
|
305
|
+
const rates = repo.mergeRate === null ? "nothing settled" : `${(repo.mergeRate * 100).toFixed(1)}% of ${repo.settled} settled`;
|
|
306
|
+
const attempts = repo.runsPerMerged === null ? "—" : `${repo.runsPerMerged.toFixed(2)} per merged issue`;
|
|
307
|
+
lines.push(`${indent}${repo.repo} merged ${repo.merged} (${rates})`);
|
|
308
|
+
lines.push(`${indent} lead ${lead} (first claim → merge)`);
|
|
309
|
+
lines.push(`${indent} attempts ${attempts}`);
|
|
310
|
+
lines.push(`${indent} cost ${cost}${unmetered.length === 0 ? "" : ` · ${unmetered.join(" · ")}`}`);
|
|
311
|
+
const classes = Object.entries(repo.failureClasses).sort((a, b) => b[1] - a[1]);
|
|
312
|
+
if (classes.length > 0) {
|
|
313
|
+
lines.push(`${indent} failures ${classes.map(([cls, n]) => `${cls}: ${n}`).join(" · ")}`);
|
|
314
|
+
}
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Render the human form of the same {@link StatsReport} `--json` prints. */
|
|
319
|
+
export function renderStatsHuman(report: StatsReport): string {
|
|
320
|
+
const w = report.window;
|
|
321
|
+
const header = `${report.project} — outcomes ${w.sinceDay} → ${w.untilDay}`;
|
|
322
|
+
if (report.empty) {
|
|
323
|
+
return `${header}\n nothing settled in the window — no runs recorded, so there are no measurements to report yet (a fresh store or an idle fleet).\n`;
|
|
324
|
+
}
|
|
325
|
+
const lines = [header, renderRepo(report.total, " "), ` gh api ${report.ghCalls} tracked ${report.ghCalls === 1 ? "call" : "calls"} in the window`];
|
|
326
|
+
if (report.repos.length > 0) {
|
|
327
|
+
lines.push(" per repo:");
|
|
328
|
+
for (const repo of report.repos) lines.push(renderRepo(repo, " "));
|
|
329
|
+
}
|
|
330
|
+
return `${lines.join("\n")}\n`;
|
|
331
|
+
}
|