atris 3.51.0 → 3.53.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/engines/SKILL.md +8 -7
- package/atris/skills/youtube/SKILL.md +8 -8
- package/ax +592 -91
- package/bin/atris.js +17 -5
- package/commands/computer.js +2 -2
- package/commands/mission.js +52 -24
- package/commands/now.js +10 -3
- package/commands/radar.js +65 -33
- package/commands/youtube.js +206 -23
- package/lib/auto-accept-certified.js +2 -1
- package/lib/engine-ask.js +11 -3
- package/lib/engine-registry.js +32 -7
- package/lib/mission-ledger-compact.js +127 -0
- package/lib/mission-runtime-loop.js +30 -2
- package/lib/next-moves.js +34 -34
- package/lib/permission-grants.js +10 -1
- package/lib/runner-command.js +5 -4
- package/lib/task-db.js +18 -2
- package/lib/task-proof.js +6 -1
- package/lib/task-receipt.js +5 -1
- package/package.json +2 -1
- package/scripts/outbound-artifact-gate.js +227 -0
package/commands/youtube.js
CHANGED
|
@@ -5,8 +5,9 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const https = require('https');
|
|
7
7
|
|
|
8
|
-
const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]';
|
|
8
|
+
const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [haiku|atris-fast|gemini|grok|codex|cursor]';
|
|
9
9
|
const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
|
|
10
|
+
const NOTES_PLAYLIST_CAP = 10;
|
|
10
11
|
|
|
11
12
|
const DEFAULT_QUERY = [
|
|
12
13
|
'Create a timestamped YouTube brief for Atris.',
|
|
@@ -24,7 +25,7 @@ const ALLOWED_CAPTION_HOST_SUFFIXES = [
|
|
|
24
25
|
|
|
25
26
|
function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
26
27
|
output('');
|
|
27
|
-
output(`Usage: ${commandName} notes <youtube-url> [engine]`);
|
|
28
|
+
output(`Usage: ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine]`);
|
|
28
29
|
output(` ${commandName} process <youtube-url> [options]`);
|
|
29
30
|
output(` ${commandName} digest [--days N]`);
|
|
30
31
|
output(` ${commandName} watch add <channel-url-or-@handle>`);
|
|
@@ -33,7 +34,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
|
33
34
|
output(` ${commandName} watch tick`);
|
|
34
35
|
output(` ${commandName} <youtube-url> [options]`);
|
|
35
36
|
output('');
|
|
36
|
-
output('notes = free local notes,
|
|
37
|
+
output('notes = free local notes for one url, several urls, or a playlist');
|
|
38
|
+
output('process = 5 credits cloud knowledge');
|
|
37
39
|
output('digest = one decision page from this week\'s video briefs');
|
|
38
40
|
output('watch = subscribed channels turn into briefs without a human');
|
|
39
41
|
output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
|
|
@@ -52,6 +54,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
|
52
54
|
output('');
|
|
53
55
|
output('Examples:');
|
|
54
56
|
output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
|
|
57
|
+
output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID https://youtu.be/OTHER_ID`);
|
|
58
|
+
output(` ${commandName} notes https://www.youtube.com/playlist?list=PLAYLIST_ID`);
|
|
55
59
|
output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
|
|
56
60
|
output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
|
|
57
61
|
output(` ${commandName} digest`);
|
|
@@ -464,6 +468,34 @@ function videoIdFromUrl(url) {
|
|
|
464
468
|
return short ? short[1] : null;
|
|
465
469
|
}
|
|
466
470
|
|
|
471
|
+
function looksLikeYoutubeUrl(arg) {
|
|
472
|
+
const text = String(arg || '').trim();
|
|
473
|
+
if (!text || text.startsWith('-')) return false;
|
|
474
|
+
return /youtube\.com|youtu\.be/i.test(text);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isPlaylistUrl(url) {
|
|
478
|
+
const text = String(url || '');
|
|
479
|
+
return /[?&]list=/.test(text) || /\/playlist(?:\?|$|\/)/i.test(text);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function parseNotesArgs(argv = []) {
|
|
483
|
+
const urls = [];
|
|
484
|
+
let engine = null;
|
|
485
|
+
let help = false;
|
|
486
|
+
for (const raw of argv) {
|
|
487
|
+
const arg = String(raw);
|
|
488
|
+
if (arg === '--help' || arg === '-h' || arg === 'help') {
|
|
489
|
+
help = true;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (arg.startsWith('-')) continue;
|
|
493
|
+
if (looksLikeYoutubeUrl(arg)) urls.push(arg);
|
|
494
|
+
else engine = arg;
|
|
495
|
+
}
|
|
496
|
+
return { urls, engine, help };
|
|
497
|
+
}
|
|
498
|
+
|
|
467
499
|
function dateStamp(now) {
|
|
468
500
|
if (typeof now === 'string' && /^\d{4}-\d{2}-\d{2}/.test(now)) {
|
|
469
501
|
return now.slice(0, 10);
|
|
@@ -512,6 +544,7 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
|
|
|
512
544
|
fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
|
|
513
545
|
|
|
514
546
|
console.log(`brief filed: ${relBrief}`);
|
|
547
|
+
return relBrief;
|
|
515
548
|
} catch {
|
|
516
549
|
// notes filing must never break the youtube command
|
|
517
550
|
}
|
|
@@ -1015,37 +1048,189 @@ async function watchCommand(args = [], deps = {}) {
|
|
|
1015
1048
|
return 2;
|
|
1016
1049
|
}
|
|
1017
1050
|
|
|
1018
|
-
function
|
|
1019
|
-
const
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1051
|
+
function defaultPlaylistExpander(playlistUrl, deps = {}) {
|
|
1052
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
1053
|
+
const result = spawn('yt-dlp', [
|
|
1054
|
+
'--no-update',
|
|
1055
|
+
'--flat-playlist',
|
|
1056
|
+
'--print',
|
|
1057
|
+
'%(id)s|%(title)s',
|
|
1058
|
+
playlistUrl,
|
|
1059
|
+
], {
|
|
1060
|
+
encoding: 'utf8',
|
|
1061
|
+
timeout: 60000,
|
|
1062
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
1063
|
+
});
|
|
1064
|
+
if (result.error || (result.status != null && result.status !== 0)) {
|
|
1065
|
+
const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
|
|
1066
|
+
throw new Error(detail || 'playlist expand failed');
|
|
1026
1067
|
}
|
|
1068
|
+
return parseFlatPlaylist(result.stdout);
|
|
1069
|
+
}
|
|
1027
1070
|
|
|
1071
|
+
function defaultNotesItemRunner(url, engine, deps = {}) {
|
|
1028
1072
|
const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
|
|
1029
1073
|
const spawn = deps.spawnSync || spawnSync;
|
|
1030
1074
|
const childArgs = engine ? [url, engine] : [url];
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1075
|
+
return spawn(script, childArgs, { stdio: 'inherit' });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function readNowMs(deps = {}) {
|
|
1079
|
+
if (typeof deps.nowMs === 'function') return Number(deps.nowMs()) || 0;
|
|
1080
|
+
if (Number.isFinite(deps.nowMs)) return Number(deps.nowMs);
|
|
1081
|
+
return Date.now();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function notesItemLabel(item = {}) {
|
|
1085
|
+
return item.id || item.url || '';
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function expandNotesTargets(urls = [], deps = {}) {
|
|
1089
|
+
const output = deps.output || ((line = '') => console.error(line));
|
|
1090
|
+
const expander = deps.expander || ((playlistUrl) => defaultPlaylistExpander(playlistUrl, deps));
|
|
1091
|
+
const items = [];
|
|
1092
|
+
for (const url of urls) {
|
|
1093
|
+
if (!isPlaylistUrl(url)) {
|
|
1094
|
+
items.push({ url, id: videoIdFromUrl(url) });
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
let videos = [];
|
|
1098
|
+
try {
|
|
1099
|
+
videos = expander(url, deps);
|
|
1100
|
+
} catch {
|
|
1101
|
+
items.push({ url, id: videoIdFromUrl(url), failed: true });
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (!Array.isArray(videos) || videos.length === 0) {
|
|
1105
|
+
items.push({ url, id: videoIdFromUrl(url), failed: true });
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (videos.length > NOTES_PLAYLIST_CAP) {
|
|
1109
|
+
output(`playlist capped at ${NOTES_PLAYLIST_CAP} videos (${videos.length} found)`);
|
|
1110
|
+
videos = videos.slice(0, NOTES_PLAYLIST_CAP);
|
|
1111
|
+
}
|
|
1112
|
+
for (const video of videos) {
|
|
1113
|
+
if (!video?.id) continue;
|
|
1114
|
+
items.push({
|
|
1115
|
+
url: `https://www.youtube.com/watch?v=${video.id}`,
|
|
1116
|
+
id: video.id,
|
|
1117
|
+
title: video.title,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return items;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function invokeNotesRunner(url, engine, deps = {}) {
|
|
1125
|
+
const runner = deps.runner;
|
|
1126
|
+
if (runner) return runner(url, engine, deps);
|
|
1127
|
+
return defaultNotesItemRunner(url, engine, deps);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function readRunnerStatus(result) {
|
|
1131
|
+
if (typeof result === 'number') return result;
|
|
1132
|
+
if (result && typeof result === 'object') {
|
|
1133
|
+
return result.status == null ? 1 : result.status;
|
|
1134
|
+
}
|
|
1135
|
+
return 1;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function fileNotesBrief(url, deps = {}) {
|
|
1139
|
+
const briefFiler = deps.briefFiler || fileBriefFromNotes;
|
|
1140
|
+
try {
|
|
1141
|
+
const filed = briefFiler({
|
|
1035
1142
|
cwd: deps.cwd || process.cwd(),
|
|
1036
1143
|
url,
|
|
1037
1144
|
workDir: deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes'),
|
|
1038
1145
|
now: deps.now || new Date(),
|
|
1039
1146
|
});
|
|
1147
|
+
return typeof filed === 'string' && filed ? filed : null;
|
|
1148
|
+
} catch {
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function runOneNotesItem(item, engine, deps = {}) {
|
|
1154
|
+
const output = deps.output || ((line = '') => console.error(line));
|
|
1155
|
+
const label = notesItemLabel(item);
|
|
1156
|
+
if (item.failed) {
|
|
1157
|
+
output(`${label} 0s FAILED`);
|
|
1158
|
+
return { url: item.url, id: item.id, seconds: 0, ok: false, brief: null };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const started = readNowMs(deps);
|
|
1162
|
+
let status = 1;
|
|
1163
|
+
try {
|
|
1164
|
+
status = readRunnerStatus(invokeNotesRunner(item.url, engine, deps));
|
|
1165
|
+
} catch {
|
|
1166
|
+
status = 1;
|
|
1167
|
+
}
|
|
1168
|
+
const brief = status === 0 ? fileNotesBrief(item.url, deps) : null;
|
|
1169
|
+
const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
|
|
1170
|
+
const ok = status === 0;
|
|
1171
|
+
output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
|
|
1172
|
+
return { url: item.url, id: item.id, seconds, ok, brief };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function formatNotesSummary(rows = []) {
|
|
1176
|
+
const lines = ['url or id seconds result'];
|
|
1177
|
+
for (const row of rows) {
|
|
1178
|
+
const result = row.ok ? (row.brief || 'ok') : 'FAILED';
|
|
1179
|
+
lines.push(`${notesItemLabel(row)} ${row.seconds}s ${result}`);
|
|
1180
|
+
}
|
|
1181
|
+
return lines.join('\n');
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function runYoutubeNotesBatch({ urls, engine } = {}, deps = {}) {
|
|
1185
|
+
const output = deps.output || ((line = '') => console.error(line));
|
|
1186
|
+
const items = expandNotesTargets(urls || [], deps);
|
|
1187
|
+
const rows = [];
|
|
1188
|
+
for (const item of items) {
|
|
1189
|
+
rows.push(runOneNotesItem(item, engine, deps));
|
|
1190
|
+
}
|
|
1191
|
+
if (rows.length) {
|
|
1192
|
+
output('');
|
|
1193
|
+
output(formatNotesSummary(rows));
|
|
1194
|
+
}
|
|
1195
|
+
if (!rows.length) return 2;
|
|
1196
|
+
return rows.some((row) => row.ok) ? 0 : 2;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function runSingleYoutubeNotes(url, engine, deps = {}) {
|
|
1200
|
+
let result;
|
|
1201
|
+
try {
|
|
1202
|
+
result = invokeNotesRunner(url, engine, deps);
|
|
1203
|
+
} catch {
|
|
1204
|
+
return 1;
|
|
1205
|
+
}
|
|
1206
|
+
const status = readRunnerStatus(result);
|
|
1207
|
+
if (status === 0) fileNotesBrief(url, deps);
|
|
1208
|
+
return status;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function runYoutubeNotes(args = [], deps = {}) {
|
|
1212
|
+
const output = deps.output || ((line = '') => console.error(line));
|
|
1213
|
+
const parsed = parseNotesArgs(args);
|
|
1214
|
+
if (parsed.help) {
|
|
1215
|
+
showYoutubeHelp(output, deps.commandName || 'atris youtube');
|
|
1216
|
+
return 0;
|
|
1217
|
+
}
|
|
1218
|
+
if (!parsed.urls.length) {
|
|
1219
|
+
output(YTNOTES_USAGE);
|
|
1220
|
+
output(YTNOTES_HINT);
|
|
1221
|
+
return 2;
|
|
1222
|
+
}
|
|
1223
|
+
if (parsed.urls.length === 1 && !isPlaylistUrl(parsed.urls[0])) {
|
|
1224
|
+
return runSingleYoutubeNotes(parsed.urls[0], parsed.engine, deps);
|
|
1040
1225
|
}
|
|
1041
|
-
return
|
|
1226
|
+
return runYoutubeNotesBatch(parsed, deps);
|
|
1042
1227
|
}
|
|
1043
1228
|
|
|
1044
1229
|
async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
|
|
1045
1230
|
const output = deps.output || ((line = '') => console.log(line));
|
|
1046
1231
|
if (argv[0] === 'notes') {
|
|
1047
1232
|
const code = runYoutubeNotes(argv.slice(1), deps);
|
|
1048
|
-
if (!deps.output && !deps.spawnSync) process.exit(code);
|
|
1233
|
+
if (!deps.output && !deps.spawnSync && !deps.runner && !deps.expander) process.exit(code);
|
|
1049
1234
|
return code;
|
|
1050
1235
|
}
|
|
1051
1236
|
if (argv[0] === 'digest') {
|
|
@@ -1078,19 +1263,17 @@ module.exports = {
|
|
|
1078
1263
|
shouldRetryWithLocalTranscript,
|
|
1079
1264
|
formatYoutubeResult,
|
|
1080
1265
|
fileBriefFromNotes,
|
|
1266
|
+
isPlaylistUrl,
|
|
1267
|
+
parseNotesArgs,
|
|
1268
|
+
expandNotesTargets,
|
|
1269
|
+
runYoutubeNotesBatch,
|
|
1081
1270
|
parseDigestArgs,
|
|
1082
1271
|
collectVideoBriefs,
|
|
1083
1272
|
buildDigestPrompt,
|
|
1084
|
-
runYoutubeDigest,
|
|
1085
1273
|
normalizeWatchChannel,
|
|
1086
1274
|
channelVideosUrl,
|
|
1087
1275
|
parseFlatPlaylist,
|
|
1088
1276
|
loadWatchState,
|
|
1089
|
-
saveWatchState,
|
|
1090
|
-
addWatchChannel,
|
|
1091
|
-
listWatchChannels,
|
|
1092
|
-
removeWatchChannel,
|
|
1093
|
-
tickWatch,
|
|
1094
1277
|
watchCommand,
|
|
1095
1278
|
youtubeCommand,
|
|
1096
1279
|
};
|
|
@@ -1099,7 +1099,8 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
1099
1099
|
if ((tierRequiresStrictVerify && !verifyResult.ok)
|
|
1100
1100
|
|| verifyResult.reason === 'verify_failed'
|
|
1101
1101
|
|| verifyResult.reason === 'verify_unrunnable'
|
|
1102
|
-
|| verifyResult.reason === 'verify_worktree_missing'
|
|
1102
|
+
|| verifyResult.reason === 'verify_worktree_missing'
|
|
1103
|
+
|| verifyResult.reason === 'verify_workdir_missing') {
|
|
1103
1104
|
return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
|
|
1104
1105
|
}
|
|
1105
1106
|
}
|
package/lib/engine-ask.js
CHANGED
|
@@ -29,7 +29,7 @@ const MAX_ASK_PROMPT_BYTES = 16 * 1024;
|
|
|
29
29
|
const MAX_ASK_TOTAL_PROMPT_BYTES = 64 * 1024;
|
|
30
30
|
const MAX_ASK_OUTPUT_BYTES = 1024 * 1024;
|
|
31
31
|
const ASK_STOP_GRACE_MS = 250;
|
|
32
|
-
const ASK_MODEL_ENGINES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok']);
|
|
32
|
+
const ASK_MODEL_ENGINES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok', 'agy']);
|
|
33
33
|
const READ_ONLY_PREAMBLE = [
|
|
34
34
|
'This is a read-only request.',
|
|
35
35
|
'Do not modify files, create worktrees, start background agents, or run commands with side effects.',
|
|
@@ -241,8 +241,16 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '') {
|
|
|
241
241
|
args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), '-p', request],
|
|
242
242
|
};
|
|
243
243
|
}
|
|
244
|
-
if (engine === '
|
|
245
|
-
|
|
244
|
+
if (engine === 'agy') {
|
|
245
|
+
// agy prompts for tool permission even in plan mode and a headless ask
|
|
246
|
+
// has no one to answer, so it denies itself `ls` and fails every ask
|
|
247
|
+
// (verified live 2026-08-19). The sandbox keeps the run read-only while
|
|
248
|
+
// skip-permissions lets sandboxed reads proceed unattended.
|
|
249
|
+
return {
|
|
250
|
+
engine,
|
|
251
|
+
bin: profile.bin,
|
|
252
|
+
args: ['--mode', 'plan', '--sandbox', '--dangerously-skip-permissions', ...(model ? ['--model', model] : []), '-p', request],
|
|
253
|
+
};
|
|
246
254
|
}
|
|
247
255
|
throw new Error(`engine ask has no read-only command for ${engine}`);
|
|
248
256
|
}
|
package/lib/engine-registry.js
CHANGED
|
@@ -34,7 +34,8 @@ function engineFailureHealthStatus(result) {
|
|
|
34
34
|
return 'credit_out';
|
|
35
35
|
}
|
|
36
36
|
if (/not installed|command not found|\benoent\b/.test(signalText)) return 'not_installed';
|
|
37
|
-
|
|
37
|
+
// Timeouts and unavailable models are transient: 'error' keeps the engine
|
|
38
|
+
// routable, while 'not_installed' would drop it from routing until a doctor run.
|
|
38
39
|
return 'error';
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -48,7 +49,19 @@ const ENGINE_SEED_META = Object.freeze({
|
|
|
48
49
|
fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
|
|
49
50
|
composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
|
|
50
51
|
haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
|
|
51
|
-
|
|
52
|
+
agy: Object.freeze({
|
|
53
|
+
tier: 'pro',
|
|
54
|
+
roles: Object.freeze(['executor']),
|
|
55
|
+
models: Object.freeze([
|
|
56
|
+
'gemini-3.7-flash-high',
|
|
57
|
+
'gemini-3.1-pro-high',
|
|
58
|
+
'claude-sonnet-4-6',
|
|
59
|
+
'claude-opus-4-6-thinking',
|
|
60
|
+
'gpt-oss-120b-medium',
|
|
61
|
+
]),
|
|
62
|
+
duty: 'errands',
|
|
63
|
+
fallback_order: 90,
|
|
64
|
+
}),
|
|
52
65
|
});
|
|
53
66
|
|
|
54
67
|
function engineRegistryFile(root = process.cwd()) {
|
|
@@ -144,9 +157,9 @@ function normalizeEngineEntry(id, saved = {}) {
|
|
|
144
157
|
};
|
|
145
158
|
}
|
|
146
159
|
|
|
147
|
-
function seededRegistry(root = process.cwd()) {
|
|
160
|
+
function seededRegistry(root = process.cwd(), preloadedRaw = null) {
|
|
148
161
|
const file = engineRegistryFile(root);
|
|
149
|
-
const raw = readRawRegistry(file);
|
|
162
|
+
const raw = preloadedRaw || readRawRegistry(file);
|
|
150
163
|
const savedById = new Map();
|
|
151
164
|
for (const entry of raw.engines || []) {
|
|
152
165
|
const id = canonicalEngineName(entry && (entry.id || entry.name));
|
|
@@ -162,7 +175,11 @@ function seededRegistry(root = process.cwd()) {
|
|
|
162
175
|
function writeEngineRegistry(root, registry) {
|
|
163
176
|
const file = engineRegistryFile(root);
|
|
164
177
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
165
|
-
|
|
178
|
+
// Atomic write: a concurrent reader must never see a torn file, because a
|
|
179
|
+
// failed parse falls back to the seed registry and erases operator policy.
|
|
180
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
181
|
+
fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
182
|
+
fs.renameSync(tmp, file);
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
|
|
@@ -221,9 +238,17 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
|
|
|
221
238
|
return { id, ...nextOverrides };
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
// A read only writes when normalization actually changed the saved engines
|
|
242
|
+
// (first seed, schema drift). Settled registries stay untouched, so read
|
|
243
|
+
// paths cannot stomp a mutation another process just landed. The comparison
|
|
244
|
+
// uses the same raw snapshot the seed was built from: one read, one decision.
|
|
224
245
|
function readEngineRegistry(root = process.cwd(), options = {}) {
|
|
225
|
-
const
|
|
226
|
-
|
|
246
|
+
const raw = readRawRegistry(engineRegistryFile(root));
|
|
247
|
+
const registry = seededRegistry(root, raw);
|
|
248
|
+
const needsPersist = JSON.stringify(raw.engines || []) !== JSON.stringify(registry.engines);
|
|
249
|
+
if (options.persist !== false && needsPersist) {
|
|
250
|
+
writeEngineRegistry(root, registry);
|
|
251
|
+
}
|
|
227
252
|
return registry;
|
|
228
253
|
}
|
|
229
254
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Mission ledger compaction. Every mission save appends a full snapshot to
|
|
4
|
+
// .atris/state/missions.jsonl, so a mission saved N times costs N rows and
|
|
5
|
+
// every reader pays for the whole history (2,417 rows for 302 missions when
|
|
6
|
+
// this shipped). Worse, old snapshots keep referencing old run receipts, so
|
|
7
|
+
// the daily runs-prune can never reclaim them. Compacting to one row per
|
|
8
|
+
// mission keeps exactly what every reader reconstructs anyway.
|
|
9
|
+
//
|
|
10
|
+
// Readers' contract (loadMissionMap in commands/mission.js): the surviving
|
|
11
|
+
// row per id must carry the LATEST state but the FIRST display number ever
|
|
12
|
+
// assigned, and first-appearance order must hold so numbering stays stable.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { displayNumber } = require('./short-name');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MIN_ROWS = 64;
|
|
19
|
+
const DEFAULT_RATIO = 4;
|
|
20
|
+
|
|
21
|
+
function parseLines(text) {
|
|
22
|
+
return String(text || '')
|
|
23
|
+
.split(/\r?\n/)
|
|
24
|
+
.map((line) => line.trim())
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((line) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(line);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Latest snapshot per id, first-seen order, first assigned display number.
|
|
36
|
+
// Rows without an id are not mission snapshots (cloud mission receipts land
|
|
37
|
+
// here as {cloud: true, ...}); they pass through untouched, in order, after
|
|
38
|
+
// the compacted snapshots so latest-row readers still find them.
|
|
39
|
+
function compactRows(rows) {
|
|
40
|
+
const order = [];
|
|
41
|
+
const latest = new Map();
|
|
42
|
+
const firstNumber = new Map();
|
|
43
|
+
const passthrough = [];
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
if (!row) continue;
|
|
46
|
+
if (!row.id) {
|
|
47
|
+
passthrough.push(row);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!latest.has(row.id)) order.push(row.id);
|
|
51
|
+
if (!firstNumber.has(row.id) && displayNumber(row.n)) firstNumber.set(row.id, displayNumber(row.n));
|
|
52
|
+
latest.set(row.id, row);
|
|
53
|
+
}
|
|
54
|
+
const compacted = order.map((id) => {
|
|
55
|
+
const row = { ...latest.get(id) };
|
|
56
|
+
if (firstNumber.has(id)) row.n = firstNumber.get(id);
|
|
57
|
+
else delete row.n;
|
|
58
|
+
return row;
|
|
59
|
+
});
|
|
60
|
+
return compacted.concat(passthrough);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Compact when history outweighs live records by `ratio`, and never bother
|
|
64
|
+
// below `minRows`. Returns a receipt either way; writes atomically so a
|
|
65
|
+
// concurrent reader sees the old file or the new one, never a torn one.
|
|
66
|
+
function compactMissionLedger(file, options = {}) {
|
|
67
|
+
const minRows = Number.isFinite(options.minRows) ? options.minRows : DEFAULT_MIN_ROWS;
|
|
68
|
+
const ratio = Number.isFinite(options.ratio) ? options.ratio : DEFAULT_RATIO;
|
|
69
|
+
// Cheap stat gate so the every-save hook does not re-read a small healthy
|
|
70
|
+
// ledger: below this size compaction cannot be worth a full read.
|
|
71
|
+
const minBytes = Number.isFinite(options.minBytes) ? options.minBytes : 262144;
|
|
72
|
+
if (options.force !== true) {
|
|
73
|
+
try {
|
|
74
|
+
if (fs.statSync(file).size < minBytes) {
|
|
75
|
+
return { compacted: false, reason: 'below_threshold', rows_before: 0, rows_after: 0 };
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
return { compacted: false, reason: 'missing', rows_before: 0, rows_after: 0 };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let text = '';
|
|
82
|
+
try {
|
|
83
|
+
text = fs.readFileSync(file, 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
return { compacted: false, reason: 'missing', rows_before: 0, rows_after: 0 };
|
|
86
|
+
}
|
|
87
|
+
const rows = parseLines(text);
|
|
88
|
+
const valid = rows.filter((row) => row && row.id);
|
|
89
|
+
const uniqueIds = new Set(valid.map((row) => row.id)).size;
|
|
90
|
+
const receipt = {
|
|
91
|
+
rows_before: rows.length,
|
|
92
|
+
unique_missions: uniqueIds,
|
|
93
|
+
bytes_before: Buffer.byteLength(text),
|
|
94
|
+
};
|
|
95
|
+
const force = options.force === true;
|
|
96
|
+
if (!force && (rows.length < minRows || rows.length < uniqueIds * ratio)) {
|
|
97
|
+
return { ...receipt, compacted: false, reason: 'below_threshold', rows_after: rows.length };
|
|
98
|
+
}
|
|
99
|
+
const compacted = compactRows(rows.filter(Boolean));
|
|
100
|
+
const nextText = compacted.length
|
|
101
|
+
? `${compacted.map((row) => JSON.stringify(row)).join('\n')}\n`
|
|
102
|
+
: '';
|
|
103
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
104
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
+
fs.writeFileSync(tmp, nextText, 'utf8');
|
|
106
|
+
// The rename replaces the whole file, so an append that landed after our
|
|
107
|
+
// read would be silently lost. Re-check right before the swap and stand
|
|
108
|
+
// down if anyone wrote in between; the next save retries compaction.
|
|
109
|
+
try {
|
|
110
|
+
if (fs.statSync(file).size !== receipt.bytes_before) {
|
|
111
|
+
fs.unlinkSync(tmp);
|
|
112
|
+
return { ...receipt, compacted: false, reason: 'concurrent_write', rows_after: rows.length };
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
fs.unlinkSync(tmp);
|
|
116
|
+
return { ...receipt, compacted: false, reason: 'missing', rows_after: 0 };
|
|
117
|
+
}
|
|
118
|
+
fs.renameSync(tmp, file);
|
|
119
|
+
return {
|
|
120
|
+
...receipt,
|
|
121
|
+
compacted: true,
|
|
122
|
+
rows_after: compacted.length,
|
|
123
|
+
bytes_after: Buffer.byteLength(nextText),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { compactMissionLedger, compactRows };
|
|
@@ -121,7 +121,34 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
|
|
|
121
121
|
...(options.env || {}),
|
|
122
122
|
},
|
|
123
123
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
124
|
+
// Own process group so a timeout can kill the whole tree: the tick
|
|
125
|
+
// spawns engine workers through a shell, and killing only the direct
|
|
126
|
+
// child leaves them running (and billing) for hours after the timeout.
|
|
127
|
+
detached: process.platform !== 'win32',
|
|
124
128
|
});
|
|
129
|
+
const killTree = (signal) => {
|
|
130
|
+
try {
|
|
131
|
+
if (process.platform !== 'win32' && proc.pid) process.kill(-proc.pid, signal);
|
|
132
|
+
else proc.kill(signal);
|
|
133
|
+
} catch {
|
|
134
|
+
try { proc.kill(signal); } catch {}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
// Detached children survive the operator's Ctrl-C (they left our process
|
|
138
|
+
// group), so relay the signal to the whole tree ourselves, then let the
|
|
139
|
+
// default exit happen by re-raising after removing our handler.
|
|
140
|
+
const relaySignal = (signal) => {
|
|
141
|
+
killTree('SIGTERM');
|
|
142
|
+
setTimeout(() => killTree('SIGKILL'), 2000).unref();
|
|
143
|
+
process.removeListener(signal, relaySignal);
|
|
144
|
+
process.kill(process.pid, signal);
|
|
145
|
+
};
|
|
146
|
+
process.once('SIGINT', relaySignal);
|
|
147
|
+
process.once('SIGTERM', relaySignal);
|
|
148
|
+
const detachSignalRelay = () => {
|
|
149
|
+
process.removeListener('SIGINT', relaySignal);
|
|
150
|
+
process.removeListener('SIGTERM', relaySignal);
|
|
151
|
+
};
|
|
125
152
|
let stdout = '';
|
|
126
153
|
let stderr = '';
|
|
127
154
|
let done = false;
|
|
@@ -130,6 +157,7 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
|
|
|
130
157
|
const finish = (status) => {
|
|
131
158
|
if (done) return;
|
|
132
159
|
done = true;
|
|
160
|
+
detachSignalRelay();
|
|
133
161
|
clearTimeout(timer);
|
|
134
162
|
if (progressTimer) clearInterval(progressTimer);
|
|
135
163
|
let payload = null;
|
|
@@ -146,8 +174,8 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
|
|
|
146
174
|
};
|
|
147
175
|
const timer = setTimeout(() => {
|
|
148
176
|
killedByTimeout = true;
|
|
149
|
-
|
|
150
|
-
setTimeout(() =>
|
|
177
|
+
killTree('SIGTERM');
|
|
178
|
+
setTimeout(() => killTree('SIGKILL'), 3000).unref();
|
|
151
179
|
}, options.timeoutMs || 660000);
|
|
152
180
|
progressTimer = options.onProgress
|
|
153
181
|
? setInterval(() => {
|