mindvest-atlas 0.4.2 → 0.4.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 +4 -0
- package/src/commands/remote-check.js +88 -0
- package/src/commands/remote-control.js +52 -23
- package/src/config.js +2 -1
- package/src/remote-control.js +45 -0
- package/src/util/help.js +1 -0
package/package.json
CHANGED
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,
|
|
18
|
+
DEFAULT_TIMEOUT_MS, computerName, jobEnv, machineId, osLabel,
|
|
19
|
+
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
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
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
|
|
@@ -70,7 +102,7 @@ async function runJob(job, root, timeoutMs) {
|
|
|
70
102
|
const late = staleness(job);
|
|
71
103
|
if (late) {
|
|
72
104
|
eprintln(color.yellow(`↷ ${name} — too late`));
|
|
73
|
-
return report(job, 'skipped_stale', started, Date.now()
|
|
105
|
+
return report(job, 'skipped_stale', started, Date.now());
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
const { dir, entry, argv } = workspace(job.automation_id, root);
|
|
@@ -79,10 +111,7 @@ async function runJob(job, root, timeoutMs) {
|
|
|
79
111
|
// was simply nothing to carry out yet. Its owner never sees the code, so
|
|
80
112
|
// they cannot tell "broken" from "unfinished" unless the status does it.
|
|
81
113
|
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
|
-
});
|
|
114
|
+
return report(job, 'not_built', started, Date.now());
|
|
86
115
|
}
|
|
87
116
|
|
|
88
117
|
eprintln(color.dim(`▶ ${name}`));
|
|
@@ -99,23 +128,20 @@ async function runJob(job, root, timeoutMs) {
|
|
|
99
128
|
const finished = Date.now();
|
|
100
129
|
if (r.timedOut) {
|
|
101
130
|
eprintln(color.red(`✗ ${name} — timed out`));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
131
|
+
writeLog(dir, job, 'failed', started, finished,
|
|
132
|
+
`Stopped after ${Math.round(timeoutMs / 1000)}s. The script did not finish.\n${tail(r.out) || ''}`);
|
|
133
|
+
return report(job, 'failed', started, finished);
|
|
106
134
|
}
|
|
107
135
|
if (r.error) {
|
|
108
136
|
eprintln(color.red(`✗ ${name} — ${r.error}`));
|
|
109
|
-
|
|
137
|
+
writeLog(dir, job, 'failed', started, finished, `${r.error}\n${tail(r.out) || ''}`);
|
|
138
|
+
return report(job, 'failed', started, finished);
|
|
110
139
|
}
|
|
111
140
|
const ok = r.code === 0;
|
|
112
141
|
eprintln((ok ? color.green('✓ ') : color.red('✗ ')) + name
|
|
113
142
|
+ color.dim(` (${((finished - started) / 1000).toFixed(1)}s)`));
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
output: tail(r.out),
|
|
117
|
-
error: ok ? null : `Exited ${r.code}.`,
|
|
118
|
-
});
|
|
143
|
+
writeLog(dir, job, ok ? 'completed' : 'failed', started, finished, tail(r.out));
|
|
144
|
+
return report(job, ok ? 'completed' : 'failed', started, finished);
|
|
119
145
|
}
|
|
120
146
|
|
|
121
147
|
export async function remoteControlCommand(args) {
|
|
@@ -126,7 +152,10 @@ export async function remoteControlCommand(args) {
|
|
|
126
152
|
eprintln(color.dim(`${computerName()} — Atlas can run jobs here. Workspace: ${workspaceRoot(root)}`));
|
|
127
153
|
eprintln(color.dim('Nothing runs while this is closed. Ctrl-C to stop.'));
|
|
128
154
|
|
|
129
|
-
|
|
155
|
+
// Identity is the machine id; the name and OS are how it is DISPLAYED. Two
|
|
156
|
+
// processes on one computer therefore share one row instead of splitting it.
|
|
157
|
+
const q = new URLSearchParams({ computer: computerName(), machine_id: machineId(), os: osLabel() });
|
|
158
|
+
const jobsPath = `${API.remoteJobs}?${q}`;
|
|
130
159
|
return runStream(jobsPath, {
|
|
131
160
|
label: 'remote-control',
|
|
132
161
|
reconnect: flags.reconnect !== false,
|
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.
|
|
5
|
+
export const VERSION = '0.4.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';
|
|
@@ -40,6 +40,7 @@ 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',
|
|
43
44
|
localRuns: '/api/local/runs',
|
|
44
45
|
};
|
|
45
46
|
|
package/src/remote-control.js
CHANGED
|
@@ -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. */
|
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'));
|