mindvest-atlas 0.100.0 → 0.100.2
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 +16 -0
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.js +31 -6
- package/src/helpers/atlas_env.py +54 -8
- package/src/remote-control.js +117 -30
package/package.json
CHANGED
|
@@ -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);
|
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.100.
|
|
5
|
+
export const VERSION = '0.100.2';
|
|
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.js
CHANGED
|
@@ -416,26 +416,51 @@ export async function mcpTools({ server = "", search = "" } = {}) {
|
|
|
416
416
|
* the payload (see phonePress()). `done: true` finishes a card that is up. At
|
|
417
417
|
* most 30 new ones an hour, so say what matters, not every step.
|
|
418
418
|
*
|
|
419
|
+
* A tap on it opens this project in Local Mode, on this computer. `view` picks
|
|
420
|
+
* its tab (files, checks, dashboard, chats, computer). `open` sends the tap
|
|
421
|
+
* anywhere else on the dashboard instead: a name such as notifications,
|
|
422
|
+
* alerts, workflow:<id>:performance or project:<folder>:checks, or a path
|
|
423
|
+
* starting /atlas/dashboard?. The answer's `opens` says where it will go.
|
|
424
|
+
*
|
|
419
425
|
* await notify("Backtest done", { body: "SPY: 61% win\n214 trades", key: "backtest" });
|
|
420
426
|
* await notify("Backtest running", { key: "backtest", progress: 0.4 });
|
|
421
427
|
* await notify("Backtest done", { key: "backtest", button: "Run on QQQ" });
|
|
428
|
+
* await notify("Charts ready", { key: "charts", view: "dashboard" });
|
|
429
|
+
* await notify("Alert fired", { open: "alerts" });
|
|
422
430
|
*
|
|
423
431
|
* Returns the tool's answer. `sent` is 0 when the owner switched these off
|
|
424
432
|
* (Profile, Manage notifications, Studio); that is their choice, not an error.
|
|
425
433
|
*/
|
|
426
|
-
export async function notify(title, { body = "", key = "", progress = null, button = "", done = false } = {}) {
|
|
434
|
+
export async function notify(title, { body = "", key = "", progress = null, button = "", done = false, open = "", view = "" } = {}) {
|
|
427
435
|
const params = { title, body: body || "" };
|
|
428
436
|
if (key) params.key = key;
|
|
429
437
|
if (progress !== null && progress !== undefined) params.progress = progress;
|
|
430
|
-
if (button)
|
|
431
|
-
params.button = button;
|
|
432
|
-
// The project the button starts: this one.
|
|
433
|
-
params.project = process.env.ATLAS_JOB || "";
|
|
434
|
-
}
|
|
438
|
+
if (button) params.button = button;
|
|
435
439
|
if (done) params.done = true;
|
|
440
|
+
// This project: the one the button starts, and where a tap opens.
|
|
441
|
+
const job = process.env.ATLAS_JOB || "";
|
|
442
|
+
if (job || button) params.project = job;
|
|
443
|
+
if (job) {
|
|
444
|
+
const machine = machineHere();
|
|
445
|
+
if (machine) params.machine = machine;
|
|
446
|
+
if (view) params.view = view;
|
|
447
|
+
}
|
|
448
|
+
if (open) params.open = open;
|
|
436
449
|
return atlas("phone-notify", params);
|
|
437
450
|
}
|
|
438
451
|
|
|
452
|
+
/** This computer's id, as the atlas command line keeps it ("" if none). */
|
|
453
|
+
function machineHere() {
|
|
454
|
+
let found = (process.env.ATLAS_MACHINE || "").trim();
|
|
455
|
+
if (!found) {
|
|
456
|
+
const dir = process.env.ATLAS_CONFIG_DIR
|
|
457
|
+
|| (process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "atlas") : path.join(os_.homedir(), ".config", "atlas"));
|
|
458
|
+
try { found = fs.readFileSync(path.join(dir, "machine-id"), "utf8").trim(); } catch { found = ""; }
|
|
459
|
+
}
|
|
460
|
+
found = found.slice(0, 64);
|
|
461
|
+
return /^[\w-]+$/.test(found) ? found : "";
|
|
462
|
+
}
|
|
463
|
+
|
|
439
464
|
/**
|
|
440
465
|
* The button press this is about, or null.
|
|
441
466
|
*
|
package/src/helpers/atlas_env.py
CHANGED
|
@@ -568,7 +568,24 @@ def mcp_tools(server="", search=""):
|
|
|
568
568
|
return atlas("mcp-tools", server=server or "", search=search or "")
|
|
569
569
|
|
|
570
570
|
|
|
571
|
-
def
|
|
571
|
+
def _machine_here():
|
|
572
|
+
"""This computer's id, as the atlas command line keeps it ('' if none)."""
|
|
573
|
+
if os.environ.get("ATLAS_MACHINE"):
|
|
574
|
+
found = os.environ["ATLAS_MACHINE"].strip()
|
|
575
|
+
else:
|
|
576
|
+
base = os.environ.get("ATLAS_CONFIG_DIR") or (
|
|
577
|
+
os.path.join(os.environ["XDG_CONFIG_HOME"], "atlas") if os.environ.get("XDG_CONFIG_HOME")
|
|
578
|
+
else os.path.join(os.path.expanduser("~"), ".config", "atlas"))
|
|
579
|
+
try:
|
|
580
|
+
with open(os.path.join(base, "machine-id"), encoding="utf-8") as f:
|
|
581
|
+
found = f.read().strip()
|
|
582
|
+
except Exception:
|
|
583
|
+
found = ""
|
|
584
|
+
found = found[:64]
|
|
585
|
+
return found if found and all(c.isascii() and (c.isalnum() or c in "_-") for c in found) else ""
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def notify(title, body="", key="", progress=None, button="", done=False, open="", view=""):
|
|
572
589
|
"""Tell the owner's PHONE (the MindVest Atlas app). A fixed layout you fill in.
|
|
573
590
|
|
|
574
591
|
A title (cut at 40 characters) and up to two short lines; plain words. The
|
|
@@ -578,9 +595,17 @@ def notify(title, body="", key="", progress=None, button="", done=False):
|
|
|
578
595
|
in the payload (see phone_press()). `done=True` finishes a card that is up.
|
|
579
596
|
At most 30 new ones an hour, so say what matters, not every step.
|
|
580
597
|
|
|
598
|
+
A tap on it opens this project in Local Mode, on this computer. `view`
|
|
599
|
+
picks its tab (files, checks, dashboard, chats, computer). `open` sends the
|
|
600
|
+
tap anywhere else on the dashboard instead: a name such as notifications,
|
|
601
|
+
alerts, workflow:<id>:performance or project:<folder>:checks, or a path
|
|
602
|
+
starting /atlas/dashboard?. The answer's `opens` says where it will go.
|
|
603
|
+
|
|
581
604
|
notify("Backtest done", "SPY: 61% win\n214 trades", key="backtest")
|
|
582
605
|
notify("Backtest running", key="backtest", progress=0.4)
|
|
583
606
|
notify("Backtest done", "Run it on QQQ too?", key="backtest", button="Run on QQQ")
|
|
607
|
+
notify("Charts ready", key="charts", view="dashboard")
|
|
608
|
+
notify("Alert fired", open="alerts")
|
|
584
609
|
|
|
585
610
|
Returns the tool's answer. `sent` is 0 when the owner switched these off
|
|
586
611
|
(Profile, Manage notifications, Studio); that is their choice, not an error.
|
|
@@ -592,10 +617,20 @@ def notify(title, body="", key="", progress=None, button="", done=False):
|
|
|
592
617
|
params["progress"] = progress
|
|
593
618
|
if button:
|
|
594
619
|
params["button"] = button
|
|
595
|
-
# The project the button starts: this one.
|
|
596
|
-
params["project"] = os.environ.get("ATLAS_JOB", "")
|
|
597
620
|
if done:
|
|
598
621
|
params["done"] = True
|
|
622
|
+
# This project: the one the button starts, and where a tap opens.
|
|
623
|
+
job = os.environ.get("ATLAS_JOB", "")
|
|
624
|
+
if job or button:
|
|
625
|
+
params["project"] = job
|
|
626
|
+
if job:
|
|
627
|
+
machine = _machine_here()
|
|
628
|
+
if machine:
|
|
629
|
+
params["machine"] = machine
|
|
630
|
+
if view:
|
|
631
|
+
params["view"] = view
|
|
632
|
+
if open:
|
|
633
|
+
params["open"] = open
|
|
599
634
|
return atlas("phone-notify", **params)
|
|
600
635
|
|
|
601
636
|
|
|
@@ -1144,12 +1179,13 @@ class _CheckRun:
|
|
|
1144
1179
|
data["running"] = None
|
|
1145
1180
|
data["last_run"] = {"id": self.id, "at": _time.strftime("%Y-%m-%dT%H:%M:%S", _time.localtime()),
|
|
1146
1181
|
"took_s": round(_time.time() - self.started, 1), "replay_of": self.replay_of,
|
|
1147
|
-
"model": self.model, "error": msg
|
|
1182
|
+
"model": self.model, "error": msg,
|
|
1183
|
+
"only": self.only or None, "skip": self._skipped()}
|
|
1148
1184
|
_write_json(path, data)
|
|
1149
1185
|
_history_add({
|
|
1150
1186
|
"kind": "run", "id": self.id, "at": data["last_run"]["at"], "why": self.why,
|
|
1151
1187
|
"replay_of": self.replay_of, "model": self.model, "took_s": data["last_run"]["took_s"],
|
|
1152
|
-
"error": msg, "only": self.only or None, "checks": self._results,
|
|
1188
|
+
"error": msg, "only": self.only or None, "skip": self._skipped(), "checks": self._results,
|
|
1153
1189
|
})
|
|
1154
1190
|
if self.why in _UNATTENDED:
|
|
1155
1191
|
conf = _notify_conf()
|
|
@@ -1179,6 +1215,10 @@ class _CheckRun:
|
|
|
1179
1215
|
"id": self.id,
|
|
1180
1216
|
"replay_of": self.replay_of,
|
|
1181
1217
|
"only": self.only or None,
|
|
1218
|
+
# WHAT THIS RUN COVERS (2026-10-17): a run on the project's
|
|
1219
|
+
# schedule leaves out the checks whose own time it is not, so the
|
|
1220
|
+
# screens show only the others running.
|
|
1221
|
+
"skip": self._skipped(),
|
|
1182
1222
|
"step": self._current[0] if self._current else "Starting",
|
|
1183
1223
|
"done": len(steps),
|
|
1184
1224
|
"total": self._total,
|
|
@@ -1322,6 +1362,11 @@ class _CheckRun:
|
|
|
1322
1362
|
except Exception:
|
|
1323
1363
|
return ""
|
|
1324
1364
|
|
|
1365
|
+
def _skipped(self):
|
|
1366
|
+
"""The checks this run leaves out (a run with `only` names what it
|
|
1367
|
+
covers instead), or None."""
|
|
1368
|
+
return (list(self.skip) or None) if not self.only else None
|
|
1369
|
+
|
|
1325
1370
|
# ── results ──
|
|
1326
1371
|
def wants(self, check_id):
|
|
1327
1372
|
"""False for a check this run was not asked for (Recheck of one, or a
|
|
@@ -1413,7 +1458,7 @@ class _CheckRun:
|
|
|
1413
1458
|
data = _read_json(path, {})
|
|
1414
1459
|
data["last_run"] = {"id": self.id, "at": _time.strftime("%Y-%m-%dT%H:%M:%S", _time.localtime()),
|
|
1415
1460
|
"took_s": round(_time.time() - self.started, 1), "replay_of": self.replay_of,
|
|
1416
|
-
"model": self.model}
|
|
1461
|
+
"model": self.model, "only": self.only or None, "skip": self._skipped()}
|
|
1417
1462
|
data["running"] = None
|
|
1418
1463
|
_write_json(path, data)
|
|
1419
1464
|
self._finished = True
|
|
@@ -1421,7 +1466,7 @@ class _CheckRun:
|
|
|
1421
1466
|
_history_add({
|
|
1422
1467
|
"kind": "run", "id": self.id, "at": data["last_run"]["at"], "why": self.why,
|
|
1423
1468
|
"replay_of": self.replay_of, "model": self.model, "took_s": data["last_run"]["took_s"],
|
|
1424
|
-
"checks": self._results,
|
|
1469
|
+
"only": self.only or None, "skip": self._skipped(), "checks": self._results,
|
|
1425
1470
|
})
|
|
1426
1471
|
# NOBODY WAS WATCHING (its schedule, a change, a fix): what the
|
|
1427
1472
|
# project's Notifications tab asks for goes to the person's phone, per
|
|
@@ -1507,7 +1552,8 @@ def _checks_notify(conf, told, line, why, project):
|
|
|
1507
1552
|
title = template.replace("{project}", project).replace("{count}", str(len(told))).replace("{what}", what)
|
|
1508
1553
|
when = "%s, %s" % (_WHY_WORDS.get(why, "Ran"), _time.strftime("%H:%M"))
|
|
1509
1554
|
try:
|
|
1510
|
-
|
|
1555
|
+
# A tap opens this project's Checks, on this computer.
|
|
1556
|
+
notify(title[:40], "%s\n%s" % (line, when), key="checks-%s" % project, view="checks")
|
|
1511
1557
|
except Exception:
|
|
1512
1558
|
pass
|
|
1513
1559
|
|
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`;
|
|
@@ -699,14 +781,19 @@ export function checksWhen(dir) {
|
|
|
699
781
|
// The zone it is read in: the checks' own, else the project's, else UTC like
|
|
700
782
|
// every schedule (Atlas Studio and the web say it in these words too).
|
|
701
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.
|
|
702
786
|
const sched = (raw) => {
|
|
703
787
|
const expr = typeof raw === 'string' ? raw.trim() : '';
|
|
704
|
-
|
|
705
|
-
|
|
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 };
|
|
706
792
|
};
|
|
707
793
|
// A TIME PER CHECK (owner, 2026-10-16: "for each checks, let this be here"):
|
|
708
794
|
// "each": {"<check id>": "<five-field cron>"}, read in the same zone. A check
|
|
709
|
-
// with no entry
|
|
795
|
+
// with no entry keeps the project's schedule; one whose entry is not a valid
|
|
796
|
+
// cron runs on no time at all.
|
|
710
797
|
const each = {};
|
|
711
798
|
const e = o.each && typeof o.each === 'object' && !Array.isArray(o.each) ? o.each : {};
|
|
712
799
|
for (const [id, raw] of Object.entries(e).slice(0, 200)) {
|