@bamr87/fleet-engines 0.1.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.
@@ -0,0 +1,74 @@
1
+ // Observe-mode I/O: fetch a repo's .github/workflows/*.yml and reverse-import them into a
2
+ // read-only Fleet. Pairs with the pure parsers in parse.ts/facts.ts. Never writes to the repo.
3
+ // Since specs/014 the same pass reads the repo's committed `fleet.manifest.yml` — the hub's
4
+ // `fleet/v1` interchange contract — and pins each recorded lane to its workflow.
5
+ import { parseFleetManifest } from '../harness/lanes.js';
6
+ import { extractFacts } from './facts.js';
7
+ import { buildFleet, parseWorkflow } from './parse.js';
8
+ const WORKFLOW_DIR = '.github/workflows';
9
+ export const MANIFEST_PATH = 'fleet.manifest.yml';
10
+ /** Fetch each workflow file once; returns `{path, text}` pairs (missing files skipped). */
11
+ async function fetchWorkflowTexts(client, repo) {
12
+ const entries = await client.listDir(repo, WORKFLOW_DIR);
13
+ const files = entries.filter((e) => e.type === 'file' && /\.ya?ml$/.test(e.name));
14
+ const fetched = await Promise.all(files.map(async (e) => {
15
+ const file = await client.getFile(repo, e.path);
16
+ return file ? { path: e.path, text: file.content } : null;
17
+ }));
18
+ return fetched.filter((f) => f !== null);
19
+ }
20
+ /** The repo's committed manifest, or null — a missing or unreadable file never fails an import. */
21
+ export async function fetchFleetManifest(client, repo) {
22
+ try {
23
+ const file = await client.getFile(repo, MANIFEST_PATH);
24
+ if (!file)
25
+ return null;
26
+ const manifest = parseFleetManifest(file.content);
27
+ return manifest.lanes.length > 0 || manifest.spec_version ? manifest : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** Pin each recorded lane to its workflow (by implementation path, else by id = basename). */
34
+ export function attachLanes(fleet, manifest) {
35
+ fleet.manifest = manifest;
36
+ if (!manifest)
37
+ return fleet;
38
+ for (const w of fleet.workflows) {
39
+ const lane = manifest.lanes.find((l) => l.implementation === w.path) ??
40
+ manifest.lanes.find((l) => l.id === w.slug) ??
41
+ null;
42
+ if (lane)
43
+ w.lane = lane;
44
+ }
45
+ return fleet;
46
+ }
47
+ export async function importFleet(client, repo) {
48
+ const [texts, manifest] = await Promise.all([
49
+ fetchWorkflowTexts(client, repo),
50
+ fetchFleetManifest(client, repo),
51
+ ]);
52
+ const parsed = texts.map((f) => parseWorkflow(f.path, f.text));
53
+ const fleet = attachLanes(buildFleet(repo, parsed), manifest);
54
+ if (texts.length === 0) {
55
+ fleet.warnings.push('No workflows found under .github/workflows/ — nothing to observe.');
56
+ }
57
+ return fleet;
58
+ }
59
+ /**
60
+ * Fleet Ops import: one fetch pass, both parsers — the observe graph (Fleet) plus the
61
+ * deep per-workflow facts the audit/metrics engines consume. Each file is read once.
62
+ */
63
+ export async function importFleetDeep(client, repo) {
64
+ const [texts, manifest] = await Promise.all([
65
+ fetchWorkflowTexts(client, repo),
66
+ fetchFleetManifest(client, repo),
67
+ ]);
68
+ const parsed = texts.map((f) => parseWorkflow(f.path, f.text));
69
+ const fleet = attachLanes(buildFleet(repo, parsed), manifest);
70
+ if (texts.length === 0) {
71
+ fleet.warnings.push('No workflows found under .github/workflows/ — nothing to observe.');
72
+ }
73
+ return { fleet, facts: texts.map((f) => extractFacts(f.path, f.text)) };
74
+ }
@@ -0,0 +1,23 @@
1
+ import type { RosterEntry } from './types.js';
2
+ /**
3
+ * Parse .gitmodules INI text into a fleet roster. Reads `url` (→ slug) and `branch`
4
+ * from each `[submodule "…"]` section; other keys (`path`, `update`, …) and sections
5
+ * are ignored. Entries whose url is not a github.com clone URL are skipped, order is
6
+ * preserved, and duplicate slugs (case-insensitive) keep the first occurrence. PURE
7
+ * and total: malformed lines are skipped silently, never throws.
8
+ */
9
+ export declare function parseGitmodules(text: string): RosterEntry[];
10
+ /**
11
+ * Parse a hand-typed repo list — newline/comma/space separated `owner/name` tokens,
12
+ * tolerating full GitHub URLs (validated via {@link parseRepo}). Invalid tokens are
13
+ * dropped, duplicates (case-insensitive) keep the first occurrence. PURE and total.
14
+ */
15
+ export declare function parseRosterText(text: string): RosterEntry[];
16
+ /**
17
+ * Union two rosters, deduped by lowercase slug: `existing` entries first (order kept),
18
+ * then unseen `incoming` entries. On a collision the existing entry wins, except that a
19
+ * `branch: null` is upgraded from the incoming entry and a `source: 'gitmodules'`
20
+ * incoming record upgrades a manual one (gitmodules metadata is richer). PURE — the
21
+ * inputs are never mutated.
22
+ */
23
+ export declare function mergeRoster(existing: RosterEntry[], incoming: RosterEntry[]): RosterEntry[];
@@ -0,0 +1,118 @@
1
+ // Fleet-roster manifest parsing — how the cockpit learns WHICH repos make up the
2
+ // fleet. `parseGitmodules` mines a monorepo's .gitmodules (the bamr87 hub pattern:
3
+ // ~40 submodules, each a fleet member) into roster entries; `parseRosterText` accepts
4
+ // hand-typed owner/name lists; `mergeRoster` unions the two, preferring the richer
5
+ // gitmodules metadata. Slugs only — never tokens (golden rule #7). No I/O, never
6
+ // throws on malformed input: junk lines and non-GitHub urls are skipped silently.
7
+ import { parseRepo, repoSlug } from '../github/types.js';
8
+ // ── url → slug ────────────────────────────────────────────────────────────────
9
+ /** `owner/name` from a github.com clone URL (https or ssh form), or null for any other host. */
10
+ function slugFromGithubUrl(url) {
11
+ const m = /^https?:\/\/github\.com\/(.+)$/i.exec(url.trim()) ??
12
+ /^git@github\.com:(.+)$/i.exec(url.trim());
13
+ if (!m)
14
+ return null;
15
+ const repo = parseRepo(m[1]); // strips a trailing `.git`/`/` and validates owner/name
16
+ return repo ? repoSlug(repo) : null;
17
+ }
18
+ const SUBMODULE_HEADER = /^\s*\[submodule\s+"[^"]*"\]\s*$/;
19
+ const ANY_HEADER = /^\s*\[[^\]]*\]\s*$/;
20
+ const KEY_VALUE = /^\s*([A-Za-z][A-Za-z0-9-]*)\s*=\s*(.*?)\s*$/;
21
+ /**
22
+ * Parse .gitmodules INI text into a fleet roster. Reads `url` (→ slug) and `branch`
23
+ * from each `[submodule "…"]` section; other keys (`path`, `update`, …) and sections
24
+ * are ignored. Entries whose url is not a github.com clone URL are skipped, order is
25
+ * preserved, and duplicate slugs (case-insensitive) keep the first occurrence. PURE
26
+ * and total: malformed lines are skipped silently, never throws.
27
+ */
28
+ export function parseGitmodules(text) {
29
+ const sections = [];
30
+ let current = null;
31
+ for (const line of text.split(/\r?\n/)) {
32
+ if (SUBMODULE_HEADER.test(line)) {
33
+ current = { url: null, branch: null };
34
+ sections.push(current);
35
+ continue;
36
+ }
37
+ if (ANY_HEADER.test(line)) {
38
+ current = null; // some other section ([core] etc.) — its keys are not ours
39
+ continue;
40
+ }
41
+ if (!current)
42
+ continue;
43
+ const kv = KEY_VALUE.exec(line);
44
+ if (!kv || kv[2] === '')
45
+ continue;
46
+ if (kv[1] === 'url')
47
+ current.url = kv[2];
48
+ else if (kv[1] === 'branch')
49
+ current.branch = kv[2];
50
+ }
51
+ const out = [];
52
+ const seen = new Set();
53
+ for (const s of sections) {
54
+ const slug = s.url === null ? null : slugFromGithubUrl(s.url);
55
+ if (!slug || seen.has(slug.toLowerCase()))
56
+ continue;
57
+ seen.add(slug.toLowerCase());
58
+ out.push({ slug, source: 'gitmodules', branch: s.branch });
59
+ }
60
+ return out;
61
+ }
62
+ // ── hand-typed rosters ────────────────────────────────────────────────────────
63
+ /**
64
+ * Parse a hand-typed repo list — newline/comma/space separated `owner/name` tokens,
65
+ * tolerating full GitHub URLs (validated via {@link parseRepo}). Invalid tokens are
66
+ * dropped, duplicates (case-insensitive) keep the first occurrence. PURE and total.
67
+ */
68
+ export function parseRosterText(text) {
69
+ const out = [];
70
+ const seen = new Set();
71
+ for (const token of text.split(/[\s,]+/)) {
72
+ if (token === '')
73
+ continue;
74
+ const repo = parseRepo(token);
75
+ if (!repo)
76
+ continue;
77
+ const slug = repoSlug(repo);
78
+ if (seen.has(slug.toLowerCase()))
79
+ continue;
80
+ seen.add(slug.toLowerCase());
81
+ out.push({ slug, source: 'manual', branch: null });
82
+ }
83
+ return out;
84
+ }
85
+ // ── merging ───────────────────────────────────────────────────────────────────
86
+ /**
87
+ * Union two rosters, deduped by lowercase slug: `existing` entries first (order kept),
88
+ * then unseen `incoming` entries. On a collision the existing entry wins, except that a
89
+ * `branch: null` is upgraded from the incoming entry and a `source: 'gitmodules'`
90
+ * incoming record upgrades a manual one (gitmodules metadata is richer). PURE — the
91
+ * inputs are never mutated.
92
+ */
93
+ export function mergeRoster(existing, incoming) {
94
+ const out = [];
95
+ const indexByKey = new Map();
96
+ for (const entry of existing) {
97
+ const key = entry.slug.toLowerCase();
98
+ if (indexByKey.has(key))
99
+ continue;
100
+ indexByKey.set(key, out.length);
101
+ out.push({ ...entry });
102
+ }
103
+ for (const entry of incoming) {
104
+ const key = entry.slug.toLowerCase();
105
+ const at = indexByKey.get(key);
106
+ if (at === undefined) {
107
+ indexByKey.set(key, out.length);
108
+ out.push({ ...entry });
109
+ continue;
110
+ }
111
+ const kept = out[at];
112
+ if (kept.branch === null && entry.branch !== null)
113
+ kept.branch = entry.branch;
114
+ if (kept.source !== 'gitmodules' && entry.source === 'gitmodules')
115
+ kept.source = 'gitmodules';
116
+ }
117
+ return out;
118
+ }
@@ -0,0 +1,45 @@
1
+ import type { FactoryRun } from '../github/types.js';
2
+ import type { DashType, MetricsRollup, WorkflowMetrics } from './types.js';
3
+ /** Windowing options. `nowIso` is the analysis instant — callers own the clock. */
4
+ export interface MetricsOptions {
5
+ nowIso: string;
6
+ /** Lookback window in days (dash default: 14). */
7
+ windowDays?: number;
8
+ }
9
+ /**
10
+ * Fold a window of runs into per-workflow {@link WorkflowMetrics}, keyed by path.
11
+ * Only `status === 'completed'` runs created inside the window fold; a run whose
12
+ * timestamps don't parse is skipped (the dash skips runs without a computable
13
+ * duration). A path absent from `labels` still gets metrics under its basename
14
+ * slug with dashType 'other'. `flags`/`priority` come back zeroed — scope-aware
15
+ * flagging is {@link assignFlags}'s job. Never throws.
16
+ */
17
+ export declare function computeWorkflowMetrics(runs: FactoryRun[], labels: ReadonlyMap<string, {
18
+ name: string;
19
+ dashType: DashType;
20
+ }>, opts: MetricsOptions): WorkflowMetrics[];
21
+ /**
22
+ * Fill `flags` + `priority` across a workflow group and rank it for triage. The
23
+ * high-cost median is taken over the *given* group — the caller chooses the scope
24
+ * (one repo, one type, the whole fleet). Returns a NEW array (inputs untouched)
25
+ * sorted by (priority desc, totalMin desc). Thresholds are the dash's; `failing`
26
+ * and `flaky` are mutually exclusive by construction; `rework-heavy` is the
27
+ * gitorio addition.
28
+ */
29
+ export declare function assignFlags(workflows: WorkflowMetrics[]): WorkflowMetrics[];
30
+ /**
31
+ * Aggregate a group of workflows into one {@link MetricsRollup}. WorkflowMetrics
32
+ * does not retain successMin, so group effectiveness is reconstructed as
33
+ * pct(Σ totalMin·effectivenessPct/100, Σ totalMin) — exact up to the per-workflow
34
+ * pct rounding already baked into effectivenessPct. The rework denominator is
35
+ * Σ runs, which is the completed-run count (only completed runs ever fold).
36
+ */
37
+ export declare function rollup(workflows: WorkflowMetrics[]): MetricsRollup;
38
+ /**
39
+ * Roll workflows up by dashType: one row per type present, each a {@link rollup}
40
+ * plus its sharePct of the grand total minutes (0-guarded), sorted totalMin desc.
41
+ */
42
+ export declare function rollupByType(workflows: WorkflowMetrics[]): ({
43
+ type: DashType;
44
+ sharePct: number;
45
+ } & MetricsRollup)[];
@@ -0,0 +1,284 @@
1
+ // Run-metrics engine for Fleet Ops — a TypeScript port of the dash's
2
+ // actions_analytics.py aggregation (bamr87/bamr87 .github/scripts/dash-gen).
3
+ // Cost = wall-clock run minutes (runStartedAt → updatedAt), a shadow price for
4
+ // billable minutes; value = share of minutes ending in success; waste = minutes on
5
+ // failed/cancelled/timed-out/startup-failed runs. Two gitorio additions on top of
6
+ // the dash formulas: rework (runAttempt > 1) and queue latency (createdAt →
7
+ // runStartedAt). PURE and total: time arrives as an ISO-string parameter, malformed
8
+ // timestamps skip a run rather than throwing, and nothing here does I/O.
9
+ const DEFAULT_WINDOW_DAYS = 14;
10
+ /** Non-success terminal conclusions whose minutes count as waste (dash WASTE_CONCLUSIONS). */
11
+ const WASTE_CONCLUSIONS = new Set([
12
+ 'failure',
13
+ 'cancelled',
14
+ 'timed_out',
15
+ 'startup_failure',
16
+ ]);
17
+ // Flag thresholds — mirrors of the dash's tunable constants, plus the rework
18
+ // addition (same 25% bar as cancel-heavy).
19
+ const SLOW_AVG_MIN = 12;
20
+ const LOW_EFFECTIVENESS = 55;
21
+ const CANCEL_HEAVY_PCT = 25;
22
+ const CRON_HEAVY_PCT = 60;
23
+ const MIN_WASTE_MIN = 4;
24
+ const REWORK_HEAVY_PCT = 25;
25
+ // ── numeric helpers (dash: round(x, 1) / round(x, 2) / pct / p95 / median) ──
26
+ /**
27
+ * Round half to EVEN — Python 3's `round()`. The dash computes every rounded value
28
+ * with Python `round()`, so an exact TS port must tie-break the same way (JS
29
+ * `Math.round` ties toward +∞, which diverges on exact `.5` values, e.g. p95 index
30
+ * `round(28.5)` = 28 in Python vs 29 in JS, and `round(6.25, 1)` = 6.2 vs 6.3).
31
+ */
32
+ function bankersRound(x) {
33
+ const floor = Math.floor(x);
34
+ const diff = x - floor;
35
+ if (diff < 0.5)
36
+ return floor;
37
+ if (diff > 0.5)
38
+ return floor + 1;
39
+ return floor % 2 === 0 ? floor : floor + 1; // exact .5 → nearest even
40
+ }
41
+ function round1(x) {
42
+ return bankersRound(x * 10) / 10;
43
+ }
44
+ function round2(x) {
45
+ return bankersRound(x * 100) / 100;
46
+ }
47
+ /** Percentage of `part` in `whole`, one decimal; 0 when `whole` is 0 (dash pct()). */
48
+ function pct(part, whole) {
49
+ return whole ? round1((100 * part) / whole) : 0;
50
+ }
51
+ /** Statistical median: mean of the two middles for an even count; 0 for empty. */
52
+ function median(values) {
53
+ if (values.length === 0)
54
+ return 0;
55
+ const s = [...values].sort((a, b) => a - b);
56
+ const mid = s.length >> 1;
57
+ return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
58
+ }
59
+ /** Nearest-rank p95 (NOT interpolated): sorted[min(n−1, round(0.95·(n−1)))], two decimals. */
60
+ function p95(values) {
61
+ if (values.length === 0)
62
+ return 0;
63
+ const s = [...values].sort((a, b) => a - b);
64
+ const idx = Math.min(s.length - 1, bankersRound(0.95 * (s.length - 1)));
65
+ return round2(s[idx]);
66
+ }
67
+ /** Basename of a path with a trailing `.yml`/`.yaml` stripped — the unlabeled-name fallback. */
68
+ function baseNoExt(path) {
69
+ const base = path.split('/').pop() ?? path;
70
+ return base.replace(/\.ya?ml$/i, '');
71
+ }
72
+ function newBucket() {
73
+ return {
74
+ runs: 0,
75
+ totalMin: 0,
76
+ successMin: 0,
77
+ wasteMin: 0,
78
+ success: 0,
79
+ failure: 0,
80
+ cancelled: 0,
81
+ other: 0,
82
+ durations: [],
83
+ queueSecs: [],
84
+ reworkRuns: 0,
85
+ events: new Map(),
86
+ };
87
+ }
88
+ // ── public API ───────────────────────────────────────────────────────────────
89
+ /**
90
+ * Fold a window of runs into per-workflow {@link WorkflowMetrics}, keyed by path.
91
+ * Only `status === 'completed'` runs created inside the window fold; a run whose
92
+ * timestamps don't parse is skipped (the dash skips runs without a computable
93
+ * duration). A path absent from `labels` still gets metrics under its basename
94
+ * slug with dashType 'other'. `flags`/`priority` come back zeroed — scope-aware
95
+ * flagging is {@link assignFlags}'s job. Never throws.
96
+ */
97
+ export function computeWorkflowMetrics(runs, labels, opts) {
98
+ const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
99
+ const cutoffMs = Date.parse(opts.nowIso) - windowDays * 86_400_000;
100
+ const weeks = Math.max(windowDays / 7, 1 / 7);
101
+ const buckets = new Map();
102
+ for (const run of runs) {
103
+ const createdMs = Date.parse(run.createdAt);
104
+ // Negated comparison so a NaN cutoff or createdAt excludes rather than throws.
105
+ if (!(createdMs >= cutoffMs))
106
+ continue;
107
+ if (run.status !== 'completed')
108
+ continue;
109
+ const startMs = run.runStartedAt !== null ? Date.parse(run.runStartedAt) : createdMs;
110
+ const rawMin = (Date.parse(run.updatedAt) - startMs) / 60_000;
111
+ if (!Number.isFinite(rawMin))
112
+ continue;
113
+ const m = Math.max(0, rawMin);
114
+ let b = buckets.get(run.path);
115
+ if (!b) {
116
+ b = newBucket();
117
+ buckets.set(run.path, b);
118
+ }
119
+ b.runs += 1;
120
+ b.totalMin += m;
121
+ b.durations.push(m);
122
+ b.events.set(run.event, (b.events.get(run.event) ?? 0) + 1);
123
+ const c = run.conclusion;
124
+ // Rework counts re-run attempts among OUTCOME-BEARING runs only (success/failure/
125
+ // cancelled) — the same `completed` denominator the rework-heavy flag and
126
+ // successRatePct use — so reworkPct is a fraction of the same base, never >100%.
127
+ let outcomeBearing = false;
128
+ if (c === 'success') {
129
+ b.successMin += m;
130
+ b.success += 1;
131
+ outcomeBearing = true;
132
+ }
133
+ else if (c !== null && WASTE_CONCLUSIONS.has(c)) {
134
+ b.wasteMin += m;
135
+ if (c === 'cancelled')
136
+ b.cancelled += 1;
137
+ else
138
+ b.failure += 1;
139
+ outcomeBearing = true;
140
+ }
141
+ else {
142
+ b.other += 1;
143
+ }
144
+ if (run.runStartedAt !== null && Number.isFinite(startMs - createdMs)) {
145
+ b.queueSecs.push(Math.max(0, (startMs - createdMs) / 1000));
146
+ }
147
+ if (outcomeBearing && run.runAttempt > 1)
148
+ b.reworkRuns += 1;
149
+ }
150
+ const out = [];
151
+ for (const [path, b] of buckets) {
152
+ const label = labels.get(path);
153
+ out.push({
154
+ path,
155
+ name: label?.name ?? baseNoExt(path),
156
+ dashType: label?.dashType ?? 'other',
157
+ runs: b.runs,
158
+ totalMin: round1(b.totalMin),
159
+ avgMin: b.runs ? round2(b.totalMin / b.runs) : 0,
160
+ p95Min: p95(b.durations),
161
+ wasteMin: round1(b.wasteMin),
162
+ runsPerWeek: round1(b.runs / weeks),
163
+ success: b.success,
164
+ failure: b.failure,
165
+ cancelled: b.cancelled,
166
+ other: b.other,
167
+ successRatePct: pct(b.success, b.success + b.failure + b.cancelled),
168
+ // "Other" minutes (skipped/neutral/…) live in totalMin only, so
169
+ // effectiveness + waste share need not sum to 100.
170
+ effectivenessPct: b.totalMin > 0 ? pct(b.successMin, b.totalMin) : 100,
171
+ schedPct: pct(b.events.get('schedule') ?? 0, b.runs),
172
+ reworkRuns: b.reworkRuns,
173
+ // Denominator is the outcome-bearing count (matches the rework-heavy flag +
174
+ // successRatePct), NOT `runs` — `runs` also includes skipped/neutral ('other')
175
+ // runs, which would dilute the rate and wrongly suppress the flag.
176
+ reworkPct: pct(b.reworkRuns, b.success + b.failure + b.cancelled),
177
+ queueP50Sec: b.queueSecs.length > 0 ? round1(median(b.queueSecs)) : null,
178
+ events: Object.fromEntries(b.events),
179
+ flags: [],
180
+ priority: 0,
181
+ });
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * Fill `flags` + `priority` across a workflow group and rank it for triage. The
187
+ * high-cost median is taken over the *given* group — the caller chooses the scope
188
+ * (one repo, one type, the whole fleet). Returns a NEW array (inputs untouched)
189
+ * sorted by (priority desc, totalMin desc). Thresholds are the dash's; `failing`
190
+ * and `flaky` are mutually exclusive by construction; `rework-heavy` is the
191
+ * gitorio addition.
192
+ */
193
+ export function assignFlags(workflows) {
194
+ const medianMin = median(workflows.map((w) => w.totalMin));
195
+ const out = workflows.map((w) => {
196
+ const flags = [];
197
+ const completed = w.success + w.failure + w.cancelled;
198
+ if (w.totalMin >= medianMin &&
199
+ w.effectivenessPct < LOW_EFFECTIVENESS &&
200
+ w.wasteMin >= MIN_WASTE_MIN) {
201
+ flags.push('high-cost-low-value');
202
+ }
203
+ if (completed >= 3 && w.successRatePct < 50) {
204
+ flags.push('failing');
205
+ }
206
+ else if (completed >= 4 && w.successRatePct >= 50 && w.successRatePct < 85) {
207
+ flags.push('flaky');
208
+ }
209
+ if (w.avgMin > SLOW_AVG_MIN)
210
+ flags.push('slow');
211
+ if (completed >= 4 && pct(w.cancelled, completed) > CANCEL_HEAVY_PCT) {
212
+ flags.push('cancel-heavy');
213
+ }
214
+ if (w.runs >= 5 && w.schedPct > CRON_HEAVY_PCT)
215
+ flags.push('cron-heavy');
216
+ if (completed >= 4 && w.reworkPct > REWORK_HEAVY_PCT)
217
+ flags.push('rework-heavy');
218
+ // Priority: minutes wasted, then raw consumption — the "high running, low
219
+ // effective" triage rank.
220
+ const priority = round1(w.wasteMin + w.totalMin * (1 - w.effectivenessPct / 100));
221
+ return { ...w, flags, priority };
222
+ });
223
+ out.sort((a, b) => b.priority - a.priority || b.totalMin - a.totalMin);
224
+ return out;
225
+ }
226
+ /**
227
+ * Aggregate a group of workflows into one {@link MetricsRollup}. WorkflowMetrics
228
+ * does not retain successMin, so group effectiveness is reconstructed as
229
+ * pct(Σ totalMin·effectivenessPct/100, Σ totalMin) — exact up to the per-workflow
230
+ * pct rounding already baked into effectivenessPct. The rework denominator is
231
+ * Σ runs, which is the completed-run count (only completed runs ever fold).
232
+ */
233
+ export function rollup(workflows) {
234
+ let runs = 0;
235
+ let totalMin = 0;
236
+ let wasteMin = 0;
237
+ let successMin = 0;
238
+ let success = 0;
239
+ let completed = 0;
240
+ let reworkRuns = 0;
241
+ for (const w of workflows) {
242
+ runs += w.runs;
243
+ totalMin += w.totalMin;
244
+ wasteMin += w.wasteMin;
245
+ successMin += (w.totalMin * w.effectivenessPct) / 100;
246
+ success += w.success;
247
+ completed += w.success + w.failure + w.cancelled;
248
+ reworkRuns += w.reworkRuns;
249
+ }
250
+ return {
251
+ runs,
252
+ totalMin: round1(totalMin),
253
+ wasteMin: round1(wasteMin),
254
+ // Mirror the per-workflow + dash guard: an all-zero-minute group scores 100, not 0.
255
+ effectivenessPct: totalMin > 0 ? pct(successMin, totalMin) : 100,
256
+ successRatePct: pct(success, completed),
257
+ // Same outcome-bearing denominator as the per-workflow reworkPct, not `runs`.
258
+ reworkPct: pct(reworkRuns, completed),
259
+ };
260
+ }
261
+ /**
262
+ * Roll workflows up by dashType: one row per type present, each a {@link rollup}
263
+ * plus its sharePct of the grand total minutes (0-guarded), sorted totalMin desc.
264
+ */
265
+ export function rollupByType(workflows) {
266
+ const groups = new Map();
267
+ for (const w of workflows) {
268
+ const group = groups.get(w.dashType);
269
+ if (group)
270
+ group.push(w);
271
+ else
272
+ groups.set(w.dashType, [w]);
273
+ }
274
+ const rows = [...groups.entries()].map(([type, group]) => ({
275
+ type,
276
+ sharePct: 0,
277
+ ...rollup(group),
278
+ }));
279
+ const grandMin = rows.reduce((sum, r) => sum + r.totalMin, 0);
280
+ for (const r of rows)
281
+ r.sharePct = grandMin ? round1((100 * r.totalMin) / grandMin) : 0;
282
+ rows.sort((a, b) => b.totalMin - a.totalMin);
283
+ return rows;
284
+ }
@@ -0,0 +1,15 @@
1
+ import type { RepoRef } from '../github/types.js';
2
+ import type { Fleet, ImportedWorkflow } from './types.js';
3
+ /**
4
+ * Reverse-import one workflow file into an {@link ImportedWorkflow}. PURE and total:
5
+ * never throws. Name + `on:` come from the YAML parser when the file parses; all
6
+ * agent/sink/cross-repo/gate signals come from a raw-text scan, so odd YAML shapes
7
+ * (and even unparseable files) still yield useful data.
8
+ */
9
+ export declare function parseWorkflow(path: string, yamlText: string): ImportedWorkflow;
10
+ /**
11
+ * Stitch a repo's parsed workflows into a {@link Fleet}: workflow_run belts (a workflow
12
+ * whose `on.workflow_run.workflows` names another workflow in the same repo) and
13
+ * cross-repo belts (a `gh … --repo owner/name` targeting a *different* repo). PURE.
14
+ */
15
+ export declare function buildFleet(repo: RepoRef, parsed: ImportedWorkflow[]): Fleet;