atris 3.46.0 → 3.47.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/atris/skills/design/SKILL.md +2 -1
- package/commands/ci.js +1 -1
- package/commands/close.js +167 -6
- package/lib/ci-runner.js +215 -29
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: design
|
|
3
3
|
description: Frontend aesthetics policy. Use when building UI, components, landing pages, dashboards, or any frontend work. Prevents generic ai-generated look.
|
|
4
|
-
version: 3.1.
|
|
4
|
+
version: 3.1.1
|
|
5
5
|
allowed-tools: Read, Write, Edit, Bash, Glob
|
|
6
6
|
tags:
|
|
7
7
|
- design
|
|
@@ -112,6 +112,7 @@ Every entry: id, rule, detector, status. A detector is a regex/command a gate ca
|
|
|
112
112
|
| D11 | layout contracts, not dioramas: min-height over fixed height, fluid max-width over fixed px, every overflow reachable (scroll or +N more), stress-test with hostile content before shipping | judgment | active |
|
|
113
113
|
| D12 | one accent moment per card: brand accent for the primary action only, gold for confidence/progress fills, green only for completed; everything else tonal | judgment | active |
|
|
114
114
|
| D13 | motion is calm and eased: 120-300ms ease-out on opacity/transform only, loops match a measured source cadence, prefers-reduced-motion always freezes them | judgment | active |
|
|
115
|
+
| D14 | compact selectors lead with the chosen name and a discriminating icon; remove redundant field labels and visible type explanations when icon, title, and accessible label carry them | judgment | active |
|
|
115
116
|
|
|
116
117
|
Measured recipes live in the mimic studies: `~/arena/mimic-beautiful-ui/LESSONS.md` (18 AI-interface components with exact tokens) and its `remix.css` :root block (the portable Atris token sheet, coffee + paper themes). Start there before designing an agent surface.
|
|
117
118
|
|
package/commands/ci.js
CHANGED
|
@@ -14,7 +14,7 @@ function showCiHelp() {
|
|
|
14
14
|
console.log('atris ci runs github actions jobs on this machine with a warm local work folder.');
|
|
15
15
|
console.log('change runs-on: ubuntu-latest to runs-on: atris.');
|
|
16
16
|
console.log('a GITHUB_TOKEN or an authenticated gh cli is required.');
|
|
17
|
-
console.log('usage: atris ci runner --repo <owner/name> [--label <name>] [--once]');
|
|
17
|
+
console.log('usage: atris ci runner --repo <owner/name> [--repo <owner/name> ...] [--label <name>] [--once]');
|
|
18
18
|
console.log(' atris ci usage [--repo <owner/name>]');
|
|
19
19
|
}
|
|
20
20
|
|
package/commands/close.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
4
5
|
const { knownCommands } = require('../lib/known-commands');
|
|
5
6
|
const { readUsage, usagePath } = require('../lib/usage');
|
|
6
7
|
const pulse = require('../lib/pulse');
|
|
@@ -16,6 +17,12 @@ const EXPERIMENTS_RELATIVE_PATH = path.join('.atris', 'state', 'experiments.json
|
|
|
16
17
|
const EXPERIMENTS_DIR_RELATIVE_PATH = path.join('atris', 'experiments');
|
|
17
18
|
const RESOLVED_IN_SOURCE_PROOF = 'resolved in source store';
|
|
18
19
|
const FAILED_TASK_BATCH_PREFIX = 'tasks:failed:';
|
|
20
|
+
// Live-probe incidents: a diagnosis may only be recorded alongside a command
|
|
21
|
+
// that fails right now, and may only close after that command passes twice
|
|
22
|
+
// with a real gap between runs (catches the bug that comes back hours later).
|
|
23
|
+
const PROBE_TIMEOUT_MS = 60 * 1000;
|
|
24
|
+
const PROBE_HOLD_GAP_MS = 60 * 60 * 1000;
|
|
25
|
+
const PROBE_STOP_AFTER_FAILED_CHECKS = 2;
|
|
19
26
|
|
|
20
27
|
function ledgerPath(cwd = process.cwd()) {
|
|
21
28
|
return path.join(cwd, LEDGER_RELATIVE_PATH);
|
|
@@ -111,6 +118,8 @@ function foldEvents(events, options = {}) {
|
|
|
111
118
|
ttl_days: Number(event.ttl_days) || 7,
|
|
112
119
|
close_condition: normalizeSpaces(event.close_condition || 'the loop is resolved'),
|
|
113
120
|
source: normalizeSpaces(event.source || 'manual'),
|
|
121
|
+
probe: normalizeSpaces(event.probe || '') || null,
|
|
122
|
+
probe_runs: [],
|
|
114
123
|
status: 'open',
|
|
115
124
|
closed_at: null,
|
|
116
125
|
dissolved_at: null,
|
|
@@ -138,6 +147,12 @@ function foldEvents(events, options = {}) {
|
|
|
138
147
|
} else if (event.kind === 'escalated') {
|
|
139
148
|
const day = event.day || (event.at ? String(event.at).slice(0, 10) : null);
|
|
140
149
|
if (day && !flag.escalated_days.includes(day)) flag.escalated_days.push(day);
|
|
150
|
+
} else if (event.kind === 'probed') {
|
|
151
|
+
flag.probe_runs.push({
|
|
152
|
+
at: eventTime(event),
|
|
153
|
+
pass: !!event.pass,
|
|
154
|
+
exit_code: Number.isFinite(event.exit_code) ? event.exit_code : null,
|
|
155
|
+
});
|
|
141
156
|
}
|
|
142
157
|
}
|
|
143
158
|
|
|
@@ -161,9 +176,49 @@ function withComputedState(flag, now = new Date()) {
|
|
|
161
176
|
days_old: daysOld,
|
|
162
177
|
days_past_ttl: daysPastTtl,
|
|
163
178
|
overdue,
|
|
179
|
+
...probeState(flag),
|
|
164
180
|
};
|
|
165
181
|
}
|
|
166
182
|
|
|
183
|
+
// Probe state from recorded runs. 'failing' until a check passes, 'passed
|
|
184
|
+
// once' until a second pass lands PROBE_HOLD_GAP_MS later, then 'holding'
|
|
185
|
+
// (eligible to close). failed_checks counts re-checks that still failed:
|
|
186
|
+
// each one is a fix attempt that did not move reality.
|
|
187
|
+
function probeState(flag) {
|
|
188
|
+
if (!flag.probe) {
|
|
189
|
+
return { probe_status: null, failed_checks: 0, probe_holding: false };
|
|
190
|
+
}
|
|
191
|
+
const runs = flag.probe_runs || [];
|
|
192
|
+
const failedChecks = runs.filter((run) => !run.pass).length;
|
|
193
|
+
|
|
194
|
+
const trailing = [];
|
|
195
|
+
for (let index = runs.length - 1; index >= 0 && runs[index].pass; index -= 1) {
|
|
196
|
+
trailing.unshift(runs[index]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let status = 'failing';
|
|
200
|
+
let holding = false;
|
|
201
|
+
if (trailing.length >= 1) {
|
|
202
|
+
const firstMs = parseDate(trailing[0].at) || 0;
|
|
203
|
+
const lastMs = parseDate(trailing[trailing.length - 1].at) || 0;
|
|
204
|
+
holding = trailing.length >= 2 && lastMs - firstMs >= PROBE_HOLD_GAP_MS;
|
|
205
|
+
status = holding ? 'holding' : 'passed once';
|
|
206
|
+
}
|
|
207
|
+
return { probe_status: status, failed_checks: failedChecks, probe_holding: holding };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function runProbe(command, context = {}) {
|
|
211
|
+
const result = spawnSync(command, {
|
|
212
|
+
shell: true,
|
|
213
|
+
cwd: context.cwd || process.cwd(),
|
|
214
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
215
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
216
|
+
encoding: 'utf8',
|
|
217
|
+
});
|
|
218
|
+
const exitCode = typeof result.status === 'number' ? result.status : 1;
|
|
219
|
+
return { pass: exitCode === 0, exit_code: exitCode };
|
|
220
|
+
}
|
|
221
|
+
|
|
167
222
|
function openFlags(cwd = process.cwd(), options = {}) {
|
|
168
223
|
return foldEvents(readEvents(cwd), options).filter((flag) => flag.status === 'open');
|
|
169
224
|
}
|
|
@@ -202,6 +257,7 @@ function listLine(flag) {
|
|
|
202
257
|
flag.what.toLowerCase(),
|
|
203
258
|
`${formatDays(flag.days_old)} old`,
|
|
204
259
|
];
|
|
260
|
+
if (flag.probe) parts.push(`probe ${flag.probe_status}`);
|
|
205
261
|
if (flag.overdue) {
|
|
206
262
|
parts.push(`${formatDays(flag.days_past_ttl)} past ttl`);
|
|
207
263
|
}
|
|
@@ -269,10 +325,11 @@ function publicFlag(flag) {
|
|
|
269
325
|
}
|
|
270
326
|
|
|
271
327
|
function printHelp() {
|
|
272
|
-
console.log('usage: atris close <add|list|done|dissolve|snooze|sweep|scan>');
|
|
273
|
-
console.log('add "<what>" [--owner x] [--lane life|business|code] [--ttl 7] [--when "<condition>"] [--source y]');
|
|
328
|
+
console.log('usage: atris close <add|list|done|dissolve|snooze|sweep|scan|check>');
|
|
329
|
+
console.log('add "<what>" [--owner x] [--lane life|business|code] [--ttl 7] [--when "<condition>"] [--source y] [--probe "<cmd that fails now>"]');
|
|
274
330
|
console.log('list [--json] [--lane x]');
|
|
275
|
-
console.log('done <id> [--proof "..."]');
|
|
331
|
+
console.log('done <id> [--proof "..."] (a probed incident closes only after its probe holds)');
|
|
332
|
+
console.log('check [id] [--json] (run live probes; failing probe = old theory still live)');
|
|
276
333
|
console.log('dissolve <id> --why "..."');
|
|
277
334
|
console.log('snooze <id> --days n');
|
|
278
335
|
console.log('sweep [--json]');
|
|
@@ -295,6 +352,20 @@ function commandAdd(args, context = {}) {
|
|
|
295
352
|
return 0;
|
|
296
353
|
}
|
|
297
354
|
|
|
355
|
+
// A probe turns the flag into a live incident: the claim in `what` is only
|
|
356
|
+
// recordable while the probe command fails. A passing probe means there is
|
|
357
|
+
// nothing to diagnose, so refuse instead of storing a story.
|
|
358
|
+
if (options.probe === true) throw new Error('--probe needs a command');
|
|
359
|
+
const probe = normalizeSpaces(options.probe || '');
|
|
360
|
+
let probeExit = null;
|
|
361
|
+
if (probe) {
|
|
362
|
+
const result = (context.runProbe || runProbe)(probe, context);
|
|
363
|
+
if (result.pass) {
|
|
364
|
+
throw new Error('probe passes right now; an incident needs a command that currently fails. fix the probe or drop --probe');
|
|
365
|
+
}
|
|
366
|
+
probeExit = result.exit_code;
|
|
367
|
+
}
|
|
368
|
+
|
|
298
369
|
const openedAt = now.toISOString();
|
|
299
370
|
const event = {
|
|
300
371
|
kind: 'opened',
|
|
@@ -308,11 +379,88 @@ function commandAdd(args, context = {}) {
|
|
|
308
379
|
close_condition: normalizeSpaces(options.when || 'the loop is resolved'),
|
|
309
380
|
source: normalizeSpaces(options.source || 'manual'),
|
|
310
381
|
};
|
|
382
|
+
if (probe) event.probe = probe;
|
|
311
383
|
appendEvent(event, context.cwd);
|
|
312
|
-
|
|
384
|
+
if (probe) {
|
|
385
|
+
console.log(`opened ${event.id}, probe failing as required (exit ${probeExit})`);
|
|
386
|
+
} else {
|
|
387
|
+
console.log(`opened ${event.id}`);
|
|
388
|
+
}
|
|
313
389
|
return 0;
|
|
314
390
|
}
|
|
315
391
|
|
|
392
|
+
// atris close check [id] — run the live probes on open incidents. This is the
|
|
393
|
+
// first move for any session entering an area: if a probe still fails, the
|
|
394
|
+
// recorded theory is still live; do not diagnose fresh. After
|
|
395
|
+
// PROBE_STOP_AFTER_FAILED_CHECKS failing re-checks the verdict is automatic:
|
|
396
|
+
// stop and tell the human.
|
|
397
|
+
function commandCheck(args, context = {}) {
|
|
398
|
+
const { positional, options } = parseArgs(args);
|
|
399
|
+
const id = positional[0] || null;
|
|
400
|
+
const now = context.now ? new Date(context.now) : new Date();
|
|
401
|
+
|
|
402
|
+
let flags = openFlags(context.cwd, { now }).filter((flag) => flag.probe);
|
|
403
|
+
if (id) {
|
|
404
|
+
flags = flags.filter((flag) => flag.id === id);
|
|
405
|
+
if (flags.length === 0) throw new Error(`${id} is not an open incident with a probe`);
|
|
406
|
+
}
|
|
407
|
+
if (flags.length === 0) {
|
|
408
|
+
console.log('no open incidents with probes.');
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const results = [];
|
|
413
|
+
for (const flag of flags) {
|
|
414
|
+
const run = (context.runProbe || runProbe)(flag.probe, context);
|
|
415
|
+
appendEvent({
|
|
416
|
+
kind: 'probed',
|
|
417
|
+
at: now.toISOString(),
|
|
418
|
+
id: flag.id,
|
|
419
|
+
pass: run.pass,
|
|
420
|
+
exit_code: run.exit_code,
|
|
421
|
+
}, context.cwd);
|
|
422
|
+
results.push({ id: flag.id, what: flag.what, ...run });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const after = new Map(
|
|
426
|
+
openFlags(context.cwd, { now }).map((flag) => [flag.id, flag])
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
if (options.json) {
|
|
430
|
+
printJson({
|
|
431
|
+
checked: results.map((result) => {
|
|
432
|
+
const flag = after.get(result.id);
|
|
433
|
+
return {
|
|
434
|
+
...result,
|
|
435
|
+
probe_status: flag ? flag.probe_status : null,
|
|
436
|
+
failed_checks: flag ? flag.failed_checks : null,
|
|
437
|
+
};
|
|
438
|
+
}),
|
|
439
|
+
});
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
let anyFailing = false;
|
|
444
|
+
for (const result of results) {
|
|
445
|
+
const flag = after.get(result.id);
|
|
446
|
+
if (result.pass) {
|
|
447
|
+
if (flag && flag.probe_holding) {
|
|
448
|
+
console.log(`${result.id}: probe held. close it: atris close done ${result.id}`);
|
|
449
|
+
} else {
|
|
450
|
+
console.log(`${result.id}: probe passed once. re-check in an hour to confirm it holds.`);
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
anyFailing = true;
|
|
455
|
+
const failedChecks = flag ? flag.failed_checks : 0;
|
|
456
|
+
console.log(`${result.id}: probe still failing (exit ${result.exit_code}). the recorded theory is still live.`);
|
|
457
|
+
if (failedChecks >= PROBE_STOP_AFTER_FAILED_CHECKS) {
|
|
458
|
+
console.log(`${result.id}: ${failedChecks} fix attempts have not moved the probe. stop and tell the human.`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return anyFailing ? 1 : 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
316
464
|
function commandList(args, context = {}) {
|
|
317
465
|
const { options } = parseArgs(args);
|
|
318
466
|
const lane = options.lane ? String(options.lane).toLowerCase() : null;
|
|
@@ -346,13 +494,25 @@ function commandDone(args, context = {}) {
|
|
|
346
494
|
const { positional, options } = parseArgs(args);
|
|
347
495
|
const id = positional[0];
|
|
348
496
|
if (!id) throw new Error('id is required');
|
|
349
|
-
requireOpenFlag(id, context);
|
|
497
|
+
const flag = requireOpenFlag(id, context);
|
|
498
|
+
|
|
499
|
+
// An incident with a live probe closes on held evidence, not on a claim:
|
|
500
|
+
// the probe must pass twice, an hour apart, so the storm that returns
|
|
501
|
+
// hours later cannot be marked solved in the same breath as the fix.
|
|
502
|
+
if (flag.probe && !flag.probe_holding) {
|
|
503
|
+
if (flag.probe_status === 'failing') {
|
|
504
|
+
throw new Error(`probe still fails; run the fix, then: atris close check ${id}`);
|
|
505
|
+
}
|
|
506
|
+
throw new Error(`probe passed once; confirm it holds with atris close check ${id} at least an hour after the first pass`);
|
|
507
|
+
}
|
|
508
|
+
|
|
350
509
|
const at = (context.now ? new Date(context.now) : new Date()).toISOString();
|
|
510
|
+
const defaultProof = flag.probe ? `probe held: ${flag.probe}` : '';
|
|
351
511
|
appendEvent({
|
|
352
512
|
kind: 'closed',
|
|
353
513
|
at,
|
|
354
514
|
id,
|
|
355
|
-
proof: normalizeSpaces(options.proof ||
|
|
515
|
+
proof: normalizeSpaces(options.proof || defaultProof),
|
|
356
516
|
}, context.cwd);
|
|
357
517
|
console.log(`closed ${id}`);
|
|
358
518
|
return 0;
|
|
@@ -1097,6 +1257,7 @@ function run(args = [], context = {}) {
|
|
|
1097
1257
|
if (subcommand === 'snooze') return commandSnooze(rest, context);
|
|
1098
1258
|
if (subcommand === 'sweep') return commandSweep(rest, context);
|
|
1099
1259
|
if (subcommand === 'scan') return commandScan(rest, context);
|
|
1260
|
+
if (subcommand === 'check') return commandCheck(rest, context);
|
|
1100
1261
|
console.error(`unknown close command: ${subcommand}`);
|
|
1101
1262
|
return 2;
|
|
1102
1263
|
} catch (error) {
|
package/lib/ci-runner.js
CHANGED
|
@@ -25,6 +25,14 @@ function clockDate(clock) {
|
|
|
25
25
|
return date;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function parseRunnerMarker(line) {
|
|
29
|
+
const text = String(line);
|
|
30
|
+
if (text.includes('Running job:')) return { type: 'start' };
|
|
31
|
+
const completed = text.match(/completed with result:\s*([a-z]+)/i);
|
|
32
|
+
if (completed) return { type: 'complete', result: completed[1].toLowerCase() };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
function runnerAssetName(platform, arch, version = RUNNER_VERSION) {
|
|
29
37
|
const platformNames = { darwin: 'osx', linux: 'linux' };
|
|
30
38
|
const archNames = { x64: 'x64', arm64: 'arm64' };
|
|
@@ -53,14 +61,19 @@ function parseRepo(value) {
|
|
|
53
61
|
|
|
54
62
|
function parseRunnerArgs(argv) {
|
|
55
63
|
const options = { label: null, once: false };
|
|
56
|
-
|
|
64
|
+
const repos = [];
|
|
65
|
+
const seenRepos = new Set();
|
|
57
66
|
|
|
58
67
|
for (let index = 0; index < argv.length; index += 1) {
|
|
59
68
|
const arg = argv[index];
|
|
60
69
|
if (arg === '--repo') {
|
|
61
|
-
|
|
62
|
-
repoValue = argv[index + 1];
|
|
70
|
+
const repoValue = argv[index + 1];
|
|
63
71
|
if (!repoValue || repoValue.startsWith('--')) throw new Error('--repo owner/name is required');
|
|
72
|
+
const repo = parseRepo(repoValue);
|
|
73
|
+
const key = repo.slug.toLowerCase();
|
|
74
|
+
if (seenRepos.has(key)) throw new Error(`duplicate --repo: ${repo.slug}`);
|
|
75
|
+
seenRepos.add(key);
|
|
76
|
+
repos.push(repo);
|
|
64
77
|
index += 1;
|
|
65
78
|
} else if (arg === '--label') {
|
|
66
79
|
if (options.label !== null) throw new Error('--label may only be set once');
|
|
@@ -78,7 +91,8 @@ function parseRunnerArgs(argv) {
|
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
|
|
81
|
-
|
|
94
|
+
if (repos.length === 0) throw new Error('--repo owner/name is required');
|
|
95
|
+
return { repos, label: options.label, once: options.once };
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
function parseUsageArgs(argv) {
|
|
@@ -143,6 +157,11 @@ function summarizeUsage(records, options = {}) {
|
|
|
143
157
|
const repo = repos.get(record.repo) || { repo: record.repo, jobs: 0, minutes: 0 };
|
|
144
158
|
repo.jobs += 1;
|
|
145
159
|
repo.minutes += minutes;
|
|
160
|
+
if (typeof record.result === 'string' && /^[a-z]+$/i.test(record.result)) {
|
|
161
|
+
if (!repo.results) repo.results = {};
|
|
162
|
+
const result = record.result.toLowerCase();
|
|
163
|
+
repo.results[result] = (repo.results[result] || 0) + 1;
|
|
164
|
+
}
|
|
146
165
|
repos.set(record.repo, repo);
|
|
147
166
|
}
|
|
148
167
|
|
|
@@ -161,10 +180,25 @@ function formatUsageSummary(summary) {
|
|
|
161
180
|
`total minutes: ${summary.totalMinutes}`,
|
|
162
181
|
`minutes this month: ${summary.monthMinutes}`,
|
|
163
182
|
'per repo:',
|
|
164
|
-
...summary.repos.map((repo) =>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
183
|
+
...summary.repos.map((repo) => {
|
|
184
|
+
const resultOrder = ['succeeded', 'failed', 'cancelled', 'skipped'];
|
|
185
|
+
const results = repo.results
|
|
186
|
+
? Object.entries(repo.results).sort(([left], [right]) => {
|
|
187
|
+
const leftIndex = resultOrder.indexOf(left);
|
|
188
|
+
const rightIndex = resultOrder.indexOf(right);
|
|
189
|
+
if (leftIndex !== -1 || rightIndex !== -1) {
|
|
190
|
+
return (leftIndex === -1 ? resultOrder.length : leftIndex)
|
|
191
|
+
- (rightIndex === -1 ? resultOrder.length : rightIndex);
|
|
192
|
+
}
|
|
193
|
+
return left.localeCompare(right);
|
|
194
|
+
})
|
|
195
|
+
: [];
|
|
196
|
+
return [
|
|
197
|
+
`${repo.repo}: ${repo.jobs} ${repo.jobs === 1 ? 'job' : 'jobs'}`,
|
|
198
|
+
...results.map(([result, count]) => `${count} ${result}`),
|
|
199
|
+
`${repo.minutes} ${repo.minutes === 1 ? 'minute' : 'minutes'}`,
|
|
200
|
+
].join(', ');
|
|
201
|
+
}),
|
|
168
202
|
].join('\n');
|
|
169
203
|
}
|
|
170
204
|
|
|
@@ -314,21 +348,130 @@ async function ensureRunner(options = {}) {
|
|
|
314
348
|
return runnerDir;
|
|
315
349
|
}
|
|
316
350
|
|
|
317
|
-
function
|
|
351
|
+
function linkRunnerTree(source, destination) {
|
|
352
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
353
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
354
|
+
const sourcePath = path.join(source, entry.name);
|
|
355
|
+
const destinationPath = path.join(destination, entry.name);
|
|
356
|
+
if (fs.existsSync(destinationPath)) continue;
|
|
357
|
+
if (entry.isDirectory()) {
|
|
358
|
+
linkRunnerTree(sourcePath, destinationPath);
|
|
359
|
+
} else if (entry.isSymbolicLink()) {
|
|
360
|
+
fs.symlinkSync(fs.readlinkSync(sourcePath), destinationPath);
|
|
361
|
+
} else {
|
|
362
|
+
try {
|
|
363
|
+
fs.linkSync(sourcePath, destinationPath);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!error || error.code !== 'EXDEV') throw error;
|
|
366
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function ensureRepoRunnerDirectory(runnerDir, repo) {
|
|
373
|
+
const destination = path.join(runnerDir, 'repos', repo.owner.toLowerCase(), repo.repo.toLowerCase());
|
|
374
|
+
if (fs.existsSync(path.join(destination, 'run.sh'))) return destination;
|
|
375
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
376
|
+
const ignored = new Set([
|
|
377
|
+
'.credentials',
|
|
378
|
+
'.credentials_rsaparams',
|
|
379
|
+
'.runner',
|
|
380
|
+
'.service',
|
|
381
|
+
'_diag',
|
|
382
|
+
'_work',
|
|
383
|
+
'repos',
|
|
384
|
+
]);
|
|
385
|
+
for (const entry of fs.readdirSync(runnerDir, { withFileTypes: true })) {
|
|
386
|
+
if (ignored.has(entry.name)) continue;
|
|
387
|
+
const sourcePath = path.join(runnerDir, entry.name);
|
|
388
|
+
const destinationPath = path.join(destination, entry.name);
|
|
389
|
+
if (fs.existsSync(destinationPath)) continue;
|
|
390
|
+
if (entry.isDirectory()) {
|
|
391
|
+
linkRunnerTree(sourcePath, destinationPath);
|
|
392
|
+
} else if (entry.isSymbolicLink()) {
|
|
393
|
+
fs.symlinkSync(fs.readlinkSync(sourcePath), destinationPath);
|
|
394
|
+
} else {
|
|
395
|
+
try {
|
|
396
|
+
fs.linkSync(sourcePath, destinationPath);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (!error || error.code !== 'EXDEV') throw error;
|
|
399
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!fs.existsSync(path.join(destination, 'run.sh'))) {
|
|
404
|
+
throw new Error('github actions runner cache did not contain run.sh');
|
|
405
|
+
}
|
|
406
|
+
return destination;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function forwardRunnerStream(stream, output, prefix, inspectLine) {
|
|
410
|
+
if (!stream) return;
|
|
411
|
+
let pending = '';
|
|
412
|
+
stream.setEncoding('utf8');
|
|
413
|
+
stream.on('data', (chunk) => {
|
|
414
|
+
pending += chunk;
|
|
415
|
+
let newline = pending.indexOf('\n');
|
|
416
|
+
while (newline !== -1) {
|
|
417
|
+
const line = pending.slice(0, newline).replace(/\r$/, '');
|
|
418
|
+
pending = pending.slice(newline + 1);
|
|
419
|
+
inspectLine(line);
|
|
420
|
+
output.write(`${prefix}${line}\n`);
|
|
421
|
+
newline = pending.indexOf('\n');
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
stream.on('end', () => {
|
|
425
|
+
if (!pending) return;
|
|
426
|
+
const line = pending.replace(/\r$/, '');
|
|
427
|
+
inspectLine(line);
|
|
428
|
+
output.write(`${prefix}${line}`);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function runWorker(runnerDir, jitConfig, options = {}) {
|
|
318
433
|
return new Promise((resolve, reject) => {
|
|
319
|
-
const
|
|
434
|
+
const clock = options.clock || (() => new Date());
|
|
435
|
+
const child = (options.start || spawn)('./run.sh', ['--jitconfig', jitConfig], {
|
|
320
436
|
cwd: runnerDir,
|
|
321
437
|
env: process.env,
|
|
322
|
-
stdio: 'inherit',
|
|
438
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
323
439
|
});
|
|
324
|
-
|
|
325
|
-
|
|
440
|
+
let startedAt = null;
|
|
441
|
+
let finishedAt = null;
|
|
442
|
+
let result = null;
|
|
443
|
+
let settled = false;
|
|
444
|
+
const measurement = () => ({ startedAt, finishedAt, result });
|
|
445
|
+
const inspectLine = (line) => {
|
|
446
|
+
const marker = parseRunnerMarker(line);
|
|
447
|
+
if (!marker) return;
|
|
448
|
+
if (marker.type === 'start' && !startedAt) startedAt = clockDate(clock);
|
|
449
|
+
if (marker.type === 'complete' && !finishedAt) {
|
|
450
|
+
finishedAt = clockDate(clock);
|
|
451
|
+
result = marker.result;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
forwardRunnerStream(child.stdout, options.stdout || process.stdout, options.prefix || '', inspectLine);
|
|
455
|
+
forwardRunnerStream(child.stderr, options.stderr || process.stderr, options.prefix || '', inspectLine);
|
|
456
|
+
child.on('error', (error) => {
|
|
457
|
+
if (settled) return;
|
|
458
|
+
settled = true;
|
|
459
|
+
error.ciUsage = measurement();
|
|
460
|
+
reject(error);
|
|
461
|
+
});
|
|
462
|
+
child.on('close', (code, signal) => {
|
|
463
|
+
if (settled) return;
|
|
464
|
+
settled = true;
|
|
326
465
|
if (code === 0) {
|
|
327
|
-
resolve();
|
|
466
|
+
resolve(measurement());
|
|
328
467
|
} else if (signal) {
|
|
329
|
-
|
|
468
|
+
const error = new Error(`github actions runner stopped with signal ${signal}`);
|
|
469
|
+
error.ciUsage = measurement();
|
|
470
|
+
reject(error);
|
|
330
471
|
} else {
|
|
331
|
-
|
|
472
|
+
const error = new Error(`github actions runner exited with code ${code}`);
|
|
473
|
+
error.ciUsage = measurement();
|
|
474
|
+
reject(error);
|
|
332
475
|
}
|
|
333
476
|
});
|
|
334
477
|
});
|
|
@@ -348,20 +491,39 @@ async function runJobLoop(options, dependencies = {}) {
|
|
|
348
491
|
const runnerName = `${options.runnerName}-${completedJobs + 1}`;
|
|
349
492
|
const jitConfig = await mint(options.token, options.repo, options.label, runnerName);
|
|
350
493
|
log(`worker ready, waiting for jobs on ${options.repo.slug}`);
|
|
351
|
-
const startedAt = clockDate(clock);
|
|
352
494
|
let workerError = null;
|
|
495
|
+
let workerUsage = null;
|
|
353
496
|
try {
|
|
354
|
-
await startWorker(options.runnerDir, jitConfig
|
|
497
|
+
workerUsage = await startWorker(options.runnerDir, jitConfig, {
|
|
498
|
+
clock,
|
|
499
|
+
prefix: options.logPrefix || '',
|
|
500
|
+
start: dependencies.spawn,
|
|
501
|
+
stderr: dependencies.stderr,
|
|
502
|
+
stdout: dependencies.stdout,
|
|
503
|
+
});
|
|
355
504
|
} catch (error) {
|
|
356
505
|
workerError = error;
|
|
506
|
+
workerUsage = error && error.ciUsage;
|
|
357
507
|
} finally {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
508
|
+
const markerStart = workerUsage && workerUsage.startedAt
|
|
509
|
+
? clockDate(() => workerUsage.startedAt)
|
|
510
|
+
: null;
|
|
511
|
+
const markerFinish = workerUsage && workerUsage.finishedAt
|
|
512
|
+
? clockDate(() => workerUsage.finishedAt)
|
|
513
|
+
: null;
|
|
514
|
+
const startedAt = markerStart || markerFinish || clockDate(clock);
|
|
515
|
+
const durationSeconds = markerStart && markerFinish
|
|
516
|
+
? Math.max(0, Math.ceil((markerFinish.getTime() - markerStart.getTime()) / 1000))
|
|
517
|
+
: 0;
|
|
518
|
+
const record = {
|
|
361
519
|
repo: options.repo.slug,
|
|
362
520
|
started_at: startedAt.toISOString(),
|
|
363
521
|
duration_seconds: durationSeconds,
|
|
364
|
-
}
|
|
522
|
+
};
|
|
523
|
+
if (workerUsage && typeof workerUsage.result === 'string') {
|
|
524
|
+
record.result = workerUsage.result.toLowerCase();
|
|
525
|
+
}
|
|
526
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
365
527
|
await appendUsage(file, line);
|
|
366
528
|
}
|
|
367
529
|
if (workerError) throw workerError;
|
|
@@ -374,18 +536,42 @@ async function runCiRunner(options, dependencies = {}) {
|
|
|
374
536
|
const token = (dependencies.resolveGithubToken || resolveGithubToken)();
|
|
375
537
|
const runnerDir = await (dependencies.ensureRunner || ensureRunner)({ log: dependencies.log });
|
|
376
538
|
const host = String((dependencies.hostname || os.hostname)()).toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
539
|
+
const log = dependencies.log || console.log;
|
|
540
|
+
const prepare = dependencies.ensureRepoRunnerDirectory || ensureRepoRunnerDirectory;
|
|
541
|
+
const startLoop = dependencies.runJobLoop || runJobLoop;
|
|
542
|
+
const manyRepos = options.repos.length > 1;
|
|
543
|
+
const loops = options.repos.map((repo, index) => (async () => {
|
|
544
|
+
const repoRunnerDir = await prepare(runnerDir, repo);
|
|
545
|
+
const repoLog = manyRepos ? (line) => log(`${repo.slug}: ${line}`) : log;
|
|
546
|
+
return startLoop({
|
|
547
|
+
...options,
|
|
548
|
+
repo,
|
|
549
|
+
token,
|
|
550
|
+
runnerDir: repoRunnerDir,
|
|
551
|
+
runnerName: `atris-${host || 'worker'}-${process.pid}-${index + 1}`,
|
|
552
|
+
logPrefix: manyRepos ? `${repo.slug}: ` : '',
|
|
553
|
+
}, { ...dependencies, log: repoLog });
|
|
554
|
+
})());
|
|
555
|
+
const settled = await Promise.allSettled(loops);
|
|
556
|
+
const failures = settled
|
|
557
|
+
.map((outcome, index) => ({ outcome, repo: options.repos[index] }))
|
|
558
|
+
.filter(({ outcome }) => outcome.status === 'rejected');
|
|
559
|
+
for (const failure of failures) {
|
|
560
|
+
const detail = String(failure.outcome.reason?.message || failure.outcome.reason || 'runner loop failed')
|
|
561
|
+
.replace(/\s+/g, ' ')
|
|
562
|
+
.trim();
|
|
563
|
+
log(`${failure.repo.slug}: ${detail.toLowerCase()}`);
|
|
564
|
+
}
|
|
565
|
+
if (failures.length > 0) {
|
|
566
|
+
throw new Error(`${failures.length} ci runner ${failures.length === 1 ? 'loop' : 'loops'} failed`);
|
|
567
|
+
}
|
|
568
|
+
return settled.reduce((total, outcome) => total + outcome.value, 0);
|
|
383
569
|
}
|
|
384
570
|
|
|
385
571
|
module.exports = {
|
|
386
|
-
appendUsageLine,
|
|
387
572
|
buildJitConfigRequest,
|
|
388
573
|
formatUsageSummary,
|
|
574
|
+
parseRunnerMarker,
|
|
389
575
|
parseRunnerArgs,
|
|
390
576
|
parseUsageArgs,
|
|
391
577
|
readUsageRecords,
|