mindvest-atlas 0.6.1 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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,9 @@ 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
+ writeHelpers,
20
21
  } from '../remote-control.js';
21
22
  import fs from 'node:fs';
22
23
  import path from 'node:path';
@@ -121,6 +122,17 @@ async function runJob(job, root, timeoutMs) {
121
122
  return report(job, 'not_built', started, Date.now());
122
123
  }
123
124
 
125
+ // ONE RUN PER PROJECT. A schedule firing again mid-run would race two copies
126
+ // of the same script over one folder, one .env and one broker account.
127
+ if (!claimRun(dir, name)) {
128
+ eprintln(color.yellow(`· ${name} — already running, left alone`));
129
+ return undefined;
130
+ }
131
+
132
+ // Refreshed every run, so a job always has the supported way to reach a
133
+ // secret and never a reason to open .env itself.
134
+ writeHelpers(dir);
135
+
124
136
  eprintln(color.dim(`▶ ${name}`));
125
137
  const env = {
126
138
  ...process.env,
@@ -137,17 +149,20 @@ async function runJob(job, root, timeoutMs) {
137
149
  eprintln(color.red(`✗ ${name} — timed out`));
138
150
  writeLog(dir, job, 'failed', started, finished,
139
151
  `Stopped after ${Math.round(timeoutMs / 1000)}s. The script did not finish.\n${tail(r.out) || ''}`);
152
+ releaseRun(dir);
140
153
  return report(job, 'failed', started, finished);
141
154
  }
142
155
  if (r.error) {
143
156
  eprintln(color.red(`✗ ${name} — ${r.error}`));
144
157
  writeLog(dir, job, 'failed', started, finished, `${r.error}\n${tail(r.out) || ''}`);
158
+ releaseRun(dir);
145
159
  return report(job, 'failed', started, finished);
146
160
  }
147
161
  const ok = r.code === 0;
148
162
  eprintln((ok ? color.green('✓ ') : color.red('✗ ')) + name
149
163
  + color.dim(` (${((finished - started) / 1000).toFixed(1)}s)`));
150
164
  writeLog(dir, job, ok ? 'completed' : 'failed', started, finished, tail(r.out));
165
+ releaseRun(dir);
151
166
  return report(job, ok ? 'completed' : 'failed', started, finished);
152
167
  }
153
168
 
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.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';
@@ -168,6 +168,70 @@ export function readEnvFile(file) {
168
168
  * Shared first so a key is typed once; the job's own last so a job that wants
169
169
  * its own value gets it. Neither is required.
170
170
  */
171
+ /**
172
+ * Written into every job folder before it runs, in both languages.
173
+ *
174
+ * The job imports this instead of opening .env — so the script the model writes
175
+ * has no reason to touch the file, and a model debugging that script has nothing
176
+ * to learn by trying. Regenerated on every run, so editing or deleting it
177
+ * changes nothing.
178
+ */
179
+ export function writeHelpers(dir) {
180
+ const py = `"""Secrets for this job. Written by Atlas — edits are overwritten.
181
+
182
+ Values come from the environment Atlas set up before starting this script. Do
183
+ NOT open .env: this module is the supported way to reach a secret, and the file
184
+ itself is not something a script or a model needs to read.
185
+ """
186
+ import os
187
+ import sys
188
+
189
+
190
+ def secret(name, default=None):
191
+ """The value of one secret, for USE — never for printing or logging."""
192
+ return os.environ.get(name, default)
193
+
194
+
195
+ def has_secret(name):
196
+ """Whether a secret has been filled in. True/False only, never the value."""
197
+ return bool(os.environ.get(name))
198
+
199
+
200
+ def require(*names):
201
+ """Stop cleanly when a secret has not been filled in yet."""
202
+ missing = [n for n in names if not os.environ.get(n)]
203
+ if missing:
204
+ print("Missing: %s. Add the value in Atlas Remote Control, then run again."
205
+ % ", ".join(missing))
206
+ sys.exit(2)
207
+ `;
208
+ const js = `// Secrets for this job. Written by Atlas — edits are overwritten.
209
+ //
210
+ // Values come from the environment Atlas set up before starting this script. Do
211
+ // NOT open .env: this module is the supported way to reach a secret, and the
212
+ // file itself is not something a script or a model needs to read.
213
+
214
+ /** The value of one secret, for USE — never for printing or logging. */
215
+ export const secret = (name, fallback) => process.env[name] ?? fallback;
216
+
217
+ /** Whether a secret has been filled in. True/false only, never the value. */
218
+ export const hasSecret = (name) => Boolean(process.env[name]);
219
+
220
+ /** Stop cleanly when a secret has not been filled in yet. */
221
+ export function require_(...names) {
222
+ const missing = names.filter((n) => !process.env[n]);
223
+ if (missing.length) {
224
+ console.log(\`Missing: \${missing.join(", ")}. Add the value in Atlas Remote Control, then run again.\`);
225
+ process.exit(2);
226
+ }
227
+ }
228
+ `;
229
+ try {
230
+ fs.writeFileSync(path.join(dir, 'atlas_env.py'), py);
231
+ fs.writeFileSync(path.join(dir, 'atlas_env.js'), js);
232
+ } catch { /* a helper is a courtesy, never a failure */ }
233
+ }
234
+
171
235
  export function jobEnv(dir, root) {
172
236
  return { ...readEnvFile(path.join(workspaceRoot(root), '.env')), ...readEnvFile(path.join(dir, '.env')) };
173
237
  }
@@ -307,6 +371,43 @@ export function readJobFile(job, rel, root) {
307
371
  * the filename (when, and how it ended) and the log's own second line (how
308
372
  * long), which is why the writer stamps both.
309
373
  */
374
+ export const RUNNING_MARKER = '.running';
375
+
376
+ export const runningMarker = (dir) => path.join(dir, 'runs', RUNNING_MARKER);
377
+
378
+ /**
379
+ * Take a project's single run slot, or say who already has it.
380
+ *
381
+ * ONE RUN PER PROJECT. A job that fires again while its last run is still going
382
+ * would have two copies of the same script racing over the same folder, the
383
+ * same .env and the same broker — so the second one does not start. The marker
384
+ * is a file rather than memory so it survives the runner restarting mid-job and
385
+ * is visible to anyone looking at the folder.
386
+ */
387
+ export function claimRun(dir, name) {
388
+ const m = runningMarker(dir);
389
+ try {
390
+ fs.mkdirSync(path.dirname(m), { recursive: true });
391
+ // Exclusive create: two runners on one folder cannot both win.
392
+ fs.writeFileSync(m, `${Date.now() / 1000}\n${name || ''}\n`, { flag: 'wx' });
393
+ return true;
394
+ } catch (err) {
395
+ if (err.code !== 'EEXIST') return true; // cannot write a marker: better to run than never
396
+ let started = 0;
397
+ try { started = parseFloat(fs.readFileSync(m, 'utf8').split('\n')[0]) || 0; } catch { started = 0; }
398
+ // A marker left behind by a crash must not wedge a project forever.
399
+ if (started && Date.now() / 1000 - started > 24 * 3600) {
400
+ releaseRun(dir);
401
+ return claimRun(dir, name);
402
+ }
403
+ return false;
404
+ }
405
+ }
406
+
407
+ export function releaseRun(dir) {
408
+ try { fs.unlinkSync(runningMarker(dir)); } catch { /* already gone */ }
409
+ }
410
+
310
411
  export function listRuns(root, limit = 50) {
311
412
  const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
312
413
  const runs = [];
@@ -315,6 +416,19 @@ export function listRuns(root, limit = 50) {
315
416
  jobs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
316
417
  } catch { return runs; }
317
418
  for (const job of jobs) {
419
+ // A project running right now has no log yet — it is the most important row
420
+ // on the page and would otherwise be invisible until it finished.
421
+ const m = runningMarker(path.join(base, job));
422
+ if (fs.existsSync(m)) {
423
+ let started = Date.now() / 1000;
424
+ let nm = job;
425
+ try {
426
+ const [a, b] = fs.readFileSync(m, 'utf8').split('\n');
427
+ started = parseFloat(a) || started;
428
+ if (b) nm = b;
429
+ } catch { /* mid-write */ }
430
+ runs.push({ job, name: nm, status: 'running', started_at: started, duration_s: null, log: '' });
431
+ }
318
432
  let files = [];
319
433
  try {
320
434
  files = fs.readdirSync(path.join(base, job, 'runs')).filter((f) => f.endsWith('.log')).sort().reverse();