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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.99.0",
3
+ "version": "0.100.1",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 (when.schedule && scheduleDueMinutes(when.schedule, s.at / 1000, nowMs / 1000).length) why = 'schedule';
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
- if (r.ok) { started.push([slug, why]); eprintln(color.dim(`· ${slug} — checks (${why})`)); }
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) => /^[A-Za-z0-9_-]{1,80}$/.test(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.99.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';
@@ -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), and
1325
- for one the person paused or deleted."""
1326
- if _check_id(check_id) in self.off:
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
@@ -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
- * Parse a five-field cron line into sets, or null when it is not one.
267
- *
268
- * Supports what people actually write: `*`, `n`, `a-b`, `* /n` (no space),
269
- * `a-b/n` and comma lists. Day-of-week 7 is Sunday like 0. No names.
270
- */
271
- export function parseCron(expr) {
272
- const parts = String(expr || '').trim().split(/\s+/);
273
- if (parts.length !== 5) return null;
274
- const fields = [];
275
- for (let i = 0; i < 5; i += 1) {
276
- const set = new Set();
277
- for (const piece of parts[i].split(',')) {
278
- const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(piece);
279
- if (!m) return null;
280
- let [lo, hi] = CRON_RANGES[i];
281
- if (m[1] !== '*') {
282
- const [a, b] = m[1].split('-').map(Number);
283
- lo = a; hi = b === undefined ? a : b;
284
- }
285
- const step = m[2] ? Number(m[2]) : 1;
286
- if (!(step > 0) || lo < CRON_RANGES[i][0] || hi > CRON_RANGES[i][1] || lo > hi) return null;
287
- for (let v = lo; v <= hi; v += step) set.add(i === 4 && v === 7 ? 0 : v);
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
- fields.push(set);
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
- minute: fields[0], hour: fields[1], dom: fields[2], month: fields[3], dow: fields[4],
293
- domStar: parts[2] === '*', dowStar: parts[4] === '*',
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
- return {
705
- schedule: cron ? { expr, lines: [{ kind: 'cron', cron, text: expr }], tz: String(tz).trim(), error: null, cron } : null,
706
- files: on.includes('files'),
707
- data: on.includes('data'),
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. Nothing else is sent
1460
- * as bytes: a .doc or a zip is not drawn there, so it is not carried.
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
  }