mindvest-atlas 0.100.2 → 0.100.3
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/cli.js +5 -0
- package/src/commands/device.js +72 -0
- package/src/commands/remote-control.js +102 -26
- package/src/config.js +1 -1
- package/src/dashboard-view.js +111 -0
- package/src/helpers/__pycache__/atlas_env.cpython-312.pyc +0 -0
- package/src/helpers/atlas_env.js +516 -0
- package/src/helpers/atlas_env.py +768 -8
- package/src/remote-control.js +54 -2
- package/src/util/children.js +328 -0
- package/src/util/help.js +1 -0
- package/src/util/stream-loop.js +5 -2
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { screenshotCommand } from './commands/screenshot.js';
|
|
|
20
20
|
import { runCommand } from './commands/run.js';
|
|
21
21
|
import { computerAccess } from './access.js';
|
|
22
22
|
import { installCommand } from './commands/install.js';
|
|
23
|
+
import { deviceCommand } from './commands/device.js';
|
|
23
24
|
import { computerUseCommand } from './commands/computer-use.js';
|
|
24
25
|
import { dashboardCommand } from './commands/dashboard.js';
|
|
25
26
|
import { chatSearchCommand } from './commands/chat-search.js';
|
|
@@ -125,6 +126,10 @@ export async function main(rawArgs) {
|
|
|
125
126
|
// environment under the Atlas folder; Studio's Libraries tab uses it too.
|
|
126
127
|
case 'install':
|
|
127
128
|
return await installCommand(withoutCommand(args));
|
|
129
|
+
// WHAT THIS COMPUTER HAS, so heavy work is sized to it (owner,
|
|
130
|
+
// 2026-10-17). Read here only; nothing is sent anywhere.
|
|
131
|
+
case 'device':
|
|
132
|
+
return await deviceCommand(withoutCommand(args));
|
|
128
133
|
case 'run': {
|
|
129
134
|
// A run on this computer needs a plan (or the free week): asked
|
|
130
135
|
// before anything starts, never in the middle of one.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `atlas device`: what this computer has, for sizing heavy work to it (owner,
|
|
2
|
+
// 2026-10-17: "it first queries the user's device to see their resources: can
|
|
3
|
+
// this computer run this algorithm, and optimize for it").
|
|
4
|
+
//
|
|
5
|
+
// atlas device in words
|
|
6
|
+
// atlas device --json the answer as JSON (the helper's device())
|
|
7
|
+
// atlas device --job <name> a project's disk and Python (default: this folder)
|
|
8
|
+
// atlas device --path <dir> the disk at that folder
|
|
9
|
+
//
|
|
10
|
+
// Read on this computer only: nothing is sent anywhere. The answer is the
|
|
11
|
+
// helpers' own (helpers/atlas_env.js deviceAnswer, field for field the Python
|
|
12
|
+
// helper's device()), so a script and an agent in a shell see the same thing.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { println } from '../util/ui.js';
|
|
16
|
+
import { deviceAnswer } from '../helpers/atlas_env.js';
|
|
17
|
+
import { ambientPython, envPython, librariesVenv, safeSlug, venvPython, workspaceRoot } from '../remote-control.js';
|
|
18
|
+
|
|
19
|
+
/** The folder asked about: --path, else --job under the Atlas folder, else here. */
|
|
20
|
+
export function deviceFolder(flags = {}, cwd = process.cwd()) {
|
|
21
|
+
if (typeof flags.path === 'string' && flags.path) return path.resolve(flags.path);
|
|
22
|
+
if (typeof flags.job === 'string' && flags.job) return path.join(workspaceRoot(), 'scripts', safeSlug(flags.job));
|
|
23
|
+
return cwd;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The Python a project there runs with: its own .venv, the computer's
|
|
27
|
+
* libraries, else the one on PATH (the runner's own order). */
|
|
28
|
+
export function devicePythonFor(dir) {
|
|
29
|
+
const own = venvPython(dir);
|
|
30
|
+
if (own) return [own];
|
|
31
|
+
const shared = envPython(librariesVenv());
|
|
32
|
+
if (fs.existsSync(shared)) return [shared];
|
|
33
|
+
return ambientPython();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const gbWords = (n) => (n === null || n === undefined ? 'unknown' : `${Number(n).toFixed(1)} GB`);
|
|
37
|
+
|
|
38
|
+
/** The answer in words, for a person or an agent reading a terminal. */
|
|
39
|
+
export function deviceWords(a) {
|
|
40
|
+
const lines = ['This computer'];
|
|
41
|
+
const cpu = a.cpu || {};
|
|
42
|
+
lines.push(` Processor ${cpu.model}, ${cpu.cores} cores${cpu.physical_cores ? ` (${cpu.physical_cores} physical)` : ''}`
|
|
43
|
+
+ `${cpu.load_1m !== null && cpu.load_1m !== undefined ? `, load ${Number(cpu.load_1m).toFixed(1)}` : ''}`);
|
|
44
|
+
lines.push(` Memory ${gbWords(a.memory?.free_gb)} free of ${gbWords(a.memory?.total_gb)}`);
|
|
45
|
+
const gpus = a.gpus || [];
|
|
46
|
+
lines.push(` Graphics ${gpus.length ? gpus.map((g) => `${g.name}${g.memory_gb ? ` (${gbWords(g.memory_gb)}${g.unified ? ', shared with memory' : ''})` : ''}`).join('; ') : 'none found'}`);
|
|
47
|
+
lines.push(` Disk ${gbWords(a.disk?.free_gb)} free of ${gbWords(a.disk?.total_gb)} at ${a.disk?.path}`);
|
|
48
|
+
if (a.python) {
|
|
49
|
+
const libs = Object.entries(a.python.libraries || {});
|
|
50
|
+
const have = libs.filter(([, v]) => v).map(([k, v]) => `${k} ${v}`);
|
|
51
|
+
lines.push(` Python ${a.python.version} (${a.python.path})`);
|
|
52
|
+
lines.push(` Libraries ${have.length ? have.join(', ') : 'none of the heavy ones'}`);
|
|
53
|
+
} else {
|
|
54
|
+
lines.push(' Python none found');
|
|
55
|
+
}
|
|
56
|
+
lines.push(` Allowed ${Math.round((a.allowed?.share ?? 0.6) * 100)}% of this computer${a.allowed?.set ? ' (set in Atlas Studio)' : ' (not set; Atlas Studio\'s default)'}`);
|
|
57
|
+
const ad = a.advice || {};
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push(`Size work to: ${ad.workers} worker${ad.workers === 1 ? '' : 's'}, under ${gbWords(ad.memory_gb)} of memory, `
|
|
60
|
+
+ `and stop if the disk has under ${gbWords(ad.disk_floor_gb)} free${ad.disk_ok ? '' : ' (it has: do not start)'}.`);
|
|
61
|
+
for (const n of a.notes || []) lines.push(`- ${n}`);
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function deviceCommand(args) {
|
|
66
|
+
const flags = { ...(args.flags || {}) };
|
|
67
|
+
const where = deviceFolder(flags);
|
|
68
|
+
const a = deviceAnswer({ where, python: devicePythonFor(where), root: workspaceRoot() });
|
|
69
|
+
if (flags.json) println(JSON.stringify(a));
|
|
70
|
+
else println(deviceWords(a));
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
@@ -9,19 +9,20 @@
|
|
|
9
9
|
// A job that could NOT run is reported too: silence is indistinguishable from
|
|
10
10
|
// a schedule that quietly broke, which is the failure the record exists to make
|
|
11
11
|
// visible.
|
|
12
|
-
import { spawn
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
13
|
import { API, baseUrl } from '../config.js';
|
|
14
14
|
import { requestJson, jobToken } from '../api/client.js';
|
|
15
15
|
import { runStream } from '../util/stream-loop.js';
|
|
16
16
|
import { computerAccess } from '../access.js';
|
|
17
17
|
import { color, eprintln } from '../util/ui.js';
|
|
18
|
+
import * as children from '../util/children.js';
|
|
18
19
|
import {
|
|
19
20
|
DEFAULT_TIMEOUT_MS, CHECKS_TIMEOUT_MS, checksCommand, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
|
|
20
21
|
osLabel, readJobFile, readJobTable, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
|
|
21
22
|
takeRequests,
|
|
22
23
|
takeActionCommands, writeActionReply, actionArgv, runAction, actionHandler, claimAsk, REPLIES_DIR, COMMANDS_DIR,
|
|
23
24
|
writeCommand,
|
|
24
|
-
beatRunner,
|
|
25
|
+
beatRunner, forgetRunner, sweepActionTemp, releaseComputerClaim,
|
|
25
26
|
setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission, stopChat, chatState, chatUpdatedAt,
|
|
26
27
|
createProject, ensureProjectFiles, listChats, readChat, deleteChat, appendChat,
|
|
27
28
|
computerUseAllowed, listComputerSessions, readComputerActions, listComputerVideo, readComputerVideoPart,
|
|
@@ -86,28 +87,18 @@ async function report() {
|
|
|
86
87
|
const running = new Map();
|
|
87
88
|
|
|
88
89
|
/** Kill a run's whole process tree. On Windows a plain kill leaves the
|
|
89
|
-
* script's own children going; taskkill /T takes the tree.
|
|
90
|
+
* script's own children going; taskkill /T takes the tree. THE WHOLE GROUP
|
|
91
|
+
* on macOS and Linux: runs start in a process group of their own (see
|
|
92
|
+
* runOnce), so a script's children go with it instead of living on holding
|
|
93
|
+
* its output open. One implementation for every child: util/children.js. */
|
|
90
94
|
function killTree(child, hard = false) {
|
|
91
95
|
if (!child || child.pid === undefined) return;
|
|
92
|
-
|
|
93
|
-
try { spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }); return; } catch { /* fall through */ }
|
|
94
|
-
}
|
|
95
|
-
// THE WHOLE GROUP on macOS and Linux: runs start in a process group of their
|
|
96
|
-
// own (see runOnce), so a script's children go with it instead of living on
|
|
97
|
-
// holding its output open.
|
|
98
|
-
const pid = child.pid;
|
|
99
|
-
const signal = (sig) => {
|
|
100
|
-
try { process.kill(-pid, sig); return; } catch { /* no group: the child alone */ }
|
|
101
|
-
try { child.kill(sig); } catch { /* already gone */ }
|
|
102
|
-
};
|
|
103
|
-
if (hard) { signal('SIGKILL'); return; }
|
|
104
|
-
signal('SIGTERM');
|
|
105
|
-
setTimeout(() => {
|
|
106
|
-
try { process.kill(pid, 0); } catch { try { process.kill(-pid, 0); } catch { return; } }
|
|
107
|
-
signal('SIGKILL');
|
|
108
|
-
}, 3_000).unref();
|
|
96
|
+
children.killTree(child, { hard, tree: process.platform !== 'win32' });
|
|
109
97
|
}
|
|
110
98
|
|
|
99
|
+
/** The run slots this runner holds right now, so an exit gives them back. */
|
|
100
|
+
const heldSlots = new Set();
|
|
101
|
+
|
|
111
102
|
/** End every run this runner started (it is going away). */
|
|
112
103
|
export function stopAllRuns() {
|
|
113
104
|
for (const [, entry] of running) {
|
|
@@ -185,6 +176,9 @@ export function runOnce(argv, dir, env, timeoutMs, key = path.basename(dir)) {
|
|
|
185
176
|
const child = spawn(argv[0], argv.slice(1), {
|
|
186
177
|
cwd: dir, env, windowsHide: true, detached: process.platform !== 'win32',
|
|
187
178
|
});
|
|
179
|
+
// On the runner's list: ended with the runner, and by the next start if
|
|
180
|
+
// the runner is killed outright.
|
|
181
|
+
children.track(child, { name: key, group: 'run', tree: process.platform !== 'win32' });
|
|
188
182
|
const entry = { child, stopped: false };
|
|
189
183
|
running.set(key, entry);
|
|
190
184
|
starting.delete(key);
|
|
@@ -396,6 +390,9 @@ async function handleAsk(ask, root) {
|
|
|
396
390
|
PYTHONPATH: [dir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter),
|
|
397
391
|
},
|
|
398
392
|
});
|
|
393
|
+
// It ends by itself (two minutes, or when nobody holds the computer);
|
|
394
|
+
// on the list so a runner that goes first takes it along.
|
|
395
|
+
children.track(child, { name: `watch:${path.basename(dir)}`, group: 'watch' });
|
|
399
396
|
let buf = '';
|
|
400
397
|
child.stdout.on('data', (d) => {
|
|
401
398
|
buf += d;
|
|
@@ -701,7 +698,9 @@ async function runJob(job, root, timeoutMs) {
|
|
|
701
698
|
// releaseRun, NOT itself: calling `release()` here (0.89.0 to 0.97.0) never
|
|
702
699
|
// freed the slot, so every later run of the project on this runner was
|
|
703
700
|
// turned away as "already running" until the runner restarted.
|
|
704
|
-
|
|
701
|
+
heldSlots.add(slot);
|
|
702
|
+
const release = () => { if (!released) { heldSlots.delete(slot); released = true; releaseRun(slot); } };
|
|
703
|
+
let computerSessionId = '';
|
|
705
704
|
try {
|
|
706
705
|
|
|
707
706
|
// Refreshed every run, so a job always has the supported way to reach a
|
|
@@ -722,6 +721,7 @@ async function runJob(job, root, timeoutMs) {
|
|
|
722
721
|
ATLAS_COMPUTER_SESSION: new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19),
|
|
723
722
|
}
|
|
724
723
|
: {};
|
|
724
|
+
computerSessionId = computerEnv.ATLAS_COMPUTER_SESSION || '';
|
|
725
725
|
const env = {
|
|
726
726
|
...process.env,
|
|
727
727
|
// Secrets first (shared .env, then the job's own), so a job's own env can
|
|
@@ -780,6 +780,9 @@ async function runJob(job, root, timeoutMs) {
|
|
|
780
780
|
} finally {
|
|
781
781
|
release();
|
|
782
782
|
if (isChecks) checksEndedAt.set(path.basename(dir), Date.now());
|
|
783
|
+
// And the COMPUTER, if this run took it (the Python runner always did):
|
|
784
|
+
// only the claim this run's own session holds, and only once it has ended.
|
|
785
|
+
if (computerSessionId) releaseComputerClaim(computerSessionId, root);
|
|
783
786
|
}
|
|
784
787
|
}
|
|
785
788
|
|
|
@@ -1179,14 +1182,24 @@ export async function remoteControlCommand(args) {
|
|
|
1179
1182
|
// here and keep going whether or not the server can be reached. The stream
|
|
1180
1183
|
// below adds one thing only: the web being able to see this computer and ask
|
|
1181
1184
|
// it for something.
|
|
1185
|
+
// NOTHING LEFT BEHIND (owner, 2026-10-17). What an earlier runner that was
|
|
1186
|
+
// killed outright left running is ended first (only processes it recorded,
|
|
1187
|
+
// still the same ones), and its scratch files with it.
|
|
1188
|
+
const parentPid = takeParentPid();
|
|
1189
|
+
children.useStateDir(childrenDir(root));
|
|
1190
|
+
reportReaped(children.reapOrphans());
|
|
1191
|
+
sweepActionTemp();
|
|
1182
1192
|
const stopSchedule = startLocalSchedule(root, timeoutMs);
|
|
1183
1193
|
const stopRequests = startLocalRequests(root, timeoutMs);
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
//
|
|
1187
|
-
|
|
1188
|
-
|
|
1194
|
+
const leave = onLeaving(root, [stopSchedule, stopRequests]);
|
|
1195
|
+
process.once('exit', leave);
|
|
1196
|
+
// Runs have process groups of their own, so a Ctrl-C, a closed terminal or a
|
|
1197
|
+
// stop from the app no longer reaches them by itself: the runner takes them
|
|
1198
|
+
// down on its way. Quick on purpose (Atlas Studio waits two seconds).
|
|
1199
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
1200
|
+
process.once(sig, () => { leave(); process.exit(0); });
|
|
1189
1201
|
}
|
|
1202
|
+
watchForParent(parentPid, leave);
|
|
1190
1203
|
startAlertListener(root, timeoutMs);
|
|
1191
1204
|
// Say at the start whether runs will start here: a plan or the free week
|
|
1192
1205
|
// (the first ask is also what starts the week for somebody new).
|
|
@@ -1245,6 +1258,69 @@ export async function remoteControlCommand(args) {
|
|
|
1245
1258
|
}
|
|
1246
1259
|
}
|
|
1247
1260
|
|
|
1261
|
+
/** Where the runner keeps the list of what it started (util/children.js). */
|
|
1262
|
+
export const childrenDir = (root) => path.join(workspaceRoot(root), '.runner-children');
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* The parent this runner should go with (ATLAS_PARENT_PID, which Atlas Studio
|
|
1266
|
+
* sets), taken OUT of the environment so no run, action, watcher or other
|
|
1267
|
+
* process this runner starts inherits it: only this runner acts on it.
|
|
1268
|
+
*/
|
|
1269
|
+
export function takeParentPid(env = process.env) {
|
|
1270
|
+
const v = String(env[children.PARENT_ENV] || '').trim();
|
|
1271
|
+
delete env[children.PARENT_ENV];
|
|
1272
|
+
return v;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function reportReaped(r) {
|
|
1276
|
+
for (const c of r.ended) eprintln(color.dim(`· ended ${c.name || 'a process'} (pid ${c.pid}) left by a runner that stopped`));
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Everything a runner does on its way out, once: the clocks stopped, every
|
|
1281
|
+
* run and child ended with its tree, its run slots and its heartbeat given
|
|
1282
|
+
* back (only its own), its list removed. Synchronous: no network.
|
|
1283
|
+
*/
|
|
1284
|
+
export function onLeaving(root, stops = []) {
|
|
1285
|
+
let left = false;
|
|
1286
|
+
return () => {
|
|
1287
|
+
if (left) return;
|
|
1288
|
+
left = true;
|
|
1289
|
+
for (const stop of stops) { try { stop(); } catch { /* going anyway */ } }
|
|
1290
|
+
try { stopAllRuns(); } catch { /* going anyway */ }
|
|
1291
|
+
try { children.killAll(); } catch { /* going anyway */ }
|
|
1292
|
+
for (const slot of [...heldSlots]) { heldSlots.delete(slot); releaseRun(slot); }
|
|
1293
|
+
forgetRunner(root);
|
|
1294
|
+
children.forgetStateFile();
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* GO WITH THE PARENT. Atlas Studio starts this runner in a process group of its
|
|
1300
|
+
* own; if Studio is force-quit nothing tells the runner, and it and its runs
|
|
1301
|
+
* would carry on with nobody to show them. With a parent pid, this notices the
|
|
1302
|
+
* parent is gone (two checks in a row, three seconds apart) and leaves. A
|
|
1303
|
+
* closed stdout or stderr pipe asks at once, and is never on its own a reason
|
|
1304
|
+
* to go. Without a parent pid (a person in a terminal) nothing changes.
|
|
1305
|
+
*/
|
|
1306
|
+
export function watchForParent(parentPid, leave, { intervalMs = 3_000, exit = (c) => process.exit(c) } = {}) {
|
|
1307
|
+
if (!parentPid) return null;
|
|
1308
|
+
const watch = children.watchParent(parentPid, (pid) => {
|
|
1309
|
+
try { eprintln(color.dim(`● the app that started this (pid ${pid}) is gone; stopping`)); } catch { /* no one to tell */ }
|
|
1310
|
+
leave();
|
|
1311
|
+
exit(0);
|
|
1312
|
+
}, { intervalMs });
|
|
1313
|
+
if (!watch) return null;
|
|
1314
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
1315
|
+
stream.on('error', (err) => {
|
|
1316
|
+
// A pipe nobody reads any more: ask about the parent now. The runner
|
|
1317
|
+
// keeps going while the parent is alive (it stops nothing on a guess).
|
|
1318
|
+
if (err && err.code === 'EPIPE') watch.checkNow();
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
return watch;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1248
1324
|
/** For the tests: the one road every run takes. */
|
|
1249
1325
|
export const runJobForTests = (job, root, timeoutMs) => runJob(job, root, timeoutMs);
|
|
1250
1326
|
|
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.3';
|
|
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';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// A project's dashboard.json, as the website is sent it (owner, 2026-10-17:
|
|
2
|
+
// "a dashboard's file overflowed past 200 and the dashboard just stopped
|
|
3
|
+
// working").
|
|
4
|
+
//
|
|
5
|
+
// WHAT BROKE: the relay's `read` sends no text file over 256 KB (MAX_VIEW_BYTES),
|
|
6
|
+
// so once a long-running project's dashboard.json grew past that, every read
|
|
7
|
+
// from the web answered "That file is too large to show here." The web's
|
|
8
|
+
// dashboard keeps its last good draw on a failed read, so it simply stopped:
|
|
9
|
+
// the numbers froze and nothing said why. A dashboard of a few hundred rows
|
|
10
|
+
// with a dozen fields each, written with indent=2, is past 256 KB.
|
|
11
|
+
//
|
|
12
|
+
// NOW: dashboard.json is read here whole (up to 64 MB), every list in it is cut
|
|
13
|
+
// to its newest DRAW_MAX rows (the helpers' order rule: newest last unless the
|
|
14
|
+
// first row is dated after the last), and the answer is held under SEND_MAX by
|
|
15
|
+
// halving the longest list again. What was cut rides in the JSON as
|
|
16
|
+
// `__atlas_cut` [{list, shown, of}], which the website takes off the data and
|
|
17
|
+
// hands its dashboard frame to say "Showing the newest 5,000 of 48,213". A
|
|
18
|
+
// small file with short lists goes exactly as it is, byte for byte.
|
|
19
|
+
//
|
|
20
|
+
// The python runner does the same (read_job_file -> _dashboard_view), with the
|
|
21
|
+
// helper's own functions.
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import { cutList, dataLists } from './helpers/atlas_env.js';
|
|
24
|
+
|
|
25
|
+
/** Rows of one list sent to be drawn. Above the helpers' 500, so a project
|
|
26
|
+
* that keeps its window is never cut here. */
|
|
27
|
+
export const DRAW_MAX = 5000;
|
|
28
|
+
/** What one read of the dashboard's data may send. */
|
|
29
|
+
export const SEND_MAX = 4 * 1024 * 1024;
|
|
30
|
+
/** Past this the file is not even parsed. */
|
|
31
|
+
export const READ_MAX = 64 * 1024 * 1024;
|
|
32
|
+
/** The key the cut rides under, taken off by the website before drawing. */
|
|
33
|
+
export const CUT_KEY = '__atlas_cut';
|
|
34
|
+
|
|
35
|
+
const sizeWords = (n) => (n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
|
|
36
|
+
|
|
37
|
+
function copyObjects(d, depth = 0) {
|
|
38
|
+
if (!d || typeof d !== 'object' || Array.isArray(d) || depth > 4) return d;
|
|
39
|
+
return Object.fromEntries(Object.entries(d).map(([k, v]) => [k, copyObjects(v, depth + 1)]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The data with every list cut to its newest `max`, and what was cut:
|
|
44
|
+
* {data, cut: [{list, shown, of}]}. The caller's own object is not changed.
|
|
45
|
+
* A top-level list (not an object) is cut too, with nowhere to say so.
|
|
46
|
+
*/
|
|
47
|
+
export function capDashboardData(data, max = DRAW_MAX, sendMax = Infinity) {
|
|
48
|
+
if (Array.isArray(data)) {
|
|
49
|
+
const kept = cutList(data, max)[0];
|
|
50
|
+
return { data: kept, cut: kept.length < data.length ? [{ list: '', shown: kept.length, of: data.length }] : [] };
|
|
51
|
+
}
|
|
52
|
+
const out = copyObjects(data);
|
|
53
|
+
const of = {};
|
|
54
|
+
for (const [p, holder, k] of dataLists(out)) {
|
|
55
|
+
if (holder[k].length > max) {
|
|
56
|
+
of[p] = holder[k].length;
|
|
57
|
+
holder[k] = cutList(holder[k], max)[0];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Still too big to send: the longest list again, by halves, never under 50.
|
|
61
|
+
let text = JSON.stringify(out);
|
|
62
|
+
while (text.length > sendMax) {
|
|
63
|
+
const lists = dataLists(out).filter(([, h, k]) => h[k].length > 50);
|
|
64
|
+
if (!lists.length) break;
|
|
65
|
+
const [p, h, k] = lists.reduce((a, b) => (b[1][b[2]].length > a[1][a[2]].length ? b : a));
|
|
66
|
+
if (!(p in of)) of[p] = h[k].length;
|
|
67
|
+
h[k] = cutList(h[k], Math.max(50, Math.floor(h[k].length / 2)))[0];
|
|
68
|
+
text = JSON.stringify(out);
|
|
69
|
+
}
|
|
70
|
+
const shown = Object.fromEntries(dataLists(out).map(([p, h, k]) => [p, h[k].length]));
|
|
71
|
+
return { data: out, cut: Object.keys(of).map((p) => ({ list: p, shown: shown[p] ?? 0, of: of[p] })) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const kept = new Map();
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* dashboard.json at `target` as the relay answers it: {ok, text} or {ok: false,
|
|
78
|
+
* error}. The same file, unchanged since the last read, is not parsed again.
|
|
79
|
+
*/
|
|
80
|
+
export function dashboardView(target, viewMax = 256 * 1024) {
|
|
81
|
+
let st;
|
|
82
|
+
try { st = fs.statSync(target); } catch { return { ok: false, error: 'That file could not be read.' }; }
|
|
83
|
+
if (st.size > READ_MAX) {
|
|
84
|
+
return { ok: false, error: `This dashboard's data is ${sizeWords(st.size)}, too large to draw. Ask the agent to keep less of it on the dashboard.` };
|
|
85
|
+
}
|
|
86
|
+
const stamp = `${st.mtimeMs}:${st.size}`;
|
|
87
|
+
const was = kept.get(target);
|
|
88
|
+
if (was && was.stamp === stamp) return was.answer;
|
|
89
|
+
let text;
|
|
90
|
+
try { text = fs.readFileSync(target, 'utf8'); } catch { return { ok: false, error: 'That file could not be read.' }; }
|
|
91
|
+
let parsed;
|
|
92
|
+
try { parsed = JSON.parse(text); } catch {
|
|
93
|
+
// Half written, or not JSON: as it is when small (the dashboard keeps its
|
|
94
|
+
// last good draw), a failed read when large.
|
|
95
|
+
return st.size <= viewMax ? { ok: true, text } : { ok: false, error: 'That file could not be read.' };
|
|
96
|
+
}
|
|
97
|
+
const { data, cut } = capDashboardData(parsed, DRAW_MAX, SEND_MAX - 4096);
|
|
98
|
+
let answer;
|
|
99
|
+
if (!cut.length && st.size <= viewMax) {
|
|
100
|
+
answer = { ok: true, text };
|
|
101
|
+
} else {
|
|
102
|
+
if (cut.length && data && typeof data === 'object' && !Array.isArray(data)) data[CUT_KEY] = cut;
|
|
103
|
+
const out = JSON.stringify(data);
|
|
104
|
+
answer = out.length > SEND_MAX
|
|
105
|
+
? { ok: false, error: `This dashboard's data is ${sizeWords(st.size)}, too large to send to the web. Ask the agent to keep less of it on the dashboard.` }
|
|
106
|
+
: { ok: true, text: out };
|
|
107
|
+
}
|
|
108
|
+
kept.set(target, { stamp, answer });
|
|
109
|
+
if (kept.size > 50) kept.delete(kept.keys().next().value);
|
|
110
|
+
return answer;
|
|
111
|
+
}
|
|
Binary file
|