atris 3.46.1 → 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.
@@ -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.0
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
- console.log(`opened ${event.id}`);
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
- let repoValue = null;
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
- if (repoValue !== null) throw new Error('--repo may only be set once');
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
- return { repo: parseRepo(repoValue), label: options.label, once: options.once };
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
- `${repo.repo}: ${repo.jobs} ${repo.jobs === 1 ? 'job' : 'jobs'}, `
166
- + `${repo.minutes} ${repo.minutes === 1 ? 'minute' : 'minutes'}`
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 runWorker(runnerDir, jitConfig, start = spawn) {
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 child = start('./run.sh', ['--jitconfig', jitConfig], {
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
- child.on('error', reject);
325
- child.on('exit', (code, signal) => {
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
- reject(new Error(`github actions runner stopped with signal ${signal}`));
468
+ const error = new Error(`github actions runner stopped with signal ${signal}`);
469
+ error.ciUsage = measurement();
470
+ reject(error);
330
471
  } else {
331
- reject(new Error(`github actions runner exited with code ${code}`));
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 finishedAt = clockDate(clock);
359
- const durationSeconds = Math.max(0, Math.ceil((finishedAt.getTime() - startedAt.getTime()) / 1000));
360
- const line = `${JSON.stringify({
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
- })}\n`;
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,17 +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
- return runJobLoop({
378
- ...options,
379
- token,
380
- runnerDir,
381
- runnerName: `atris-${host || 'worker'}-${process.pid}`,
382
- }, dependencies);
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
572
  buildJitConfigRequest,
387
573
  formatUsageSummary,
574
+ parseRunnerMarker,
388
575
  parseRunnerArgs,
389
576
  parseUsageArgs,
390
577
  readUsageRecords,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.46.1",
3
+ "version": "3.47.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {