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