omp-conductor 0.15.10 → 0.15.12

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.
Files changed (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
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
+ }