mindvest-atlas 0.4.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/remote-control.js +33 -24
- package/src/config.js +2 -1
- package/src/remote-control.js +109 -0
package/package.json
CHANGED
|
@@ -15,8 +15,8 @@ import { requestJson } from '../api/client.js';
|
|
|
15
15
|
import { runStream } from '../util/stream-loop.js';
|
|
16
16
|
import { color, eprintln } from '../util/ui.js';
|
|
17
17
|
import {
|
|
18
|
-
DEFAULT_TIMEOUT_MS, computerName, jobEnv, machineId, osLabel,
|
|
19
|
-
staleness, summarize, tail, workspace, workspaceRoot,
|
|
18
|
+
DEFAULT_TIMEOUT_MS, computerName, jobEnv, listJobFiles, listRuns, machineId, osLabel,
|
|
19
|
+
readJobFile, staleness, summarize, tail, workspace, workspaceRoot,
|
|
20
20
|
} from '../remote-control.js';
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
@@ -53,30 +53,15 @@ function writeLog(dir, job, status, started, finished, text) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
56
|
+
* Deliberately does nothing.
|
|
57
57
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
58
|
+
* A run is recorded in the log beside the script that produced it, on the
|
|
59
|
+
* computer that ran it, and nowhere else. This is kept as the single place every
|
|
60
|
+
* outcome funnels through, so "where does a run go" has one answer rather than
|
|
61
|
+
* five call sites each deciding.
|
|
60
62
|
*/
|
|
61
|
-
async function report(
|
|
62
|
-
|
|
63
|
-
await requestJson(API.localRuns, {
|
|
64
|
-
method: 'POST',
|
|
65
|
-
body: {
|
|
66
|
-
automation_id: job.automation_id || job.id,
|
|
67
|
-
name: job.name || '',
|
|
68
|
-
source: job.source || 'schedule',
|
|
69
|
-
status,
|
|
70
|
-
started_at: iso(started),
|
|
71
|
-
finished_at: iso(finished),
|
|
72
|
-
// One account can run jobs on several computers; the results view says
|
|
73
|
-
// which one produced each record.
|
|
74
|
-
machine: computerName(),
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
} catch (err) {
|
|
78
|
-
eprintln(color.dim(` ↳ could not report the run: ${err.message}`));
|
|
79
|
-
}
|
|
63
|
+
async function report() {
|
|
64
|
+
return undefined;
|
|
80
65
|
}
|
|
81
66
|
|
|
82
67
|
function runOnce(argv, dir, env, timeoutMs) {
|
|
@@ -95,6 +80,28 @@ function runOnce(argv, dir, env, timeoutMs) {
|
|
|
95
80
|
});
|
|
96
81
|
}
|
|
97
82
|
|
|
83
|
+
/** Send one answer back up. Pass-through: the server relays it to whoever asked
|
|
84
|
+
* and stores none of it, which is what keeps a file's contents on this computer
|
|
85
|
+
* even while someone is looking at them. */
|
|
86
|
+
async function answer(requestId, payload) {
|
|
87
|
+
try {
|
|
88
|
+
await requestJson(API.remoteAnswer, { method: 'POST', body: { request_id: requestId, ...payload } });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
eprintln(color.dim(` ↳ could not answer: ${err.message}`));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A question from the web about THIS computer's workspace. Read-only and
|
|
95
|
+
* confined to the jobs folder: it can list what is there and show one file; it
|
|
96
|
+
* cannot write, delete, or reach outside. */
|
|
97
|
+
async function handleAsk(ask, root) {
|
|
98
|
+
if (!ask?.request_id) return;
|
|
99
|
+
if (ask.op === 'list') return answer(ask.request_id, { ok: true, jobs: listJobFiles(root) });
|
|
100
|
+
if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
|
|
101
|
+
if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
|
|
102
|
+
return answer(ask.request_id, { ok: false, error: 'Unknown request.' });
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
async function runJob(job, root, timeoutMs) {
|
|
99
106
|
const name = job.name || job.automation_id || 'job';
|
|
100
107
|
const started = Date.now();
|
|
@@ -177,6 +184,8 @@ export async function remoteControlCommand(args) {
|
|
|
177
184
|
onEvent: async ({ event, payload }) => {
|
|
178
185
|
if (event === 'job' || event === 'message' || event === 'run') {
|
|
179
186
|
await runJob(payload, root, timeoutMs);
|
|
187
|
+
} else if (event === 'ask') {
|
|
188
|
+
await handleAsk(payload, root);
|
|
180
189
|
}
|
|
181
190
|
},
|
|
182
191
|
});
|
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.6.0';
|
|
6
6
|
export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
|
|
7
7
|
export const DEFAULT_SCOPE = 'atlas broker';
|
|
8
8
|
export const CLIENT_NAME = 'Atlas CLI';
|
|
@@ -41,6 +41,7 @@ export const API = {
|
|
|
41
41
|
remoteJobs: '/api/v1/remote-computer/jobs',
|
|
42
42
|
remotePending: '/api/v1/remote-computer/pending',
|
|
43
43
|
remoteStatus: '/api/v1/remote-computer/status',
|
|
44
|
+
remoteAnswer: '/api/v1/remote-computer/answer',
|
|
44
45
|
localRuns: '/api/local/runs',
|
|
45
46
|
};
|
|
46
47
|
|
package/src/remote-control.js
CHANGED
|
@@ -218,3 +218,112 @@ export function summarize(output, ok) {
|
|
|
218
218
|
if (!lines.length) return ok ? 'Ran and finished.' : null;
|
|
219
219
|
return lines[lines.length - 1].slice(0, 2000);
|
|
220
220
|
}
|
|
221
|
+
|
|
222
|
+
/** Files bigger than this are not read into a browser: a job's script or log is
|
|
223
|
+
* a few KB, and anything past it is a data file the viewer would choke on. */
|
|
224
|
+
export const MAX_VIEW_BYTES = 256 * 1024;
|
|
225
|
+
const SKIP_DIRS = new Set(['.venv', 'node_modules', '__pycache__']);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Every job folder and what is in it, as plain metadata.
|
|
229
|
+
*
|
|
230
|
+
* Names, sizes and times only — never contents. Confined to the workspace: this
|
|
231
|
+
* answers a request that arrived over the network, so it must not be able to
|
|
232
|
+
* describe anything outside the folder Atlas was given.
|
|
233
|
+
*/
|
|
234
|
+
export function listJobFiles(root) {
|
|
235
|
+
const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
|
|
236
|
+
let jobs = [];
|
|
237
|
+
try {
|
|
238
|
+
jobs = fs.readdirSync(base, { withFileTypes: true })
|
|
239
|
+
.filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
240
|
+
} catch { return []; }
|
|
241
|
+
return jobs.map((job) => {
|
|
242
|
+
const files = [];
|
|
243
|
+
const walk = (dir, rel) => {
|
|
244
|
+
if (files.length >= 200) return;
|
|
245
|
+
let entries = [];
|
|
246
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
247
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
248
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
249
|
+
const abs = path.join(dir, e.name);
|
|
250
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
251
|
+
if (e.isDirectory()) { walk(abs, r); continue; }
|
|
252
|
+
try {
|
|
253
|
+
const st = fs.statSync(abs);
|
|
254
|
+
files.push({ path: r, size: st.size, modified: st.mtimeMs / 1000 });
|
|
255
|
+
} catch { /* vanished mid-walk */ }
|
|
256
|
+
if (files.length >= 200) return;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
walk(path.join(base, job), '');
|
|
260
|
+
return { job, files };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* One file's text, or a reason it cannot be shown.
|
|
266
|
+
*
|
|
267
|
+
* The path is resolved and then checked to still be INSIDE the job's folder,
|
|
268
|
+
* which is what stops `../../.ssh/id_rsa` — a request arriving over the network
|
|
269
|
+
* must not reach a file the user never put in the workspace.
|
|
270
|
+
*/
|
|
271
|
+
export function readJobFile(job, rel, root) {
|
|
272
|
+
const base = path.resolve(path.join(workspaceRoot(root), SCRIPTS_DIR, safeSlug(job)));
|
|
273
|
+
const target = path.resolve(path.join(base, rel));
|
|
274
|
+
if (target !== base && !target.startsWith(base + path.sep)) {
|
|
275
|
+
return { ok: false, error: "That file is outside this job's folder." };
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
if (fs.statSync(target).size > MAX_VIEW_BYTES) {
|
|
279
|
+
return { ok: false, error: 'That file is too large to show here.' };
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, text: fs.readFileSync(target, 'utf8') };
|
|
282
|
+
} catch {
|
|
283
|
+
return { ok: false, error: 'That file could not be read.' };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Recent runs, read from the logs this computer wrote.
|
|
289
|
+
*
|
|
290
|
+
* The machine that ran them is the only place they exist — nothing is reported
|
|
291
|
+
* to a server — so this is the source of truth, not a cache of one. Parsed from
|
|
292
|
+
* the filename (when, and how it ended) and the log's own second line (how
|
|
293
|
+
* long), which is why the writer stamps both.
|
|
294
|
+
*/
|
|
295
|
+
export function listRuns(root, limit = 50) {
|
|
296
|
+
const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
|
|
297
|
+
const runs = [];
|
|
298
|
+
let jobs = [];
|
|
299
|
+
try {
|
|
300
|
+
jobs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
301
|
+
} catch { return runs; }
|
|
302
|
+
for (const job of jobs) {
|
|
303
|
+
let files = [];
|
|
304
|
+
try {
|
|
305
|
+
files = fs.readdirSync(path.join(base, job, 'runs')).filter((f) => f.endsWith('.log')).sort().reverse();
|
|
306
|
+
} catch { continue; }
|
|
307
|
+
for (const f of files.slice(0, limit)) {
|
|
308
|
+
const stem = f.replace(/\.log$/, '');
|
|
309
|
+
const parts = stem.split('-');
|
|
310
|
+
if (parts.length < 3) continue;
|
|
311
|
+
const status = parts.slice(2).join('-');
|
|
312
|
+
const [d, t] = parts;
|
|
313
|
+
const started = Date.parse(
|
|
314
|
+
`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}T${t.slice(0, 2)}:${t.slice(2, 4)}:${t.slice(4, 6)}`,
|
|
315
|
+
) / 1000;
|
|
316
|
+
let name = job;
|
|
317
|
+
let took = null;
|
|
318
|
+
try {
|
|
319
|
+
const [first, second] = fs.readFileSync(path.join(base, job, 'runs', f), 'utf8').split('\n');
|
|
320
|
+
const bits = (first || '').split(' ');
|
|
321
|
+
if (bits.length >= 3) name = bits[2];
|
|
322
|
+
if ((second || '').startsWith('took ')) took = parseFloat(second.slice(5));
|
|
323
|
+
} catch { /* unreadable: the filename still tells the story */ }
|
|
324
|
+
runs.push({ job, name, status, started_at: Number.isFinite(started) ? started : 0, duration_s: took, log: f });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
runs.sort((a, b) => b.started_at - a.started_at);
|
|
328
|
+
return runs.slice(0, limit);
|
|
329
|
+
}
|