mindvest-atlas 0.6.1 → 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.1",
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.1';
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';
@@ -307,6 +307,43 @@ export function readJobFile(job, rel, root) {
307
307
  * the filename (when, and how it ended) and the log's own second line (how
308
308
  * long), which is why the writer stamps both.
309
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
+
310
347
  export function listRuns(root, limit = 50) {
311
348
  const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
312
349
  const runs = [];
@@ -315,6 +352,19 @@ export function listRuns(root, limit = 50) {
315
352
  jobs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
316
353
  } catch { return runs; }
317
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
+ }
318
368
  let files = [];
319
369
  try {
320
370
  files = fs.readdirSync(path.join(base, job, 'runs')).filter((f) => f.endsWith('.log')).sort().reverse();