@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.
- package/README.md +43 -0
- package/VERSION +31 -0
- package/dist/fleet/audit.d.ts +26 -0
- package/dist/fleet/audit.js +467 -0
- package/dist/fleet/facts.d.ts +25 -0
- package/dist/fleet/facts.js +617 -0
- package/dist/fleet/import.d.ts +17 -0
- package/dist/fleet/import.js +74 -0
- package/dist/fleet/manifest.d.ts +23 -0
- package/dist/fleet/manifest.js +118 -0
- package/dist/fleet/metrics.d.ts +45 -0
- package/dist/fleet/metrics.js +284 -0
- package/dist/fleet/parse.d.ts +15 -0
- package/dist/fleet/parse.js +298 -0
- package/dist/fleet/types.d.ts +250 -0
- package/dist/fleet/types.js +7 -0
- package/dist/github/telemetry.d.ts +40 -0
- package/dist/github/telemetry.js +131 -0
- package/dist/github/types.d.ts +240 -0
- package/dist/github/types.js +36 -0
- package/dist/harness/health.d.ts +135 -0
- package/dist/harness/health.js +431 -0
- package/dist/harness/hub-paths.d.ts +45 -0
- package/dist/harness/hub-paths.js +35 -0
- package/dist/harness/hubread.d.ts +7 -0
- package/dist/harness/hubread.js +101 -0
- package/dist/harness/lanes.d.ts +45 -0
- package/dist/harness/lanes.js +92 -0
- package/dist/harness/manifest-yaml.d.ts +12 -0
- package/dist/harness/manifest-yaml.js +69 -0
- package/dist/harness/signals.d.ts +32 -0
- package/dist/harness/signals.js +74 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +19 -0
- package/package.json +55 -0
|
@@ -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;
|