mindvest-atlas 0.98.0 → 0.100.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/package.json +1 -1
- package/src/commands/remote-control.js +84 -9
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.py +45 -2
- package/src/remote-control.js +110 -10
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
computerUseAllowed, listComputerSessions, readComputerActions, listComputerVideo, readComputerVideoPart,
|
|
28
28
|
sessionReplay, venvPython, ambientPython,
|
|
29
29
|
readSchedule, cronMatches, dueMinutes, scheduleMatches, scheduleDueMinutes, writeNext,
|
|
30
|
-
SCRIPTS_DIR, projectsForAlert, readMode, checksWhen, projectPrints,
|
|
30
|
+
SCRIPTS_DIR, projectsForAlert, readMode, checksWhen, checksDue, projectPrints,
|
|
31
31
|
streamStudioFrames,
|
|
32
32
|
localBrokers, localBrokerCall,
|
|
33
33
|
localMemory,
|
|
@@ -283,6 +283,8 @@ async function handleAsk(ask, root) {
|
|
|
283
283
|
// results are read like any file (op 'read' on checks.json and checks/).
|
|
284
284
|
if (ask.op === 'checks') return answer(ask.request_id, startChecks({ ...ask, why: 'web' }, root));
|
|
285
285
|
if (ask.op === 'checks_auto') return answer(ask.request_id, setCheckAuto(ask, root));
|
|
286
|
+
// Pause, resume or delete one check (owner, 2026-10-15), in checks/settings.json.
|
|
287
|
+
if (ask.op === 'checks_state') return answer(ask.request_id, setCheckState(ask, root));
|
|
286
288
|
// ── Sharing a project ────────────────────────────────────────────────
|
|
287
289
|
// Not awaited: a pack, an upload and a download are seconds to minutes on a
|
|
288
290
|
// home connection, and the ask loop has the run queue, the schedule and every
|
|
@@ -908,12 +910,12 @@ const REQUEST_TICK_MS = 1000;
|
|
|
908
910
|
*/
|
|
909
911
|
export function requestTick(root, timeoutMs, spawn = spawnJob, stop = stopRun, nowMs = Date.now()) {
|
|
910
912
|
const acted = [];
|
|
911
|
-
for (const { dir, op, only, replay, why, assume } of takeRequests(root, nowMs)) {
|
|
913
|
+
for (const { dir, op, only, skip, replay, why, assume } of takeRequests(root, nowMs)) {
|
|
912
914
|
const slug = path.basename(dir);
|
|
913
915
|
// Atlas Studio's Run checks / Recheck: the same road a check run from the
|
|
914
916
|
// web takes, so it runs exactly as the project's own run does.
|
|
915
917
|
if (op === 'checks') {
|
|
916
|
-
const r = startChecks({ job: slug, only, replay, why, assume }, root, spawn);
|
|
918
|
+
const r = startChecks({ job: slug, only, skip, replay, why, assume }, root, spawn);
|
|
917
919
|
if (r.ok) eprintln(color.dim(`· ${slug} — checks asked for here`));
|
|
918
920
|
acted.push([slug, 'checks']);
|
|
919
921
|
continue;
|
|
@@ -1045,27 +1047,37 @@ export function checksTick(root, nowMs = Date.now(), spawn = spawnJob) {
|
|
|
1045
1047
|
if (!fs.existsSync(path.join(dir, 'checks.py'))) continue;
|
|
1046
1048
|
const when = checksWhen(dir);
|
|
1047
1049
|
const prints = projectPrints(dir);
|
|
1050
|
+
const off = checksOff(dir);
|
|
1048
1051
|
let s = checksSeen.get(slug);
|
|
1049
1052
|
if (!s) { checksSeen.set(slug, { at: nowMs, ...prints, filesAt: 0, dataAt: 0, fixed: new Set() }); continue; }
|
|
1050
1053
|
const busy = running.has(`${slug}:checks`) || starting.has(`${slug}:checks`) || nowMs - (checksEndedAt.get(slug) ?? 0) < 30_000;
|
|
1051
1054
|
if (busy) { Object.assign(s, prints, { filesAt: 0, dataAt: 0, at: nowMs }); continue; }
|
|
1052
1055
|
if (prints.files !== s.files) { s.files = prints.files; s.filesAt = nowMs; }
|
|
1053
1056
|
if (prints.data !== s.data) { s.data = prints.data; s.dataAt = nowMs; }
|
|
1057
|
+
const results = readJsonFile(path.join(dir, 'checks.json'));
|
|
1058
|
+
const rows = Array.isArray(results?.checks) ? results.checks : [];
|
|
1059
|
+
// ON THEIR TIMES: each check's own (atlas.json "checks"."each"), and the
|
|
1060
|
+
// project's for every check without one; one run for all that are due.
|
|
1061
|
+
const due = checksDue(when, rows.map((c) => String(c?.id)), off, s.at / 1000, nowMs / 1000);
|
|
1054
1062
|
let why = null;
|
|
1055
|
-
if (
|
|
1063
|
+
if (due) why = 'schedule';
|
|
1056
1064
|
else if (when.files && s.filesAt && nowMs - s.filesAt >= SETTLE_MS) why = 'files';
|
|
1057
1065
|
else if (when.data && s.dataAt && nowMs - s.dataAt >= SETTLE_MS) why = 'data';
|
|
1058
1066
|
s.at = nowMs;
|
|
1067
|
+
// EVERY CHECK PAUSED OR DELETED: nothing to run by itself (atlas_env would
|
|
1068
|
+
// skip each one anyway, after fetching its data for nothing).
|
|
1069
|
+
if (why && rows.length && rows.every((c) => off.has(String(c?.id)))) { s.filesAt = 0; s.dataAt = 0; continue; }
|
|
1059
1070
|
if (why) {
|
|
1060
1071
|
s.filesAt = 0; s.dataAt = 0;
|
|
1061
|
-
const r = startChecks({ job: slug, why }, root, spawn);
|
|
1062
|
-
|
|
1072
|
+
const r = startChecks({ job: slug, why, ...(due ? due : {}) }, root, spawn);
|
|
1073
|
+
const which = due?.only.length ? `: ${due.only.join(', ')}` : '';
|
|
1074
|
+
if (r.ok) { started.push([slug, why]); eprintln(color.dim(`· ${slug} — checks (${why}${which})`)); }
|
|
1063
1075
|
continue;
|
|
1064
1076
|
}
|
|
1065
1077
|
// A FIX THAT NOTHING HAS RUN SINCE: that check again, fresh.
|
|
1066
|
-
const results = readJsonFile(path.join(dir, 'checks.json'));
|
|
1067
1078
|
const fixes = readJsonFile(path.join(dir, 'checks', 'resolutions.json'));
|
|
1068
|
-
for (const c of
|
|
1079
|
+
for (const c of rows) {
|
|
1080
|
+
if (off.has(String(c?.id))) continue;
|
|
1069
1081
|
const f = fixes && fixes[c?.id];
|
|
1070
1082
|
if (!f || f.ok === false || c.status === 'pass' || s.fixed.has(`${c.id}@${f.at}`)) continue;
|
|
1071
1083
|
if (Date.parse(f.at) <= Date.parse(c.ran_at || '')) continue;
|
|
@@ -1220,12 +1232,21 @@ export async function remoteControlCommand(args) {
|
|
|
1220
1232
|
/** For the tests: the one road every run takes. */
|
|
1221
1233
|
export const runJobForTests = (job, root, timeoutMs) => runJob(job, root, timeoutMs);
|
|
1222
1234
|
|
|
1235
|
+
const CHECK_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
1236
|
+
|
|
1223
1237
|
/** Start a project's checks.py through the ordinary run (plan check included). */
|
|
1224
1238
|
export function startChecks(ask, root, spawn = spawnJob) {
|
|
1225
1239
|
const job = String(ask?.job || '');
|
|
1226
1240
|
const { dir } = workspace(job, root);
|
|
1227
1241
|
if (!job || !fs.existsSync(path.join(dir, 'checks.py'))) return { ok: false, error: 'This project has no checks yet.' };
|
|
1228
|
-
const
|
|
1242
|
+
const asked = (Array.isArray(ask.only) ? ask.only : []).map(String).filter((x) => CHECK_ID_RE.test(x));
|
|
1243
|
+
// A paused or deleted check is never run, not even when named.
|
|
1244
|
+
const off = checksOff(dir);
|
|
1245
|
+
const only = asked.filter((x) => !off.has(x));
|
|
1246
|
+
if (asked.length && !only.length) return { ok: false, error: 'That check is paused. Resume it to run it.' };
|
|
1247
|
+
// Not due on this minute: a check whose own time did not come round, in a
|
|
1248
|
+
// run the project's schedule started (atlas_env's wants() says no).
|
|
1249
|
+
const skip = (Array.isArray(ask.skip) ? ask.skip : []).map(String).filter((x) => CHECK_ID_RE.test(x)).slice(0, 200);
|
|
1229
1250
|
const replay = /^\d{8}-\d{6}$/.test(String(ask.replay || '')) ? String(ask.replay) : '';
|
|
1230
1251
|
// What started it, for the run's history line (a word, or nothing).
|
|
1231
1252
|
const why = /^[a-z]{1,20}$/.test(String(ask.why || '')) ? String(ask.why) : '';
|
|
@@ -1235,6 +1256,7 @@ export function startChecks(ask, root, spawn = spawnJob) {
|
|
|
1235
1256
|
automation_id: job, name: job, source: 'local', file: 'checks.py',
|
|
1236
1257
|
env: {
|
|
1237
1258
|
ATLAS_CHECK_ONLY: only.join(','), ATLAS_CHECK_REPLAY: replay, ...(why ? { ATLAS_CHECK_WHY: why } : {}),
|
|
1259
|
+
...(skip.length && !only.length ? { ATLAS_CHECK_SKIP: skip.join(',') } : {}),
|
|
1238
1260
|
...(assume ? { ATLAS_CHECK_ASSUME: assume } : {}),
|
|
1239
1261
|
},
|
|
1240
1262
|
}, root, CHECKS_TIMEOUT_MS);
|
|
@@ -1264,6 +1286,59 @@ export function cleanAssume(raw) {
|
|
|
1264
1286
|
return Object.keys(out).length ? JSON.stringify(out) : '';
|
|
1265
1287
|
}
|
|
1266
1288
|
|
|
1289
|
+
/** The checks the person paused or deleted (checks/settings.json), by id. */
|
|
1290
|
+
export function checksOff(dir) {
|
|
1291
|
+
const d = readJsonFile(path.join(dir, 'checks', 'settings.json')) || {};
|
|
1292
|
+
const paused = d.paused && typeof d.paused === 'object' ? d.paused : {};
|
|
1293
|
+
const deleted = d.deleted && typeof d.deleted === 'object' ? d.deleted : {};
|
|
1294
|
+
return new Set([...Object.keys(paused).filter((k) => paused[k]), ...Object.keys(deleted)]);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* PAUSE, RESUME OR DELETE ONE CHECK (owner, 2026-10-15: "I can pause and
|
|
1299
|
+
* delete checks ... a button to pause checks so it never runs"), from the web
|
|
1300
|
+
* or a phone. checks/settings.json, which Atlas Studio writes the same way
|
|
1301
|
+
* and atlas_env reads at every run: a paused check never runs; a deleted one
|
|
1302
|
+
* also leaves the results and is not written back.
|
|
1303
|
+
*/
|
|
1304
|
+
export function setCheckState(ask, root) {
|
|
1305
|
+
const id = String(ask?.id || '');
|
|
1306
|
+
const action = String(ask?.action || '');
|
|
1307
|
+
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: 'That is not a check.' };
|
|
1308
|
+
if (!['pause', 'resume', 'delete'].includes(action)) return { ok: false, error: 'Pause, resume or delete.' };
|
|
1309
|
+
const { dir } = workspace(String(ask?.job || ''), root);
|
|
1310
|
+
if (!fs.existsSync(dir)) return { ok: false, error: 'There is no such project on this computer.' };
|
|
1311
|
+
const file = path.join(dir, 'checks', 'settings.json');
|
|
1312
|
+
const d = readJsonFile(file) || {};
|
|
1313
|
+
const paused = d.paused && typeof d.paused === 'object' ? d.paused : {};
|
|
1314
|
+
const deleted = d.deleted && typeof d.deleted === 'object' ? d.deleted : {};
|
|
1315
|
+
if (action === 'pause') paused[id] = true;
|
|
1316
|
+
else delete paused[id];
|
|
1317
|
+
if (action === 'delete') {
|
|
1318
|
+
deleted[id] = new Date().toISOString();
|
|
1319
|
+
if (d.auto_resolve && typeof d.auto_resolve === 'object') delete d.auto_resolve[id];
|
|
1320
|
+
}
|
|
1321
|
+
d.paused = paused;
|
|
1322
|
+
d.deleted = deleted;
|
|
1323
|
+
try {
|
|
1324
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1325
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(d, null, 2));
|
|
1326
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
1327
|
+
} catch {
|
|
1328
|
+
return { ok: false, error: 'That could not be saved.' };
|
|
1329
|
+
}
|
|
1330
|
+
if (action === 'delete') {
|
|
1331
|
+
// Its row leaves the results; its history stays (the person's record).
|
|
1332
|
+
const res = path.join(dir, 'checks.json');
|
|
1333
|
+
const r = readJsonFile(res);
|
|
1334
|
+
if (r && Array.isArray(r.checks) && r.checks.some((c) => c?.id === id)) {
|
|
1335
|
+
r.checks = r.checks.filter((c) => c?.id !== id);
|
|
1336
|
+
try { fs.writeFileSync(`${res}.tmp`, JSON.stringify(r, null, 2)); fs.renameSync(`${res}.tmp`, res); } catch { /* the settings already hide it */ }
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return { ok: true };
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1267
1342
|
/** One check's auto-resolve switch, in checks/settings.json (Atlas Studio reads it too). */
|
|
1268
1343
|
export function setCheckAuto(ask, root) {
|
|
1269
1344
|
const id = String(ask?.id || '');
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
|
|
5
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.100.0';
|
|
6
6
|
export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
|
|
7
7
|
export const DEFAULT_SCOPE = 'atlas broker';
|
|
8
8
|
export const CLIENT_NAME = 'Atlas CLI';
|
package/src/helpers/atlas_env.py
CHANGED
|
@@ -1030,6 +1030,23 @@ def _read_json(path, default):
|
|
|
1030
1030
|
return default
|
|
1031
1031
|
|
|
1032
1032
|
|
|
1033
|
+
def _check_id(value):
|
|
1034
|
+
return "".join(c if c.isalnum() or c in "-_" else "-" for c in str(value))[:80] or "check"
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _checks_off():
|
|
1038
|
+
"""THE CHECKS THE PERSON PAUSED OR DELETED (owner, 2026-10-15: "a button to
|
|
1039
|
+
pause checks so it never runs"). Kept in checks/settings.json beside the
|
|
1040
|
+
auto-fix switches, by Atlas Studio, the web (through the runner) and
|
|
1041
|
+
restore_check(). A paused or deleted check never runs: wants() says no and
|
|
1042
|
+
result() keeps nothing, whatever started the run (its schedule, a change,
|
|
1043
|
+
Run all, an agent building)."""
|
|
1044
|
+
d = _read_json(os.path.join(_checks_dir(), "checks", "settings.json"), {})
|
|
1045
|
+
paused = d.get("paused") if isinstance(d.get("paused"), dict) else {}
|
|
1046
|
+
deleted = d.get("deleted") if isinstance(d.get("deleted"), dict) else {}
|
|
1047
|
+
return {str(k) for k, v in paused.items() if v} | {str(k) for k in deleted}
|
|
1048
|
+
|
|
1049
|
+
|
|
1033
1050
|
def _library_versions():
|
|
1034
1051
|
try:
|
|
1035
1052
|
from importlib import metadata as _md
|
|
@@ -1048,6 +1065,9 @@ class _CheckRun:
|
|
|
1048
1065
|
import time as _time
|
|
1049
1066
|
self.dir = _checks_dir()
|
|
1050
1067
|
self.only = [x for x in (os.environ.get("ATLAS_CHECK_ONLY") or "").split(",") if x]
|
|
1068
|
+
# A run its project's schedule started, minus the checks that keep a
|
|
1069
|
+
# time of their own which is not now (atlas.json "checks"."each").
|
|
1070
|
+
self.skip = [x for x in (os.environ.get("ATLAS_CHECK_SKIP") or "").split(",") if x]
|
|
1051
1071
|
self.replay_of = (os.environ.get("ATLAS_CHECK_REPLAY") or "").strip() or None
|
|
1052
1072
|
self.started = _time.time()
|
|
1053
1073
|
self.id = _time.strftime("%Y%m%d-%H%M%S", _time.localtime(self.started))
|
|
@@ -1069,6 +1089,8 @@ class _CheckRun:
|
|
|
1069
1089
|
# per check): what assume() handed out, per check, in order.
|
|
1070
1090
|
self._assumed = {}
|
|
1071
1091
|
self._mine_cache = None
|
|
1092
|
+
# Paused or deleted by the person: never run, never recorded.
|
|
1093
|
+
self.off = _checks_off()
|
|
1072
1094
|
self._watch_the_end()
|
|
1073
1095
|
# THE ONE MODEL THAT RUNS THESE CHECKS (owner, 2026-10-15: "what model
|
|
1074
1096
|
# performed the test"): the project's atlas.json "checks": {"model"},
|
|
@@ -1302,7 +1324,12 @@ class _CheckRun:
|
|
|
1302
1324
|
|
|
1303
1325
|
# ── results ──
|
|
1304
1326
|
def wants(self, check_id):
|
|
1305
|
-
"""False for a check this run was not asked for (Recheck of one
|
|
1327
|
+
"""False for a check this run was not asked for (Recheck of one, or a
|
|
1328
|
+
run on the times of a few checks), for one whose own time is not now
|
|
1329
|
+
in a run on the project's schedule, and for one the person paused or
|
|
1330
|
+
deleted."""
|
|
1331
|
+
if _check_id(check_id) in self.off or check_id in self.skip:
|
|
1332
|
+
return False
|
|
1306
1333
|
return not self.only or check_id in self.only
|
|
1307
1334
|
|
|
1308
1335
|
def result(self, check_id, status, title, summary="", fixed=None, report=None,
|
|
@@ -1316,7 +1343,11 @@ class _CheckRun:
|
|
|
1316
1343
|
agent is asked when the person presses Resolve."""
|
|
1317
1344
|
import time as _time
|
|
1318
1345
|
status = status if status in _CHECK_STATUSES else "warn"
|
|
1319
|
-
cid =
|
|
1346
|
+
cid = _check_id(check_id)
|
|
1347
|
+
# Paused or deleted: nothing of it is kept, even from a checks.py that
|
|
1348
|
+
# did not ask wants() first.
|
|
1349
|
+
if cid in self.off:
|
|
1350
|
+
return
|
|
1320
1351
|
report_rel = None
|
|
1321
1352
|
if report:
|
|
1322
1353
|
text = str(report)
|
|
@@ -1583,6 +1614,18 @@ def checks():
|
|
|
1583
1614
|
return _CheckRun()
|
|
1584
1615
|
|
|
1585
1616
|
|
|
1617
|
+
def restore_check(check_id):
|
|
1618
|
+
"""Bring back a check the person deleted (or paused), ONLY when they ask
|
|
1619
|
+
for it back: it runs again from the next run."""
|
|
1620
|
+
cid = _check_id(check_id)
|
|
1621
|
+
path = os.path.join(_checks_dir(), "checks", "settings.json")
|
|
1622
|
+
data = _read_json(path, {})
|
|
1623
|
+
for key in ("paused", "deleted"):
|
|
1624
|
+
if isinstance(data.get(key), dict):
|
|
1625
|
+
data[key].pop(cid, None)
|
|
1626
|
+
_write_json(path, data)
|
|
1627
|
+
|
|
1628
|
+
|
|
1586
1629
|
def progress(step="", done=None, total=None, message="", **extra):
|
|
1587
1630
|
"""Say where you are, so the dashboard can draw it.
|
|
1588
1631
|
|
package/src/remote-control.js
CHANGED
|
@@ -689,19 +689,59 @@ export function checksCommand(dir) {
|
|
|
689
689
|
|
|
690
690
|
/**
|
|
691
691
|
* When a project's checks run by themselves: atlas.json "checks": {"schedule":
|
|
692
|
-
* "30 8 * * 1-5", "on": ["files", "data"]
|
|
692
|
+
* "30 8 * * 1-5", "on": ["files", "data"], "timezone": "America/New_York"}.
|
|
693
693
|
*/
|
|
694
694
|
export function checksWhen(dir) {
|
|
695
|
-
const
|
|
695
|
+
const m = readManifest(dir);
|
|
696
|
+
const c = m.checks;
|
|
696
697
|
const o = c && typeof c === 'object' ? c : {};
|
|
697
|
-
const expr = typeof o.schedule === 'string' ? o.schedule.trim() : '';
|
|
698
|
-
const cron = expr ? parseCron(expr) : null;
|
|
699
698
|
const on = Array.isArray(o.on) ? o.on.map(String) : [];
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
699
|
+
// The zone it is read in: the checks' own, else the project's, else UTC like
|
|
700
|
+
// every schedule (Atlas Studio and the web say it in these words too).
|
|
701
|
+
const tz = String([o.timezone, m.timezone].find((z) => typeof z === 'string' && z.trim()) || '').trim();
|
|
702
|
+
const sched = (raw) => {
|
|
703
|
+
const expr = typeof raw === 'string' ? raw.trim() : '';
|
|
704
|
+
const cron = expr ? parseCron(expr) : null;
|
|
705
|
+
return cron ? { expr, lines: [{ kind: 'cron', cron, text: expr }], tz, error: null, cron } : null;
|
|
704
706
|
};
|
|
707
|
+
// A TIME PER CHECK (owner, 2026-10-16: "for each checks, let this be here"):
|
|
708
|
+
// "each": {"<check id>": "<five-field cron>"}, read in the same zone. A check
|
|
709
|
+
// with no entry (or one that is not a cron) keeps the project's schedule.
|
|
710
|
+
const each = {};
|
|
711
|
+
const e = o.each && typeof o.each === 'object' && !Array.isArray(o.each) ? o.each : {};
|
|
712
|
+
for (const [id, raw] of Object.entries(e).slice(0, 200)) {
|
|
713
|
+
if (!CHECK_ID.test(id)) continue;
|
|
714
|
+
const s = sched(raw);
|
|
715
|
+
if (s) each[id] = s;
|
|
716
|
+
}
|
|
717
|
+
return { schedule: sched(o.schedule), each, files: on.includes('files'), data: on.includes('data') };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const CHECK_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* WHICH CHECKS ARE DUE between two moments (from, to], or null for none: the
|
|
724
|
+
* checks whose own time ("each") came round, and, when the project's schedule
|
|
725
|
+
* came round, every check without a time of its own. One answer, so two
|
|
726
|
+
* schedules on the same minute start ONE run. `known` is the ids checks.json
|
|
727
|
+
* holds, `off` the paused and deleted.
|
|
728
|
+
* {only: [...]} : just these checks (only their own times came round);
|
|
729
|
+
* {only: [], skip: [...]} : all of them but these (their own time did not).
|
|
730
|
+
* The python runner's checks_due is the same, line for line.
|
|
731
|
+
*/
|
|
732
|
+
export function checksDue(when, known, off, fromEpoch, toEpoch) {
|
|
733
|
+
const own = Object.keys(when?.each || {}).filter((id) => !off.has(id)).sort();
|
|
734
|
+
const ownDue = own.filter((id) => scheduleDueMinutes(when.each[id], fromEpoch, toEpoch).length);
|
|
735
|
+
const projectDue = !!(when?.schedule && scheduleDueMinutes(when.schedule, fromEpoch, toEpoch).length);
|
|
736
|
+
if (projectDue) {
|
|
737
|
+
const skip = own.filter((id) => !ownDue.includes(id));
|
|
738
|
+
// Nothing left to run: every check it knows is paused, deleted, or keeps
|
|
739
|
+
// a time of its own that is not now.
|
|
740
|
+
const left = known.filter((id) => !off.has(id) && !skip.includes(id));
|
|
741
|
+
if (known.length && !left.length) return null;
|
|
742
|
+
return { only: [], skip };
|
|
743
|
+
}
|
|
744
|
+
return ownDue.length ? { only: ownDue, skip: [] } : null;
|
|
705
745
|
}
|
|
706
746
|
|
|
707
747
|
/** What a folder holds, as one string that changes when anything in it does. */
|
|
@@ -1448,12 +1488,72 @@ export function readJobTable(job, rel, root) {
|
|
|
1448
1488
|
}
|
|
1449
1489
|
}
|
|
1450
1490
|
|
|
1491
|
+
/**
|
|
1492
|
+
* A PDF, Word or PowerPoint file goes to the web as its bytes, like a picture
|
|
1493
|
+
* (owner, 2026-10-15: "pdf file support, word docs ... for everything"): the
|
|
1494
|
+
* website draws it (its components/files/DocView.tsx). Read as text it was a
|
|
1495
|
+
* screenful of bytes, or refused past the text ceiling. Since 2026-10-16 the
|
|
1496
|
+
* old Office files (.doc, .ppt, .xls) and Parquet too (owner: "yes for
|
|
1497
|
+
* parquet ... yes for old office files"): the website reads their words or
|
|
1498
|
+
* their first rows itself. Nothing else is sent as bytes: a zip or an
|
|
1499
|
+
* OpenDocument file is not drawn there, so it is not carried.
|
|
1500
|
+
*/
|
|
1501
|
+
export const DOC_TYPES = {
|
|
1502
|
+
'.pdf': 'application/pdf',
|
|
1503
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1504
|
+
'.docm': 'application/vnd.ms-word.document.macroEnabled.12',
|
|
1505
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
1506
|
+
'.pptm': 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
|
|
1507
|
+
'.ppsx': 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
|
|
1508
|
+
'.doc': 'application/msword',
|
|
1509
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
1510
|
+
'.xls': 'application/vnd.ms-excel',
|
|
1511
|
+
'.parquet': 'application/vnd.apache.parquet',
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
/** The relay's ceiling for a document: a picture's. Atlas's server holds it too. */
|
|
1515
|
+
export const MAX_DOC_BYTES = MAX_IMAGE_BYTES;
|
|
1516
|
+
|
|
1517
|
+
/** The content type for a document's name, or '' if it is not one. */
|
|
1518
|
+
export function docType(name) {
|
|
1519
|
+
const n = String(name).toLowerCase();
|
|
1520
|
+
const dot = n.lastIndexOf('.');
|
|
1521
|
+
return dot >= 0 ? (DOC_TYPES[n.slice(dot)] || '') : '';
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/** A Jupyter notebook: JSON text with its outputs' pictures inside. */
|
|
1525
|
+
export function isNotebook(name) {
|
|
1526
|
+
return String(name).toLowerCase().endsWith('.ipynb');
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** "12.4 MB", the website's words (lib/doc-view.ts sizeWords). */
|
|
1530
|
+
function sizeWords(bytes) {
|
|
1531
|
+
if (bytes < 1024) return `${bytes} bytes`;
|
|
1532
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
1533
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1451
1536
|
export function readJobFile(job, rel, root) {
|
|
1452
1537
|
const where = jobTarget(job, rel, root);
|
|
1453
1538
|
if (!where.ok) return where;
|
|
1454
1539
|
const { target } = where;
|
|
1455
1540
|
const mime = imageType(path.basename(target));
|
|
1541
|
+
const doc = docType(path.basename(target));
|
|
1456
1542
|
try {
|
|
1543
|
+
if (doc) {
|
|
1544
|
+
const size = fs.statSync(target).size;
|
|
1545
|
+
// Worded as the website's viewer words it, which tells it apart from a
|
|
1546
|
+
// computer from before (that one refused a document as too long a text).
|
|
1547
|
+
if (size > MAX_DOC_BYTES) return { ok: false, error: `This file is ${sizeWords(size)}, too large to show here.` };
|
|
1548
|
+
return { ok: true, mime: doc, b64: fs.readFileSync(target).toString('base64') };
|
|
1549
|
+
}
|
|
1550
|
+
// A JUPYTER NOTEBOOK (2026-10-16) is text, but its pictures are inside it:
|
|
1551
|
+
// it may be as large as a document, and past that it is worded the same.
|
|
1552
|
+
if (isNotebook(target)) {
|
|
1553
|
+
const size = fs.statSync(target).size;
|
|
1554
|
+
if (size > MAX_DOC_BYTES) return { ok: false, error: `This file is ${sizeWords(size)}, too large to show here.` };
|
|
1555
|
+
return { ok: true, text: fs.readFileSync(target, 'utf8') };
|
|
1556
|
+
}
|
|
1457
1557
|
if (fs.statSync(target).size > (mime ? MAX_IMAGE_BYTES : MAX_VIEW_BYTES)) {
|
|
1458
1558
|
return { ok: false, error: 'That file is too large to show here.' };
|
|
1459
1559
|
}
|
|
@@ -1805,7 +1905,7 @@ export function writeRequest(job, op, root, extra = {}) {
|
|
|
1805
1905
|
try {
|
|
1806
1906
|
fs.mkdirSync(dir, { recursive: true });
|
|
1807
1907
|
// A checks request says which checks and which run it repeats.
|
|
1808
|
-
const more = want === 'checks' ? { only: extra.only, replay: extra.replay, why: extra.why, assume: extra.assume } : {};
|
|
1908
|
+
const more = want === 'checks' ? { only: extra.only, skip: extra.skip, replay: extra.replay, why: extra.why, assume: extra.assume } : {};
|
|
1809
1909
|
fs.writeFileSync(path.join(dir, `${at}-${want}.json`), JSON.stringify({ op: want, at: at / 1000, ...more }), 'utf8');
|
|
1810
1910
|
} catch (err) {
|
|
1811
1911
|
return { ok: false, error: `Could not leave the request: ${err.message}` };
|
|
@@ -1849,7 +1949,7 @@ export function takeRequests(root, nowMs = Date.now()) {
|
|
|
1849
1949
|
if (Number.isFinite(body.at) && nowMs - body.at * 1000 > REQUEST_TTL_MS) continue;
|
|
1850
1950
|
// A checks request carries which checks and which run it repeats.
|
|
1851
1951
|
out.push(op === 'checks'
|
|
1852
|
-
? { dir: path.join(base, slug), op, only: body.only, replay: body.replay, why: body.why, assume: body.assume }
|
|
1952
|
+
? { dir: path.join(base, slug), op, only: body.only, skip: body.skip, replay: body.replay, why: body.why, assume: body.assume }
|
|
1853
1953
|
: { dir: path.join(base, slug), op });
|
|
1854
1954
|
}
|
|
1855
1955
|
}
|