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 +1 -1
- package/src/commands/remote-control.js +17 -2
- package/src/config.js +1 -1
- package/src/remote-control.js +114 -0
package/package.json
CHANGED
|
@@ -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,
|
|
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.
|
|
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';
|
package/src/remote-control.js
CHANGED
|
@@ -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();
|