@ngockhoale/ukit 3.4.27 → 3.4.28
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/CHANGELOG.md +18 -0
- package/manifests/documentation.yaml +59 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +6 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +13 -0
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +19 -7
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -1
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// /ukit:ci-check orchestrator. MANUAL ONLY: the single place that may dispatch the CI workflow, and
|
|
3
|
+
// only when the user invoked the command (`run --explicit`). One dispatch per invocation, no retry,
|
|
4
|
+
// no push, no merge, no publish. State lives in <project>/.ukit/storage/ci-check/ (never the User Layer).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export const DEFAULTS = Object.freeze({
|
|
11
|
+
workflow: 'cross-platform-ci.yml',
|
|
12
|
+
maxDispatchPerInvocation: 1,
|
|
13
|
+
maxJobs: 3,
|
|
14
|
+
maxBilledMinutesPerRun: 400,
|
|
15
|
+
monthlyFreeMinutes: 2000,
|
|
16
|
+
lockStaleMs: 10 * 60 * 1000,
|
|
17
|
+
staleInvocationMs: 6 * 60 * 60 * 1000,
|
|
18
|
+
discoverAttempts: 6,
|
|
19
|
+
discoverDelayMs: 5000,
|
|
20
|
+
waitIntervalMs: 30000,
|
|
21
|
+
waitTimeoutMs: 45 * 60 * 1000,
|
|
22
|
+
maxWaitPerCallMs: 9 * 60 * 1000,
|
|
23
|
+
pollErrorTolerance: 3,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const STANDARD_RUNNER = /^(ubuntu|windows|macos)-(latest|\d{2}(\.\d{2})?)$/;
|
|
27
|
+
const MINUTE_MULTIPLIER = { ubuntu: 1, windows: 2, macos: 10 };
|
|
28
|
+
const SAFE_NAME = /^[A-Za-z0-9._/-]+$/;
|
|
29
|
+
const OS_LABEL = { ubuntu: 'LINUX', windows: 'WINDOWS', macos: 'MACOS' };
|
|
30
|
+
const IN_FLIGHT = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------- workflow parsing (no yaml dep)
|
|
33
|
+
const stripComment = (l) => l.replace(/\s+#.*$/, '').replace(/^\s*#.*$/, '');
|
|
34
|
+
const indentOf = (l) => l.match(/^ */)[0].length;
|
|
35
|
+
const unquote = (s) => s.trim().replace(/^['"]|['"]$/g, '');
|
|
36
|
+
const flowList = (s) => {
|
|
37
|
+
const m = s.trim().match(/^\[(.*)\]$/);
|
|
38
|
+
return m ? m[1].split(',').map(unquote).filter(Boolean) : null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function parseWorkflowFacts(text) {
|
|
42
|
+
const errors = [];
|
|
43
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n').map(stripComment);
|
|
44
|
+
const triggers = [];
|
|
45
|
+
const onIdx = lines.findIndex((l) => /^["']?on["']?\s*:/.test(l));
|
|
46
|
+
if (onIdx < 0) errors.push('no top-level `on:` block');
|
|
47
|
+
else {
|
|
48
|
+
const rest = lines[onIdx].replace(/^["']?on["']?\s*:/, '').trim();
|
|
49
|
+
if (rest) {
|
|
50
|
+
const list = flowList(rest);
|
|
51
|
+
if (list) triggers.push(...list);
|
|
52
|
+
else if (/^\{.*\}$/.test(rest)) {
|
|
53
|
+
triggers.push(...rest.slice(1, -1).split(',').map((p) => unquote(p.split(':')[0])).filter(Boolean));
|
|
54
|
+
} else triggers.push(unquote(rest));
|
|
55
|
+
} else {
|
|
56
|
+
const block = [];
|
|
57
|
+
for (let i = onIdx + 1; i < lines.length; i += 1) {
|
|
58
|
+
if (!lines[i].trim()) continue;
|
|
59
|
+
if (indentOf(lines[i]) === 0) break;
|
|
60
|
+
block.push(lines[i]);
|
|
61
|
+
}
|
|
62
|
+
const min = Math.min(...block.map(indentOf));
|
|
63
|
+
for (const l of block) {
|
|
64
|
+
if (indentOf(l) !== min) continue;
|
|
65
|
+
const m = l.trim().match(/^(?:-\s*)?["']?([A-Za-z_]+)["']?\s*:?/);
|
|
66
|
+
if (m) triggers.push(m[1]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const jobs = [];
|
|
72
|
+
const jobsIdx = lines.findIndex((l) => /^jobs\s*:/.test(l));
|
|
73
|
+
if (jobsIdx < 0) errors.push('no `jobs:` block');
|
|
74
|
+
else {
|
|
75
|
+
const body = [];
|
|
76
|
+
for (let i = jobsIdx + 1; i < lines.length; i += 1) {
|
|
77
|
+
if (lines[i].trim() && indentOf(lines[i]) === 0) break;
|
|
78
|
+
body.push(lines[i]);
|
|
79
|
+
}
|
|
80
|
+
const nonEmpty = body.filter((l) => l.trim());
|
|
81
|
+
const jobIndent = nonEmpty.length ? Math.min(...nonEmpty.map(indentOf)) : 0;
|
|
82
|
+
let cur = null;
|
|
83
|
+
for (const l of body) {
|
|
84
|
+
if (!l.trim()) continue;
|
|
85
|
+
if (indentOf(l) === jobIndent && /^\s*[\w-]+\s*:\s*$/.test(l)) {
|
|
86
|
+
cur = { id: l.trim().slice(0, -1), lines: [] };
|
|
87
|
+
jobs.push(cur);
|
|
88
|
+
} else if (cur) cur.lines.push(l);
|
|
89
|
+
}
|
|
90
|
+
for (const job of jobs) {
|
|
91
|
+
job.timeoutMinutes = null;
|
|
92
|
+
job.runsOn = null;
|
|
93
|
+
job.matrix = {};
|
|
94
|
+
let inMatrix = false;
|
|
95
|
+
let matrixIndent = -1;
|
|
96
|
+
for (const l of job.lines) {
|
|
97
|
+
const t = l.trim();
|
|
98
|
+
const ind = indentOf(l);
|
|
99
|
+
let m = t.match(/^timeout-minutes\s*:\s*(\d+)$/);
|
|
100
|
+
if (m && ind === jobIndent + 2) job.timeoutMinutes = Number(m[1]);
|
|
101
|
+
m = t.match(/^runs-on\s*:\s*(.+)$/);
|
|
102
|
+
if (m && ind === jobIndent + 2) job.runsOn = unquote(m[1]);
|
|
103
|
+
if (/^matrix\s*:\s*$/.test(t)) { inMatrix = true; matrixIndent = ind; continue; }
|
|
104
|
+
if (inMatrix && ind <= matrixIndent) inMatrix = false;
|
|
105
|
+
if (inMatrix) {
|
|
106
|
+
m = t.match(/^([\w-]+)\s*:\s*(.*)$/);
|
|
107
|
+
if (!m) { errors.push(`job ${job.id}: unsupported matrix line "${t}"`); continue; }
|
|
108
|
+
if (m[1] === 'include' || m[1] === 'exclude') { errors.push(`job ${job.id}: matrix ${m[1]} is not supported (fail-closed)`); continue; }
|
|
109
|
+
const list = flowList(m[2]);
|
|
110
|
+
if (!list) errors.push(`job ${job.id}: matrix.${m[1]} must be a flow list`);
|
|
111
|
+
else job.matrix[m[1]] = list;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
delete job.lines;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { ok: errors.length === 0, errors, triggers, jobs };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function assertManualOnly(text) {
|
|
121
|
+
const facts = parseWorkflowFacts(text);
|
|
122
|
+
const only = facts.triggers.length === 1 && facts.triggers[0] === 'workflow_dispatch';
|
|
123
|
+
const errors = [...facts.errors];
|
|
124
|
+
if (!only) errors.push(`triggers must be exactly [workflow_dispatch], found [${facts.triggers.join(', ') || 'none'}]`);
|
|
125
|
+
return { ok: errors.length === 0, triggers: facts.triggers, errors };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function workflowReferencedPaths(text) {
|
|
129
|
+
const out = new Set();
|
|
130
|
+
const code = text.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
131
|
+
for (const m of code.matchAll(/(?:\.github\/ci\/[\w.-]+\.mjs|tests\/[\w/.-]+\.test\.js|tests\/python\/[\w.-]+\.(?:mjs|txt))/g)) out.add(m[0]);
|
|
132
|
+
return [...out].sort();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------- budget guard (fail-closed)
|
|
136
|
+
export function loadPolicy(root) {
|
|
137
|
+
const p = path.join(root, '.ukit', 'storage', 'config.json');
|
|
138
|
+
let override = {};
|
|
139
|
+
if (fs.existsSync(p)) {
|
|
140
|
+
try { override = JSON.parse(fs.readFileSync(p, 'utf8')).ciCheck || {}; } catch { return { policy: { ...DEFAULTS }, error: `${p} is not valid JSON` }; }
|
|
141
|
+
}
|
|
142
|
+
return { policy: { ...DEFAULTS, ...override }, error: null };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function budgetGuard(text, policy) {
|
|
146
|
+
const failures = [];
|
|
147
|
+
const facts = parseWorkflowFacts(text);
|
|
148
|
+
failures.push(...facts.errors);
|
|
149
|
+
let jobCount = 0;
|
|
150
|
+
let billed = 0;
|
|
151
|
+
const perOs = {};
|
|
152
|
+
for (const job of facts.jobs) {
|
|
153
|
+
if (job.timeoutMinutes == null) { failures.push(`job ${job.id}: timeout-minutes missing`); continue; }
|
|
154
|
+
const dims = Object.values(job.matrix);
|
|
155
|
+
const osList = job.runsOn && /\$\{\{\s*matrix\.os\s*\}\}/.test(job.runsOn) ? job.matrix.os : job.runsOn ? [job.runsOn] : null;
|
|
156
|
+
if (!osList) { failures.push(`job ${job.id}: runs-on not resolvable`); continue; }
|
|
157
|
+
const others = dims.filter((d) => d !== job.matrix.os).reduce((n, d) => n * d.length, 1);
|
|
158
|
+
for (const label of osList) {
|
|
159
|
+
if (!STANDARD_RUNNER.test(label)) { failures.push(`job ${job.id}: runner "${label}" is not a standard GitHub-hosted runner`); continue; }
|
|
160
|
+
const family = label.split('-')[0];
|
|
161
|
+
const n = others;
|
|
162
|
+
jobCount += n;
|
|
163
|
+
const minutes = n * job.timeoutMinutes * MINUTE_MULTIPLIER[family];
|
|
164
|
+
billed += minutes;
|
|
165
|
+
perOs[family] = (perOs[family] || 0) + minutes;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (jobCount === 0) failures.push('no resolvable jobs');
|
|
169
|
+
if (jobCount > policy.maxJobs) failures.push(`${jobCount} jobs exceeds ciCheck.maxJobs=${policy.maxJobs}`);
|
|
170
|
+
if (billed > policy.maxBilledMinutesPerRun) failures.push(`worst-case ${billed} billed minutes exceeds ciCheck.maxBilledMinutesPerRun=${policy.maxBilledMinutesPerRun}`);
|
|
171
|
+
if (policy.maxDispatchPerInvocation !== 1) failures.push('ciCheck.maxDispatchPerInvocation must be 1');
|
|
172
|
+
return {
|
|
173
|
+
ok: failures.length === 0,
|
|
174
|
+
failures,
|
|
175
|
+
estimate: {
|
|
176
|
+
jobs: jobCount,
|
|
177
|
+
worstCaseBilledMinutes: billed,
|
|
178
|
+
perRunner: perOs,
|
|
179
|
+
note: 'Worst case = timeout-minutes x runner multiplier (Linux 1x, Windows 2x, macOS 10x). Actual usage is usually far lower.',
|
|
180
|
+
monthlyFreeMinutes: policy.monthlyFreeMinutes,
|
|
181
|
+
shareOfMonthlyFree: policy.monthlyFreeMinutes ? Number((billed / policy.monthlyFreeMinutes).toFixed(3)) : null,
|
|
182
|
+
billingVerified: false,
|
|
183
|
+
quotaNote: 'ESTIMATE ONLY (timeout x multiplier). Billing/quota is NOT verified: this does not guarantee the run fits in any free quota. Check Settings > Billing > Actions and set a spending budget with a hard stop yourself.',
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------- state (write-ahead)
|
|
189
|
+
const stateDir = (root) => path.join(root, '.ukit', 'storage', 'ci-check');
|
|
190
|
+
const statePath = (root) => path.join(stateDir(root), 'state.json');
|
|
191
|
+
|
|
192
|
+
export class StateCorruptError extends Error {}
|
|
193
|
+
// Missing file = empty history. An unreadable/corrupt file must NOT read as empty: that would forget a
|
|
194
|
+
// past dispatch and allow a duplicate one (fail closed instead).
|
|
195
|
+
export function readState(root) {
|
|
196
|
+
let raw;
|
|
197
|
+
try { raw = fs.readFileSync(statePath(root), 'utf8'); } catch (e) {
|
|
198
|
+
if (e.code === 'ENOENT') return { version: 1, invocations: [] };
|
|
199
|
+
throw new StateCorruptError(`${statePath(root)} unreadable: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const st = JSON.parse(raw);
|
|
203
|
+
if (!st || !Array.isArray(st.invocations)) throw new Error('missing invocations[]');
|
|
204
|
+
return st;
|
|
205
|
+
} catch (e) { throw new StateCorruptError(`${statePath(root)} is corrupt (${e.message}); inspect or move it aside, UKit will not guess`); }
|
|
206
|
+
}
|
|
207
|
+
export function writeState(root, state) {
|
|
208
|
+
fs.mkdirSync(stateDir(root), { recursive: true });
|
|
209
|
+
const tmp = `${statePath(root)}.${process.pid}.tmp`;
|
|
210
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
211
|
+
fs.renameSync(tmp, statePath(root));
|
|
212
|
+
}
|
|
213
|
+
function updateRecord(root, id, patch) {
|
|
214
|
+
const state = readState(root);
|
|
215
|
+
const rec = state.invocations.find((r) => r.id === id);
|
|
216
|
+
if (rec) Object.assign(rec, patch);
|
|
217
|
+
writeState(root, state);
|
|
218
|
+
return rec;
|
|
219
|
+
}
|
|
220
|
+
const pidAlive = (pid) => {
|
|
221
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
222
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
223
|
+
};
|
|
224
|
+
// A lock is only taken over when its owner process is gone. An old lock whose owner is still alive is a
|
|
225
|
+
// slow dispatcher, not a crashed one: stealing it would allow two dispatches for one invocation.
|
|
226
|
+
function acquireLock(root, policy, now) {
|
|
227
|
+
fs.mkdirSync(stateDir(root), { recursive: true });
|
|
228
|
+
const lock = path.join(stateDir(root), 'dispatch.lock');
|
|
229
|
+
const mine = JSON.stringify({ pid: process.pid, at: now() });
|
|
230
|
+
const tryCreate = () => { fs.writeFileSync(lock, mine, { flag: 'wx' }); return true; };
|
|
231
|
+
try { tryCreate(); } catch (e) {
|
|
232
|
+
if (e.code !== 'EEXIST') return null;
|
|
233
|
+
let owner = null;
|
|
234
|
+
try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')).pid; } catch { /* unreadable: treat as unknown owner */ }
|
|
235
|
+
const ageMs = (() => { try { return now() - fs.statSync(lock).mtimeMs; } catch { return 0; } })();
|
|
236
|
+
const dead = owner != null ? !pidAlive(owner) : ageMs > policy.lockStaleMs;
|
|
237
|
+
if (!dead) return null;
|
|
238
|
+
// Atomic takeover: only one contender can rename the stale lock away.
|
|
239
|
+
try { fs.renameSync(lock, `${lock}.stale.${process.pid}`); } catch { return null; }
|
|
240
|
+
try { fs.rmSync(`${lock}.stale.${process.pid}`, { force: true }); tryCreate(); } catch { return null; }
|
|
241
|
+
}
|
|
242
|
+
return () => {
|
|
243
|
+
try { if (JSON.parse(fs.readFileSync(lock, 'utf8')).pid === process.pid) fs.rmSync(lock); } catch { /* already gone */ }
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------- deps
|
|
248
|
+
export function defaultDeps(root) {
|
|
249
|
+
const exec = (cmd, args, opts = {}) => {
|
|
250
|
+
const r = spawnSync(cmd, args, { cwd: root, encoding: 'utf8', shell: process.platform === 'win32' && cmd !== 'git' && cmd !== 'gh', maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
251
|
+
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
git: (args) => exec('git', args),
|
|
255
|
+
gh: (args) => exec('gh', args),
|
|
256
|
+
run: (cmd, args) => exec(cmd, args),
|
|
257
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
258
|
+
now: () => Date.now(),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const ghJson = (deps, args) => {
|
|
263
|
+
const r = deps.gh(args);
|
|
264
|
+
if (r.code !== 0) return { ok: false, error: (r.stderr || r.stdout).trim() };
|
|
265
|
+
try { return { ok: true, data: JSON.parse(r.stdout || 'null') }; } catch { return { ok: false, error: 'invalid JSON from gh' }; }
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------- args
|
|
269
|
+
export function parseArgs(argv) {
|
|
270
|
+
const a = { mode: 'run', ref: null, dryRun: false, explicit: false, wait: false, maxWaitMs: null, json: false, runId: null, workflow: null, allowSameSha: false, errors: [] };
|
|
271
|
+
const args = [...argv];
|
|
272
|
+
if (args[0] === 'status') { a.mode = 'status'; args.shift(); } else if (args[0] === 'run') args.shift();
|
|
273
|
+
while (args.length) {
|
|
274
|
+
const t = args.shift();
|
|
275
|
+
if (t === '--ref') a.ref = args.shift() ?? '';
|
|
276
|
+
else if (t === '--dry-run') a.dryRun = true;
|
|
277
|
+
else if (t === '--explicit') a.explicit = true;
|
|
278
|
+
else if (t === '--wait') a.wait = true;
|
|
279
|
+
else if (t === '--max-wait') { const n = Number(args.shift()); if (Number.isFinite(n) && n > 0) a.maxWaitMs = n * 1000; else a.errors.push('--max-wait needs seconds > 0'); }
|
|
280
|
+
else if (t === '--json') a.json = true;
|
|
281
|
+
else if (t === '--allow-same-sha') a.allowSameSha = true;
|
|
282
|
+
else if (t === '--workflow') a.workflow = args.shift() ?? '';
|
|
283
|
+
else if (/^\d+$/.test(t) && a.mode === 'status') a.runId = t;
|
|
284
|
+
else a.errors.push(`unknown argument: ${t}`);
|
|
285
|
+
}
|
|
286
|
+
for (const [k, v] of [['ref', a.ref], ['workflow', a.workflow]]) {
|
|
287
|
+
if (v !== null && (!SAFE_NAME.test(v) || v.startsWith('-'))) a.errors.push(`invalid --${k} value`);
|
|
288
|
+
}
|
|
289
|
+
if (a.mode === 'status' && a.dryRun) a.errors.push('--dry-run does not apply to status');
|
|
290
|
+
return a;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------- prechecks
|
|
294
|
+
function gitShow(deps, sha, p) {
|
|
295
|
+
const r = deps.git(['show', `${sha}:${p}`]);
|
|
296
|
+
return r.code === 0 ? r.stdout : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function precheck({ root, deps, args, policy, policyError }) {
|
|
300
|
+
const blockers = [];
|
|
301
|
+
const warnings = [];
|
|
302
|
+
const checks = [];
|
|
303
|
+
const note = (name, ok, detail) => { checks.push({ name, ok, detail }); if (!ok) blockers.push(`${name}: ${detail}`); };
|
|
304
|
+
const ctx = { workflow: args.workflow || policy.workflow };
|
|
305
|
+
|
|
306
|
+
note('git-repository', deps.git(['rev-parse', '--is-inside-work-tree']).code === 0, 'not inside a Git repository');
|
|
307
|
+
const remote = deps.git(['remote', 'get-url', 'origin']);
|
|
308
|
+
note('git-remote', remote.code === 0, 'no `origin` remote');
|
|
309
|
+
let ref = args.ref;
|
|
310
|
+
if (!ref) {
|
|
311
|
+
const b = deps.git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
312
|
+
ref = b.code === 0 ? b.stdout.trim() : '';
|
|
313
|
+
if (b.code !== 0) { const sym = deps.git(['symbolic-ref', '--short', 'HEAD']); ref = sym.code === 0 ? sym.stdout.trim() : ''; }
|
|
314
|
+
if (!ref || ref === 'HEAD') { note('git-branch', false, 'detached HEAD; pass --ref <branch>'); ref = null; }
|
|
315
|
+
else if (b.code !== 0) { note('git-commits', false, `branch "${ref}" has no commits yet`); }
|
|
316
|
+
}
|
|
317
|
+
ctx.ref = ref;
|
|
318
|
+
if (blockers.length) return { ...ctx, blockers, warnings, checks };
|
|
319
|
+
|
|
320
|
+
note('gh-auth', deps.gh(['auth', 'status']).code === 0, 'GitHub CLI is not authenticated (run `gh auth login`)');
|
|
321
|
+
|
|
322
|
+
const local = deps.git(['rev-parse', '--verify', `refs/heads/${ref}`]);
|
|
323
|
+
const lsr = deps.git(['ls-remote', '--heads', 'origin', `refs/heads/${ref}`]);
|
|
324
|
+
const remoteSha = lsr.code === 0 ? (lsr.stdout.trim().split(/\s+/)[0] || null) : null;
|
|
325
|
+
if (lsr.code !== 0) note('remote-branch', false, `cannot query remote: ${lsr.stderr.trim()}`);
|
|
326
|
+
else if (!remoteSha) note('remote-branch', false, `branch "${ref}" does not exist on origin. UKit will NOT push; pushing needs your separate approval`);
|
|
327
|
+
else if (local.code === 0 && local.stdout.trim() !== remoteSha) {
|
|
328
|
+
note('remote-commit', false, `local ${local.stdout.trim().slice(0, 8)} differs from origin ${remoteSha.slice(0, 8)}; CI would test the remote commit. Pushing needs your separate approval`);
|
|
329
|
+
} else checks.push({ name: 'remote-commit', ok: true, detail: remoteSha.slice(0, 8) });
|
|
330
|
+
ctx.sha = remoteSha;
|
|
331
|
+
|
|
332
|
+
const wfPath = `.github/workflows/${ctx.workflow}`;
|
|
333
|
+
const dirty = deps.git(['status', '--porcelain', '--', '.github']);
|
|
334
|
+
if (dirty.stdout.trim()) warnings.push('uncommitted changes under .github/: CI runs the committed remote version');
|
|
335
|
+
|
|
336
|
+
let wfText = null;
|
|
337
|
+
let siblings = [];
|
|
338
|
+
if (remoteSha) {
|
|
339
|
+
wfText = gitShow(deps, remoteSha, wfPath);
|
|
340
|
+
if (wfText === null) note('workflow-file', false, `${wfPath} is not present at ${remoteSha.slice(0, 8)} (fetch the branch, or the workflow is missing)`);
|
|
341
|
+
const ls = deps.git(['ls-tree', '--name-only', remoteSha, '.github/workflows/']);
|
|
342
|
+
siblings = ls.code === 0 ? ls.stdout.split('\n').filter((f) => /\.ya?ml$/.test(f)) : [];
|
|
343
|
+
for (const f of siblings) {
|
|
344
|
+
const t = gitShow(deps, remoteSha, f);
|
|
345
|
+
const m = t === null ? { ok: false, errors: ['unreadable'] } : assertManualOnly(t);
|
|
346
|
+
note(`manual-only:${path.basename(f)}`, m.ok, m.errors.join('; '));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
ctx.workflowText = wfText;
|
|
350
|
+
|
|
351
|
+
const repo = ghJson(deps, ['repo', 'view', '--json', 'nameWithOwner,isPrivate,defaultBranchRef']);
|
|
352
|
+
if (!repo.ok) note('gh-repo', false, repo.error);
|
|
353
|
+
else {
|
|
354
|
+
ctx.repo = repo.data.nameWithOwner;
|
|
355
|
+
ctx.private = repo.data.isPrivate;
|
|
356
|
+
ctx.defaultBranch = repo.data.defaultBranchRef?.name;
|
|
357
|
+
}
|
|
358
|
+
const wfs = ghJson(deps, ['workflow', 'list', '--all', '--json', 'name,path,state']);
|
|
359
|
+
if (!wfs.ok) note('workflow-registered', false, wfs.error);
|
|
360
|
+
else {
|
|
361
|
+
const reg = wfs.data.find((w) => w.path === wfPath);
|
|
362
|
+
if (!reg) note('workflow-registered', false, `${wfPath} is not registered on the default branch (${ctx.defaultBranch || '?'}). workflow_dispatch needs a bootstrap stub there; see docs/design/CI-CROSS-PLATFORM-MATRIX.md "Default-branch bootstrap". Not pushing`);
|
|
363
|
+
else if (reg.state !== 'active') note('workflow-registered', false, `workflow state is "${reg.state}"`);
|
|
364
|
+
else checks.push({ name: 'workflow-registered', ok: true, detail: reg.state });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const runs = ghJson(deps, ['run', 'list', '--workflow', ctx.workflow, '--limit', '20', '--json', 'databaseId,status,conclusion,headSha,headBranch,event,createdAt,url']);
|
|
368
|
+
if (!runs.ok) note('existing-runs', false, runs.error);
|
|
369
|
+
else {
|
|
370
|
+
ctx.knownRunIds = runs.data.map((r) => r.databaseId);
|
|
371
|
+
const flightHere = runs.data.filter((r) => IN_FLIGHT.has(r.status) && r.headBranch === ref);
|
|
372
|
+
for (const r of flightHere) note('duplicate-run', false, `run ${r.databaseId} is already ${r.status} on "${ref}" (${r.url}); use \`/ukit:ci-check status\``);
|
|
373
|
+
const sameSha = runs.data.find((r) => r.headSha === remoteSha && r.status === 'completed');
|
|
374
|
+
if (sameSha && !args.allowSameSha) note('duplicate-run', false, `run ${sameSha.databaseId} already completed (${sameSha.conclusion}) for ${remoteSha?.slice(0, 8)}; pass --allow-same-sha to run again`);
|
|
375
|
+
const elsewhere = runs.data.filter((r) => IN_FLIGHT.has(r.status) && r.headBranch !== ref);
|
|
376
|
+
if (elsewhere.length) warnings.push(`${elsewhere.length} other in-flight run(s) on other refs`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let state = null;
|
|
380
|
+
try { state = readState(root); } catch (e) { note('state-file', false, e.message); }
|
|
381
|
+
for (const r of state?.invocations || []) {
|
|
382
|
+
if (!(r.workflow === ctx.workflow && ['dispatching', 'dispatched'].includes(r.status) && !r.concluded)) continue;
|
|
383
|
+
if (r.sha !== remoteSha && !r.runId) continue;
|
|
384
|
+
const age = deps.now() - Date.parse(r.dispatchedAt || r.startedAt);
|
|
385
|
+
const staleAfter = r.status === 'dispatching' ? policy.lockStaleMs : policy.staleInvocationMs;
|
|
386
|
+
if (!r.runId && age > staleAfter) {
|
|
387
|
+
// Interrupted record. Read-only look for a run it may have created; only if none exists is it abandoned.
|
|
388
|
+
const found = findRunForRecord(deps, r, ctx.workflow);
|
|
389
|
+
if (!found) { warnings.push(`invocation ${r.id} was ${r.status} for ${Math.round(age / 60000)} min with no run visible on GitHub: treated as abandoned`); continue; }
|
|
390
|
+
}
|
|
391
|
+
if (r.sha !== remoteSha && r.runId == null) continue;
|
|
392
|
+
note('duplicate-invocation', false, `invocation ${r.id} already ${r.status}${r.runId ? ` (run ${r.runId})` : ''}; use \`/ukit:ci-check status\``);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (policyError) note('budget-guard', false, policyError);
|
|
396
|
+
else if (wfText) {
|
|
397
|
+
const g = budgetGuard(wfText, policy);
|
|
398
|
+
ctx.estimate = g.estimate;
|
|
399
|
+
if (!g.ok) for (const f of g.failures) note('budget-guard', false, f);
|
|
400
|
+
else {
|
|
401
|
+
checks.push({ name: 'budget-guard', ok: true, detail: `${g.estimate.jobs} jobs, worst case ${g.estimate.worstCaseBilledMinutes} billed min` });
|
|
402
|
+
const missing = ['windows', 'macos', 'ubuntu'].filter((f) => !(f in g.estimate.perRunner));
|
|
403
|
+
if (missing.length) note('real-matrix', false, `workflow at the ref does not cover ${missing.join(', ')}: it looks like the bootstrap stub or an incomplete matrix, not the real cross-platform CI. Not dispatching`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { ...ctx, blockers, warnings, checks };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function localPreflight({ root, deps, ctx }) {
|
|
410
|
+
const results = [];
|
|
411
|
+
const add = (name, ok, detail = '') => results.push({ name, ok, detail });
|
|
412
|
+
for (const p of workflowReferencedPaths(ctx.workflowText || '')) {
|
|
413
|
+
const r = deps.git(['cat-file', '-e', `${ctx.sha}:${p}`]);
|
|
414
|
+
add(`tracked:${p}`, r.code === 0, r.code === 0 ? '' : 'referenced by the workflow but missing from the remote commit (clean checkout would fail)');
|
|
415
|
+
}
|
|
416
|
+
const lock = deps.git(['cat-file', '-e', `${ctx.sha}:yarn.lock`]);
|
|
417
|
+
if (fs.existsSync(path.join(root, 'package.json'))) add('tracked:yarn.lock', lock.code === 0, 'yarn.lock missing from remote commit (frozen-lockfile install fails)');
|
|
418
|
+
let scripts = {};
|
|
419
|
+
try { scripts = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).scripts || {}; } catch { /* no package.json */ }
|
|
420
|
+
const hasPkg = fs.existsSync(path.join(root, 'package.json'));
|
|
421
|
+
if (!hasPkg) add('local-checks', true, 'SKIPPED: no package.json at the project root, docs/registry checks not run');
|
|
422
|
+
if (hasPkg && scripts['docs:render:check']) { const r = deps.run('yarn', ['docs:render:check']); add('docs:render:check', r.code === 0, r.code === 0 ? '' : (r.stdout + r.stderr).trim().split('\n').slice(-8).join('\n')); }
|
|
423
|
+
if (hasPkg && fs.existsSync(path.join(root, 'tests/consistency/docRegistry.test.js'))) {
|
|
424
|
+
const r = deps.run('yarn', ['vitest', 'run', 'tests/consistency/docRegistry.test.js']);
|
|
425
|
+
add('registry:docRegistry', r.code === 0, r.code === 0 ? '' : (r.stdout + r.stderr).trim().split('\n').slice(-8).join('\n'));
|
|
426
|
+
}
|
|
427
|
+
return { ok: results.every((r) => r.ok), results };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------- run discovery + dispatch
|
|
431
|
+
const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
432
|
+
const RUN_FIELDS = 'databaseId,headSha,createdAt,status,url';
|
|
433
|
+
// Run created by THIS dispatch: a dispatch-event run that was not in the pre-dispatch snapshot. Identity
|
|
434
|
+
// comes from the snapshot, not the local clock (GitHub/local clock skew must not lose the run).
|
|
435
|
+
async function discoverRun({ deps, workflow, ref, knownRunIds, policy }) {
|
|
436
|
+
for (let i = 0; i < policy.discoverAttempts; i += 1) {
|
|
437
|
+
const r = ghJson(deps, ['run', 'list', '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--limit', '10', '--json', RUN_FIELDS]);
|
|
438
|
+
if (r.ok) {
|
|
439
|
+
const fresh = r.data.filter((x) => !knownRunIds.includes(x.databaseId));
|
|
440
|
+
if (fresh.length) return fresh.sort((a, b) => b.databaseId - a.databaseId)[0];
|
|
441
|
+
}
|
|
442
|
+
if (i < policy.discoverAttempts - 1) await deps.sleep(policy.discoverDelayMs);
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
// Resume path (no snapshot): same ref + the SHA recorded at dispatch + created after the dispatch (skew-tolerant).
|
|
447
|
+
// Ambiguous (more than one candidate) => no guess.
|
|
448
|
+
function findRunForRecord(deps, rec, workflow) {
|
|
449
|
+
const r = ghJson(deps, ['run', 'list', '--workflow', workflow, '--branch', rec.ref, '--event', 'workflow_dispatch', '--limit', '20', '--json', RUN_FIELDS]);
|
|
450
|
+
if (!r.ok) return null;
|
|
451
|
+
const since = Date.parse(rec.dispatchedAt || rec.startedAt) - CLOCK_SKEW_MS;
|
|
452
|
+
const cands = r.data.filter((x) => x.headSha === rec.sha && Date.parse(x.createdAt) >= since);
|
|
453
|
+
return cands.length === 1 ? cands[0] : null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function runCheck({ root, args, deps = defaultDeps(root) }) {
|
|
457
|
+
const { policy, error: policyError } = loadPolicy(root);
|
|
458
|
+
const pre = precheck({ root, deps, args, policy, policyError });
|
|
459
|
+
const out = { mode: args.dryRun ? 'dry-run' : 'run', ref: pre.ref, sha: pre.sha, workflow: pre.workflow, repo: pre.repo, private: pre.private, checks: pre.checks, warnings: pre.warnings, blockers: [...pre.blockers], estimate: pre.estimate || null, preflight: null, dispatched: false };
|
|
460
|
+
if (!args.dryRun && !args.explicit) out.blockers.push('dispatch requires the explicit /ukit:ci-check invocation (--explicit)');
|
|
461
|
+
if (out.blockers.length) return { ...out, ok: false, outcome: 'BLOCKED' };
|
|
462
|
+
|
|
463
|
+
out.preflight = localPreflight({ root, deps, ctx: pre });
|
|
464
|
+
if (!out.preflight.ok) {
|
|
465
|
+
out.blockers.push(...out.preflight.results.filter((r) => !r.ok).map((r) => `preflight ${r.name}: ${r.detail}`));
|
|
466
|
+
return { ...out, ok: false, outcome: 'PREFLIGHT_FAILED' };
|
|
467
|
+
}
|
|
468
|
+
if (args.dryRun) return { ...out, ok: true, outcome: 'DRY_RUN' };
|
|
469
|
+
|
|
470
|
+
const release = acquireLock(root, policy, deps.now);
|
|
471
|
+
if (!release) return { ...out, blockers: [...out.blockers, 'another /ukit:ci-check invocation holds the dispatch lock'], ok: false, outcome: 'BLOCKED' };
|
|
472
|
+
try {
|
|
473
|
+
// Re-read state under the lock so a concurrent invocation cannot slip through.
|
|
474
|
+
let fresh;
|
|
475
|
+
try { fresh = readState(root); } catch (e) { return { ...out, blockers: [...out.blockers, `state-file: ${e.message}`], ok: false, outcome: 'BLOCKED' }; }
|
|
476
|
+
const dup = fresh.invocations.find((r) => r.workflow === pre.workflow && r.sha === pre.sha && ['dispatching', 'dispatched'].includes(r.status) && !r.concluded
|
|
477
|
+
&& (r.runId || deps.now() - Date.parse(r.dispatchedAt || r.startedAt) <= (r.status === 'dispatching' ? policy.lockStaleMs : policy.staleInvocationMs)));
|
|
478
|
+
if (dup) return { ...out, blockers: [...out.blockers, `invocation ${dup.id} already ${dup.status}`], ok: false, outcome: 'BLOCKED' };
|
|
479
|
+
|
|
480
|
+
const id = `ci-${deps.now()}`;
|
|
481
|
+
const dispatchedAt = deps.now();
|
|
482
|
+
const state = fresh;
|
|
483
|
+
for (const r of state.invocations) if (!r.runId && !r.concluded && ['dispatching', 'dispatched'].includes(r.status)) { r.status = 'abandoned'; r.concluded = true; }
|
|
484
|
+
state.invocations.push({ id, workflow: pre.workflow, ref: pre.ref, sha: pre.sha, repo: pre.repo, status: 'dispatching', runId: null, url: null, startedAt: new Date(dispatchedAt).toISOString() });
|
|
485
|
+
writeState(root, state); // write-ahead: survives an interruption between dispatch and run-id capture
|
|
486
|
+
|
|
487
|
+
const d = deps.gh(['workflow', 'run', pre.workflow, '--ref', pre.ref]); // the ONE dispatch; never retried
|
|
488
|
+
if (d.code !== 0) {
|
|
489
|
+
updateRecord(root, id, { status: 'dispatch-failed', error: (d.stderr || d.stdout).trim(), concluded: true });
|
|
490
|
+
return { ...out, ok: false, outcome: 'DISPATCH_FAILED', error: (d.stderr || d.stdout).trim(), invocationId: id };
|
|
491
|
+
}
|
|
492
|
+
updateRecord(root, id, { status: 'dispatched', dispatchedAt: new Date(dispatchedAt).toISOString() });
|
|
493
|
+
const run = await discoverRun({ deps, workflow: pre.workflow, ref: pre.ref, knownRunIds: pre.knownRunIds || [], policy });
|
|
494
|
+
if (run) updateRecord(root, id, { runId: run.databaseId, url: run.url, runSha: run.headSha });
|
|
495
|
+
if (run && run.headSha !== pre.sha) out.warnings.push(`run ${run.databaseId} tests ${String(run.headSha).slice(0, 8)}, not the validated ${String(pre.sha).slice(0, 8)} (branch moved during dispatch)`);
|
|
496
|
+
return { ...out, dispatched: true, ok: true, outcome: run ? 'DISPATCHED' : 'DISPATCHED_RUN_ID_PENDING', invocationId: id, runId: run?.databaseId ?? null, url: run?.url ?? null };
|
|
497
|
+
} finally { release(); }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------- status / report
|
|
501
|
+
const STAGES = [
|
|
502
|
+
['checkout', /checkout/i],
|
|
503
|
+
['pythonNodeSetup', /setup-(node|python)|toolchain versions/i],
|
|
504
|
+
['homeIsolation', /native gates/i],
|
|
505
|
+
['documentationRegistry', /documentation registry/i],
|
|
506
|
+
['hooks', /native gates/i],
|
|
507
|
+
['artifactChecks', /artifact checks/i],
|
|
508
|
+
['npmPack', /npm pack dry-run/i],
|
|
509
|
+
['userLayerSafety', /user layer/i],
|
|
510
|
+
];
|
|
511
|
+
const rank = { failure: 4, timed_out: 4, cancelled: 3, skipped: 1, success: 0 };
|
|
512
|
+
|
|
513
|
+
const osOf = (jobName) => {
|
|
514
|
+
const fam = (jobName.match(/^(ubuntu|windows|macos)/i) || [])[1]?.toLowerCase();
|
|
515
|
+
return fam ? { family: fam, label: OS_LABEL[fam] } : null;
|
|
516
|
+
};
|
|
517
|
+
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, '');
|
|
518
|
+
|
|
519
|
+
// The workflow runs vitest several times per job: sum EVERY "Tests ..." summary, never just the first.
|
|
520
|
+
export function parseTestCounts(log) {
|
|
521
|
+
const all = [...stripAnsi(log).matchAll(/^\S*\s*Tests\s+([^\n(]*?)\s*\((\d+)\)/gm)];
|
|
522
|
+
if (!all.length) return null;
|
|
523
|
+
const n = (m, k) => Number((m[1].match(new RegExp(`(\\d+)\\s+${k}`)) || [0, 0])[1]);
|
|
524
|
+
return all.reduce((t, m) => ({ passed: t.passed + n(m, 'passed'), failed: t.failed + n(m, 'failed'), skipped: t.skipped + n(m, 'skipped'), total: t.total + Number(m[2]) }), { passed: 0, failed: 0, skipped: 0, total: 0 });
|
|
525
|
+
}
|
|
526
|
+
export function skippedTestNames(log) {
|
|
527
|
+
return [...stripAnsi(log).matchAll(/(?:↓|SKIPPED|skipped)[:\s]+(.+)/g)].map((m) => m[1].trim()).filter((s) => s.length < 200).slice(0, 20);
|
|
528
|
+
}
|
|
529
|
+
export function failureEvidence(log) {
|
|
530
|
+
return stripAnsi(log).split('\n').map((l) => l.replace(/^.*?\d{4}-\d\d-\d\dT[\d:.]+Z\s?/, '').trim())
|
|
531
|
+
.filter((l) => /(FAIL|AssertionError|Error:|ENOENT|✗|×|exit code|not found)/.test(l)).slice(0, 12);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function buildReport(run, logs = {}) {
|
|
535
|
+
const expected = ['WINDOWS', 'MACOS', 'LINUX'];
|
|
536
|
+
const platforms = {};
|
|
537
|
+
for (const label of expected) platforms[label] = { present: false };
|
|
538
|
+
const failingStages = [];
|
|
539
|
+
for (const job of run.jobs || []) {
|
|
540
|
+
const os = osOf(job.name);
|
|
541
|
+
if (!os) continue;
|
|
542
|
+
const log = logs[job.databaseId] || '';
|
|
543
|
+
const stages = {};
|
|
544
|
+
for (const [stage, re] of STAGES) {
|
|
545
|
+
const steps = (job.steps || []).filter((s) => re.test(s.name));
|
|
546
|
+
if (!steps.length) { stages[stage] = 'N/A'; continue; }
|
|
547
|
+
const worst = steps.map((s) => (s.status !== 'completed' ? 'pending' : s.conclusion || 'unknown'))
|
|
548
|
+
.sort((a, b) => (rank[b] ?? 2) - (rank[a] ?? 2))[0];
|
|
549
|
+
stages[stage] = worst === 'success' ? 'PASS' : worst === 'skipped' ? 'SKIP' : worst === 'pending' ? 'PENDING' : 'FAIL';
|
|
550
|
+
}
|
|
551
|
+
const failed = (job.steps || []).filter((s) => ['failure', 'timed_out'].includes(s.conclusion)).map((s) => s.name);
|
|
552
|
+
const secs = job.startedAt && job.completedAt ? Math.max(0, Math.round((Date.parse(job.completedAt) - Date.parse(job.startedAt)) / 1000)) : null;
|
|
553
|
+
// PASS needs evidence of every required stage: a green job that lacks a stage (renamed/removed step, stub) is not a pass.
|
|
554
|
+
const missingStages = Object.entries(stages).filter(([, v]) => v === 'N/A').map(([k]) => k);
|
|
555
|
+
const verdict = job.status !== 'completed' ? 'INCOMPLETE'
|
|
556
|
+
: job.conclusion === 'success' ? (missingStages.length || Object.values(stages).some((v) => v !== 'PASS') ? 'INCOMPLETE' : 'PASS')
|
|
557
|
+
: ['failure', 'timed_out'].includes(job.conclusion) ? 'FAIL' : 'INCOMPLETE';
|
|
558
|
+
platforms[os.label] = {
|
|
559
|
+
present: true, job: job.name, verdict, conclusion: job.conclusion || null, url: job.url || null, durationSeconds: secs,
|
|
560
|
+
billedMinutes: secs == null ? null : Math.ceil(secs / 60) * MINUTE_MULTIPLIER[os.family],
|
|
561
|
+
stages, missingStages, failedSteps: failed, tests: parseTestCounts(log), skippedTests: skippedTestNames(log),
|
|
562
|
+
evidence: verdict === 'FAIL' ? failureEvidence(log) : [],
|
|
563
|
+
};
|
|
564
|
+
for (const s of failed) failingStages.push(`${os.label}: ${s}`);
|
|
565
|
+
}
|
|
566
|
+
const vs = expected.map((l) => (platforms[l].present ? platforms[l].verdict : 'INCOMPLETE'));
|
|
567
|
+
const verdict = run.status !== 'completed' ? 'INCOMPLETE' : vs.includes('FAIL') ? 'FAIL' : vs.every((v) => v === 'PASS') ? 'PASS' : 'INCOMPLETE';
|
|
568
|
+
const sum = (k) => expected.reduce((n, l) => n + (platforms[l].tests?.[k] || 0), 0);
|
|
569
|
+
return {
|
|
570
|
+
verdict, runId: run.databaseId, sha: run.headSha, url: run.url, status: run.status, conclusion: run.conclusion || null,
|
|
571
|
+
platforms, failingStages,
|
|
572
|
+
totals: { total: sum('total'), passed: sum('passed'), failed: sum('failed'), skipped: sum('skipped') },
|
|
573
|
+
billedMinutes: expected.reduce((n, l) => n + (platforms[l].billedMinutes || 0), 0),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export async function statusCheck({ root, args, deps = defaultDeps(root) }) {
|
|
578
|
+
const { policy } = loadPolicy(root);
|
|
579
|
+
let state;
|
|
580
|
+
try { state = readState(root); } catch (e) { return { mode: 'status', dispatched: false, ok: false, outcome: 'BLOCKED', blockers: [`state-file: ${e.message}`] }; }
|
|
581
|
+
let rec = args.runId ? state.invocations.find((r) => String(r.runId) === args.runId) : [...state.invocations].reverse().find((r) => r.status !== 'dispatch-failed');
|
|
582
|
+
let runId = args.runId || rec?.runId || null;
|
|
583
|
+
const workflow = args.workflow || rec?.workflow || policy.workflow;
|
|
584
|
+
if (!runId && rec && ['dispatched', 'dispatching'].includes(rec.status)) { // interrupted before run-id capture: read-only discovery
|
|
585
|
+
const found = findRunForRecord(deps, rec, workflow);
|
|
586
|
+
if (found) { runId = found.databaseId; updateRecord(root, rec.id, { runId, url: found.url, runSha: found.headSha }); }
|
|
587
|
+
}
|
|
588
|
+
if (!runId) {
|
|
589
|
+
const list = ghJson(deps, ['run', 'list', '--workflow', workflow, '--limit', '5', '--json', 'databaseId,status,conclusion,headSha,headBranch,event,url']);
|
|
590
|
+
return { mode: 'status', dispatched: false, ok: list.ok, outcome: list.ok ? 'NO_TRACKED_RUN' : 'GH_ERROR', recentRuns: list.ok ? list.data : [], error: list.ok ? null : list.error };
|
|
591
|
+
}
|
|
592
|
+
// One call must fit inside a single tool call (Bash tool caps at 10 min): bounded per-call wait, resumable by re-running status.
|
|
593
|
+
const deadline = deps.now() + Math.min(args.maxWaitMs ?? policy.maxWaitPerCallMs, policy.waitTimeoutMs);
|
|
594
|
+
let readErrors = 0;
|
|
595
|
+
for (;;) {
|
|
596
|
+
const v = ghJson(deps, ['run', 'view', String(runId), '--json', 'databaseId,status,conclusion,headSha,url,jobs']);
|
|
597
|
+
if (!v.ok) {
|
|
598
|
+
readErrors += 1; // read-only poll: transient API errors are tolerated; dispatch is never retried
|
|
599
|
+
if (readErrors > policy.pollErrorTolerance || !args.wait) return { mode: 'status', dispatched: false, ok: false, outcome: 'GH_ERROR', runId, error: v.error };
|
|
600
|
+
await deps.sleep(policy.waitIntervalMs);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
readErrors = 0;
|
|
604
|
+
if (v.data.status === 'completed' || !args.wait || deps.now() >= deadline) {
|
|
605
|
+
const logs = {};
|
|
606
|
+
if (v.data.status === 'completed') {
|
|
607
|
+
for (const job of v.data.jobs || []) {
|
|
608
|
+
const l = deps.gh(['run', 'view', String(runId), '--log', '--job', String(job.databaseId)]);
|
|
609
|
+
if (l.code === 0) logs[job.databaseId] = l.stdout;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const report = buildReport(v.data, logs);
|
|
613
|
+
if (rec && v.data.status === 'completed') updateRecord(root, rec.id, { status: 'completed', concluded: true, conclusion: v.data.conclusion });
|
|
614
|
+
return { mode: 'status', dispatched: false, ok: true, outcome: report.verdict, runId, report, timedOutWaiting: v.data.status !== 'completed' && args.wait };
|
|
615
|
+
}
|
|
616
|
+
await deps.sleep(policy.waitIntervalMs);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------- rendering + CLI
|
|
621
|
+
export function renderReport(result) {
|
|
622
|
+
const L = [];
|
|
623
|
+
if (result.report) {
|
|
624
|
+
const r = result.report;
|
|
625
|
+
L.push(`CROSS_PLATFORM_CI: ${r.verdict}`, '');
|
|
626
|
+
for (const label of ['WINDOWS', 'MACOS', 'LINUX']) {
|
|
627
|
+
const p = r.platforms[label];
|
|
628
|
+
if (!p.present) { L.push(`${label}: INCOMPLETE (no job reported)`); continue; }
|
|
629
|
+
const t = p.tests ? `${p.tests.passed} pass / ${p.tests.failed} fail / ${p.tests.skipped} skip` : 'tests n/a';
|
|
630
|
+
L.push(`${label}: ${p.verdict} | ${t} | ${p.durationSeconds ?? '?'}s | ${p.url || ''}`);
|
|
631
|
+
L.push(` stages: ${Object.entries(p.stages).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
632
|
+
for (const e of p.evidence) L.push(` evidence: ${e}`);
|
|
633
|
+
}
|
|
634
|
+
L.push('', `Tests total ${r.totals.total}: ${r.totals.passed} passed, ${r.totals.failed} failed, ${r.totals.skipped} skipped`);
|
|
635
|
+
L.push(`Failing stages: ${r.failingStages.join('; ') || 'none'}`);
|
|
636
|
+
L.push(`Runner usage (billed-minute equivalent): ${r.billedMinutes} min`, `Run: ${r.runId} @ ${String(r.sha).slice(0, 8)} ${r.url}`);
|
|
637
|
+
return L.join('\n');
|
|
638
|
+
}
|
|
639
|
+
L.push(`ci-check ${result.mode}: ${result.outcome}`);
|
|
640
|
+
if (result.ref) L.push(`ref=${result.ref} sha=${String(result.sha || '').slice(0, 8)} workflow=${result.workflow}`);
|
|
641
|
+
for (const b of result.blockers || []) L.push(`BLOCKER: ${b}`);
|
|
642
|
+
for (const w of result.warnings || []) L.push(`warning: ${w}`);
|
|
643
|
+
if (result.estimate) L.push(`ESTIMATED (not verified billing; no free-quota guarantee): ${result.estimate.jobs} jobs, worst case ${result.estimate.worstCaseBilledMinutes} billed min (${JSON.stringify(result.estimate.perRunner)})`);
|
|
644
|
+
if (result.runId) L.push(`run: ${result.runId} ${result.url || ''}`);
|
|
645
|
+
if (result.outcome === 'DISPATCHED_RUN_ID_PENDING') L.push('dispatched; run id not yet visible. Use `/ukit:ci-check status` (it will NOT dispatch again).');
|
|
646
|
+
if (result.error) L.push(`error: ${result.error}`);
|
|
647
|
+
return L.join('\n');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export async function main(argv = process.argv.slice(2), root = process.cwd()) {
|
|
651
|
+
const args = parseArgs(argv);
|
|
652
|
+
if (args.errors.length) { process.stderr.write(`${args.errors.join('\n')}\n`); return 2; }
|
|
653
|
+
const result = args.mode === 'status' ? await statusCheck({ root, args }) : await runCheck({ root, args });
|
|
654
|
+
process.stdout.write(`${args.json ? JSON.stringify(result, null, 2) : renderReport(result)}\n`);
|
|
655
|
+
if (['BLOCKED', 'PREFLIGHT_FAILED', 'DISPATCH_FAILED', 'GH_ERROR'].includes(result.outcome)) return 2;
|
|
656
|
+
if (['FAIL', 'INCOMPLETE'].includes(result.outcome)) return 3;
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const isEntry = () => {
|
|
661
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
662
|
+
};
|
|
663
|
+
if (process.argv[1] && isEntry()) {
|
|
664
|
+
main().then((c) => { process.exitCode = c; }, (e) => { process.stderr.write(`${e.stack || e}\n`); process.exitCode = 1; });
|
|
665
|
+
}
|