mindvest-atlas 0.6.3 → 0.8.0
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/api/client.js +7 -0
- package/src/commands/remote-control.js +63 -6
- package/src/config.js +1 -1
- package/src/remote-control.js +26 -1
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -41,6 +41,13 @@ async function refreshAndStore(creds) {
|
|
|
41
41
|
return updated;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The bearer a job uses to call the Atlas API directly. Exported so the runner
|
|
45
|
+
* can put it in a child's environment; empty when not signed in, because a
|
|
46
|
+
* missing token is a script's problem to report, not the runner's to crash on. */
|
|
47
|
+
export async function jobToken() {
|
|
48
|
+
try { return await currentAccessToken(); } catch { return ''; }
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
async function currentAccessToken() {
|
|
45
52
|
const env = envToken();
|
|
46
53
|
if (env) return env;
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
// a schedule that quietly broke, which is the failure the record exists to make
|
|
11
11
|
// visible.
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import { API } from '../config.js';
|
|
14
|
-
import { requestJson } from '../api/client.js';
|
|
13
|
+
import { API, baseUrl } from '../config.js';
|
|
14
|
+
import { requestJson, jobToken } 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, claimRun, computerName, jobEnv, listJobFiles, listRuns, machineId,
|
|
18
|
+
DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
|
|
19
19
|
osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
|
|
20
|
-
writeHelpers,
|
|
20
|
+
setPaused, writeHelpers,
|
|
21
21
|
} from '../remote-control.js';
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
@@ -98,11 +98,53 @@ async function answer(requestId, payload) {
|
|
|
98
98
|
async function handleAsk(ask, root) {
|
|
99
99
|
if (!ask?.request_id) return;
|
|
100
100
|
if (ask.op === 'list') return answer(ask.request_id, { ok: true, jobs: listJobFiles(root) });
|
|
101
|
+
if (ask.op === 'pause') {
|
|
102
|
+
const { dir } = workspace(ask.job || '', root);
|
|
103
|
+
return answer(ask.request_id, { ok: true, paused: setPaused(dir, Boolean(ask.paused)) });
|
|
104
|
+
}
|
|
101
105
|
if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
|
|
102
106
|
if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
|
|
103
107
|
return answer(ask.request_id, { ok: false, error: 'Unknown request.' });
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/**
|
|
111
|
+
* How many projects may run at once.
|
|
112
|
+
*
|
|
113
|
+
* Different projects are genuinely independent — own folder, own .env, own
|
|
114
|
+
* single run — so serialising them would make a five-minute job delay a
|
|
115
|
+
* five-second one for no reason. Capped so a burst of due schedules cannot
|
|
116
|
+
* fork-bomb a laptop.
|
|
117
|
+
*/
|
|
118
|
+
const MAX_PARALLEL = 4;
|
|
119
|
+
let active = 0;
|
|
120
|
+
const waiting = [];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run a job WITHOUT blocking the stream.
|
|
124
|
+
*
|
|
125
|
+
* Awaiting it inline was a real bug: a ten-minute script held the SSE loop, so
|
|
126
|
+
* no other job started, the keepalive stopped, and the web's questions about
|
|
127
|
+
* this computer went unanswered until it finished. The loop's job is to listen;
|
|
128
|
+
* the work belongs beside it.
|
|
129
|
+
*
|
|
130
|
+
* One run per PROJECT is still enforced by the marker inside runJob, so this
|
|
131
|
+
* parallelism is across projects only.
|
|
132
|
+
*/
|
|
133
|
+
function spawnJob(job, root, timeoutMs) {
|
|
134
|
+
const start = () => {
|
|
135
|
+
active += 1;
|
|
136
|
+
void runJob(job, root, timeoutMs)
|
|
137
|
+
.catch((err) => eprintln(color.red(`✗ ${err.message}`)))
|
|
138
|
+
.finally(() => {
|
|
139
|
+
active -= 1;
|
|
140
|
+
const next = waiting.shift();
|
|
141
|
+
if (next) next();
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
if (active < MAX_PARALLEL) start();
|
|
145
|
+
else waiting.push(start);
|
|
146
|
+
}
|
|
147
|
+
|
|
106
148
|
async function runJob(job, root, timeoutMs) {
|
|
107
149
|
const name = job.name || job.automation_id || 'job';
|
|
108
150
|
const started = Date.now();
|
|
@@ -114,6 +156,13 @@ async function runJob(job, root, timeoutMs) {
|
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
const { dir, entry, argv } = workspace(job.automation_id, root);
|
|
159
|
+
// Paused is the owner saying "not now". A scheduled run that fires anyway
|
|
160
|
+
// would make the switch meaningless; a MANUAL run is the owner asking
|
|
161
|
+
// directly, so it goes through.
|
|
162
|
+
if (isPaused(dir) && job.source !== 'manual') {
|
|
163
|
+
eprintln(color.dim(`· ${name} — paused`));
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
117
166
|
if (!fs.existsSync(entry)) {
|
|
118
167
|
// NOT a failure. The schedule fired and this computer took the job; there
|
|
119
168
|
// was simply nothing to carry out yet. Its owner never sees the code, so
|
|
@@ -139,6 +188,12 @@ async function runJob(job, root, timeoutMs) {
|
|
|
139
188
|
// Secrets first (shared .env, then the job's own), so a job's own env can
|
|
140
189
|
// deliberately override a shared key.
|
|
141
190
|
...jobEnv(dir, root),
|
|
191
|
+
// The job's own credentials for the Atlas API, so a script calls it directly
|
|
192
|
+
// instead of shelling out to the CLI once per ticker. The token is the SAME
|
|
193
|
+
// one this runner is signed in with — the job acts as its owner, which is
|
|
194
|
+
// the only identity it could sensibly have.
|
|
195
|
+
ATLAS_BASE_URL: baseUrl(),
|
|
196
|
+
ATLAS_TOKEN: await jobToken(),
|
|
142
197
|
...(job.env && typeof job.env === 'object'
|
|
143
198
|
? Object.fromEntries(Object.entries(job.env).map(([k, v]) => [String(k), String(v)])) : {}),
|
|
144
199
|
ATLAS_JOB_PAYLOAD: JSON.stringify(job.payload || {}),
|
|
@@ -194,12 +249,14 @@ export async function remoteControlCommand(args) {
|
|
|
194
249
|
}
|
|
195
250
|
const jobs = Array.isArray(pending) ? pending : (pending?.jobs || []);
|
|
196
251
|
if (jobs.length) eprintln(color.dim(`● ${jobs.length} job(s) queued while away`));
|
|
197
|
-
for (const job of jobs)
|
|
252
|
+
for (const job of jobs) spawnJob(job, root, timeoutMs);
|
|
198
253
|
},
|
|
199
254
|
onEvent: async ({ event, payload }) => {
|
|
200
255
|
if (event === 'job' || event === 'message' || event === 'run') {
|
|
201
|
-
|
|
256
|
+
spawnJob(payload, root, timeoutMs);
|
|
202
257
|
} else if (event === 'ask') {
|
|
258
|
+
// Questions ARE awaited: they are fast, and answering them in order
|
|
259
|
+
// keeps a reply matched to the request that asked for it.
|
|
203
260
|
await handleAsk(payload, root);
|
|
204
261
|
}
|
|
205
262
|
},
|
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.
|
|
5
|
+
export const VERSION = '0.8.0';
|
|
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
|
@@ -331,7 +331,7 @@ export function listJobFiles(root) {
|
|
|
331
331
|
}
|
|
332
332
|
};
|
|
333
333
|
walk(path.join(base, job), '');
|
|
334
|
-
return { job, files };
|
|
334
|
+
return { job, files, paused: isPaused(path.join(base, job)) };
|
|
335
335
|
});
|
|
336
336
|
}
|
|
337
337
|
|
|
@@ -372,6 +372,31 @@ export function readJobFile(job, rel, root) {
|
|
|
372
372
|
* long), which is why the writer stamps both.
|
|
373
373
|
*/
|
|
374
374
|
export const RUNNING_MARKER = '.running';
|
|
375
|
+
/**
|
|
376
|
+
* Marks a project the owner has switched off.
|
|
377
|
+
*
|
|
378
|
+
* Local, like everything else about a project: pausing is a fact about this
|
|
379
|
+
* computer's copy, and a file is the only form of it that survives the runner
|
|
380
|
+
* restarting and is visible in the folder to anyone wondering why nothing has
|
|
381
|
+
* happened.
|
|
382
|
+
*/
|
|
383
|
+
export const PAUSED_MARKER = '.paused';
|
|
384
|
+
|
|
385
|
+
export const isPaused = (dir) => fs.existsSync(path.join(dir, PAUSED_MARKER));
|
|
386
|
+
|
|
387
|
+
/** Switch one project off or back on. Returns the state it ended in. */
|
|
388
|
+
export function setPaused(dir, paused) {
|
|
389
|
+
const m = path.join(dir, PAUSED_MARKER);
|
|
390
|
+
try {
|
|
391
|
+
if (paused) {
|
|
392
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
393
|
+
fs.writeFileSync(m, `${Date.now() / 1000}\n`);
|
|
394
|
+
} else {
|
|
395
|
+
fs.rmSync(m, { force: true });
|
|
396
|
+
}
|
|
397
|
+
} catch { /* best-effort */ }
|
|
398
|
+
return isPaused(dir);
|
|
399
|
+
}
|
|
375
400
|
|
|
376
401
|
export const runningMarker = (dir) => path.join(dir, 'runs', RUNNING_MARKER);
|
|
377
402
|
|