mindvest-atlas 0.6.3 → 0.8.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.6.3",
3
+ "version": "0.8.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/api/client.js CHANGED
@@ -41,6 +41,13 @@ async function refreshAndStore(creds) {
41
41
  return updated;
42
42
  }
43
43
 
44
+ /** The bearer a job uses to call the Atlas API directly. Exported so the runner
45
+ * can put it in a child's environment; empty when not signed in, because a
46
+ * missing token is a script's problem to report, not the runner's to crash on. */
47
+ export async function jobToken() {
48
+ try { return await currentAccessToken(); } catch { return ''; }
49
+ }
50
+
44
51
  async function currentAccessToken() {
45
52
  const env = envToken();
46
53
  if (env) return env;
@@ -10,14 +10,14 @@
10
10
  // a schedule that quietly broke, which is the failure the record exists to make
11
11
  // visible.
12
12
  import { spawn } from 'node:child_process';
13
- import { API } from '../config.js';
14
- import { requestJson } from '../api/client.js';
13
+ import { API, baseUrl } from '../config.js';
14
+ import { requestJson, jobToken } 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, claimRun, computerName, jobEnv, listJobFiles, listRuns, machineId,
18
+ DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
19
19
  osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
20
- writeHelpers,
20
+ setPaused, writeHelpers,
21
21
  } from '../remote-control.js';
22
22
  import fs from 'node:fs';
23
23
  import path from 'node:path';
@@ -98,11 +98,53 @@ async function answer(requestId, payload) {
98
98
  async function handleAsk(ask, root) {
99
99
  if (!ask?.request_id) return;
100
100
  if (ask.op === 'list') return answer(ask.request_id, { ok: true, jobs: listJobFiles(root) });
101
+ if (ask.op === 'pause') {
102
+ const { dir } = workspace(ask.job || '', root);
103
+ return answer(ask.request_id, { ok: true, paused: setPaused(dir, Boolean(ask.paused)) });
104
+ }
101
105
  if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
102
106
  if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
103
107
  return answer(ask.request_id, { ok: false, error: 'Unknown request.' });
104
108
  }
105
109
 
110
+ /**
111
+ * How many projects may run at once.
112
+ *
113
+ * Different projects are genuinely independent — own folder, own .env, own
114
+ * single run — so serialising them would make a five-minute job delay a
115
+ * five-second one for no reason. Capped so a burst of due schedules cannot
116
+ * fork-bomb a laptop.
117
+ */
118
+ const MAX_PARALLEL = 4;
119
+ let active = 0;
120
+ const waiting = [];
121
+
122
+ /**
123
+ * Run a job WITHOUT blocking the stream.
124
+ *
125
+ * Awaiting it inline was a real bug: a ten-minute script held the SSE loop, so
126
+ * no other job started, the keepalive stopped, and the web's questions about
127
+ * this computer went unanswered until it finished. The loop's job is to listen;
128
+ * the work belongs beside it.
129
+ *
130
+ * One run per PROJECT is still enforced by the marker inside runJob, so this
131
+ * parallelism is across projects only.
132
+ */
133
+ function spawnJob(job, root, timeoutMs) {
134
+ const start = () => {
135
+ active += 1;
136
+ void runJob(job, root, timeoutMs)
137
+ .catch((err) => eprintln(color.red(`✗ ${err.message}`)))
138
+ .finally(() => {
139
+ active -= 1;
140
+ const next = waiting.shift();
141
+ if (next) next();
142
+ });
143
+ };
144
+ if (active < MAX_PARALLEL) start();
145
+ else waiting.push(start);
146
+ }
147
+
106
148
  async function runJob(job, root, timeoutMs) {
107
149
  const name = job.name || job.automation_id || 'job';
108
150
  const started = Date.now();
@@ -114,6 +156,13 @@ async function runJob(job, root, timeoutMs) {
114
156
  }
115
157
 
116
158
  const { dir, entry, argv } = workspace(job.automation_id, root);
159
+ // Paused is the owner saying "not now". A scheduled run that fires anyway
160
+ // would make the switch meaningless; a MANUAL run is the owner asking
161
+ // directly, so it goes through.
162
+ if (isPaused(dir) && job.source !== 'manual') {
163
+ eprintln(color.dim(`· ${name} — paused`));
164
+ return undefined;
165
+ }
117
166
  if (!fs.existsSync(entry)) {
118
167
  // NOT a failure. The schedule fired and this computer took the job; there
119
168
  // was simply nothing to carry out yet. Its owner never sees the code, so
@@ -139,6 +188,12 @@ async function runJob(job, root, timeoutMs) {
139
188
  // Secrets first (shared .env, then the job's own), so a job's own env can
140
189
  // deliberately override a shared key.
141
190
  ...jobEnv(dir, root),
191
+ // The job's own credentials for the Atlas API, so a script calls it directly
192
+ // instead of shelling out to the CLI once per ticker. The token is the SAME
193
+ // one this runner is signed in with — the job acts as its owner, which is
194
+ // the only identity it could sensibly have.
195
+ ATLAS_BASE_URL: baseUrl(),
196
+ ATLAS_TOKEN: await jobToken(),
142
197
  ...(job.env && typeof job.env === 'object'
143
198
  ? Object.fromEntries(Object.entries(job.env).map(([k, v]) => [String(k), String(v)])) : {}),
144
199
  ATLAS_JOB_PAYLOAD: JSON.stringify(job.payload || {}),
@@ -194,12 +249,14 @@ export async function remoteControlCommand(args) {
194
249
  }
195
250
  const jobs = Array.isArray(pending) ? pending : (pending?.jobs || []);
196
251
  if (jobs.length) eprintln(color.dim(`● ${jobs.length} job(s) queued while away`));
197
- for (const job of jobs) await runJob(job, root, timeoutMs);
252
+ for (const job of jobs) spawnJob(job, root, timeoutMs);
198
253
  },
199
254
  onEvent: async ({ event, payload }) => {
200
255
  if (event === 'job' || event === 'message' || event === 'run') {
201
- await runJob(payload, root, timeoutMs);
256
+ spawnJob(payload, root, timeoutMs);
202
257
  } else if (event === 'ask') {
258
+ // Questions ARE awaited: they are fast, and answering them in order
259
+ // keeps a reply matched to the request that asked for it.
203
260
  await handleAsk(payload, root);
204
261
  }
205
262
  },
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.6.3';
5
+ export const VERSION = '0.8.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';
@@ -331,7 +331,7 @@ export function listJobFiles(root) {
331
331
  }
332
332
  };
333
333
  walk(path.join(base, job), '');
334
- return { job, files };
334
+ return { job, files, paused: isPaused(path.join(base, job)) };
335
335
  });
336
336
  }
337
337
 
@@ -372,6 +372,31 @@ export function readJobFile(job, rel, root) {
372
372
  * long), which is why the writer stamps both.
373
373
  */
374
374
  export const RUNNING_MARKER = '.running';
375
+ /**
376
+ * Marks a project the owner has switched off.
377
+ *
378
+ * Local, like everything else about a project: pausing is a fact about this
379
+ * computer's copy, and a file is the only form of it that survives the runner
380
+ * restarting and is visible in the folder to anyone wondering why nothing has
381
+ * happened.
382
+ */
383
+ export const PAUSED_MARKER = '.paused';
384
+
385
+ export const isPaused = (dir) => fs.existsSync(path.join(dir, PAUSED_MARKER));
386
+
387
+ /** Switch one project off or back on. Returns the state it ended in. */
388
+ export function setPaused(dir, paused) {
389
+ const m = path.join(dir, PAUSED_MARKER);
390
+ try {
391
+ if (paused) {
392
+ fs.mkdirSync(dir, { recursive: true });
393
+ fs.writeFileSync(m, `${Date.now() / 1000}\n`);
394
+ } else {
395
+ fs.rmSync(m, { force: true });
396
+ }
397
+ } catch { /* best-effort */ }
398
+ return isPaused(dir);
399
+ }
375
400
 
376
401
  export const runningMarker = (dir) => path.join(dir, 'runs', RUNNING_MARKER);
377
402