atris 3.42.0 → 3.44.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 +7 -1
- package/atris/skills/engines/SKILL.md +44 -13
- package/atris/team/customer-lead/MEMBER.md +45 -0
- package/atris/team/customer-lead/SOUL.md +33 -0
- package/atris/team/customer-lead/START_HERE.md +7 -0
- package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
- package/atris/team/improver/MEMBER.md +33 -0
- package/bin/atris.js +37 -4
- package/commands/autoland.js +15 -1
- package/commands/caretaker.js +303 -0
- package/commands/clean.js +76 -0
- package/commands/engine-watch.js +212 -0
- package/commands/engine.js +99 -11
- package/commands/founder.js +304 -0
- package/commands/human-missions.js +844 -0
- package/commands/init.js +16 -7
- package/commands/lesson.js +178 -4
- package/commands/mission.js +124 -69
- package/commands/slop.js +34 -3
- package/commands/task.js +51 -4
- package/commands/team.js +329 -13
- package/commands/verify.js +99 -6
- package/commands/worktree.js +119 -4
- package/lib/auto-accept-certified.js +302 -0
- package/lib/cloud-mission.js +59 -2
- package/lib/conductor-artifacts.js +1 -1
- package/lib/dispatch-scout.js +383 -0
- package/lib/engine-ask.js +645 -0
- package/lib/engine-job-lifecycle.js +65 -0
- package/lib/engine-receipt-sweep.js +98 -0
- package/lib/engine-registry.js +2 -2
- package/lib/engine-validate.js +374 -0
- package/lib/fleet.js +459 -106
- package/lib/known-commands.js +2 -2
- package/lib/lesson-ledger.js +84 -0
- package/lib/member-alive.js +2 -2
- package/lib/policy-lessons.js +70 -0
- package/lib/receipt-evidence.js +56 -1
- package/lib/runner-command.js +1 -1
- package/lib/secret-gateway.js +588 -0
- package/lib/team-presence.js +13 -1
- package/lib/voice-gate.js +6 -0
- package/lib/wish-audit.js +5 -205
- package/lib/wish-delegate.js +5 -2
- package/package.json +6 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const RECEIPT_REL = path.join('.atris', 'state', 'caretaker.scan.latest.json');
|
|
8
|
+
const LIST_LIMIT = '100';
|
|
9
|
+
const LIST_JSON_FIELDS = ['number', 'title', 'headRefOid'].join(',');
|
|
10
|
+
const DETAIL_JSON_FIELDS = [
|
|
11
|
+
'number',
|
|
12
|
+
'title',
|
|
13
|
+
'headRefOid',
|
|
14
|
+
'statusCheckRollup',
|
|
15
|
+
'reviews',
|
|
16
|
+
'latestReviews',
|
|
17
|
+
'commits',
|
|
18
|
+
'reviewDecision',
|
|
19
|
+
].join(',');
|
|
20
|
+
|
|
21
|
+
const FAIL_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED']);
|
|
22
|
+
const FAIL_STATES = new Set(['FAILURE', 'ERROR']);
|
|
23
|
+
const PASS_CONCLUSIONS = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL']);
|
|
24
|
+
const PASS_STATES = new Set(['SUCCESS']);
|
|
25
|
+
const PENDING_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'REQUESTED', 'WAITING', 'EXPECTED']);
|
|
26
|
+
|
|
27
|
+
function runGh(args, { cwd, env } = {}) {
|
|
28
|
+
return spawnSync('gh', args, {
|
|
29
|
+
cwd: cwd || process.cwd(),
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
env: env || process.env,
|
|
32
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function printGhUnavailable() {
|
|
37
|
+
console.error('gh is missing or not signed in. install github cli and run gh auth login.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureGh(options = {}) {
|
|
41
|
+
const version = runGh(['--version'], options);
|
|
42
|
+
if ((version.error && version.error.code === 'ENOENT') || version.error || version.status !== 0) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const auth = runGh(['auth', 'status'], options);
|
|
46
|
+
if ((auth.error && auth.error.code === 'ENOENT') || auth.error || auth.status !== 0) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ghFailedDetail(result, fallback) {
|
|
53
|
+
const detail = String(result.stderr || result.stdout || result.error?.message || fallback).trim();
|
|
54
|
+
return detail.split(/\r?\n/).find(Boolean) || fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function checkName(check) {
|
|
58
|
+
if (!check || typeof check !== 'object') return '';
|
|
59
|
+
return String(check.name || check.context || '').trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkTime(check) {
|
|
63
|
+
const raw = check?.completedAt || check?.startedAt || check?.submittedAt || '';
|
|
64
|
+
const ms = Date.parse(raw);
|
|
65
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isFailingCheck(check) {
|
|
69
|
+
const conclusion = String(check?.conclusion || '').toUpperCase();
|
|
70
|
+
const state = String(check?.state || '').toUpperCase();
|
|
71
|
+
const status = String(check?.status || '').toUpperCase();
|
|
72
|
+
if (FAIL_CONCLUSIONS.has(conclusion) || FAIL_STATES.has(state)) return true;
|
|
73
|
+
if (status === 'COMPLETED' && conclusion && !PASS_CONCLUSIONS.has(conclusion)) return true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPassingCheck(check) {
|
|
78
|
+
const conclusion = String(check?.conclusion || '').toUpperCase();
|
|
79
|
+
const state = String(check?.state || '').toUpperCase();
|
|
80
|
+
if (PASS_CONCLUSIONS.has(conclusion) || PASS_STATES.has(state)) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isPendingCheck(check) {
|
|
85
|
+
const status = String(check?.status || '').toUpperCase();
|
|
86
|
+
const state = String(check?.state || '').toUpperCase();
|
|
87
|
+
if (PENDING_STATUSES.has(status) || PENDING_STATUSES.has(state)) return true;
|
|
88
|
+
if (!check?.conclusion && !PASS_STATES.has(state) && !FAIL_STATES.has(state)) {
|
|
89
|
+
return status !== 'COMPLETED';
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function latestChecks(rollup = []) {
|
|
95
|
+
const byName = new Map();
|
|
96
|
+
for (const check of Array.isArray(rollup) ? rollup : []) {
|
|
97
|
+
const name = checkName(check);
|
|
98
|
+
if (!name) continue;
|
|
99
|
+
const prev = byName.get(name);
|
|
100
|
+
if (!prev || checkTime(check) >= checkTime(prev)) byName.set(name, check);
|
|
101
|
+
}
|
|
102
|
+
return [...byName.values()];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function lastPushAt(pr) {
|
|
106
|
+
const commits = Array.isArray(pr?.commits) ? pr.commits : [];
|
|
107
|
+
let latest = 0;
|
|
108
|
+
let latestIso = '';
|
|
109
|
+
for (const commit of commits) {
|
|
110
|
+
const iso = commit?.committedDate || commit?.authoredDate || '';
|
|
111
|
+
const ms = Date.parse(iso);
|
|
112
|
+
if (Number.isFinite(ms) && ms >= latest) {
|
|
113
|
+
latest = ms;
|
|
114
|
+
latestIso = iso;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return latestIso;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function collectReviews(pr) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const list of [pr?.reviews, pr?.latestReviews]) {
|
|
123
|
+
if (!Array.isArray(list)) continue;
|
|
124
|
+
for (const review of list) out.push(review);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function activeChangesRequested(pr) {
|
|
130
|
+
const pushAt = lastPushAt(pr);
|
|
131
|
+
const pushMs = Date.parse(pushAt);
|
|
132
|
+
const reviews = collectReviews(pr).filter((review) => {
|
|
133
|
+
if (String(review?.state || '').toUpperCase() !== 'CHANGES_REQUESTED') return false;
|
|
134
|
+
const submittedMs = Date.parse(review?.submittedAt || '');
|
|
135
|
+
if (!Number.isFinite(submittedMs)) return false;
|
|
136
|
+
if (!Number.isFinite(pushMs)) return true;
|
|
137
|
+
return submittedMs > pushMs;
|
|
138
|
+
});
|
|
139
|
+
return reviews;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function classifyPullRequest(pr) {
|
|
143
|
+
const checks = latestChecks(pr?.statusCheckRollup);
|
|
144
|
+
const failing = checks.filter(isFailingCheck);
|
|
145
|
+
if (failing.length) {
|
|
146
|
+
const name = checkName(failing[0]) || 'ci';
|
|
147
|
+
return {
|
|
148
|
+
state: 'ci-red',
|
|
149
|
+
reason: `the latest ${name} check failed`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (activeChangesRequested(pr).length) {
|
|
154
|
+
return {
|
|
155
|
+
state: 'changes-requested',
|
|
156
|
+
reason: 'a review asked for changes after the last push',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!checks.length) {
|
|
161
|
+
return {
|
|
162
|
+
state: 'waiting',
|
|
163
|
+
reason: 'no checks have reported yet',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (checks.some(isPendingCheck)) {
|
|
168
|
+
return {
|
|
169
|
+
state: 'waiting',
|
|
170
|
+
reason: 'checks are still running',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (checks.every(isPassingCheck)) {
|
|
175
|
+
return {
|
|
176
|
+
state: 'green-mergeable',
|
|
177
|
+
reason: 'checks passed and nothing blocks merge',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
state: 'waiting',
|
|
183
|
+
reason: 'checks or reviews are not ready to judge',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatSentence(entry) {
|
|
188
|
+
const title = String(entry.title || 'untitled').trim() || 'untitled';
|
|
189
|
+
return `pr ${entry.number} "${title}" is ${entry.state} because ${entry.reason}.`.toLowerCase();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function writeReceipt(root, receipt) {
|
|
193
|
+
const file = path.join(root, RECEIPT_REL);
|
|
194
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
195
|
+
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
|
196
|
+
return file;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function listOpenPullRequestRefs(options = {}) {
|
|
200
|
+
const result = runGh([
|
|
201
|
+
'pr',
|
|
202
|
+
'list',
|
|
203
|
+
'--state',
|
|
204
|
+
'open',
|
|
205
|
+
'--limit',
|
|
206
|
+
LIST_LIMIT,
|
|
207
|
+
'--json',
|
|
208
|
+
LIST_JSON_FIELDS,
|
|
209
|
+
], options);
|
|
210
|
+
if (result.error || result.status !== 0) {
|
|
211
|
+
throw new Error(ghFailedDetail(result, 'gh pr list failed'));
|
|
212
|
+
}
|
|
213
|
+
const raw = String(result.stdout || '').trim();
|
|
214
|
+
if (!raw) return [];
|
|
215
|
+
const parsed = JSON.parse(raw);
|
|
216
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function fetchPullRequestDetail(number, options = {}) {
|
|
220
|
+
const result = runGh([
|
|
221
|
+
'pr',
|
|
222
|
+
'view',
|
|
223
|
+
String(number),
|
|
224
|
+
'--json',
|
|
225
|
+
DETAIL_JSON_FIELDS,
|
|
226
|
+
], options);
|
|
227
|
+
if (result.error || result.status !== 0) {
|
|
228
|
+
throw new Error(ghFailedDetail(result, `gh pr view ${number} failed`));
|
|
229
|
+
}
|
|
230
|
+
const raw = String(result.stdout || '').trim();
|
|
231
|
+
if (!raw) {
|
|
232
|
+
throw new Error(`gh pr view ${number} returned empty output`);
|
|
233
|
+
}
|
|
234
|
+
return JSON.parse(raw);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scanPullRequests({ cwd, env, now } = {}) {
|
|
238
|
+
const root = path.resolve(cwd || process.cwd());
|
|
239
|
+
const scannedAt = (now instanceof Date ? now : new Date()).toISOString();
|
|
240
|
+
const refs = listOpenPullRequestRefs({ cwd: root, env });
|
|
241
|
+
const prs = refs.map((ref) => {
|
|
242
|
+
const number = Number(ref.number);
|
|
243
|
+
const detail = fetchPullRequestDetail(number, { cwd: root, env });
|
|
244
|
+
const pr = {
|
|
245
|
+
...detail,
|
|
246
|
+
number: Number(detail.number || number),
|
|
247
|
+
title: detail.title != null ? detail.title : ref.title,
|
|
248
|
+
headRefOid: detail.headRefOid || ref.headRefOid || '',
|
|
249
|
+
};
|
|
250
|
+
const classified = classifyPullRequest(pr);
|
|
251
|
+
return {
|
|
252
|
+
number: pr.number,
|
|
253
|
+
title: String(pr.title || ''),
|
|
254
|
+
state: classified.state,
|
|
255
|
+
reason: classified.reason,
|
|
256
|
+
head_sha: String(pr.headRefOid || ''),
|
|
257
|
+
scanned_at: scannedAt,
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
const receipt = { scanned_at: scannedAt, prs };
|
|
261
|
+
const receiptPath = writeReceipt(root, receipt);
|
|
262
|
+
return { root, receipt, receiptPath, prs };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function showCaretakerHelp() {
|
|
266
|
+
console.log('usage: atris caretaker scan');
|
|
267
|
+
console.log('classify open pull requests on origin. detection only; no fix, comment, or merge.');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function caretakerCommand(args = [], options = {}) {
|
|
271
|
+
const argv = Array.isArray(args) ? args.filter((arg) => arg !== '--') : [];
|
|
272
|
+
if (!argv.length || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) {
|
|
273
|
+
showCaretakerHelp();
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
if (argv[0] !== 'scan') {
|
|
277
|
+
console.error(`unknown caretaker command: ${argv[0]}`);
|
|
278
|
+
showCaretakerHelp();
|
|
279
|
+
return 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
283
|
+
const env = options.env || process.env;
|
|
284
|
+
if (!ensureGh({ cwd, env })) {
|
|
285
|
+
printGhUnavailable();
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const { prs } = scanPullRequests({ cwd, env, now: options.now });
|
|
291
|
+
if (!prs.length) {
|
|
292
|
+
console.log('no open pull requests.');
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
for (const entry of prs) console.log(formatSentence(entry));
|
|
296
|
+
return 0;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error(`caretaker scan failed: ${error.message || error}`);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
module.exports = { caretakerCommand };
|
package/commands/clean.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
4
5
|
const escapeRegExp = require('../lib/escape-regexp');
|
|
5
6
|
const { wikiMetabolismNudge } = require('../lib/wiki');
|
|
6
7
|
|
|
@@ -49,6 +50,10 @@ function cleanAtris(options = {}) {
|
|
|
49
50
|
results.healedRefDetails = replacements;
|
|
50
51
|
results.unhealableRefs = unhealable;
|
|
51
52
|
|
|
53
|
+
// 2b. Let the repo refresh and validate its own MAP.md, if it ships scripts for it
|
|
54
|
+
const mapScripts = runMapScripts(cwd, options.dryRun);
|
|
55
|
+
results.mapScripts = mapScripts;
|
|
56
|
+
|
|
52
57
|
// 3. Archive old journals (>30 days)
|
|
53
58
|
const archived = archiveOldJournals(atrisDir, options.dryRun);
|
|
54
59
|
results.archivedJournals = archived;
|
|
@@ -127,6 +132,9 @@ function cleanAtris(options = {}) {
|
|
|
127
132
|
console.log('✓ All MAP.md refs valid');
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
// Repo-local map scripts
|
|
136
|
+
mapScripts.forEach(step => console.log(mapScriptLine(step)));
|
|
137
|
+
|
|
130
138
|
// Archived journals
|
|
131
139
|
if (archived > 0) {
|
|
132
140
|
const verb = options.dryRun ? 'Would archive' : 'Archived';
|
|
@@ -185,6 +193,70 @@ function cleanAtris(options = {}) {
|
|
|
185
193
|
return results;
|
|
186
194
|
}
|
|
187
195
|
|
|
196
|
+
// Repo-local MAP.md maintenance scripts, run in order when the workspace ships them.
|
|
197
|
+
const MAP_SCRIPTS = [
|
|
198
|
+
{
|
|
199
|
+
file: 'refresh_map_active.py',
|
|
200
|
+
ranLabel: 'Refreshed MAP.md active files',
|
|
201
|
+
dryLabel: 'Would refresh MAP.md active files',
|
|
202
|
+
failLabel: 'MAP.md active files refresh failed',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
file: 'validate_map.py',
|
|
206
|
+
ranLabel: 'MAP.md validation passed',
|
|
207
|
+
dryLabel: 'Would validate MAP.md',
|
|
208
|
+
failLabel: 'MAP.md validation failed',
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Run the workspace's own MAP.md scripts (scripts/refresh_map_active.py, then
|
|
214
|
+
* scripts/validate_map.py) when they exist. Report-only: a failing script does
|
|
215
|
+
* not stop clean.
|
|
216
|
+
*/
|
|
217
|
+
function runMapScripts(cwd, dryRun = false) {
|
|
218
|
+
const steps = [];
|
|
219
|
+
|
|
220
|
+
for (const script of MAP_SCRIPTS) {
|
|
221
|
+
const scriptPath = path.join(cwd, 'scripts', script.file);
|
|
222
|
+
if (!fs.existsSync(scriptPath)) continue;
|
|
223
|
+
|
|
224
|
+
const step = { script: `scripts/${script.file}`, status: 'ok', detail: '' };
|
|
225
|
+
|
|
226
|
+
if (dryRun) {
|
|
227
|
+
step.status = 'would_run';
|
|
228
|
+
steps.push(step);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const proc = spawnSync('python3', [scriptPath], { cwd, encoding: 'utf8', env: process.env });
|
|
233
|
+
if (proc.error) {
|
|
234
|
+
step.status = 'skipped';
|
|
235
|
+
step.detail = proc.error.code === 'ENOENT' ? 'python3 not available' : proc.error.message;
|
|
236
|
+
} else if ((proc.status ?? 0) !== 0) {
|
|
237
|
+
step.status = 'failed';
|
|
238
|
+
step.detail = firstLine(proc.stderr) || firstLine(proc.stdout) || `exit ${proc.status}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
steps.push(step);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return steps;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function firstLine(text) {
|
|
248
|
+
if (!text) return '';
|
|
249
|
+
return String(text).split('\n').map(l => l.trim()).find(l => l.length > 0) || '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function mapScriptLine(step) {
|
|
253
|
+
const script = MAP_SCRIPTS.find(s => step.script.endsWith(s.file));
|
|
254
|
+
if (step.status === 'would_run') return `✓ ${script.dryLabel} (${step.script})`;
|
|
255
|
+
if (step.status === 'skipped') return `○ Skipped ${step.script} — ${step.detail}`;
|
|
256
|
+
if (step.status === 'failed') return `✗ ${script.failLabel} — ${step.detail}`;
|
|
257
|
+
return `✓ ${script.ranLabel}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
188
260
|
function cleanResultPayload(results, options = {}, cwd = process.cwd()) {
|
|
189
261
|
const manualAction = [];
|
|
190
262
|
if (results.staleTasks.length > 0) manualAction.push('Delete stale tasks or finish them');
|
|
@@ -227,6 +299,10 @@ function cleanResultPayload(results, options = {}, cwd = process.cwd()) {
|
|
|
227
299
|
items: results.deadFiles || [],
|
|
228
300
|
test_only: results.testOnlyFiles || [],
|
|
229
301
|
},
|
|
302
|
+
map_scripts: {
|
|
303
|
+
count: (results.mapScripts || []).length,
|
|
304
|
+
items: results.mapScripts || [],
|
|
305
|
+
},
|
|
230
306
|
},
|
|
231
307
|
manual_action: manualAction,
|
|
232
308
|
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TAIL_LINES = 20;
|
|
7
|
+
const DEFAULT_POLL_MS = 250;
|
|
8
|
+
const MAX_TAIL_BYTES = 128 * 1024;
|
|
9
|
+
|
|
10
|
+
function processIsAlive(pid) {
|
|
11
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return error && error.code !== 'ESRCH';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readReceipt(receiptPath, fsModule = fs) {
|
|
21
|
+
try {
|
|
22
|
+
const receipt = JSON.parse(fsModule.readFileSync(receiptPath, 'utf8'));
|
|
23
|
+
return receipt && typeof receipt === 'object' ? receipt : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function receiptStartedMs(receipt, stat) {
|
|
30
|
+
const parsed = Date.parse(receipt.started_at || receipt.at || '');
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : stat.mtimeMs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listWatchableReceipts(root, fsModule = fs) {
|
|
35
|
+
const runsDir = path.join(root, 'atris', 'runs');
|
|
36
|
+
let names;
|
|
37
|
+
try {
|
|
38
|
+
names = fsModule.readdirSync(runsDir).filter((name) => name.endsWith('.json'));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const rows = [];
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
const receiptPath = path.join(runsDir, name);
|
|
45
|
+
const receipt = readReceipt(receiptPath, fsModule);
|
|
46
|
+
if (!receipt || !receipt.live_log) continue;
|
|
47
|
+
let stat;
|
|
48
|
+
try { stat = fsModule.statSync(receiptPath); } catch { continue; }
|
|
49
|
+
rows.push({
|
|
50
|
+
id: path.basename(name, '.json'),
|
|
51
|
+
receipt,
|
|
52
|
+
receiptPath,
|
|
53
|
+
startedMs: receiptStartedMs(receipt, stat),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return rows.sort((left, right) => right.startedMs - left.startedMs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveWatchReceipt(root, id, fsModule = fs) {
|
|
60
|
+
const rows = listWatchableReceipts(root, fsModule);
|
|
61
|
+
if (id === 'latest') return rows[0] || null;
|
|
62
|
+
const wanted = String(id || '').replace(/\.json$/, '');
|
|
63
|
+
return rows.find((row) => row.id === wanted || row.receipt.id === id) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function liveLogPathForReceipt(root, row) {
|
|
67
|
+
const value = String(row.receipt.live_log || '');
|
|
68
|
+
return path.isAbsolute(value) ? value : path.resolve(root, value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function receiptDisplayStatus(receipt, pidIsAlive = processIsAlive) {
|
|
72
|
+
const status = String(receipt.status || 'unknown').trim().toLowerCase();
|
|
73
|
+
if (status === 'running' && !pidIsAlive(Number(receipt.pid))) return 'presumed dead';
|
|
74
|
+
return status;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatAge(value, nowMs = Date.now()) {
|
|
78
|
+
const elapsedMs = Math.max(0, nowMs - Number(value || 0));
|
|
79
|
+
const seconds = Math.floor(elapsedMs / 1000);
|
|
80
|
+
if (seconds < 2) return 'now';
|
|
81
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
82
|
+
const minutes = Math.floor(seconds / 60);
|
|
83
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
84
|
+
const hours = Math.floor(minutes / 60);
|
|
85
|
+
if (hours < 48) return `${hours}h ago`;
|
|
86
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function lastLogAge(root, row, nowMs, fsModule = fs) {
|
|
90
|
+
try {
|
|
91
|
+
const logPath = liveLogPathForReceipt(root, row);
|
|
92
|
+
const stat = fsModule.statSync(logPath);
|
|
93
|
+
if (stat.size === 0) return 'none';
|
|
94
|
+
return formatAge(stat.mtimeMs, nowMs);
|
|
95
|
+
} catch {
|
|
96
|
+
return 'none';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readLastLines(filePath, count = DEFAULT_TAIL_LINES, fsModule = fs) {
|
|
101
|
+
let descriptor;
|
|
102
|
+
try {
|
|
103
|
+
const stat = fsModule.statSync(filePath);
|
|
104
|
+
if (!stat.size) return [];
|
|
105
|
+
const bytes = Math.min(stat.size, MAX_TAIL_BYTES);
|
|
106
|
+
const start = stat.size - bytes;
|
|
107
|
+
const buffer = Buffer.alloc(bytes);
|
|
108
|
+
descriptor = fsModule.openSync(filePath, 'r');
|
|
109
|
+
fsModule.readSync(descriptor, buffer, 0, bytes, start);
|
|
110
|
+
const lines = buffer.toString('utf8').split(/\r?\n/);
|
|
111
|
+
if (start > 0) lines.shift();
|
|
112
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
113
|
+
return lines.slice(-count);
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
} finally {
|
|
117
|
+
if (descriptor !== undefined) fsModule.closeSync(descriptor);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeNewLogBytes(filePath, offset, write, fsModule = fs) {
|
|
122
|
+
let descriptor;
|
|
123
|
+
try {
|
|
124
|
+
const stat = fsModule.statSync(filePath);
|
|
125
|
+
let position = stat.size < offset ? 0 : offset;
|
|
126
|
+
if (stat.size <= position) return position;
|
|
127
|
+
descriptor = fsModule.openSync(filePath, 'r');
|
|
128
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
129
|
+
while (position < stat.size) {
|
|
130
|
+
const bytes = fsModule.readSync(descriptor, buffer, 0, Math.min(buffer.length, stat.size - position), position);
|
|
131
|
+
if (!bytes) break;
|
|
132
|
+
write(buffer.subarray(0, bytes).toString('utf8'));
|
|
133
|
+
position += bytes;
|
|
134
|
+
}
|
|
135
|
+
return position;
|
|
136
|
+
} catch {
|
|
137
|
+
return offset;
|
|
138
|
+
} finally {
|
|
139
|
+
if (descriptor !== undefined) fsModule.closeSync(descriptor);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function followReceipt(row, root, deps = {}) {
|
|
144
|
+
const fsModule = deps.fs || fs;
|
|
145
|
+
const pidIsAlive = deps.pidIsAlive || processIsAlive;
|
|
146
|
+
const write = deps.write || process.stdout.write.bind(process.stdout);
|
|
147
|
+
const pollMs = deps.pollMs || DEFAULT_POLL_MS;
|
|
148
|
+
const liveLogPath = liveLogPathForReceipt(root, row);
|
|
149
|
+
let offset = 0;
|
|
150
|
+
try { offset = fsModule.statSync(liveLogPath).size; } catch {}
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
const interval = setInterval(() => {
|
|
153
|
+
offset = writeNewLogBytes(liveLogPath, offset, write, fsModule);
|
|
154
|
+
const receipt = readReceipt(row.receiptPath, fsModule);
|
|
155
|
+
if (!receipt || receiptDisplayStatus(receipt, pidIsAlive) !== 'running') {
|
|
156
|
+
offset = writeNewLogBytes(liveLogPath, offset, write, fsModule);
|
|
157
|
+
clearInterval(interval);
|
|
158
|
+
resolve(0);
|
|
159
|
+
}
|
|
160
|
+
}, pollMs);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function printRunningRoster(root, deps = {}) {
|
|
165
|
+
const fsModule = deps.fs || fs;
|
|
166
|
+
const pidIsAlive = deps.pidIsAlive || processIsAlive;
|
|
167
|
+
const log = deps.log || console.log;
|
|
168
|
+
const nowMs = deps.nowMs === undefined ? Date.now() : deps.nowMs;
|
|
169
|
+
const rows = listWatchableReceipts(root, fsModule)
|
|
170
|
+
.filter((row) => String(row.receipt.status || '').toLowerCase() === 'running');
|
|
171
|
+
if (!rows.length) {
|
|
172
|
+
log('no engine work is running.');
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
const engine = String(row.receipt.engine || 'unknown');
|
|
177
|
+
const status = receiptDisplayStatus(row.receipt, pidIsAlive);
|
|
178
|
+
const started = row.receipt.started_at || row.receipt.at || 'unknown';
|
|
179
|
+
log(`${row.id} ${engine} ${status} started ${started} last log ${lastLogAge(root, row, nowMs, fsModule)}`);
|
|
180
|
+
}
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runEngineWatchCommand(args = [], root = process.cwd(), deps = {}) {
|
|
185
|
+
const fsModule = deps.fs || fs;
|
|
186
|
+
const pidIsAlive = deps.pidIsAlive || processIsAlive;
|
|
187
|
+
const log = deps.log || console.log;
|
|
188
|
+
const noFollow = args.includes('--no-follow');
|
|
189
|
+
const id = args.find((arg) => !String(arg).startsWith('--')) || '';
|
|
190
|
+
if (!id) return printRunningRoster(root, deps);
|
|
191
|
+
|
|
192
|
+
const row = resolveWatchReceipt(root, id, fsModule);
|
|
193
|
+
if (!row) {
|
|
194
|
+
log(`engine watch: no receipt found for ${id}`);
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
const status = receiptDisplayStatus(row.receipt, pidIsAlive);
|
|
198
|
+
const engine = String(row.receipt.engine || 'unknown');
|
|
199
|
+
const started = row.receipt.started_at || row.receipt.at || 'unknown';
|
|
200
|
+
log(`${row.id} ${engine} ${status} started ${started}`);
|
|
201
|
+
const liveLogPath = liveLogPathForReceipt(root, row);
|
|
202
|
+
const lines = readLastLines(liveLogPath, DEFAULT_TAIL_LINES, fsModule);
|
|
203
|
+
if (lines.length) lines.forEach((line) => log(line));
|
|
204
|
+
else log('no live output yet.');
|
|
205
|
+
|
|
206
|
+
if (noFollow || status !== 'running') return 0;
|
|
207
|
+
return followReceipt(row, root, deps);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
runEngineWatchCommand,
|
|
212
|
+
};
|