@ruvector/ruvllm-gpu-loop 0.0.0-stage → 1.0.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.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { setTimeout as wait } from 'node:timers/promises';
3
+ import { tickLoop, readLoopStatus, readLoopConfig } from './gpu-loop.mjs';
4
+ import { loadHostAdapter } from './gpu-host.mjs';
5
+
6
+ const usage = 'Usage: node src/gpu-loop-cli.mjs tick|status|run --config ABS\n'
7
+ + ' tick/run require --adapter ABS.mjs --adapter-sha256 SHA256\n'
8
+ + ' run accepts --max-ticks 1..10000 (default 1); no infinite loop is started.\n'
9
+ + 'The trusted host resolves ruOS secret references and executes Vast.ai operations.\n'
10
+ + 'No provider or secret access is supplied by this CLI.\n';
11
+
12
+ function options(argv, command) {
13
+ const result = {};
14
+ const allowed = command === 'status' ? ['--config']
15
+ : ['--config', '--adapter', '--adapter-sha256', ...(command === 'run' ? ['--max-ticks'] : [])];
16
+ for (let index = 0; index < argv.length; index += 2) {
17
+ const key = argv[index], value = argv[index + 1];
18
+ if (!allowed.includes(key) || !value || value.startsWith('--') || Object.hasOwn(result, key)) throw new Error('INVALID_ARGUMENTS');
19
+ result[key] = value;
20
+ }
21
+ if (!result['--config'] || (command !== 'status' && (!result['--adapter'] || !result['--adapter-sha256']))) throw new Error('INVALID_ARGUMENTS');
22
+ const maxTicks = Number(result['--max-ticks'] ?? 1);
23
+ if (!Number.isSafeInteger(maxTicks) || maxTicks < 1 || maxTicks > 10000) throw new Error('INVALID_ARGUMENTS');
24
+ return { ...result, maxTicks };
25
+ }
26
+
27
+ const [command, ...argv] = process.argv.slice(2);
28
+ if (command === '--help' || command === 'help') process.stdout.write(usage);
29
+ else if (!['tick', 'status', 'run'].includes(command)) {
30
+ process.stderr.write(usage); process.exitCode = 2;
31
+ } else {
32
+ const stop = new AbortController();
33
+ const stopLoop = () => stop.abort();
34
+ process.once('SIGINT', stopLoop); process.once('SIGTERM', stopLoop);
35
+ try {
36
+ const flags = options(argv, command);
37
+ const configPath = flags['--config'];
38
+ if (command === 'status') process.stdout.write(JSON.stringify(await readLoopStatus({ configPath })) + '\n');
39
+ else {
40
+ const ticks = command === 'run' ? flags.maxTicks : 1;
41
+ for (let index = 0; index < ticks && !stop.signal.aborted; index++) {
42
+ // Revalidate code and immutable config before each tick. Tick locks and
43
+ // durable submission markers own duplicate prevention across processes.
44
+ const config = await readLoopConfig({ configPath });
45
+ const adapter = await loadHostAdapter({ modulePath: flags['--adapter'], sha256: flags['--adapter-sha256'] });
46
+ const state = await tickLoop({ configPath, adapter });
47
+ process.stdout.write(JSON.stringify(state) + '\n');
48
+ if (index + 1 < ticks && !stop.signal.aborted) {
49
+ // The scheduler may shorten nextTickAt to a job deadline or immediate
50
+ // cleanup. Never sleep the full cadence across those boundaries.
51
+ const delayMs = Math.max(10, Math.min(config.intervalSeconds * 1000, state.nextTickAt - Date.now()));
52
+ try { await wait(delayMs, undefined, { signal: stop.signal }); }
53
+ catch (error) { if (error.name !== 'AbortError') throw error; }
54
+ }
55
+ }
56
+ }
57
+ } catch (error) {
58
+ // Raw host exceptions can carry credentials. Surface only scheduler-owned
59
+ // fixed codes; detailed provider diagnostics remain with the host.
60
+ const code = typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code)
61
+ ? error.code : 'GPU_LOOP_FAILED';
62
+ process.stderr.write(JSON.stringify({ ok: false, code, productionActivation: false }) + '\n');
63
+ process.exitCode = 1;
64
+ } finally {
65
+ process.removeListener('SIGINT', stopLoop); process.removeListener('SIGTERM', stopLoop);
66
+ }
67
+ }
@@ -0,0 +1,184 @@
1
+ // Persistent orchestration only. A trusted host adapter owns provider APIs and secret resolution.
2
+ import { randomUUID, createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { open, lstat, mkdir, rename, rm } from 'node:fs/promises';
5
+ import { join, isAbsolute, resolve } from 'node:path';
6
+ import { checkedPath, parseJson } from './engine.mjs';
7
+
8
+ const fail = (ok, message) => { if (!ok) throw new Error(message); };
9
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value);
10
+ const sha = bytes => createHash('sha256').update(bytes).digest('hex');
11
+ const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
12
+ && Number.isSafeInteger(Math.round(value * 1e6)) && Math.round(value * 1e6) / 1e6 === value;
13
+ const exact = (value, required, optional = []) => fail(value && typeof value === 'object' && !Array.isArray(value)
14
+ && required.every(key => Object.hasOwn(value, key)) && Object.keys(value).every(key => [...required, ...optional].includes(key)), 'Invalid or unknown fields');
15
+ const privateOwner = stat => fail((stat.mode & 0o077) === 0 && stat.uid === process.getuid(), 'Owner-only paths required');
16
+ const freeze = value => {
17
+ if (value && typeof value === 'object') {
18
+ for (const nested of Object.values(value)) freeze(nested);
19
+ Object.freeze(value);
20
+ }
21
+ return value;
22
+ };
23
+
24
+ async function readJson(path) {
25
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ try {
27
+ const stat = await handle.stat(); privateOwner(stat);
28
+ fail(stat.isFile() && stat.size <= 1024 * 1024, 'Bounded regular JSON file required');
29
+ const bytes = await handle.readFile(); fail(bytes.length === stat.size, 'JSON file changed');
30
+ return { value: parseJson(bytes), digest: sha(bytes) };
31
+ } finally { await handle.close(); }
32
+ }
33
+
34
+ export async function readLoopConfig({ configPath }) {
35
+ await checkedPath(configPath);
36
+ const { value: config, digest } = await readJson(configPath);
37
+ exact(config, ['schema', 'stateRoot', 'intervalSeconds', 'maxAttempts', 'budgetUsd', 'jobs']);
38
+ fail(config.schema === 'ruvllm-gpu-loop/v1', 'Unsupported loop config');
39
+ fail(Number.isInteger(config.intervalSeconds) && config.intervalSeconds >= 1 && config.intervalSeconds <= 86400, 'Invalid interval');
40
+ fail(Number.isInteger(config.maxAttempts) && config.maxAttempts >= 1 && config.maxAttempts <= 10, 'Invalid attempts');
41
+ fail(amount(config.budgetUsd), 'Invalid loop budget');
42
+ await checkedPath(config.stateRoot, { directory: true }); privateOwner(await lstat(config.stateRoot));
43
+ fail(configPath !== join(config.stateRoot, 'state.json') && configPath !== join(config.stateRoot, 'tick.lock'), 'Config collides with scheduler state');
44
+ fail(Array.isArray(config.jobs) && config.jobs.length >= 1 && config.jobs.length <= 128, 'Expected 1..128 jobs');
45
+ const seen = new Set();
46
+ for (const job of config.jobs) {
47
+ exact(job, ['id', 'provider', 'model', 'task', 'secretRef', 'resource', 'maxCostUsd', 'timeoutSeconds'], ['experiment']);
48
+ fail(id(job.id) && !seen.has(job.id), 'Invalid or duplicate job ID'); seen.add(job.id);
49
+ fail(job.provider === 'vast.ai' && ['evaluate', 'train'].includes(job.task), 'Unsupported provider/task');
50
+ exact(job.model, ['repo', 'revision']);
51
+ fail(typeof job.model.repo === 'string' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(job.model.repo)
52
+ && /^[a-f0-9]{40}$/.test(job.model.revision), 'Pinned model revision required');
53
+ fail(id(job.secretRef), 'Opaque host secret reference required');
54
+ exact(job.resource, [], ['instanceId', 'offerId']);
55
+ fail(Object.keys(job.resource).length === 1 && Object.values(job.resource).every(id), 'Exactly one instanceId or offerId required');
56
+ fail(amount(job.maxCostUsd) && job.maxCostUsd > 0, 'Invalid job cost ceiling');
57
+ fail(Number.isInteger(job.timeoutSeconds) && job.timeoutSeconds >= 1 && job.timeoutSeconds <= 86400, 'Invalid job timeout');
58
+ if (job.experiment) {
59
+ exact(job.experiment, ['configPath', 'planPath']);
60
+ fail(Object.values(job.experiment).every(path => typeof path === 'string' && isAbsolute(path) && path === resolve(path)), 'Absolute experiment paths required');
61
+ }
62
+ }
63
+ return freeze({ ...config, configSha256: digest });
64
+ }
65
+
66
+ function initial(config) {
67
+ return { schema: 'ruvllm-gpu-loop-state/v1', configSha256: config.configSha256,
68
+ nextTickAt: 0, reservedUsd: 0, jobs: config.jobs.map(job => ({ id: job.id, status: 'queued', attempts: 0 })), productionActivation: false };
69
+ }
70
+ async function load(config) {
71
+ const path = join(config.stateRoot, 'state.json');
72
+ let state;
73
+ try { state = (await readJson(path)).value; }
74
+ catch (error) { if (error.code === 'ENOENT') return initial(config); throw error; }
75
+ fail(state.schema === 'ruvllm-gpu-loop-state/v1' && state.configSha256 === config.configSha256, 'State/config binding mismatch');
76
+ fail(Array.isArray(state.jobs) && state.jobs.length === config.jobs.length && state.jobs.every((job, index) =>
77
+ job.id === config.jobs[index].id && ['queued', 'submitting', 'running', 'cleanup', 'succeeded', 'failed', 'blocked'].includes(job.status)
78
+ && Number.isInteger(job.attempts) && job.attempts >= 0 && job.attempts <= config.maxAttempts), 'Invalid state records');
79
+ fail(amount(state.reservedUsd) && state.reservedUsd <= config.budgetUsd
80
+ && Number.isSafeInteger(state.nextTickAt) && state.nextTickAt >= 0 && state.productionActivation === false, 'Invalid state budget/timing');
81
+ fail(Math.round(state.reservedUsd * 1e6) === state.jobs.reduce((sum, entry, index) =>
82
+ sum + entry.attempts * Math.round(config.jobs[index].maxCostUsd * 1e6), 0), 'State reservation ledger mismatch');
83
+ for (const entry of state.jobs) {
84
+ if (['submitting', 'running', 'cleanup', 'succeeded', 'blocked'].includes(entry.status)) {
85
+ fail(entry.attempts > 0 && id(entry.idempotencyKey) && Number.isSafeInteger(entry.startedAt) && entry.startedAt >= 0, 'Missing attempt identity');
86
+ }
87
+ if (['running', 'cleanup', 'succeeded'].includes(entry.status)) fail(id(entry.runId), 'Missing provider run identity');
88
+ if (entry.status === 'cleanup') fail(['succeeded', 'failed'].includes(entry.targetState) && typeof entry.retryable === 'boolean', 'Invalid cleanup intent');
89
+ if (entry.status === 'succeeded') fail(entry.cleanupConfirmed === true, 'Completion requires cleanup');
90
+ }
91
+ return state;
92
+ }
93
+ async function persist(config, state) {
94
+ const temporary = join(config.stateRoot, `.state-${randomUUID()}.json`);
95
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
96
+ try { await handle.writeFile(JSON.stringify(state, null, 2) + '\n'); await handle.sync(); } finally { await handle.close(); }
97
+ await rename(temporary, join(config.stateRoot, 'state.json'));
98
+ const directory = await open(config.stateRoot, constants.O_RDONLY); try { await directory.sync(); } finally { await directory.close(); }
99
+ }
100
+ async function call(method, input, milliseconds) {
101
+ const controller = new AbortController(); let timer;
102
+ try {
103
+ return await Promise.race([Promise.resolve().then(() => method({ ...input, signal: controller.signal })),
104
+ new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('ADAPTER_TIMEOUT')); }, milliseconds); })]);
105
+ } finally { clearTimeout(timer); }
106
+ }
107
+ function verified(completion, job, entry) {
108
+ return completion && completion.completionVersion === 1 && completion.completionVerified === true
109
+ && completion.status === 'ok' && completion.exitCode === 0 && completion.assertionsVerified === true
110
+ && completion.evidenceComplete === true && completion.truncated === false
111
+ && completion.jobId === job.id && completion.modelRevision === job.model.revision
112
+ && completion.modelRepo === job.model.repo && completion.runId === entry.runId
113
+ && completion.idempotencyKey === entry.idempotencyKey;
114
+ }
115
+
116
+ export async function readLoopStatus({ configPath }) { return load(await readLoopConfig({ configPath })); }
117
+
118
+ export async function tickLoop({ configPath, adapter, now = Date.now() }) {
119
+ fail(Number.isSafeInteger(now) && now >= 0, 'Invalid clock');
120
+ fail(adapter && ['submit', 'inspect', 'cleanup'].every(method => typeof adapter[method] === 'function'), 'Host adapter required');
121
+ const config = await readLoopConfig({ configPath }), lock = join(config.stateRoot, 'tick.lock');
122
+ await mkdir(lock, { mode: 0o700 }); // Never break an existing lock: operator reconciliation is required after a crash.
123
+ try {
124
+ const state = await load(config);
125
+ const deadlineDue = state.jobs.some(entry => entry.status === 'running'
126
+ && now >= entry.startedAt + config.jobs.find(job => job.id === entry.id).timeoutSeconds * 1000);
127
+ if (state.jobs.some(job => job.status === 'blocked') || (now < state.nextTickAt && !deadlineDue)) return state;
128
+ state.nextTickAt = now + config.intervalSeconds * 1000;
129
+ const entry = state.jobs.find(job => ['submitting', 'running', 'cleanup'].includes(job.status))
130
+ ?? state.jobs.find(job => job.status === 'queued');
131
+ if (!entry) { await persist(config, state); return state; }
132
+ const job = config.jobs.find(job => job.id === entry.id);
133
+ if (entry.status === 'submitting') { entry.status = 'blocked'; entry.reason = 'ambiguous_submission_reconcile'; }
134
+ else if (entry.status === 'queued') {
135
+ const reserved = Math.round(state.reservedUsd * 1e6) + Math.round(job.maxCostUsd * 1e6);
136
+ if (entry.attempts >= config.maxAttempts || reserved > Math.round(config.budgetUsd * 1e6)) {
137
+ entry.status = 'failed'; entry.reason = 'attempt_or_budget_limit';
138
+ } else {
139
+ entry.attempts++; state.reservedUsd = reserved / 1e6;
140
+ for (const key of ['runId', 'cleanupConfirmed', 'targetState', 'retryable', 'completionSha256']) delete entry[key];
141
+ entry.status = 'submitting'; entry.idempotencyKey = randomUUID(); entry.startedAt = now;
142
+ await persist(config, state); // Persist intent and reservation before the provider effect.
143
+ try {
144
+ const submitted = await call(adapter.submit.bind(adapter), { job, idempotencyKey: entry.idempotencyKey }, Math.min(30000, job.timeoutSeconds * 1000));
145
+ fail(submitted && id(submitted.runId), 'Invalid provider run ID');
146
+ entry.runId = submitted.runId; entry.status = 'running'; entry.reason = 'submitted';
147
+ } catch { entry.status = 'blocked'; entry.reason = 'ambiguous_submission_reconcile'; }
148
+ }
149
+ } else if (entry.status === 'running') {
150
+ let result;
151
+ if (now >= entry.startedAt + job.timeoutSeconds * 1000) {
152
+ entry.status = 'cleanup'; entry.targetState = 'failed'; entry.retryable = false; entry.reason = 'job_deadline';
153
+ } else {
154
+ try {
155
+ result = await call(adapter.inspect.bind(adapter), { job, runId: entry.runId, idempotencyKey: entry.idempotencyKey }, Math.min(30000, entry.startedAt + job.timeoutSeconds * 1000 - now));
156
+ fail(result && ['running', 'completed', 'failed'].includes(result.status), 'Invalid provider inspection');
157
+ if (result.costUsd !== undefined) fail(amount(result.costUsd) && result.costUsd <= job.maxCostUsd, 'Provider cost breach');
158
+ if (result.status !== 'running') {
159
+ entry.status = 'cleanup'; entry.targetState = result.status === 'completed' && verified(result.completion, job, entry) ? 'succeeded' : 'failed';
160
+ entry.retryable = result.status === 'failed'; entry.reason = entry.targetState === 'succeeded' ? 'verified_completion' : result.status === 'failed' ? 'provider_failed' : 'unverified_completion';
161
+ if (entry.targetState === 'succeeded') entry.completionSha256 = sha(JSON.stringify(result.completion));
162
+ }
163
+ } catch { entry.status = 'cleanup'; entry.targetState = 'failed'; entry.retryable = false; entry.reason = 'inspection_failed_or_invalid'; }
164
+ }
165
+ } else if (entry.status === 'cleanup') {
166
+ await persist(config, state); // Cleanup intent is retained until resource absence is confirmed by the host.
167
+ try {
168
+ const result = await call(adapter.cleanup.bind(adapter), { job, runId: entry.runId }, 30000);
169
+ if (result?.released === true) {
170
+ entry.history ??= [];
171
+ entry.history.push({ attempt: entry.attempts, runId: entry.runId, idempotencyKey: entry.idempotencyKey,
172
+ outcome: entry.targetState, reason: entry.reason, cleanupConfirmed: true,
173
+ ...(entry.completionSha256 ? { completionSha256: entry.completionSha256 } : {}) });
174
+ entry.status = entry.targetState;
175
+ if (entry.status === 'failed' && entry.retryable && entry.attempts < config.maxAttempts) entry.status = 'queued';
176
+ entry.cleanupConfirmed = true;
177
+ } else entry.reason = 'cleanup_unconfirmed';
178
+ } catch { entry.reason = 'cleanup_unconfirmed'; }
179
+ }
180
+ if (entry.status === 'running') state.nextTickAt = Math.min(state.nextTickAt, entry.startedAt + job.timeoutSeconds * 1000);
181
+ if (entry.status === 'cleanup' && entry.reason !== 'cleanup_unconfirmed') state.nextTickAt = now;
182
+ await persist(config, state); return state;
183
+ } finally { await rm(lock, { recursive: true }); }
184
+ }
@@ -0,0 +1,80 @@
1
+ // Optional host handoff: writes job requests, never contacts a GPU provider or resolves secrets.
2
+ import { constants } from 'node:fs';
3
+ import { open, lstat, realpath, writeFile } from 'node:fs/promises';
4
+ import { join, resolve, isAbsolute } from 'node:path';
5
+ import { parseJson } from './engine.mjs';
6
+
7
+ const ensure = (test, message) => { if (!test) throw new Error(message); };
8
+ const safeId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,159}$/.test(value);
9
+
10
+ async function spoolRoot() {
11
+ const root = process.env.RUVLLM_GPU_SPOOL_ROOT;
12
+ ensure(typeof root === 'string' && isAbsolute(root) && resolve(root) === root, 'SPOOL_NOT_CONFIGURED');
13
+ ensure(await realpath(root) === root, 'SPOOL_PATH_NOT_CANONICAL');
14
+ const stat = await lstat(root);
15
+ ensure(stat.isDirectory() && !stat.isSymbolicLink() && (stat.mode & 0o077) === 0
16
+ && stat.uid === process.getuid(), 'SPOOL_NOT_PRIVATE');
17
+ return root;
18
+ }
19
+
20
+ async function writeOnce(path, value) {
21
+ const text = JSON.stringify(value, null, 2) + '\n';
22
+ ensure(Buffer.byteLength(text) <= 65536, 'SPOOL_REQUEST_TOO_LARGE');
23
+ try { await writeFile(path, text, { flag: 'wx', mode: 0o600 }); }
24
+ catch (error) {
25
+ if (error.code !== 'EEXIST') throw new Error('SPOOL_WRITE_FAILED');
26
+ const prior = await readReply(path);
27
+ ensure(JSON.stringify(prior) === JSON.stringify(value), 'SPOOL_IDENTITY_CONFLICT');
28
+ }
29
+ }
30
+
31
+ async function readReply(path) {
32
+ let handle;
33
+ try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
34
+ catch (error) { if (error.code === 'ENOENT') return null; throw new Error('SPOOL_READ_FAILED'); }
35
+ try {
36
+ const stat = await handle.stat();
37
+ ensure(stat.isFile() && stat.size <= 65536 && (stat.mode & 0o077) === 0
38
+ && stat.uid === process.getuid(), 'SPOOL_REPLY_NOT_PRIVATE');
39
+ const bytes = Buffer.alloc(stat.size);
40
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
41
+ ensure(bytesRead === bytes.length, 'SPOOL_REPLY_CHANGED');
42
+ return parseJson(bytes);
43
+ } finally { await handle.close(); }
44
+ }
45
+
46
+ export async function submit({ job, idempotencyKey, signal }) {
47
+ ensure(!signal?.aborted && safeId(idempotencyKey), 'SPOOL_INVALID_REQUEST');
48
+ const root = await spoolRoot();
49
+ await writeOnce(join(root, `${idempotencyKey}.request.json`), {
50
+ schema: 'ruvllm-gpu-spool-request/v1', runId: idempotencyKey, job,
51
+ credentialHandling: 'host-resolves-secretRef-no-secret-values-in-spool',
52
+ });
53
+ return { runId: idempotencyKey };
54
+ }
55
+
56
+ export async function inspect({ runId, signal }) {
57
+ ensure(!signal?.aborted && safeId(runId), 'SPOOL_INVALID_RUN_ID');
58
+ const root = await spoolRoot();
59
+ const reply = await readReply(join(root, `${runId}.status.json`));
60
+ if (!reply) return { status: 'running' };
61
+ ensure(reply.schema === 'ruvllm-gpu-spool-status/v1' && reply.runId === runId
62
+ && ['running', 'completed', 'failed'].includes(reply.status)
63
+ && Object.keys(reply).every(key => ['schema', 'runId', 'status', 'costUsd', 'completion'].includes(key)), 'SPOOL_INVALID_STATUS');
64
+ return { status: reply.status, ...(reply.costUsd === undefined ? {} : { costUsd: reply.costUsd }),
65
+ ...(reply.completion === undefined ? {} : { completion: reply.completion }) };
66
+ }
67
+
68
+ export async function cleanup({ runId, job, signal }) {
69
+ ensure(!signal?.aborted && safeId(runId), 'SPOOL_INVALID_RUN_ID');
70
+ const root = await spoolRoot();
71
+ await writeOnce(join(root, `${runId}.cleanup-request.json`), {
72
+ schema: 'ruvllm-gpu-spool-cleanup-request/v1', runId, jobId: job.id,
73
+ });
74
+ const reply = await readReply(join(root, `${runId}.cleanup.json`));
75
+ if (!reply) return { released: false };
76
+ ensure(reply.schema === 'ruvllm-gpu-spool-cleanup/v1' && reply.runId === runId
77
+ && typeof reply.released === 'boolean'
78
+ && Object.keys(reply).every(key => ['schema', 'runId', 'released'].includes(key)), 'SPOOL_INVALID_CLEANUP');
79
+ return { released: reply.released };
80
+ }
@@ -0,0 +1,274 @@
1
+ // Vast.ai host adapter for the scheduled GPU loop (ADR-015, ADR-017). Trusted
2
+ // operator code: it resolves the job's secretRef from an owner-only host config,
3
+ // rents exactly the configured offer, never reuses a shared instance, and only
4
+ // reports a verified completion after re-scoring the retrieved raw outputs here.
5
+ // No effect happens at import; `node vast-host-adapter.mjs guard ID REF` is the
6
+ // independent destroy timer armed for every rental.
7
+ import { spawn } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+ import { constants, createWriteStream } from 'node:fs';
10
+ import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
11
+ import { isDeepStrictEqual } from 'node:util';
12
+ import { isAbsolute, join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ export const HOST_CONFIG_ENV = 'RUVLLM_VAST_HOST_CONFIG';
16
+ const SELF = fileURLToPath(import.meta.url);
17
+ const INSPECT_BUDGET_MS = 24000;
18
+ const fail = code => { const error = new Error(code); error.code = code; throw error; };
19
+ const need = (ok, code) => { if (!ok) fail(code); };
20
+ const abs = value => typeof value === 'string' && isAbsolute(value) && resolve(value) === value;
21
+ const ceilMicro = usd => Math.ceil(usd * 1e6) / 1e6;
22
+
23
+ async function privateJson(path) {
24
+ need(abs(path), 'VAST_HOST_CONFIG_PATH');
25
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ try {
27
+ const info = await handle.stat();
28
+ need(info.isFile() && (info.mode & 0o077) === 0 && info.uid === process.getuid() && info.size <= 65536, 'VAST_HOST_CONFIG_UNSAFE');
29
+ const bytes = await handle.readFile();
30
+ return { value: JSON.parse(bytes), sha256: createHash('sha256').update(bytes).digest('hex') };
31
+ } finally { await handle.close(); }
32
+ }
33
+
34
+ export async function readHostConfig(path = process.env[HOST_CONFIG_ENV]) {
35
+ const { value: config, sha256 } = await privateJson(path);
36
+ need(config?.schema === 'ruvllm-vast-host/v1', 'VAST_HOST_CONFIG_SCHEMA');
37
+ need(/^https:\/\/[a-z0-9.-]+$/.test(config.apiBase), 'VAST_HOST_CONFIG_API');
38
+ need(/^[a-z0-9./_-]+@sha256:[a-f0-9]{64}$/.test(config.image), 'VAST_HOST_CONFIG_IMAGE');
39
+ need(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/.test(config.repoUrl) && /^[a-f0-9]{40}$/.test(config.commit), 'VAST_HOST_CONFIG_SOURCE');
40
+ need(/^v[0-9]+$/.test(config.bundle), 'VAST_HOST_CONFIG_BUNDLE');
41
+ need(Number.isInteger(config.diskGb) && config.diskGb >= 20 && config.diskGb <= 200, 'VAST_HOST_CONFIG_DISK');
42
+ need(Number.isInteger(config.armDeadlineSeconds) && config.armDeadlineSeconds >= 60 && config.armDeadlineSeconds <= 43200, 'VAST_HOST_CONFIG_DEADLINE');
43
+ need(Number.isInteger(config.guardGraceSeconds) && config.guardGraceSeconds >= 60 && config.guardGraceSeconds <= 3600, 'VAST_HOST_CONFIG_GUARD');
44
+ need(['resultsRoot', 'sshKey', 'repoRoot', 'python', 'node'].every(key => abs(config[key])), 'VAST_HOST_CONFIG_PATHS');
45
+ need(config.adapterDir === undefined || (/^[a-z0-9][a-z0-9._-]{0,63}$/.test(config.adapterDir) && !config.adapterDir.includes('..')), 'VAST_HOST_CONFIG_ADAPTER');
46
+ need(config.trainPayload === undefined || /^experiments\/model-improvement\/training\/[a-z0-9][a-z0-9._-]{0,63}$/.test(config.trainPayload), 'VAST_HOST_CONFIG_PAYLOAD');
47
+ need(config.secrets && typeof config.secrets === 'object' && Object.values(config.secrets).every(entry => abs(entry?.file)), 'VAST_HOST_CONFIG_SECRETS');
48
+ return Object.freeze({ ...config, sha256 });
49
+ }
50
+
51
+ async function apiKey(config, ref) {
52
+ const entry = Object.hasOwn(config.secrets, ref) ? config.secrets[ref] : null;
53
+ need(entry, 'VAST_SECRET_REF_UNKNOWN');
54
+ const { mode, uid } = await stat(entry.file);
55
+ need((mode & 0o077) === 0 && uid === process.getuid(), 'VAST_SECRET_FILE_UNSAFE');
56
+ const key = (await readFile(entry.file, 'utf8')).trim();
57
+ need(/^[A-Za-z0-9_-]{16,256}$/.test(key), 'VAST_SECRET_INVALID');
58
+ return key;
59
+ }
60
+
61
+ // Bounded child process without a shell. Output is collected, never echoed.
62
+ function defaultRun(command, args, { timeoutMs, cwd, stdoutFile, input } = {}) {
63
+ return new Promise(resolvePromise => {
64
+ const child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
65
+ const chunks = [], errors = [];
66
+ let size = 0;
67
+ const sink = stdoutFile ? createWriteStream(stdoutFile, { flags: 'wx', mode: 0o600 }) : null;
68
+ const flushed = sink ? new Promise(done => { sink.on('close', done); sink.on('error', done); }) : Promise.resolve();
69
+ const timer = setTimeout(() => child.kill('SIGKILL'), Math.max(1, timeoutMs ?? 10000));
70
+ if (sink) child.stdout.pipe(sink);
71
+ else child.stdout.on('data', chunk => { size += chunk.length; if (size <= 8 << 20) chunks.push(chunk); });
72
+ child.stderr.on('data', chunk => { if (errors.length < 64) errors.push(chunk); });
73
+ child.on('error', () => {});
74
+ child.on('close', async code => {
75
+ clearTimeout(timer);
76
+ await flushed;
77
+ resolvePromise({ code, stdout: Buffer.concat(chunks).toString('utf8'), stderr: Buffer.concat(errors).toString('utf8') });
78
+ });
79
+ child.stdin.end(input ?? '');
80
+ });
81
+ }
82
+
83
+ async function writePrivate(path, value) {
84
+ await writeFile(path, typeof value === 'string' ? value : JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
85
+ }
86
+
87
+ export function onstartScript(config, task = 'evaluate') {
88
+ const scripts = '/workspace/ruvllm/experiments/model-improvement/vast';
89
+ if (task === 'train') need(config.trainPayload, 'VAST_HOST_CONFIG_PAYLOAD');
90
+ const run = task === 'train'
91
+ ? `${scripts}/remote-train.sh ${config.commit} ${config.armDeadlineSeconds} ${config.trainPayload}`
92
+ : `${scripts}/remote-run.sh ${config.commit} ${config.armDeadlineSeconds} ${config.bundle} ${config.adapterDir ?? 'coding-4b'}`;
93
+ const failed = `grep -q '"failed"' /workspace/run/status.json 2>/dev/null || printf '{"schema":"ruvllm-vast-remote-status/v1","state":"failed","step":"bootstrap"}\\n' > /workspace/run/status.json`;
94
+ return ['#!/bin/bash', 'mkdir -p /workspace/run',
95
+ `nohup bash -c '(export DEBIAN_FRONTEND=noninteractive; (command -v git >/dev/null || (apt-get update -qq && apt-get install -y -qq git >/dev/null)) && git clone --quiet ${config.repoUrl} /workspace/ruvllm && git -C /workspace/ruvllm checkout --quiet ${config.commit} && bash ${run}) >/workspace/run/remote.log 2>&1 || { ${failed.replaceAll("'", "'\\''")}; }' >/dev/null 2>&1 &`, ''].join('\n');
96
+ }
97
+
98
+ export function createVastAdapter({ fetchImpl = globalThis.fetch, run = defaultRun, now = () => Date.now(), readConfig = readHostConfig } = {}) {
99
+ async function api(config, key, method, path, { body, query, signal } = {}) {
100
+ const url = new URL(config.apiBase + path);
101
+ for (const [name, value] of Object.entries(query ?? {})) url.searchParams.set(name, JSON.stringify(value));
102
+ const response = await fetchImpl(url, { method, signal, headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
103
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
104
+ if (response.status === 404) return null;
105
+ need(response.ok, `VAST_HTTP_${response.status}`);
106
+ return response.json();
107
+ }
108
+ async function instances(config, key, signal) {
109
+ const rows = [];
110
+ let after;
111
+ for (let page = 0; page < 40; page++) {
112
+ const data = await api(config, key, 'GET', '/api/v1/instances/', { signal,
113
+ query: { select_filters: {}, order_by: [{ col: 'id', dir: 'asc' }], limit: 25, ...(after ? { after_token: after } : {}) } });
114
+ rows.push(...(data?.instances ?? []));
115
+ if (!data?.next_token) return rows;
116
+ after = data.next_token;
117
+ }
118
+ fail('VAST_LIST_UNBOUNDED');
119
+ }
120
+ const attemptDir = (config, key) => join(config.resultsRoot, key);
121
+ const ssh = (config, instance, remote) => ['-i', config.sshKey, '-p', String(instance.ssh_port), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
122
+ '-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${join(config.resultsRoot, `known_hosts-${instance.id}`)}`, '-o', 'IdentitiesOnly=yes',
123
+ `root@${instance.ssh_host}`, remote];
124
+
125
+ async function submit({ job, idempotencyKey, signal }) {
126
+ const config = await readConfig(), key = await apiKey(config, job.secretRef);
127
+ need(typeof job.resource.offerId === 'string' && /^[0-9]{1,12}$/.test(job.resource.offerId), 'VAST_OFFER_REQUIRED');
128
+ const label = `ruvllm-${idempotencyKey}`;
129
+ const existing = (await instances(config, key, signal)).find(row => row.label === label);
130
+ if (existing) return { runId: String(existing.id) };
131
+ // The bundles search ignores `id`; `ask_contract_id` is the offer identity filter.
132
+ const offers = (await api(config, key, 'POST', '/api/v0/bundles/', { signal, body: { ask_contract_id: { eq: Number(job.resource.offerId) },
133
+ rentable: { eq: true }, type: 'on-demand', allocated_storage: config.diskGb, limit: 2 } }))?.offers ?? [];
134
+ need(offers.length === 1 && String(offers[0].id) === job.resource.offerId
135
+ && Number.isFinite(offers[0].dph_total) && offers[0].dph_total > 0, 'VAST_OFFER_UNAVAILABLE');
136
+ const offer = offers[0];
137
+ // Spending ceiling: the worst case (full timeout at the offer's total hourly
138
+ // price, storage included) must fit the job's reserved ceiling before renting.
139
+ const worstCaseUsd = ceilMicro(offer.dph_total * job.timeoutSeconds / 3600);
140
+ need(worstCaseUsd <= job.maxCostUsd, 'VAST_COST_CEILING');
141
+ await mkdir(attemptDir(config, idempotencyKey), { mode: 0o700 });
142
+ const created = await api(config, key, 'PUT', `/api/v0/asks/${offer.id}/`, { signal, body: { client_id: 'me', image: config.image,
143
+ env: {}, price: null, disk: config.diskGb, label, onstart: onstartScript(config, job.task), runtype: 'ssh', force: false, cancel_unavail: true } });
144
+ need(created?.success === true && Number.isSafeInteger(created.new_contract), 'VAST_CREATE_FAILED');
145
+ const instanceId = created.new_contract;
146
+ // Independent of this scheduler: a user timer destroys the rental after
147
+ // timeout + grace even if no further tick ever runs.
148
+ const guard = await run('systemd-run', ['--user', '--quiet', `--unit=ruvllm-vast-guard-${instanceId}`,
149
+ `--on-active=${job.timeoutSeconds + config.guardGraceSeconds}s`, `--setenv=${HOST_CONFIG_ENV}=${process.env[HOST_CONFIG_ENV] ?? ''}`,
150
+ config.node, SELF, 'guard', String(instanceId), job.secretRef], { timeoutMs: 8000 });
151
+ await writePrivate(join(attemptDir(config, idempotencyKey), 'submission.json'), { schema: 'ruvllm-vast-submission/v1',
152
+ jobId: job.id, idempotencyKey, label, instanceId, offerId: offer.id, gpuName: offer.gpu_name, dphTotal: offer.dph_total,
153
+ worstCaseUsd, maxCostUsd: job.maxCostUsd, timeoutSeconds: job.timeoutSeconds, image: config.image, commit: config.commit,
154
+ task: job.task, bundle: config.bundle, adapterDir: config.adapterDir ?? 'coding-4b', trainPayload: config.trainPayload ?? null, hostConfigSha256: config.sha256, guardArmed: guard.code === 0, submittedAt: new Date(now()).toISOString() });
155
+ if (guard.code !== 0) {
156
+ await api(config, key, 'DELETE', `/api/v0/instances/${instanceId}/`, { body: {} });
157
+ fail('VAST_GUARD_UNAVAILABLE');
158
+ }
159
+ return { runId: String(instanceId) };
160
+ }
161
+
162
+ async function retrieve(config, instance, dir, started) {
163
+ const left = () => INSPECT_BUDGET_MS - (now() - started);
164
+ const archive = join(dir, 'evidence.tar');
165
+ await rm(archive, { force: true }); await rm(join(dir, 'evidence'), { recursive: true, force: true });
166
+ if (left() < 4000) return false;
167
+ const pulled = await run('ssh', ssh(config, instance, 'tar -C /workspace -cf - out run'), { timeoutMs: left() - 3000, stdoutFile: archive });
168
+ if (pulled.code !== 0) return false;
169
+ await mkdir(join(dir, 'evidence'), { mode: 0o700 });
170
+ return (await run('tar', ['-xf', archive, '-C', join(dir, 'evidence'), '--no-same-owner', '--no-same-permissions'], { timeoutMs: 5000 })).code === 0;
171
+ }
172
+
173
+ async function verify(config, job, dir) {
174
+ const evidence = join(dir, 'evidence', 'out'), scored = join(dir, 'local-scored');
175
+ await rm(scored, { recursive: true, force: true });
176
+ const bundle = join(config.repoRoot, 'experiments/model-improvement/frozen', config.bundle);
177
+ const result = await run(config.python, [join(config.repoRoot, 'experiments/model-improvement/measurement.py'), '--bundle', bundle,
178
+ '--base-run', join(evidence, 'base'), '--adapter-run', join(evidence, 'adapter'), '--out', scored], { timeoutMs: 15000, cwd: config.repoRoot });
179
+ if (result.code !== 0) return { ok: false, reason: 'local_rescore_failed' };
180
+ const local = JSON.parse(await readFile(join(scored, 'report.json'), 'utf8'));
181
+ const remote = JSON.parse(await readFile(join(evidence, 'scored', 'report.json'), 'utf8'));
182
+ const ok = isDeepStrictEqual(local, remote) && local.evidence_mode === 'gpu-inference';
183
+ await writePrivate(join(dir, 'verification.json'), { schema: 'ruvllm-vast-verification/v1', jobId: job.id,
184
+ localRescoreMatchesRemote: isDeepStrictEqual(local, remote), evidenceMode: local.evidence_mode,
185
+ researchImprovementConfirmed: local.research_improvement_confirmed, reasons: local.reasons });
186
+ return { ok, reason: ok ? 'verified' : 'report_mismatch' };
187
+ }
188
+
189
+ // Training evidence: the trainer's own manifest must hash every retrieved file,
190
+ // bind the committed payload, and match the completed event in the event log.
191
+ async function verifyTraining(config, job, dir) {
192
+ const out = join(dir, 'evidence', 'out', 'training-v1');
193
+ const sha = async path => createHash('sha256').update(await readFile(path)).digest('hex');
194
+ const checks = {};
195
+ try {
196
+ const bridge = JSON.parse(await readFile(join(config.repoRoot, config.trainPayload, 'bridge.json'), 'utf8'));
197
+ const manifestBytes = await readFile(join(out, 'manifest.json'));
198
+ const manifest = JSON.parse(manifestBytes);
199
+ const events = (await readFile(join(dir, 'evidence', 'run', 'train.log'), 'utf8')).split('\n')
200
+ .filter(line => line.startsWith('RUFLO_TRAIN ')).map(line => JSON.parse(line.slice(12)));
201
+ const completed = events.find(event => event.kind === 'completed');
202
+ checks.payloadBound = manifest.training_sha256 === bridge.training_sha256;
203
+ checks.manifestEvent = completed?.data?.manifest_sha256 === createHash('sha256').update(manifestBytes).digest('hex');
204
+ checks.steps = manifest.steps === 96 && manifest.revision === job.model.revision && manifest.model === job.model.repo;
205
+ const files = Object.entries(manifest.files ?? {});
206
+ checks.files = files.length >= 6 && (await Promise.all(files.map(async ([name, digest]) =>
207
+ !name.split('/').includes('..') && await sha(join(out, name)) === digest))).every(Boolean);
208
+ checks.validation = events.filter(event => event.kind === 'validation').map(event => event.data);
209
+ } catch (error) { checks.error = error.code ?? error.name; }
210
+ const ok = Boolean(checks.payloadBound && checks.manifestEvent && checks.steps && checks.files && !checks.error);
211
+ await writePrivate(join(dir, 'verification.json'), { schema: 'ruvllm-vast-training-verification/v1', jobId: job.id, ...checks, ok });
212
+ return { ok };
213
+ }
214
+
215
+ async function inspect({ job, runId, idempotencyKey, signal }) {
216
+ const started = now(), config = await readConfig(), key = await apiKey(config, job.secretRef);
217
+ const instance = (await instances(config, key, signal)).find(row => String(row.id) === runId);
218
+ if (!instance) return { status: 'failed' };
219
+ const costUsd = ceilMicro(instance.dph_total * Math.max(0, now() / 1000 - (instance.start_date ?? now() / 1000)) / 3600);
220
+ if (costUsd > job.maxCostUsd) return { status: 'failed', costUsd };
221
+ // A host whose container runtime cannot start the image never recovers; fail now instead of at the deadline.
222
+ if (instance.actual_status !== 'running' && /error response from daemon|oci runtime|invalid runtime/i.test(instance.status_msg ?? '')) return { status: 'failed', costUsd };
223
+ if (instance.actual_status !== 'running' || !instance.ssh_host || !instance.ssh_port) return { status: 'running', costUsd };
224
+ const dir = attemptDir(config, idempotencyKey);
225
+ const probe = await run('ssh', ssh(config, instance, `cat /workspace/run/status.json && echo && { tail -c 400 /workspace/run/${job.task === 'train' ? 'train' : 'run-pair'}.log 2>/dev/null || true; }`), { timeoutMs: 10000 });
226
+ if (probe.code !== 0) return { status: 'running', costUsd };
227
+ let remote;
228
+ try { remote = JSON.parse(probe.stdout.split('\n')[0]); } catch { return { status: 'running', costUsd }; }
229
+ await writePrivate(join(dir, 'progress.json'), { at: new Date(now()).toISOString(), costUsd, remote, tail: probe.stdout.split('\n').slice(1).join('\n').slice(-400) });
230
+ if (remote.state === 'running') return { status: 'running', costUsd };
231
+ if (remote.state === 'failed') { await retrieve(config, instance, dir, started); return { status: 'failed', costUsd }; }
232
+ if (remote.state !== 'done') return { status: 'running', costUsd };
233
+ // Retain raw outputs locally before any terminal report; retry next tick on a slow pull.
234
+ if (!await retrieve(config, instance, dir, started)) return { status: 'running', costUsd };
235
+ const checked = job.task === 'train' ? await verifyTraining(config, job, dir) : await verify(config, job, dir);
236
+ return { status: 'completed', costUsd, completion: { completionVersion: 1, completionVerified: checked.ok, status: checked.ok ? 'ok' : 'error',
237
+ exitCode: checked.ok ? 0 : 1, assertionsVerified: checked.ok, evidenceComplete: checked.ok, truncated: false, jobId: job.id,
238
+ modelRepo: job.model.repo, modelRevision: job.model.revision, runId, idempotencyKey } };
239
+ }
240
+
241
+ async function destroyConfirmed(config, key, instanceId, signal) {
242
+ await api(config, key, 'DELETE', `/api/v0/instances/${instanceId}/`, { body: {}, signal });
243
+ for (let check = 0; check < 6; check++) {
244
+ if (!(await instances(config, key, signal)).some(row => String(row.id) === String(instanceId))) return true;
245
+ await new Promise(done => setTimeout(done, 2500));
246
+ }
247
+ return false;
248
+ }
249
+
250
+ async function cleanup({ job, runId, signal }) {
251
+ const config = await readConfig(), key = await apiKey(config, job.secretRef);
252
+ const released = await destroyConfirmed(config, key, runId, signal);
253
+ if (released) await run('systemctl', ['--user', 'stop', `ruvllm-vast-guard-${runId}.timer`], { timeoutMs: 5000 });
254
+ await writePrivate(join(config.resultsRoot, `cleanup-${runId}.json`), { schema: 'ruvllm-vast-cleanup/v1', jobId: job.id,
255
+ instanceId: runId, confirmedAbsent: released, at: new Date(now()).toISOString() });
256
+ return { released };
257
+ }
258
+
259
+ return { submit, inspect, cleanup, destroyConfirmed, instances };
260
+ }
261
+
262
+ const defaults = createVastAdapter();
263
+ export const submit = input => defaults.submit(input);
264
+ export const inspect = input => defaults.inspect(input);
265
+ export const cleanup = input => defaults.cleanup(input);
266
+
267
+ if (process.argv[1] === SELF && process.argv[2] === 'guard') {
268
+ const [instanceId, ref] = process.argv.slice(3);
269
+ const config = await readHostConfig();
270
+ const released = await defaults.destroyConfirmed(config, await apiKey(config, ref), instanceId);
271
+ await writePrivate(join(config.resultsRoot, `guard-${instanceId}.json`), { schema: 'ruvllm-vast-guard/v1', instanceId,
272
+ confirmedAbsent: released, at: new Date().toISOString() });
273
+ process.exitCode = released ? 0 : 1;
274
+ }