mindvest-atlas 0.4.2 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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": {
package/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import { alertsCommand } from './commands/alerts.js';
14
14
  import { flowCommand } from './commands/flow.js';
15
15
  import { remoteControlCommand } from './commands/remote-control.js';
16
16
  import { updateCliCommand } from './commands/update-cli.js';
17
+ import { remoteCheckCommand } from './commands/remote-check.js';
17
18
  import { historicalContractCommand } from './commands/historical.js';
18
19
  import { callCommand } from './commands/call.js';
19
20
 
@@ -50,6 +51,9 @@ export async function main(rawArgs) {
50
51
  case 'alerts': return await alertsCommand(withoutCommand(args));
51
52
  case 'flow': return await flowCommand(withoutCommand(args));
52
53
  // Long-lived: hold the job stream and run this computer's jobs.
54
+ // Walk the whole loop and say which hop fails.
55
+ case 'remote-check':
56
+ return await remoteCheckCommand();
53
57
  // Put the newest CLI on this machine.
54
58
  case 'update-cli':
55
59
  case 'self-update':
@@ -0,0 +1,88 @@
1
+ // `atlas remote-check` — prove the whole loop, or say exactly which link is broken.
2
+ //
3
+ // "Connected but nothing happens" is the hardest kind of failure to report,
4
+ // because every part looks fine on its own: the app says connected, the queue
5
+ // accepts jobs, the runs page is empty. The only useful question is which HOP
6
+ // fails, so this walks them in order and stops at the first one that does.
7
+ //
8
+ // Run it while `atlas remote-control` is running in another window.
9
+ import { API, VERSION } from '../config.js';
10
+ import { requestJson, isLoggedIn } from '../api/client.js';
11
+ import { computerName } from '../remote-control.js';
12
+ import { color, println } from '../util/ui.js';
13
+
14
+ const OK = () => color.green(' OK ');
15
+ const BAD = () => color.red(' FAIL ');
16
+ const step = (n, label) => { println(''); println(color.bold(`${n}. ${label}`)); };
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+
19
+ export async function remoteCheckCommand() {
20
+ step(1, 'Signed in?');
21
+ if (!isLoggedIn()) {
22
+ println(BAD() + 'Not signed in. Run `atlas login`.');
23
+ return 1;
24
+ }
25
+ try {
26
+ const who = await requestJson('/oauth/userinfo');
27
+ println(OK() + (who.email || who.sub || 'signed in'));
28
+ } catch (err) {
29
+ println(BAD() + `Could not verify the token: ${err.message}`);
30
+ return 1;
31
+ }
32
+
33
+ step(2, 'Is a computer connected?');
34
+ let status;
35
+ try {
36
+ status = await requestJson(API.remoteStatus);
37
+ } catch (err) {
38
+ println(BAD() + `Could not read the status: ${err.message}`);
39
+ return 1;
40
+ }
41
+ if (!status.online) {
42
+ println(BAD() + 'No computer is connected.');
43
+ println(' Start `atlas remote-control` (or turn on Local Mode in the app),');
44
+ println(` then run this again. This machine is ${computerName()}.`);
45
+ return 1;
46
+ }
47
+ println(OK() + `${status.computer || 'a computer'} is connected.`);
48
+
49
+ step(3, 'Queue a test job');
50
+ try {
51
+ const res = await requestJson(API.remoteJobs, {
52
+ method: 'POST',
53
+ body: { automation_id: 'remote-check', name: 'Remote check', source: 'manual', payload: { hello: 'world' } },
54
+ });
55
+ println(OK() + `queued (${res.queued ?? '?'} waiting)`);
56
+ } catch (err) {
57
+ println(BAD() + `Could not queue it: ${err.message}`);
58
+ return 1;
59
+ }
60
+
61
+ step(4, 'Did the computer pick it up?');
62
+ println(color.dim(' waiting up to 30s…'));
63
+ let seen = null;
64
+ const deadline = Date.now() + 30_000;
65
+ while (Date.now() < deadline) {
66
+ await sleep(2000);
67
+ try {
68
+ const runs = await requestJson(`${API.localRuns}?automation_id=remote-check&limit=1`);
69
+ if (Array.isArray(runs) && runs.length) { seen = runs[0]; break; }
70
+ } catch { /* keep waiting */ }
71
+ }
72
+ if (!seen) {
73
+ println(BAD() + 'Nothing came back.');
74
+ println(' The job was queued but the computer never ran it. The runner is');
75
+ println(' connected, so the break is between the queue and the run —');
76
+ println(' check the `atlas remote-control` window for errors.');
77
+ return 1;
78
+ }
79
+ println(OK() + `${seen.machine || 'the computer'} reported: ${seen.status}`);
80
+ // not_built is the RIGHT answer here: remote-check has no script, and the
81
+ // point is that the round trip completed, not that a script existed.
82
+ if (seen.status === 'not_built') {
83
+ println(color.dim(' (no script for it — which is expected, and proves the round trip)'));
84
+ }
85
+ println('');
86
+ println(color.green(`Everything works. CLI ${VERSION}.`));
87
+ return 0;
88
+ }
@@ -15,15 +15,50 @@ 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, staleness, summarize, tail, workspace, workspaceRoot,
18
+ DEFAULT_TIMEOUT_MS, computerName, jobEnv, listJobFiles, machineId, osLabel,
19
+ readJobFile, staleness, summarize, tail, workspace, workspaceRoot,
19
20
  } from '../remote-control.js';
20
21
  import fs from 'node:fs';
22
+ import path from 'node:path';
21
23
 
22
24
  const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
23
25
 
24
- /** POST one run record. Best-effort: a failed report must not take down the
25
- * runner, and the next job still deserves to run. */
26
- async function report(job, status, started, finished, extra = {}) {
26
+ /**
27
+ * Keep the run's own account of itself, on this computer.
28
+ *
29
+ * The server is told THAT a run happened; what it printed stays here. That is
30
+ * the whole promise of Local Mode, and it is also where a person debugging
31
+ * should look — the log sits next to the script that produced it.
32
+ *
33
+ * Best-effort: a job that ran must not be reported as failed because a log file
34
+ * could not be written.
35
+ */
36
+ function writeLog(dir, job, status, started, finished, text) {
37
+ try {
38
+ const logs = path.join(dir, 'runs');
39
+ fs.mkdirSync(logs, { recursive: true });
40
+ const d = new Date(started);
41
+ const p2 = (n) => String(n).padStart(2, '0');
42
+ const stamp = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}`
43
+ + `-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
44
+ fs.writeFileSync(path.join(logs, `${stamp}-${status}.log`),
45
+ `${iso(started)} ${status} ${job.name || ''}\n`
46
+ + `took ${((finished - started) / 1000).toFixed(1)}s\n\n`
47
+ + (text || '(no output)\n'));
48
+ // Keep the last 50, so a job running every five minutes does not fill a
49
+ // disk it was never given permission to fill.
50
+ const old = fs.readdirSync(logs).filter((f) => f.endsWith('.log')).sort().slice(0, -50);
51
+ for (const f of old) { try { fs.unlinkSync(path.join(logs, f)); } catch { /* gone */ } }
52
+ } catch { /* a log is a courtesy, never a failure */ }
53
+ }
54
+
55
+ /**
56
+ * POST that a run happened. METADATA ONLY — never what the script printed.
57
+ *
58
+ * Best-effort: a failed report must not take down the runner, and the next job
59
+ * still deserves to run.
60
+ */
61
+ async function report(job, status, started, finished) {
27
62
  try {
28
63
  await requestJson(API.localRuns, {
29
64
  method: 'POST',
@@ -32,9 +67,6 @@ async function report(job, status, started, finished, extra = {}) {
32
67
  name: job.name || '',
33
68
  source: job.source || 'schedule',
34
69
  status,
35
- summary: extra.summary ?? null,
36
- output: extra.output ?? null,
37
- error: extra.error ?? null,
38
70
  started_at: iso(started),
39
71
  finished_at: iso(finished),
40
72
  // One account can run jobs on several computers; the results view says
@@ -63,6 +95,27 @@ function runOnce(argv, dir, env, timeoutMs) {
63
95
  });
64
96
  }
65
97
 
98
+ /** Send one answer back up. Pass-through: the server relays it to whoever asked
99
+ * and stores none of it, which is what keeps a file's contents on this computer
100
+ * even while someone is looking at them. */
101
+ async function answer(requestId, payload) {
102
+ try {
103
+ await requestJson(API.remoteAnswer, { method: 'POST', body: { request_id: requestId, ...payload } });
104
+ } catch (err) {
105
+ eprintln(color.dim(` ↳ could not answer: ${err.message}`));
106
+ }
107
+ }
108
+
109
+ /** A question from the web about THIS computer's workspace. Read-only and
110
+ * confined to the jobs folder: it can list what is there and show one file; it
111
+ * cannot write, delete, or reach outside. */
112
+ async function handleAsk(ask, root) {
113
+ if (!ask?.request_id) return;
114
+ if (ask.op === 'list') return answer(ask.request_id, { ok: true, jobs: listJobFiles(root) });
115
+ if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
116
+ return answer(ask.request_id, { ok: false, error: 'Unknown request.' });
117
+ }
118
+
66
119
  async function runJob(job, root, timeoutMs) {
67
120
  const name = job.name || job.automation_id || 'job';
68
121
  const started = Date.now();
@@ -70,7 +123,7 @@ async function runJob(job, root, timeoutMs) {
70
123
  const late = staleness(job);
71
124
  if (late) {
72
125
  eprintln(color.yellow(`↷ ${name} — too late`));
73
- return report(job, 'skipped_stale', started, Date.now(), { summary: late });
126
+ return report(job, 'skipped_stale', started, Date.now());
74
127
  }
75
128
 
76
129
  const { dir, entry, argv } = workspace(job.automation_id, root);
@@ -79,10 +132,7 @@ async function runJob(job, root, timeoutMs) {
79
132
  // was simply nothing to carry out yet. Its owner never sees the code, so
80
133
  // they cannot tell "broken" from "unfinished" unless the status does it.
81
134
  eprintln(color.yellow(`· ${name} — nothing to run yet`));
82
- return report(job, 'not_built', started, Date.now(), {
83
- summary: 'Nothing is running yet. This setup has not been built, so there was'
84
- + ' nothing for your computer to do.',
85
- });
135
+ return report(job, 'not_built', started, Date.now());
86
136
  }
87
137
 
88
138
  eprintln(color.dim(`▶ ${name}`));
@@ -99,23 +149,20 @@ async function runJob(job, root, timeoutMs) {
99
149
  const finished = Date.now();
100
150
  if (r.timedOut) {
101
151
  eprintln(color.red(`✗ ${name} — timed out`));
102
- return report(job, 'failed', started, finished, {
103
- error: `Stopped after ${Math.round(timeoutMs / 1000)}s. The script did not finish.`,
104
- output: tail(r.out),
105
- });
152
+ writeLog(dir, job, 'failed', started, finished,
153
+ `Stopped after ${Math.round(timeoutMs / 1000)}s. The script did not finish.\n${tail(r.out) || ''}`);
154
+ return report(job, 'failed', started, finished);
106
155
  }
107
156
  if (r.error) {
108
157
  eprintln(color.red(`✗ ${name} — ${r.error}`));
109
- return report(job, 'failed', started, finished, { error: r.error, output: tail(r.out) });
158
+ writeLog(dir, job, 'failed', started, finished, `${r.error}\n${tail(r.out) || ''}`);
159
+ return report(job, 'failed', started, finished);
110
160
  }
111
161
  const ok = r.code === 0;
112
162
  eprintln((ok ? color.green('✓ ') : color.red('✗ ')) + name
113
163
  + color.dim(` (${((finished - started) / 1000).toFixed(1)}s)`));
114
- return report(job, ok ? 'completed' : 'failed', started, finished, {
115
- summary: summarize(r.out, ok),
116
- output: tail(r.out),
117
- error: ok ? null : `Exited ${r.code}.`,
118
- });
164
+ writeLog(dir, job, ok ? 'completed' : 'failed', started, finished, tail(r.out));
165
+ return report(job, ok ? 'completed' : 'failed', started, finished);
119
166
  }
120
167
 
121
168
  export async function remoteControlCommand(args) {
@@ -126,7 +173,10 @@ export async function remoteControlCommand(args) {
126
173
  eprintln(color.dim(`${computerName()} — Atlas can run jobs here. Workspace: ${workspaceRoot(root)}`));
127
174
  eprintln(color.dim('Nothing runs while this is closed. Ctrl-C to stop.'));
128
175
 
129
- const jobsPath = `${API.remoteJobs}?computer=${encodeURIComponent(computerName())}`;
176
+ // Identity is the machine id; the name and OS are how it is DISPLAYED. Two
177
+ // processes on one computer therefore share one row instead of splitting it.
178
+ const q = new URLSearchParams({ computer: computerName(), machine_id: machineId(), os: osLabel() });
179
+ const jobsPath = `${API.remoteJobs}?${q}`;
130
180
  return runStream(jobsPath, {
131
181
  label: 'remote-control',
132
182
  reconnect: flags.reconnect !== false,
@@ -148,6 +198,8 @@ export async function remoteControlCommand(args) {
148
198
  onEvent: async ({ event, payload }) => {
149
199
  if (event === 'job' || event === 'message' || event === 'run') {
150
200
  await runJob(payload, root, timeoutMs);
201
+ } else if (event === 'ask') {
202
+ await handleAsk(payload, root);
151
203
  }
152
204
  },
153
205
  });
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.4.2';
5
+ export const VERSION = '0.5.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';
@@ -40,6 +40,8 @@ export const API = {
40
40
  // goes back UP to localRuns. The web never calls this machine.
41
41
  remoteJobs: '/api/v1/remote-computer/jobs',
42
42
  remotePending: '/api/v1/remote-computer/pending',
43
+ remoteStatus: '/api/v1/remote-computer/status',
44
+ remoteAnswer: '/api/v1/remote-computer/answer',
43
45
  localRuns: '/api/local/runs',
44
46
  };
45
47
 
@@ -8,6 +8,8 @@
8
8
  import fs from 'node:fs';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { configDir } from './config.js';
11
13
 
12
14
  /**
13
15
  * Default workspace root, one directory per automation underneath.
@@ -69,6 +71,49 @@ export function computerName() {
69
71
  return host.slice(0, 200) || 'this computer';
70
72
  }
71
73
 
74
+ /**
75
+ * What kind of computer this is, in the words its owner would use.
76
+ *
77
+ * Windows 11 still reports its release as 10.x — the BUILD number is the only
78
+ * thing separating them, and 22000 is where 11 starts.
79
+ */
80
+ export function osLabel() {
81
+ const p = os.platform();
82
+ const rel = os.release();
83
+ if (p === 'win32') {
84
+ const build = Number(rel.split('.')[2] || 0);
85
+ return build >= 22000 ? 'Windows 11' : 'Windows 10';
86
+ }
87
+ if (p === 'darwin') return 'macOS';
88
+ if (p === 'linux') return `Linux ${rel}`.trim();
89
+ return `${p} ${rel}`.trim();
90
+ }
91
+
92
+ /**
93
+ * A stable id for THIS computer, created once and kept.
94
+ *
95
+ * Identity cannot be the hostname: two processes on one machine (a terminal and
96
+ * the app) would each register, and the owner would see their single laptop
97
+ * twice. Nor can it be derived from the hostname — renaming a computer would
98
+ * spawn a second one. So it is a random id written next to the credentials,
99
+ * shared by every process here and surviving a rename.
100
+ */
101
+ export function machineId() {
102
+ const file = path.join(configDir(), 'machine-id');
103
+ try {
104
+ const existing = fs.readFileSync(file, 'utf8').trim();
105
+ if (existing) return existing.slice(0, 64);
106
+ } catch { /* not created yet */ }
107
+ const id = randomUUID().replace(/-/g, '');
108
+ try {
109
+ fs.mkdirSync(configDir(), { recursive: true });
110
+ fs.writeFileSync(file, id);
111
+ } catch {
112
+ // Unwritable config dir: a per-session id beats refusing to connect.
113
+ }
114
+ return id;
115
+ }
116
+
72
117
  /** The automation's OWN interpreter, if it has one. A venv is a DEPENDENCY
73
118
  * boundary, not a security one, so falling back to the ambient python is a
74
119
  * convenience, never a safety decision. */
@@ -173,3 +218,68 @@ export function summarize(output, ok) {
173
218
  if (!lines.length) return ok ? 'Ran and finished.' : null;
174
219
  return lines[lines.length - 1].slice(0, 2000);
175
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
+ }
package/src/util/help.js CHANGED
@@ -33,6 +33,7 @@ export function printRootHelp() {
33
33
  println(' flow stream --symbol NVDA [--side call] Live options-flow feed (SSE)');
34
34
  println(' remote-control [--dir DIR] Let Atlas run this computer\'s jobs');
35
35
  println(' update-cli Install the newest Atlas CLI');
36
+ println(' remote-check Test the whole loop, hop by hop');
36
37
  println(' [--min-premium N] [--min-tag unusual] [--json]');
37
38
  println();
38
39
  println(c.bold('GLOBAL'));