@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,298 @@
1
+ // Pure reverse-import parser for GitHub Actions workflow YAML — the engine behind
2
+ // GitFactory's observe mode. `parseWorkflow` turns one workflow file into an
3
+ // ImportedWorkflow; `buildFleet` stitches a repo's workflows into a Fleet graph
4
+ // (workflow_run chains + cross-repo emissions). No I/O, never throws on bad YAML.
5
+ //
6
+ // Structure (name + the `on:` block) is read via the `yaml` parser when possible, but
7
+ // every agent/sink/cross-repo/gate signal comes from a raw-text regex scan. Text
8
+ // scanning is far more robust than deep YAML walking across the many shapes real
9
+ // workflows take: composite actions, matrices, heredocs, reusable-workflow calls, and
10
+ // even syntactically broken files (which the scan still mines for signals).
11
+ import { parse } from 'yaml';
12
+ import { externalNodeId } from './types.js';
13
+ // ── path helpers ────────────────────────────────────────────────────────────
14
+ /** Basename of a path with a trailing `.yml`/`.yaml` stripped. */
15
+ function baseNoExt(path) {
16
+ const base = path.split('/').pop() ?? path;
17
+ return base.replace(/\.ya?ml$/i, '');
18
+ }
19
+ // ── `on:` → triggers ──────────────────────────────────────────────────────────
20
+ /** Join a string/number array field (e.g. `types:`) of an event config into a detail. */
21
+ function joinField(value, field) {
22
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
23
+ const raw = value[field];
24
+ if (Array.isArray(raw)) {
25
+ const parts = raw
26
+ .filter((x) => typeof x === 'string' || typeof x === 'number')
27
+ .map((x) => String(x));
28
+ if (parts.length > 0)
29
+ return parts.join(', ');
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+ /** Cron strings from a `schedule:` value (`[{ cron: '…' }, …]`). */
35
+ function cronsOf(value) {
36
+ if (!Array.isArray(value))
37
+ return [];
38
+ const out = [];
39
+ for (const entry of value) {
40
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
41
+ const cron = entry.cron;
42
+ if (typeof cron === 'string')
43
+ out.push(cron);
44
+ }
45
+ else if (typeof entry === 'string') {
46
+ out.push(entry);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /** The `workflows:` name list from a `workflow_run:` value. */
52
+ function workflowsOf(value) {
53
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
54
+ const raw = value.workflows;
55
+ if (Array.isArray(raw)) {
56
+ return raw.filter((x) => typeof x === 'string');
57
+ }
58
+ }
59
+ return [];
60
+ }
61
+ /** Map a single `on:` key (not schedule/workflow_run, which fan out) to a summary. */
62
+ function triggerForKey(key, value) {
63
+ switch (key) {
64
+ case 'push':
65
+ return { kind: 'push' };
66
+ case 'pull_request': {
67
+ const detail = joinField(value, 'types');
68
+ return detail ? { kind: 'pull_request', detail } : { kind: 'pull_request' };
69
+ }
70
+ case 'issues': {
71
+ const detail = joinField(value, 'types');
72
+ return detail ? { kind: 'issues', detail } : { kind: 'issues' };
73
+ }
74
+ case 'issue_comment':
75
+ return { kind: 'issue_comment' };
76
+ case 'workflow_dispatch':
77
+ return { kind: 'workflow_dispatch' };
78
+ case 'repository_dispatch':
79
+ return { kind: 'repository_dispatch' };
80
+ default:
81
+ return { kind: key };
82
+ }
83
+ }
84
+ /** Expand an `on:` block (string | array | object) into triggers + workflow_run names. */
85
+ function triggersFromOn(on) {
86
+ const triggers = [];
87
+ const runAfterNames = [];
88
+ if (on == null)
89
+ return { triggers, runAfterNames };
90
+ if (typeof on === 'string') {
91
+ triggers.push(triggerForKey(on, undefined));
92
+ return { triggers, runAfterNames };
93
+ }
94
+ if (Array.isArray(on)) {
95
+ for (const item of on) {
96
+ if (typeof item === 'string')
97
+ triggers.push(triggerForKey(item, undefined));
98
+ }
99
+ return { triggers, runAfterNames };
100
+ }
101
+ if (typeof on === 'object') {
102
+ for (const [key, value] of Object.entries(on)) {
103
+ if (key === 'schedule') {
104
+ for (const cron of cronsOf(value))
105
+ triggers.push({ kind: 'schedule', detail: cron });
106
+ }
107
+ else if (key === 'workflow_run') {
108
+ const workflows = workflowsOf(value);
109
+ for (const w of workflows)
110
+ runAfterNames.push(w);
111
+ triggers.push(workflows.length > 0
112
+ ? { kind: 'workflow_run', detail: workflows.join(', ') }
113
+ : { kind: 'workflow_run' });
114
+ }
115
+ else {
116
+ triggers.push(triggerForKey(key, value));
117
+ }
118
+ }
119
+ }
120
+ return { triggers, runAfterNames };
121
+ }
122
+ // ── raw-text signal scans ─────────────────────────────────────────────────────
123
+ /** First capture group across `patterns`, or undefined. Patterns must be non-global. */
124
+ function firstCapture(text, patterns) {
125
+ for (const re of patterns) {
126
+ const m = re.exec(text);
127
+ if (m && m[1])
128
+ return m[1];
129
+ }
130
+ return undefined;
131
+ }
132
+ /** Detect the AI runner + its role/model/tools, or undefined if the workflow runs none. */
133
+ function detectAgent(text) {
134
+ let runner;
135
+ if (text.includes('./.github/actions/claude-run') || text.includes('actions/claude-run')) {
136
+ runner = 'claude-run';
137
+ }
138
+ else if (text.includes('anthropics/claude-code-action')) {
139
+ runner = 'claude-code-action';
140
+ }
141
+ else if (text.includes('scripts/ai/run.sh') || text.includes('claude -p')) {
142
+ runner = 'run-sh';
143
+ }
144
+ if (!runner)
145
+ return undefined;
146
+ const agent = { runner };
147
+ const agentName = firstCapture(text, [
148
+ /agent:\s*['"]?([A-Za-z0-9_-]+)/,
149
+ /--agent\s+([A-Za-z0-9_-]+)/,
150
+ ]);
151
+ if (agentName)
152
+ agent.agentName = agentName;
153
+ const model = firstCapture(text, [
154
+ /--model\s+([A-Za-z0-9_.-]+)/,
155
+ /model:\s*['"]?([A-Za-z0-9_.-]+)/,
156
+ ]);
157
+ if (model)
158
+ agent.model = model;
159
+ const tools = firstCapture(text, [
160
+ /tools:\s*['"]([^'"]+)['"]/,
161
+ /--allowedTools\s+['"]?([^'"\n]+)/,
162
+ ]);
163
+ if (tools)
164
+ agent.tools = tools;
165
+ return agent;
166
+ }
167
+ /** Coarse-classify what a workflow writes, deduped and in a stable order. */
168
+ function detectSinks(text) {
169
+ const sinks = [];
170
+ const add = (k) => {
171
+ if (!sinks.includes(k))
172
+ sinks.push(k);
173
+ };
174
+ if (/gh pr create/.test(text))
175
+ add('pr');
176
+ if (/gh issue create/.test(text))
177
+ add('issue');
178
+ if (/gh (pr|issue) comment/.test(text))
179
+ add('comment');
180
+ if (/gh (issue|pr) edit[^\n]*--add-label/.test(text))
181
+ add('label');
182
+ if (/git push/.test(text))
183
+ add('commit');
184
+ if (/deploy-pages|actions\/deploy-pages|environment:\s*\n?\s*name:\s*github-pages/.test(text)) {
185
+ add('deploy');
186
+ }
187
+ if (/gh issue create[^\n]*--repo\s+\S+\/\S+/.test(text))
188
+ add('cross_repo_issue');
189
+ if (/repository_dispatch|gh workflow run|gh api[^\n]*dispatches/.test(text))
190
+ add('dispatch');
191
+ return sinks;
192
+ }
193
+ /** Unique `owner/name` repos targeted via `gh … --repo owner/name`. */
194
+ function detectCrossRepoTargets(text) {
195
+ const out = [];
196
+ const re = /--repo\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)/g;
197
+ let m;
198
+ while ((m = re.exec(text)) !== null) {
199
+ if (!out.includes(m[1]))
200
+ out.push(m[1]);
201
+ }
202
+ return out;
203
+ }
204
+ /** A `*_ENABLED`-style kill-switch variable name, if the workflow references one. */
205
+ function detectGate(text) {
206
+ const m = /([A-Z][A-Z0-9_]*_ENABLED)/.exec(text);
207
+ return m ? m[1] : undefined;
208
+ }
209
+ // ── public API ────────────────────────────────────────────────────────────────
210
+ /**
211
+ * Reverse-import one workflow file into an {@link ImportedWorkflow}. PURE and total:
212
+ * never throws. Name + `on:` come from the YAML parser when the file parses; all
213
+ * agent/sink/cross-repo/gate signals come from a raw-text scan, so odd YAML shapes
214
+ * (and even unparseable files) still yield useful data.
215
+ */
216
+ export function parseWorkflow(path, yamlText) {
217
+ const slug = baseNoExt(path);
218
+ let docName;
219
+ let on;
220
+ try {
221
+ const parsed = parse(yamlText);
222
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
223
+ const doc = parsed;
224
+ const n = doc.name;
225
+ if (typeof n === 'string' && n.trim() !== '')
226
+ docName = n;
227
+ // The `on:` key round-trips as the string 'on' under YAML 1.2, but a YAML-1.1
228
+ // bool resolver hands it back under the boolean-`true` key. Read it defensively.
229
+ on = doc.on ?? doc[true] ?? doc['on'];
230
+ }
231
+ }
232
+ catch {
233
+ // Unparseable YAML: fall through to the filename name + text-only signal scan.
234
+ }
235
+ const { triggers, runAfterNames } = triggersFromOn(on);
236
+ const agent = detectAgent(yamlText);
237
+ const gate = detectGate(yamlText);
238
+ const wf = {
239
+ path,
240
+ name: docName ?? slug,
241
+ slug,
242
+ triggers,
243
+ hasAgent: agent !== undefined,
244
+ sinks: detectSinks(yamlText),
245
+ runAfterNames,
246
+ crossRepoTargets: detectCrossRepoTargets(yamlText),
247
+ };
248
+ if (agent)
249
+ wf.agent = agent;
250
+ if (gate)
251
+ wf.gate = gate;
252
+ return wf;
253
+ }
254
+ /**
255
+ * Stitch a repo's parsed workflows into a {@link Fleet}: workflow_run belts (a workflow
256
+ * whose `on.workflow_run.workflows` names another workflow in the same repo) and
257
+ * cross-repo belts (a `gh … --repo owner/name` targeting a *different* repo). PURE.
258
+ */
259
+ export function buildFleet(repo, parsed) {
260
+ const nameToPath = new Map();
261
+ for (const wf of parsed)
262
+ nameToPath.set(wf.name, wf.path);
263
+ const edges = [];
264
+ const warnings = [];
265
+ const externalRepos = [];
266
+ const self = `${repo.owner}/${repo.repo}`.toLowerCase();
267
+ for (const wf of parsed) {
268
+ for (const name of wf.runAfterNames) {
269
+ const sourcePath = nameToPath.get(name);
270
+ if (sourcePath) {
271
+ edges.push({
272
+ id: `wr:${sourcePath}->${wf.path}`,
273
+ from: sourcePath,
274
+ to: wf.path,
275
+ kind: 'workflow_run',
276
+ label: 'after',
277
+ });
278
+ }
279
+ else {
280
+ warnings.push(`${wf.slug}: runs after unknown workflow '${name}'`);
281
+ }
282
+ }
283
+ for (const target of wf.crossRepoTargets) {
284
+ if (target.toLowerCase() === self)
285
+ continue;
286
+ if (!externalRepos.includes(target))
287
+ externalRepos.push(target);
288
+ edges.push({
289
+ id: `cr:${wf.path}->${target}`,
290
+ from: wf.path,
291
+ to: externalNodeId(target),
292
+ kind: 'cross_repo',
293
+ label: 'files issues',
294
+ });
295
+ }
296
+ }
297
+ return { repo, workflows: parsed, externalRepos, edges, warnings };
298
+ }
@@ -0,0 +1,250 @@
1
+ import type { RepoRef } from '../github/types.js';
2
+ import type { FleetLane, FleetManifest } from '../harness/lanes.js';
3
+ /** Coarse classification of what a workflow writes. */
4
+ export type SinkKind = 'pr' | 'issue' | 'comment' | 'label' | 'commit' | 'merge' | 'deploy' | 'cross_repo_issue' | 'dispatch';
5
+ /** One entry from a workflow's `on:` block, summarized for display. */
6
+ export interface TriggerSummary {
7
+ /** e.g. push | pull_request | issues | issue_comment | schedule | workflow_dispatch | workflow_run | repository_dispatch */
8
+ kind: string;
9
+ /** cron string, workflow_run source names, event types, etc. */
10
+ detail?: string;
11
+ }
12
+ /** The AI step a workflow runs, if any. */
13
+ export interface AgentInfo {
14
+ runner: 'claude-run' | 'claude-code-action' | 'run-sh' | 'unknown';
15
+ /** named role from .claude/agents/<name> when passed via the `agent:` input. */
16
+ agentName?: string;
17
+ model?: string;
18
+ /** --allowedTools string, when discoverable. */
19
+ tools?: string;
20
+ }
21
+ /** One workflow file, reverse-imported. Node id on the canvas === `path`. */
22
+ export interface ImportedWorkflow {
23
+ path: string;
24
+ name: string;
25
+ slug: string;
26
+ triggers: TriggerSummary[];
27
+ hasAgent: boolean;
28
+ agent?: AgentInfo;
29
+ sinks: SinkKind[];
30
+ /** workflow NAMES this runs after (on.workflow_run.workflows); resolved to edges by buildFleet. */
31
+ runAfterNames: string[];
32
+ /** owner/name repos this workflow writes to via `gh … --repo`. */
33
+ crossRepoTargets: string[];
34
+ /** a `*_ENABLED`-style kill-switch variable, if the workflow gates on one. */
35
+ gate?: string;
36
+ /** The `fleet/v1` lane the repo's committed `fleet.manifest.yml` records for this file (specs/014). */
37
+ lane?: FleetLane;
38
+ }
39
+ export type FleetEdgeKind = 'workflow_run' | 'cross_repo';
40
+ /** A belt between two fleet nodes. `to` is a workflow path or an external repo id `ext:owner/name`. */
41
+ export interface FleetEdge {
42
+ id: string;
43
+ from: string;
44
+ to: string;
45
+ kind: FleetEdgeKind;
46
+ label?: string;
47
+ }
48
+ /** The full reverse-imported picture of one repo's automation. */
49
+ export interface Fleet {
50
+ repo: RepoRef;
51
+ workflows: ImportedWorkflow[];
52
+ /** external repos targeted cross-repo, as `owner/name`. Canvas node id === `ext:owner/name`. */
53
+ externalRepos: string[];
54
+ edges: FleetEdge[];
55
+ /** non-fatal parse notes (e.g. a workflow whose YAML could not be parsed). */
56
+ warnings: string[];
57
+ /** The repo's committed `fleet.manifest.yml` — the hub's interchange contract — when it has one. */
58
+ manifest?: FleetManifest | null;
59
+ }
60
+ /** Stable canvas node id for an external repo target. */
61
+ export declare const externalNodeId: (ownerName: string) => string;
62
+ /** Coarse cost-grouping type — port of the dash's ordered-substring classifier. */
63
+ export type DashType = 'dependencies' | 'security' | 'ai' | 'release' | 'deploy' | 'docs' | 'automation' | 'ci' | 'other';
64
+ /** Fleet workflow archetype — the design-pattern taxonomy observed across the fleet. */
65
+ export type Archetype = 'standard-ci-caller' | 'mention-handler' | 'prose-kit' | 'ci-gate' | 'pr-gate' | 'pr-editor' | 'reusable-library' | 'content-factory' | 'issue-to-content' | 'agent-gatekeeper' | 'agentic-validator' | 'perfection-loop' | 'auto-merge-bot' | 'self-repair' | 'issue-autopilot' | 'nightly-audit' | 'autonomy-loop' | 'cross-repo-filer' | 'scout' | 'meta-loop' | 'ledger' | 'dispatch-hub' | 'generated-line' | 'release' | 'security-gate' | 'deploy' | 'data-sync' | 'dependency-bot' | 'other';
66
+ /** How a `uses:` reference is pinned. */
67
+ export type PinStyle = 'sha' | 'tag' | 'branch' | 'local' | 'unpinned';
68
+ /** One `uses:` reference (marketplace action, local composite, or reusable workflow). */
69
+ export interface ActionUse {
70
+ /** e.g. `actions/checkout`, `./.github/actions/claude-run`, `bamr87/bamr87/.github/workflows/standard-ci.yml`. */
71
+ action: string;
72
+ /** The `@ref` part, or null for local `./` uses. */
73
+ ref: string | null;
74
+ pin: PinStyle;
75
+ }
76
+ /** The Claude-auth wiring convention an AI workflow follows. */
77
+ export type AuthMode = 'oauth-first' | 'oauth-only' | 'api-key-only' | 'none';
78
+ /** Loop-safety / governance guard mechanisms actually used in the fleet. */
79
+ export type GuardKind = 'attempt-limit' | 'label-opt-in' | 'actor-guard' | 'rate-limiter' | 'synchronize-skip' | 'smuggle-guard' | 'sticky-marker' | 'same-repo-only' | 'mention-phrase' | 'gate-job' | 'concurrency-singleton';
80
+ /** AI usage found in one workflow (per-file rollup; a file can mix runners). */
81
+ export interface AiFacts {
82
+ present: boolean;
83
+ /**
84
+ * Which invocation shapes appear (a workflow can use several).
85
+ * `agentic-engine` = a bare `@anthropic-ai/claude-code` CLI / `agentic_validate.py`
86
+ * driver — the it-journey quest engine, the fleet's single biggest AI cost center.
87
+ */
88
+ runners: ('claude-code-action' | 'claude-cli' | 'claude-run' | 'run-sh' | 'agentic-engine' | 'other')[];
89
+ /** Models named in the file; empty when the model lives in a composite/config (common). */
90
+ models: string[];
91
+ /** Largest --max-turns found, or null. */
92
+ maxTurns: number | null;
93
+ /** Hard dollar spend cap (`--max-cost-usd N`) — the fleet's only per-run cost ceiling, or null. */
94
+ maxCostUsd: number | null;
95
+ authMode: AuthMode;
96
+ /** Named agent roles (`agent: x` / `--agent x`). */
97
+ agents: string[];
98
+ }
99
+ /** Everything the cockpit knows about one workflow file. */
100
+ export interface WorkflowFacts {
101
+ path: string;
102
+ name: string;
103
+ dashType: DashType;
104
+ archetype: Archetype;
105
+ triggers: TriggerSummary[];
106
+ /** Active cron strings from parsed `on.schedule`. */
107
+ crons: string[];
108
+ /** Cron strings present only in comments — dormant/disabled automation. */
109
+ dormantCrons: string[];
110
+ /** True when `on:` includes `workflow_call` (a reusable library). */
111
+ isReusable: boolean;
112
+ /** Reusable workflows this one calls (`uses: owner/repo/.github/workflows/x.yml@ref`). */
113
+ reusableCalls: string[];
114
+ jobCount: number;
115
+ hasMatrix: boolean;
116
+ /** True when a matrix is computed at runtime (`matrix: fromJSON(needs.*.outputs.*)`) — unknown fan-out width. */
117
+ matrixDynamic: boolean;
118
+ /** Same-repo reusable-workflow calls (`uses: ./.github/workflows/x.yml`) — intra-repo belts. */
119
+ localWorkflowCalls: string[];
120
+ /**
121
+ * True when the workflow spends its minutes WAITING (a `sleep` + `gh pr checks` poll
122
+ * loop), not computing — its "cost" is wall-clock, retireable with `--auto` + required checks.
123
+ */
124
+ waitBound: boolean;
125
+ /** Max declared job `timeout-minutes`, or null when none declared anywhere. */
126
+ timeoutMinutes: number | null;
127
+ runners: string[];
128
+ concurrency: {
129
+ present: boolean;
130
+ group: string | null;
131
+ cancelInProgress: 'always' | 'never' | 'conditional' | 'unset';
132
+ };
133
+ permissions: {
134
+ /** True when any permissions block (top or job level) is declared. */
135
+ declared: boolean;
136
+ /** True when the top level declares read-only contents. */
137
+ topLevelRead: boolean;
138
+ /** True when the TOP level grants any write scope (prefer job-level grants). */
139
+ topLevelWrite: boolean;
140
+ /** Union of `scope: write` grants anywhere in the file. */
141
+ writeScopes: string[];
142
+ };
143
+ /** All `uses:` references with pin style. */
144
+ actions: ActionUse[];
145
+ /** Local composite actions used (`./.github/actions/*`). */
146
+ compositeLocals: string[];
147
+ ai: AiFacts;
148
+ /** Repo-variable kill switches referenced anywhere (if/env/run), e.g. CONTENT_FACTORY_ENABLED. */
149
+ killSwitches: string[];
150
+ /** True when the workflow exposes a plan/apply (dry-run) dispatch input. */
151
+ planApply: boolean;
152
+ guards: GuardKind[];
153
+ /** Token fallback chain in privilege order, e.g. ['FLEET_TOKEN','github.token']. */
154
+ tokenChain: string[];
155
+ /** All `secrets.*` names referenced. */
156
+ secretsUsed: string[];
157
+ /** All `vars.*` names referenced. */
158
+ varsUsed: string[];
159
+ sinks: SinkKind[];
160
+ crossRepoTargets: string[];
161
+ /** Set when the file carries the ⚙ GENERATED BY GITFACTORY header. */
162
+ generated: {
163
+ blueprintPath: string | null;
164
+ hash: string | null;
165
+ } | null;
166
+ /** Count of "MANUAL EDIT" markers in a generated file (blueprint drift risk). */
167
+ manualEditMarkers: number;
168
+ }
169
+ export type AuditSeverity = 'fail' | 'warn' | 'info';
170
+ /** Rule catalog entry — what the fleet standard is, for the UI's rulebook. */
171
+ export interface AuditRuleMeta {
172
+ id: string;
173
+ title: string;
174
+ severity: AuditSeverity;
175
+ /** One-sentence statement of the fleet convention this rule encodes. */
176
+ standard: string;
177
+ }
178
+ /** One violation found in one workflow (or repo-wide when `path` is null). */
179
+ export interface AuditFinding {
180
+ ruleId: string;
181
+ severity: AuditSeverity;
182
+ /** Workflow path, or null for a repo-level finding. */
183
+ path: string | null;
184
+ message: string;
185
+ /** The concrete fix, phrased as an actionable instruction. */
186
+ fix: string;
187
+ }
188
+ /** Factorio-style certification grade. */
189
+ export type Grade = 'S' | 'A' | 'B' | 'C' | 'D';
190
+ /** The audit result for one repo's whole workflow fleet. */
191
+ export interface RepoAudit {
192
+ /** 0–100 conformance score (weighted: fail 10, warn 3, info 1). */
193
+ score: number;
194
+ grade: Grade;
195
+ findings: AuditFinding[];
196
+ /** Rule ids that were applicable and fully passed. */
197
+ passedRules: string[];
198
+ /** Per-workflow finding counts for the matrix view. */
199
+ byWorkflow: Record<string, {
200
+ fails: number;
201
+ warns: number;
202
+ infos: number;
203
+ }>;
204
+ }
205
+ export type MetricFlag = 'high-cost-low-value' | 'failing' | 'flaky' | 'slow' | 'cancel-heavy' | 'cron-heavy' | 'rework-heavy';
206
+ /** Windowed run metrics for one workflow. Cost = wall-clock minutes (shadow price). */
207
+ export interface WorkflowMetrics {
208
+ path: string;
209
+ name: string;
210
+ dashType: DashType;
211
+ runs: number;
212
+ totalMin: number;
213
+ avgMin: number;
214
+ p95Min: number;
215
+ wasteMin: number;
216
+ runsPerWeek: number;
217
+ success: number;
218
+ failure: number;
219
+ cancelled: number;
220
+ other: number;
221
+ successRatePct: number;
222
+ effectivenessPct: number;
223
+ schedPct: number;
224
+ /** Runs with runAttempt > 1 — the rework signal (not in the dash; gitorio addition). */
225
+ reworkRuns: number;
226
+ reworkPct: number;
227
+ /** Median queue latency in seconds (createdAt → runStartedAt), or null. */
228
+ queueP50Sec: number | null;
229
+ events: Record<string, number>;
230
+ flags: MetricFlag[];
231
+ /** Triage priority = wasteMin + totalMin × (1 − effectiveness/100). */
232
+ priority: number;
233
+ }
234
+ /** Rollup across a group of workflows (a repo, a type, or the whole fleet). */
235
+ export interface MetricsRollup {
236
+ runs: number;
237
+ totalMin: number;
238
+ wasteMin: number;
239
+ effectivenessPct: number;
240
+ successRatePct: number;
241
+ reworkPct: number;
242
+ }
243
+ /** One repo in the fleet roster. Slugs only — never tokens (golden rule #7). */
244
+ export interface RosterEntry {
245
+ /** `owner/name`. */
246
+ slug: string;
247
+ source: 'manual' | 'gitmodules';
248
+ /** Tracked branch when known (from .gitmodules). */
249
+ branch: string | null;
250
+ }
@@ -0,0 +1,7 @@
1
+ // Observe-mode data model. A "fleet" is a read-only reverse-import of a repo's
2
+ // .github/workflows/*.yml — one node per workflow, belts for workflow_run chains and
3
+ // cross-repo `--repo` emissions. This is how GitFactory plugs into ANY repo (including
4
+ // hand-built ones like lifehacker.dev) without shoehorning them into the authoring
5
+ // machine types. Nothing here writes to the target repo.
6
+ /** Stable canvas node id for an external repo target. */
7
+ export const externalNodeId = (ownerName) => `ext:${ownerName}`;
@@ -0,0 +1,40 @@
1
+ import type { FactoryRun, LedState } from './types.js';
2
+ /**
3
+ * Map a single run (or its absence) to a LED colour.
4
+ * - no run yet → `idle`
5
+ * - still queued/in progress → `warn`
6
+ * - completed successfully → `on`
7
+ * - completed with a failing conclusion → `bad`
8
+ * - completed but cancelled/skipped/neutral/action_required/unknown → `idle`
9
+ */
10
+ export declare function ledForRun(run: FactoryRun | undefined): LedState;
11
+ /**
12
+ * The newest run per assembly-line slug. "Newest" = max ISO `createdAt` (lexical compare is
13
+ * correct for well-formed UTC timestamps), tie-broken by the larger `runId`. Deterministic.
14
+ */
15
+ export declare function latestBySlug(runs: FactoryRun[]): Map<string, FactoryRun>;
16
+ /** The LED colour for each line's latest run. */
17
+ export declare function statusBySlug(runs: FactoryRun[]): Map<string, LedState>;
18
+ /** Top-of-dashboard rollup across every polled run. */
19
+ export interface DashboardStats {
20
+ total: number;
21
+ completed: number;
22
+ success: number;
23
+ failure: number;
24
+ inProgress: number;
25
+ /** success / completed, in 0..1, or null when nothing has completed yet. */
26
+ successRate: number | null;
27
+ /** Median completed-run duration in seconds, or null when nothing has completed. */
28
+ p50DurationSec: number | null;
29
+ }
30
+ /** Aggregate stats across all runs. Pure; counts derive from the run `status`/`conclusion` fields. */
31
+ export declare function dashboardStats(runs: FactoryRun[]): DashboardStats;
32
+ /** Per-assembly-line rollup row. */
33
+ export interface LineStat {
34
+ slug: string;
35
+ total: number;
36
+ successRate: number | null;
37
+ p50DurationSec: number | null;
38
+ }
39
+ /** One {@link LineStat} per slug, sorted by slug ascending. Pure and deterministic. */
40
+ export declare function statsByLine(runs: FactoryRun[]): LineStat[];