mindvest-atlas 0.6.0 → 0.6.2

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.0",
3
+ "version": "0.6.2",
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": {
@@ -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, listJobFiles, listRuns, machineId, osLabel,
19
- readJobFile, staleness, summarize, tail, workspace, workspaceRoot,
18
+ DEFAULT_TIMEOUT_MS, claimRun, computerName, jobEnv, listJobFiles, listRuns, machineId,
19
+ osLabel, readJobFile, releaseRun, 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';
@@ -121,6 +121,13 @@ async function runJob(job, root, timeoutMs) {
121
121
  return report(job, 'not_built', started, Date.now());
122
122
  }
123
123
 
124
+ // ONE RUN PER PROJECT. A schedule firing again mid-run would race two copies
125
+ // of the same script over one folder, one .env and one broker account.
126
+ if (!claimRun(dir, name)) {
127
+ eprintln(color.yellow(`· ${name} — already running, left alone`));
128
+ return undefined;
129
+ }
130
+
124
131
  eprintln(color.dim(`▶ ${name}`));
125
132
  const env = {
126
133
  ...process.env,
@@ -137,17 +144,20 @@ async function runJob(job, root, timeoutMs) {
137
144
  eprintln(color.red(`✗ ${name} — timed out`));
138
145
  writeLog(dir, job, 'failed', started, finished,
139
146
  `Stopped after ${Math.round(timeoutMs / 1000)}s. The script did not finish.\n${tail(r.out) || ''}`);
147
+ releaseRun(dir);
140
148
  return report(job, 'failed', started, finished);
141
149
  }
142
150
  if (r.error) {
143
151
  eprintln(color.red(`✗ ${name} — ${r.error}`));
144
152
  writeLog(dir, job, 'failed', started, finished, `${r.error}\n${tail(r.out) || ''}`);
153
+ releaseRun(dir);
145
154
  return report(job, 'failed', started, finished);
146
155
  }
147
156
  const ok = r.code === 0;
148
157
  eprintln((ok ? color.green('✓ ') : color.red('✗ ')) + name
149
158
  + color.dim(` (${((finished - started) / 1000).toFixed(1)}s)`));
150
159
  writeLog(dir, job, ok ? 'completed' : 'failed', started, finished, tail(r.out));
160
+ releaseRun(dir);
151
161
  return report(job, ok ? 'completed' : 'failed', started, finished);
152
162
  }
153
163
 
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.0';
5
+ export const VERSION = '0.6.2';
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';
@@ -223,6 +223,15 @@ export function summarize(output, ok) {
223
223
  * a few KB, and anything past it is a data file the viewer would choke on. */
224
224
  export const MAX_VIEW_BYTES = 256 * 1024;
225
225
  const SKIP_DIRS = new Set(['.venv', 'node_modules', '__pycache__']);
226
+ /**
227
+ * Never leaves this computer, for any caller.
228
+ *
229
+ * A .env holds the values the user typed — API keys, tokens — and the entire
230
+ * point of asking them to type those HERE is that they go no further. It is not
231
+ * listed and it cannot be read: the web has no business seeing even that it
232
+ * exists at a given size, and neither does a model.
233
+ */
234
+ const isSecret = (name) => name === '.env' || name.startsWith('.env.');
226
235
 
227
236
  /**
228
237
  * Every job folder and what is in it, as plain metadata.
@@ -249,6 +258,7 @@ export function listJobFiles(root) {
249
258
  const abs = path.join(dir, e.name);
250
259
  const r = rel ? `${rel}/${e.name}` : e.name;
251
260
  if (e.isDirectory()) { walk(abs, r); continue; }
261
+ if (isSecret(e.name)) continue;
252
262
  try {
253
263
  const st = fs.statSync(abs);
254
264
  files.push({ path: r, size: st.size, modified: st.mtimeMs / 1000 });
@@ -274,6 +284,11 @@ export function readJobFile(job, rel, root) {
274
284
  if (target !== base && !target.startsWith(base + path.sep)) {
275
285
  return { ok: false, error: "That file is outside this job's folder." };
276
286
  }
287
+ // Refused by NAME, before the file is touched. The values in here were typed
288
+ // on this computer precisely so they would stay on it.
289
+ if (isSecret(path.basename(target))) {
290
+ return { ok: false, error: 'Secrets stay on this computer.' };
291
+ }
277
292
  try {
278
293
  if (fs.statSync(target).size > MAX_VIEW_BYTES) {
279
294
  return { ok: false, error: 'That file is too large to show here.' };
@@ -292,6 +307,43 @@ export function readJobFile(job, rel, root) {
292
307
  * the filename (when, and how it ended) and the log's own second line (how
293
308
  * long), which is why the writer stamps both.
294
309
  */
310
+ export const RUNNING_MARKER = '.running';
311
+
312
+ export const runningMarker = (dir) => path.join(dir, 'runs', RUNNING_MARKER);
313
+
314
+ /**
315
+ * Take a project's single run slot, or say who already has it.
316
+ *
317
+ * ONE RUN PER PROJECT. A job that fires again while its last run is still going
318
+ * would have two copies of the same script racing over the same folder, the
319
+ * same .env and the same broker — so the second one does not start. The marker
320
+ * is a file rather than memory so it survives the runner restarting mid-job and
321
+ * is visible to anyone looking at the folder.
322
+ */
323
+ export function claimRun(dir, name) {
324
+ const m = runningMarker(dir);
325
+ try {
326
+ fs.mkdirSync(path.dirname(m), { recursive: true });
327
+ // Exclusive create: two runners on one folder cannot both win.
328
+ fs.writeFileSync(m, `${Date.now() / 1000}\n${name || ''}\n`, { flag: 'wx' });
329
+ return true;
330
+ } catch (err) {
331
+ if (err.code !== 'EEXIST') return true; // cannot write a marker: better to run than never
332
+ let started = 0;
333
+ try { started = parseFloat(fs.readFileSync(m, 'utf8').split('\n')[0]) || 0; } catch { started = 0; }
334
+ // A marker left behind by a crash must not wedge a project forever.
335
+ if (started && Date.now() / 1000 - started > 24 * 3600) {
336
+ releaseRun(dir);
337
+ return claimRun(dir, name);
338
+ }
339
+ return false;
340
+ }
341
+ }
342
+
343
+ export function releaseRun(dir) {
344
+ try { fs.unlinkSync(runningMarker(dir)); } catch { /* already gone */ }
345
+ }
346
+
295
347
  export function listRuns(root, limit = 50) {
296
348
  const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
297
349
  const runs = [];
@@ -300,6 +352,19 @@ export function listRuns(root, limit = 50) {
300
352
  jobs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
301
353
  } catch { return runs; }
302
354
  for (const job of jobs) {
355
+ // A project running right now has no log yet — it is the most important row
356
+ // on the page and would otherwise be invisible until it finished.
357
+ const m = runningMarker(path.join(base, job));
358
+ if (fs.existsSync(m)) {
359
+ let started = Date.now() / 1000;
360
+ let nm = job;
361
+ try {
362
+ const [a, b] = fs.readFileSync(m, 'utf8').split('\n');
363
+ started = parseFloat(a) || started;
364
+ if (b) nm = b;
365
+ } catch { /* mid-write */ }
366
+ runs.push({ job, name: nm, status: 'running', started_at: started, duration_s: null, log: '' });
367
+ }
303
368
  let files = [];
304
369
  try {
305
370
  files = fs.readdirSync(path.join(base, job, 'runs')).filter((f) => f.endsWith('.log')).sort().reverse();