mindvest-atlas 0.99.0 → 0.100.1
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 +35 -9
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.py +22 -7
- package/src/remote-control.js +180 -38
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,
|
|
@@ -910,12 +910,12 @@ const REQUEST_TICK_MS = 1000;
|
|
|
910
910
|
*/
|
|
911
911
|
export function requestTick(root, timeoutMs, spawn = spawnJob, stop = stopRun, nowMs = Date.now()) {
|
|
912
912
|
const acted = [];
|
|
913
|
-
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)) {
|
|
914
914
|
const slug = path.basename(dir);
|
|
915
915
|
// Atlas Studio's Run checks / Recheck: the same road a check run from the
|
|
916
916
|
// web takes, so it runs exactly as the project's own run does.
|
|
917
917
|
if (op === 'checks') {
|
|
918
|
-
const r = startChecks({ job: slug, only, replay, why, assume }, root, spawn);
|
|
918
|
+
const r = startChecks({ job: slug, only, skip, replay, why, assume }, root, spawn);
|
|
919
919
|
if (r.ok) eprintln(color.dim(`· ${slug} — checks asked for here`));
|
|
920
920
|
acted.push([slug, 'checks']);
|
|
921
921
|
continue;
|
|
@@ -1033,6 +1033,21 @@ function startLocalRequests(root, timeoutMs) {
|
|
|
1033
1033
|
* alone, so with Studio closed a "weekdays 8:30" check never ran.
|
|
1034
1034
|
*/
|
|
1035
1035
|
const checksSeen = new Map();
|
|
1036
|
+
/** Check times that are not a valid schedule, said once each (they never run). */
|
|
1037
|
+
const checksWarned = new Set();
|
|
1038
|
+
|
|
1039
|
+
/** Say once why a checks schedule, or one check's own time, never runs. */
|
|
1040
|
+
function warnCheckTimes(slug, when) {
|
|
1041
|
+
const times = [['schedule', when.schedule], ...Object.entries(when.each || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))];
|
|
1042
|
+
for (const [label, sched] of times) {
|
|
1043
|
+
if (!sched?.error) continue;
|
|
1044
|
+
const key = `${slug}/${label}/${sched.expr}`;
|
|
1045
|
+
if (checksWarned.has(key)) continue;
|
|
1046
|
+
checksWarned.add(key);
|
|
1047
|
+
const who = label === 'schedule' ? 'checks schedule' : `check "${label}"`;
|
|
1048
|
+
eprintln(color.yellow(`· ${slug}, ${who}: ${sched.error}`));
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1036
1051
|
/** When a project's checks last ended here: what a run writes is not a change. */
|
|
1037
1052
|
export const checksEndedAt = new Map();
|
|
1038
1053
|
const SETTLE_MS = 10_000;
|
|
@@ -1046,6 +1061,7 @@ export function checksTick(root, nowMs = Date.now(), spawn = spawnJob) {
|
|
|
1046
1061
|
const dir = path.join(base, slug);
|
|
1047
1062
|
if (!fs.existsSync(path.join(dir, 'checks.py'))) continue;
|
|
1048
1063
|
const when = checksWhen(dir);
|
|
1064
|
+
warnCheckTimes(slug, when);
|
|
1049
1065
|
const prints = projectPrints(dir);
|
|
1050
1066
|
const off = checksOff(dir);
|
|
1051
1067
|
let s = checksSeen.get(slug);
|
|
@@ -1054,20 +1070,24 @@ export function checksTick(root, nowMs = Date.now(), spawn = spawnJob) {
|
|
|
1054
1070
|
if (busy) { Object.assign(s, prints, { filesAt: 0, dataAt: 0, at: nowMs }); continue; }
|
|
1055
1071
|
if (prints.files !== s.files) { s.files = prints.files; s.filesAt = nowMs; }
|
|
1056
1072
|
if (prints.data !== s.data) { s.data = prints.data; s.dataAt = nowMs; }
|
|
1073
|
+
const results = readJsonFile(path.join(dir, 'checks.json'));
|
|
1074
|
+
const rows = Array.isArray(results?.checks) ? results.checks : [];
|
|
1075
|
+
// ON THEIR TIMES: each check's own (atlas.json "checks"."each"), and the
|
|
1076
|
+
// project's for every check without one; one run for all that are due.
|
|
1077
|
+
const due = checksDue(when, rows.map((c) => String(c?.id)), off, s.at / 1000, nowMs / 1000);
|
|
1057
1078
|
let why = null;
|
|
1058
|
-
if (
|
|
1079
|
+
if (due) why = 'schedule';
|
|
1059
1080
|
else if (when.files && s.filesAt && nowMs - s.filesAt >= SETTLE_MS) why = 'files';
|
|
1060
1081
|
else if (when.data && s.dataAt && nowMs - s.dataAt >= SETTLE_MS) why = 'data';
|
|
1061
1082
|
s.at = nowMs;
|
|
1062
|
-
const results = readJsonFile(path.join(dir, 'checks.json'));
|
|
1063
|
-
const rows = Array.isArray(results?.checks) ? results.checks : [];
|
|
1064
1083
|
// EVERY CHECK PAUSED OR DELETED: nothing to run by itself (atlas_env would
|
|
1065
1084
|
// skip each one anyway, after fetching its data for nothing).
|
|
1066
1085
|
if (why && rows.length && rows.every((c) => off.has(String(c?.id)))) { s.filesAt = 0; s.dataAt = 0; continue; }
|
|
1067
1086
|
if (why) {
|
|
1068
1087
|
s.filesAt = 0; s.dataAt = 0;
|
|
1069
|
-
const r = startChecks({ job: slug, why }, root, spawn);
|
|
1070
|
-
|
|
1088
|
+
const r = startChecks({ job: slug, why, ...(due ? due : {}) }, root, spawn);
|
|
1089
|
+
const which = due?.only.length ? `: ${due.only.join(', ')}` : '';
|
|
1090
|
+
if (r.ok) { started.push([slug, why]); eprintln(color.dim(`· ${slug} — checks (${why}${which})`)); }
|
|
1071
1091
|
continue;
|
|
1072
1092
|
}
|
|
1073
1093
|
// A FIX THAT NOTHING HAS RUN SINCE: that check again, fresh.
|
|
@@ -1228,16 +1248,21 @@ export async function remoteControlCommand(args) {
|
|
|
1228
1248
|
/** For the tests: the one road every run takes. */
|
|
1229
1249
|
export const runJobForTests = (job, root, timeoutMs) => runJob(job, root, timeoutMs);
|
|
1230
1250
|
|
|
1251
|
+
const CHECK_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
1252
|
+
|
|
1231
1253
|
/** Start a project's checks.py through the ordinary run (plan check included). */
|
|
1232
1254
|
export function startChecks(ask, root, spawn = spawnJob) {
|
|
1233
1255
|
const job = String(ask?.job || '');
|
|
1234
1256
|
const { dir } = workspace(job, root);
|
|
1235
1257
|
if (!job || !fs.existsSync(path.join(dir, 'checks.py'))) return { ok: false, error: 'This project has no checks yet.' };
|
|
1236
|
-
const asked = (Array.isArray(ask.only) ? ask.only : []).map(String).filter((x) =>
|
|
1258
|
+
const asked = (Array.isArray(ask.only) ? ask.only : []).map(String).filter((x) => CHECK_ID_RE.test(x));
|
|
1237
1259
|
// A paused or deleted check is never run, not even when named.
|
|
1238
1260
|
const off = checksOff(dir);
|
|
1239
1261
|
const only = asked.filter((x) => !off.has(x));
|
|
1240
1262
|
if (asked.length && !only.length) return { ok: false, error: 'That check is paused. Resume it to run it.' };
|
|
1263
|
+
// Not due on this minute: a check whose own time did not come round, in a
|
|
1264
|
+
// run the project's schedule started (atlas_env's wants() says no).
|
|
1265
|
+
const skip = (Array.isArray(ask.skip) ? ask.skip : []).map(String).filter((x) => CHECK_ID_RE.test(x)).slice(0, 200);
|
|
1241
1266
|
const replay = /^\d{8}-\d{6}$/.test(String(ask.replay || '')) ? String(ask.replay) : '';
|
|
1242
1267
|
// What started it, for the run's history line (a word, or nothing).
|
|
1243
1268
|
const why = /^[a-z]{1,20}$/.test(String(ask.why || '')) ? String(ask.why) : '';
|
|
@@ -1247,6 +1272,7 @@ export function startChecks(ask, root, spawn = spawnJob) {
|
|
|
1247
1272
|
automation_id: job, name: job, source: 'local', file: 'checks.py',
|
|
1248
1273
|
env: {
|
|
1249
1274
|
ATLAS_CHECK_ONLY: only.join(','), ATLAS_CHECK_REPLAY: replay, ...(why ? { ATLAS_CHECK_WHY: why } : {}),
|
|
1275
|
+
...(skip.length && !only.length ? { ATLAS_CHECK_SKIP: skip.join(',') } : {}),
|
|
1250
1276
|
...(assume ? { ATLAS_CHECK_ASSUME: assume } : {}),
|
|
1251
1277
|
},
|
|
1252
1278
|
}, root, CHECKS_TIMEOUT_MS);
|
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.1';
|
|
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
|
@@ -1065,6 +1065,9 @@ class _CheckRun:
|
|
|
1065
1065
|
import time as _time
|
|
1066
1066
|
self.dir = _checks_dir()
|
|
1067
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]
|
|
1068
1071
|
self.replay_of = (os.environ.get("ATLAS_CHECK_REPLAY") or "").strip() or None
|
|
1069
1072
|
self.started = _time.time()
|
|
1070
1073
|
self.id = _time.strftime("%Y%m%d-%H%M%S", _time.localtime(self.started))
|
|
@@ -1141,12 +1144,13 @@ class _CheckRun:
|
|
|
1141
1144
|
data["running"] = None
|
|
1142
1145
|
data["last_run"] = {"id": self.id, "at": _time.strftime("%Y-%m-%dT%H:%M:%S", _time.localtime()),
|
|
1143
1146
|
"took_s": round(_time.time() - self.started, 1), "replay_of": self.replay_of,
|
|
1144
|
-
"model": self.model, "error": msg
|
|
1147
|
+
"model": self.model, "error": msg,
|
|
1148
|
+
"only": self.only or None, "skip": self._skipped()}
|
|
1145
1149
|
_write_json(path, data)
|
|
1146
1150
|
_history_add({
|
|
1147
1151
|
"kind": "run", "id": self.id, "at": data["last_run"]["at"], "why": self.why,
|
|
1148
1152
|
"replay_of": self.replay_of, "model": self.model, "took_s": data["last_run"]["took_s"],
|
|
1149
|
-
"error": msg, "only": self.only or None, "checks": self._results,
|
|
1153
|
+
"error": msg, "only": self.only or None, "skip": self._skipped(), "checks": self._results,
|
|
1150
1154
|
})
|
|
1151
1155
|
if self.why in _UNATTENDED:
|
|
1152
1156
|
conf = _notify_conf()
|
|
@@ -1176,6 +1180,10 @@ class _CheckRun:
|
|
|
1176
1180
|
"id": self.id,
|
|
1177
1181
|
"replay_of": self.replay_of,
|
|
1178
1182
|
"only": self.only or None,
|
|
1183
|
+
# WHAT THIS RUN COVERS (2026-10-17): a run on the project's
|
|
1184
|
+
# schedule leaves out the checks whose own time it is not, so the
|
|
1185
|
+
# screens show only the others running.
|
|
1186
|
+
"skip": self._skipped(),
|
|
1179
1187
|
"step": self._current[0] if self._current else "Starting",
|
|
1180
1188
|
"done": len(steps),
|
|
1181
1189
|
"total": self._total,
|
|
@@ -1319,11 +1327,18 @@ class _CheckRun:
|
|
|
1319
1327
|
except Exception:
|
|
1320
1328
|
return ""
|
|
1321
1329
|
|
|
1330
|
+
def _skipped(self):
|
|
1331
|
+
"""The checks this run leaves out (a run with `only` names what it
|
|
1332
|
+
covers instead), or None."""
|
|
1333
|
+
return (list(self.skip) or None) if not self.only else None
|
|
1334
|
+
|
|
1322
1335
|
# ── results ──
|
|
1323
1336
|
def wants(self, check_id):
|
|
1324
|
-
"""False for a check this run was not asked for (Recheck of one
|
|
1325
|
-
for one
|
|
1326
|
-
|
|
1337
|
+
"""False for a check this run was not asked for (Recheck of one, or a
|
|
1338
|
+
run on the times of a few checks), for one whose own time is not now
|
|
1339
|
+
in a run on the project's schedule, and for one the person paused or
|
|
1340
|
+
deleted."""
|
|
1341
|
+
if _check_id(check_id) in self.off or check_id in self.skip:
|
|
1327
1342
|
return False
|
|
1328
1343
|
return not self.only or check_id in self.only
|
|
1329
1344
|
|
|
@@ -1408,7 +1423,7 @@ class _CheckRun:
|
|
|
1408
1423
|
data = _read_json(path, {})
|
|
1409
1424
|
data["last_run"] = {"id": self.id, "at": _time.strftime("%Y-%m-%dT%H:%M:%S", _time.localtime()),
|
|
1410
1425
|
"took_s": round(_time.time() - self.started, 1), "replay_of": self.replay_of,
|
|
1411
|
-
"model": self.model}
|
|
1426
|
+
"model": self.model, "only": self.only or None, "skip": self._skipped()}
|
|
1412
1427
|
data["running"] = None
|
|
1413
1428
|
_write_json(path, data)
|
|
1414
1429
|
self._finished = True
|
|
@@ -1416,7 +1431,7 @@ class _CheckRun:
|
|
|
1416
1431
|
_history_add({
|
|
1417
1432
|
"kind": "run", "id": self.id, "at": data["last_run"]["at"], "why": self.why,
|
|
1418
1433
|
"replay_of": self.replay_of, "model": self.model, "took_s": data["last_run"]["took_s"],
|
|
1419
|
-
"checks": self._results,
|
|
1434
|
+
"only": self.only or None, "skip": self._skipped(), "checks": self._results,
|
|
1420
1435
|
})
|
|
1421
1436
|
# NOBODY WAS WATCHING (its schedule, a change, a fix): what the
|
|
1422
1437
|
# project's Notifications tab asks for goes to the person's phone, per
|
package/src/remote-control.js
CHANGED
|
@@ -261,39 +261,121 @@ export function readManifest(dir) {
|
|
|
261
261
|
// ticker writes a "Computer was off" row for any gap it finds when it wakes.
|
|
262
262
|
|
|
263
263
|
const CRON_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
264
|
+
const CRON_FIELD_WORDS = ['minutes', 'hours', 'days of the month', 'months', 'days of the week'];
|
|
265
|
+
const MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
266
|
+
const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
267
|
+
/** The most days each month can have (February: 29, in a leap year). */
|
|
268
|
+
const MONTH_DAYS = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
269
|
+
export const CRON_MACROS = {
|
|
270
|
+
'@yearly': '0 0 1 1 *', '@annually': '0 0 1 1 *', '@monthly': '0 0 1 * *', '@weekly': '0 0 * * 0',
|
|
271
|
+
'@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@hourly': '0 * * * *',
|
|
272
|
+
};
|
|
264
273
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
274
|
+
function cronValue(v, i, rangeEnd) {
|
|
275
|
+
if (/^\d{1,2}$/.test(v)) {
|
|
276
|
+
const n = Number(v);
|
|
277
|
+
return n >= CRON_RANGES[i][0] && n <= CRON_RANGES[i][1] ? n : null;
|
|
278
|
+
}
|
|
279
|
+
const name = v.toLowerCase();
|
|
280
|
+
if (i === 3 && MONTH_NAMES.includes(name)) return MONTH_NAMES.indexOf(name) + 1;
|
|
281
|
+
// SUN closing a range is the week's last day (FRI-SUN), else its first.
|
|
282
|
+
if (i === 4 && DAY_NAMES.includes(name)) return name === 'sun' && rangeEnd ? 7 : DAY_NAMES.indexOf(name);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** One field as its sorted values, or why it is not one (a string). */
|
|
287
|
+
function cronField(text, i) {
|
|
288
|
+
const [lo, hi] = CRON_RANGES[i];
|
|
289
|
+
const what = CRON_FIELD_WORDS[i];
|
|
290
|
+
const out = new Set();
|
|
291
|
+
for (const piece of text.split(',')) {
|
|
292
|
+
const m = /^(\*|\?|[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?)(?:\/(\d+))?$/.exec(piece);
|
|
293
|
+
if (!m) return `"${piece}" is not one of the ${what}.`;
|
|
294
|
+
if (m[1] === '?' && i !== 2 && i !== 4) return `"?" only stands in a day field, not in the ${what}.`;
|
|
295
|
+
let a = lo;
|
|
296
|
+
let b = hi;
|
|
297
|
+
if (m[1] !== '*' && m[1] !== '?') {
|
|
298
|
+
const [x, y] = m[1].split('-');
|
|
299
|
+
const va = cronValue(x, i, false);
|
|
300
|
+
if (va === null) return `"${x}" is not one of the ${what} (${lo} to ${hi}).`;
|
|
301
|
+
const vb = y === undefined ? null : cronValue(y, i, true);
|
|
302
|
+
if (y !== undefined && vb === null) return `"${y}" is not one of the ${what} (${lo} to ${hi}).`;
|
|
303
|
+
a = va;
|
|
304
|
+
b = vb ?? (m[2] ? hi : va);
|
|
305
|
+
if (a > b) return `"${piece}" runs backwards: write the smaller first.`;
|
|
288
306
|
}
|
|
289
|
-
|
|
307
|
+
const step = m[2] === undefined ? 1 : Number(m[2]);
|
|
308
|
+
if (!(step >= 1 && step <= hi)) return `A step of ${m[2]} does not fit the ${what} (1 to ${hi}).`;
|
|
309
|
+
for (let v = a; v <= b; v += step) out.add(v);
|
|
310
|
+
}
|
|
311
|
+
return [...out].sort((x, y) => x - y);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* READ A CRON LINE: `{ cron }` as sets, or `{ why }` in a sentence an agent
|
|
316
|
+
* can act on. The ONE reading, shared word for word with Atlas Studio and the
|
|
317
|
+
* web (their @shared/checks readCron) and line for line with the python
|
|
318
|
+
* runner's read_cron (owner, 2026-10-17: "the AI stores in cron, so you need to
|
|
319
|
+
* parse really well"):
|
|
320
|
+
*
|
|
321
|
+
* - five fields: minute 0-59, hour 0-23, day of the month 1-31, month 1-12 or
|
|
322
|
+
* JAN-DEC, day of the week 0-7 or SUN-SAT (0 and 7 are Sunday);
|
|
323
|
+
* - `*`, a number or name, `a-b`, `* /n` (no space), `a-b/n`, `a/n` (a to the
|
|
324
|
+
* field's end) and comma lists of those; `?` is `*` in the two day fields; a
|
|
325
|
+
* range ending in SUN ends on Sunday (FRI-SUN);
|
|
326
|
+
* - @hourly, @daily (@midnight), @weekly, @monthly, @yearly (@annually);
|
|
327
|
+
* - BOTH DAY FIELDS LIMITED (neither covers every day): a day matching either
|
|
328
|
+
* runs, the standard cron rule;
|
|
329
|
+
* - refused: six or seven fields, out of range, backwards, a step of 0 or past
|
|
330
|
+
* the field's end, L / W / #, @reboot, a day that never comes in its months.
|
|
331
|
+
*/
|
|
332
|
+
export function readCron(expr) {
|
|
333
|
+
let line = String(expr ?? '').trim();
|
|
334
|
+
if (!line) return { cron: null, why: 'It is empty.' };
|
|
335
|
+
if (line.startsWith('@')) {
|
|
336
|
+
const m = CRON_MACROS[line.toLowerCase()];
|
|
337
|
+
if (!m) {
|
|
338
|
+
return { cron: null, why: line.toLowerCase() === '@reboot' ? '@reboot runs at start-up, not at a time.'
|
|
339
|
+
: `${line} is not one of @hourly, @daily, @weekly, @monthly or @yearly.` };
|
|
340
|
+
}
|
|
341
|
+
line = m;
|
|
342
|
+
}
|
|
343
|
+
const parts = line.split(/\s+/);
|
|
344
|
+
if (parts.length !== 5) {
|
|
345
|
+
return { cron: null, why: `It has ${parts.length} ${parts.length === 1 ? 'part' : 'parts'}; a schedule has 5: minute, hour, day of the month, month, day of the week${parts.length > 5 ? ' (no seconds, no year)' : ''}.` };
|
|
346
|
+
}
|
|
347
|
+
const sets = [];
|
|
348
|
+
for (let i = 0; i < 5; i += 1) {
|
|
349
|
+
const r = cronField(parts[i], i);
|
|
350
|
+
if (typeof r === 'string') return { cron: null, why: r };
|
|
351
|
+
sets.push(r);
|
|
352
|
+
}
|
|
353
|
+
const [minutes, hours, days, months, week] = sets;
|
|
354
|
+
const weekdays = [...new Set(week.map((d) => d % 7))].sort((a, b) => a - b);
|
|
355
|
+
const domStar = days.length === 31;
|
|
356
|
+
const dowStar = weekdays.length === 7;
|
|
357
|
+
if (dowStar && !domStar && !months.some((mo) => days.some((d) => d <= MONTH_DAYS[mo]))) {
|
|
358
|
+
return { cron: null, why: 'That day never comes in those months.' };
|
|
290
359
|
}
|
|
291
360
|
return {
|
|
292
|
-
|
|
293
|
-
|
|
361
|
+
cron: {
|
|
362
|
+
minute: new Set(minutes), hour: new Set(hours), dom: new Set(days), month: new Set(months), dow: new Set(weekdays),
|
|
363
|
+
domStar, dowStar,
|
|
364
|
+
},
|
|
365
|
+
why: null,
|
|
294
366
|
};
|
|
295
367
|
}
|
|
296
368
|
|
|
369
|
+
/** A five-field cron line (or an @word) as sets, or null when it is not one (readCron says why). */
|
|
370
|
+
export function parseCron(expr) {
|
|
371
|
+
return readCron(expr).cron;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Why a cron line is not a schedule, or null when it is one. */
|
|
375
|
+
export function cronProblem(expr) {
|
|
376
|
+
return readCron(expr).why;
|
|
377
|
+
}
|
|
378
|
+
|
|
297
379
|
const DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
298
380
|
|
|
299
381
|
/** The wall-clock fields of a moment in a time zone (or this machine's). */
|
|
@@ -563,7 +645,7 @@ export function readSchedule(dir) {
|
|
|
563
645
|
if (once) { lines.push({ kind: 'once', once, text: line }); continue; }
|
|
564
646
|
const cron = parseCron(line);
|
|
565
647
|
if (cron) { lines.push({ kind: 'cron', cron, text: line }); continue; }
|
|
566
|
-
bad = bad || `schedule "${line}" is not something I understand - use a cron `
|
|
648
|
+
bad = bad || `schedule "${line}" is not something I understand (${cronProblem(line)}) - use a cron `
|
|
567
649
|
+ 'line like "*/5 9-16 * * 1-5" or "once 2026-09-15 09:30"';
|
|
568
650
|
}
|
|
569
651
|
if (!lines.length && !bad) bad = `schedule "${expr}" has nothing in it`;
|
|
@@ -695,17 +777,58 @@ export function checksWhen(dir) {
|
|
|
695
777
|
const m = readManifest(dir);
|
|
696
778
|
const c = m.checks;
|
|
697
779
|
const o = c && typeof c === 'object' ? c : {};
|
|
698
|
-
const expr = typeof o.schedule === 'string' ? o.schedule.trim() : '';
|
|
699
|
-
const cron = expr ? parseCron(expr) : null;
|
|
700
780
|
const on = Array.isArray(o.on) ? o.on.map(String) : [];
|
|
701
781
|
// The zone it is read in: the checks' own, else the project's, else UTC like
|
|
702
782
|
// every schedule (Atlas Studio and the web say it in these words too).
|
|
703
|
-
const tz = [o.timezone, m.timezone].find((z) => typeof z === 'string' && z.trim()) || '';
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
783
|
+
const tz = String([o.timezone, m.timezone].find((z) => typeof z === 'string' && z.trim()) || '').trim();
|
|
784
|
+
// A line that is not a valid cron is KEPT, with why: it never runs (the
|
|
785
|
+
// screens say "Not a valid schedule"), and the runner says why once.
|
|
786
|
+
const sched = (raw) => {
|
|
787
|
+
const expr = typeof raw === 'string' ? raw.trim() : '';
|
|
788
|
+
if (!expr) return null;
|
|
789
|
+
const { cron, why } = readCron(expr);
|
|
790
|
+
return cron ? { expr, lines: [{ kind: 'cron', cron, text: expr }], tz, error: null, cron }
|
|
791
|
+
: { expr, lines: [], tz, error: `"${expr}" is not a valid schedule: ${why}`, cron: null };
|
|
708
792
|
};
|
|
793
|
+
// A TIME PER CHECK (owner, 2026-10-16: "for each checks, let this be here"):
|
|
794
|
+
// "each": {"<check id>": "<five-field cron>"}, read in the same zone. A check
|
|
795
|
+
// with no entry keeps the project's schedule; one whose entry is not a valid
|
|
796
|
+
// cron runs on no time at all.
|
|
797
|
+
const each = {};
|
|
798
|
+
const e = o.each && typeof o.each === 'object' && !Array.isArray(o.each) ? o.each : {};
|
|
799
|
+
for (const [id, raw] of Object.entries(e).slice(0, 200)) {
|
|
800
|
+
if (!CHECK_ID.test(id)) continue;
|
|
801
|
+
const s = sched(raw);
|
|
802
|
+
if (s) each[id] = s;
|
|
803
|
+
}
|
|
804
|
+
return { schedule: sched(o.schedule), each, files: on.includes('files'), data: on.includes('data') };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const CHECK_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* WHICH CHECKS ARE DUE between two moments (from, to], or null for none: the
|
|
811
|
+
* checks whose own time ("each") came round, and, when the project's schedule
|
|
812
|
+
* came round, every check without a time of its own. One answer, so two
|
|
813
|
+
* schedules on the same minute start ONE run. `known` is the ids checks.json
|
|
814
|
+
* holds, `off` the paused and deleted.
|
|
815
|
+
* {only: [...]} : just these checks (only their own times came round);
|
|
816
|
+
* {only: [], skip: [...]} : all of them but these (their own time did not).
|
|
817
|
+
* The python runner's checks_due is the same, line for line.
|
|
818
|
+
*/
|
|
819
|
+
export function checksDue(when, known, off, fromEpoch, toEpoch) {
|
|
820
|
+
const own = Object.keys(when?.each || {}).filter((id) => !off.has(id)).sort();
|
|
821
|
+
const ownDue = own.filter((id) => scheduleDueMinutes(when.each[id], fromEpoch, toEpoch).length);
|
|
822
|
+
const projectDue = !!(when?.schedule && scheduleDueMinutes(when.schedule, fromEpoch, toEpoch).length);
|
|
823
|
+
if (projectDue) {
|
|
824
|
+
const skip = own.filter((id) => !ownDue.includes(id));
|
|
825
|
+
// Nothing left to run: every check it knows is paused, deleted, or keeps
|
|
826
|
+
// a time of its own that is not now.
|
|
827
|
+
const left = known.filter((id) => !off.has(id) && !skip.includes(id));
|
|
828
|
+
if (known.length && !left.length) return null;
|
|
829
|
+
return { only: [], skip };
|
|
830
|
+
}
|
|
831
|
+
return ownDue.length ? { only: ownDue, skip: [] } : null;
|
|
709
832
|
}
|
|
710
833
|
|
|
711
834
|
/** What a folder holds, as one string that changes when anything in it does. */
|
|
@@ -1456,8 +1579,11 @@ export function readJobTable(job, rel, root) {
|
|
|
1456
1579
|
* A PDF, Word or PowerPoint file goes to the web as its bytes, like a picture
|
|
1457
1580
|
* (owner, 2026-10-15: "pdf file support, word docs ... for everything"): the
|
|
1458
1581
|
* website draws it (its components/files/DocView.tsx). Read as text it was a
|
|
1459
|
-
* screenful of bytes, or refused past the text ceiling.
|
|
1460
|
-
*
|
|
1582
|
+
* screenful of bytes, or refused past the text ceiling. Since 2026-10-16 the
|
|
1583
|
+
* old Office files (.doc, .ppt, .xls) and Parquet too (owner: "yes for
|
|
1584
|
+
* parquet ... yes for old office files"): the website reads their words or
|
|
1585
|
+
* their first rows itself. Nothing else is sent as bytes: a zip or an
|
|
1586
|
+
* OpenDocument file is not drawn there, so it is not carried.
|
|
1461
1587
|
*/
|
|
1462
1588
|
export const DOC_TYPES = {
|
|
1463
1589
|
'.pdf': 'application/pdf',
|
|
@@ -1466,6 +1592,10 @@ export const DOC_TYPES = {
|
|
|
1466
1592
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
1467
1593
|
'.pptm': 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
|
|
1468
1594
|
'.ppsx': 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
|
|
1595
|
+
'.doc': 'application/msword',
|
|
1596
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
1597
|
+
'.xls': 'application/vnd.ms-excel',
|
|
1598
|
+
'.parquet': 'application/vnd.apache.parquet',
|
|
1469
1599
|
};
|
|
1470
1600
|
|
|
1471
1601
|
/** The relay's ceiling for a document: a picture's. Atlas's server holds it too. */
|
|
@@ -1478,6 +1608,11 @@ export function docType(name) {
|
|
|
1478
1608
|
return dot >= 0 ? (DOC_TYPES[n.slice(dot)] || '') : '';
|
|
1479
1609
|
}
|
|
1480
1610
|
|
|
1611
|
+
/** A Jupyter notebook: JSON text with its outputs' pictures inside. */
|
|
1612
|
+
export function isNotebook(name) {
|
|
1613
|
+
return String(name).toLowerCase().endsWith('.ipynb');
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1481
1616
|
/** "12.4 MB", the website's words (lib/doc-view.ts sizeWords). */
|
|
1482
1617
|
function sizeWords(bytes) {
|
|
1483
1618
|
if (bytes < 1024) return `${bytes} bytes`;
|
|
@@ -1499,6 +1634,13 @@ export function readJobFile(job, rel, root) {
|
|
|
1499
1634
|
if (size > MAX_DOC_BYTES) return { ok: false, error: `This file is ${sizeWords(size)}, too large to show here.` };
|
|
1500
1635
|
return { ok: true, mime: doc, b64: fs.readFileSync(target).toString('base64') };
|
|
1501
1636
|
}
|
|
1637
|
+
// A JUPYTER NOTEBOOK (2026-10-16) is text, but its pictures are inside it:
|
|
1638
|
+
// it may be as large as a document, and past that it is worded the same.
|
|
1639
|
+
if (isNotebook(target)) {
|
|
1640
|
+
const size = fs.statSync(target).size;
|
|
1641
|
+
if (size > MAX_DOC_BYTES) return { ok: false, error: `This file is ${sizeWords(size)}, too large to show here.` };
|
|
1642
|
+
return { ok: true, text: fs.readFileSync(target, 'utf8') };
|
|
1643
|
+
}
|
|
1502
1644
|
if (fs.statSync(target).size > (mime ? MAX_IMAGE_BYTES : MAX_VIEW_BYTES)) {
|
|
1503
1645
|
return { ok: false, error: 'That file is too large to show here.' };
|
|
1504
1646
|
}
|
|
@@ -1850,7 +1992,7 @@ export function writeRequest(job, op, root, extra = {}) {
|
|
|
1850
1992
|
try {
|
|
1851
1993
|
fs.mkdirSync(dir, { recursive: true });
|
|
1852
1994
|
// A checks request says which checks and which run it repeats.
|
|
1853
|
-
const more = want === 'checks' ? { only: extra.only, replay: extra.replay, why: extra.why, assume: extra.assume } : {};
|
|
1995
|
+
const more = want === 'checks' ? { only: extra.only, skip: extra.skip, replay: extra.replay, why: extra.why, assume: extra.assume } : {};
|
|
1854
1996
|
fs.writeFileSync(path.join(dir, `${at}-${want}.json`), JSON.stringify({ op: want, at: at / 1000, ...more }), 'utf8');
|
|
1855
1997
|
} catch (err) {
|
|
1856
1998
|
return { ok: false, error: `Could not leave the request: ${err.message}` };
|
|
@@ -1894,7 +2036,7 @@ export function takeRequests(root, nowMs = Date.now()) {
|
|
|
1894
2036
|
if (Number.isFinite(body.at) && nowMs - body.at * 1000 > REQUEST_TTL_MS) continue;
|
|
1895
2037
|
// A checks request carries which checks and which run it repeats.
|
|
1896
2038
|
out.push(op === 'checks'
|
|
1897
|
-
? { dir: path.join(base, slug), op, only: body.only, replay: body.replay, why: body.why, assume: body.assume }
|
|
2039
|
+
? { dir: path.join(base, slug), op, only: body.only, skip: body.skip, replay: body.replay, why: body.why, assume: body.assume }
|
|
1898
2040
|
: { dir: path.join(base, slug), op });
|
|
1899
2041
|
}
|
|
1900
2042
|
}
|