@ruvector/ruvllm 3.0.0 → 4.0.1

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.
Files changed (151) hide show
  1. package/GPU-LOOP.md +229 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +9 -0
  4. package/README.md +159 -360
  5. package/examples/gpu-loop.example.json +26 -0
  6. package/package.json +50 -121
  7. package/src/adapters-cli.mjs +23 -0
  8. package/src/adapters.mjs +91 -0
  9. package/src/cli.mjs +30 -0
  10. package/src/engine.mjs +238 -0
  11. package/src/fixture.mjs +38 -0
  12. package/src/gpu-host.mjs +47 -0
  13. package/src/gpu-loop-cli.mjs +67 -0
  14. package/src/gpu-loop.mjs +184 -0
  15. package/src/gpu-spool-adapter.mjs +80 -0
  16. package/src/vast-host-adapter.mjs +274 -0
  17. package/bin/cli.js +0 -1040
  18. package/dist/cjs/benchmarks/embedding-benchmark.d.ts +0 -102
  19. package/dist/cjs/benchmarks/embedding-benchmark.d.ts.map +0 -1
  20. package/dist/cjs/benchmarks/embedding-benchmark.js +0 -436
  21. package/dist/cjs/benchmarks/index.d.ts +0 -34
  22. package/dist/cjs/benchmarks/index.d.ts.map +0 -1
  23. package/dist/cjs/benchmarks/index.js +0 -132
  24. package/dist/cjs/benchmarks/model-comparison.d.ts +0 -71
  25. package/dist/cjs/benchmarks/model-comparison.d.ts.map +0 -1
  26. package/dist/cjs/benchmarks/model-comparison.js +0 -476
  27. package/dist/cjs/benchmarks/routing-benchmark.d.ts +0 -70
  28. package/dist/cjs/benchmarks/routing-benchmark.d.ts.map +0 -1
  29. package/dist/cjs/benchmarks/routing-benchmark.js +0 -289
  30. package/dist/cjs/contrastive.d.ts +0 -229
  31. package/dist/cjs/contrastive.d.ts.map +0 -1
  32. package/dist/cjs/contrastive.js +0 -589
  33. package/dist/cjs/engine.d.ts +0 -160
  34. package/dist/cjs/engine.d.ts.map +0 -1
  35. package/dist/cjs/engine.js +0 -552
  36. package/dist/cjs/export.d.ts +0 -182
  37. package/dist/cjs/export.d.ts.map +0 -1
  38. package/dist/cjs/export.js +0 -422
  39. package/dist/cjs/federated.d.ts +0 -233
  40. package/dist/cjs/federated.d.ts.map +0 -1
  41. package/dist/cjs/federated.js +0 -525
  42. package/dist/cjs/index.d.ts +0 -68
  43. package/dist/cjs/index.d.ts.map +0 -1
  44. package/dist/cjs/index.js +0 -104
  45. package/dist/cjs/intelligence.d.ts +0 -136
  46. package/dist/cjs/intelligence.d.ts.map +0 -1
  47. package/dist/cjs/intelligence.js +0 -226
  48. package/dist/cjs/lora.d.ts +0 -251
  49. package/dist/cjs/lora.d.ts.map +0 -1
  50. package/dist/cjs/lora.js +0 -509
  51. package/dist/cjs/models.d.ts +0 -129
  52. package/dist/cjs/models.d.ts.map +0 -1
  53. package/dist/cjs/models.js +0 -323
  54. package/dist/cjs/native.d.ts +0 -140
  55. package/dist/cjs/native.d.ts.map +0 -1
  56. package/dist/cjs/native.js +0 -98
  57. package/dist/cjs/session.d.ts +0 -80
  58. package/dist/cjs/session.d.ts.map +0 -1
  59. package/dist/cjs/session.js +0 -203
  60. package/dist/cjs/simd.d.ts +0 -90
  61. package/dist/cjs/simd.d.ts.map +0 -1
  62. package/dist/cjs/simd.js +0 -209
  63. package/dist/cjs/sona.d.ts +0 -223
  64. package/dist/cjs/sona.d.ts.map +0 -1
  65. package/dist/cjs/sona.js +0 -562
  66. package/dist/cjs/streaming.d.ts +0 -55
  67. package/dist/cjs/streaming.d.ts.map +0 -1
  68. package/dist/cjs/streaming.js +0 -131
  69. package/dist/cjs/training.d.ts +0 -312
  70. package/dist/cjs/training.d.ts.map +0 -1
  71. package/dist/cjs/training.js +0 -610
  72. package/dist/cjs/types.d.ts +0 -696
  73. package/dist/cjs/types.d.ts.map +0 -1
  74. package/dist/cjs/types.js +0 -6
  75. package/dist/esm/benchmarks/embedding-benchmark.d.ts +0 -102
  76. package/dist/esm/benchmarks/embedding-benchmark.d.ts.map +0 -1
  77. package/dist/esm/benchmarks/embedding-benchmark.js +0 -427
  78. package/dist/esm/benchmarks/index.d.ts +0 -34
  79. package/dist/esm/benchmarks/index.d.ts.map +0 -1
  80. package/dist/esm/benchmarks/index.js +0 -110
  81. package/dist/esm/benchmarks/model-comparison.d.ts +0 -71
  82. package/dist/esm/benchmarks/model-comparison.d.ts.map +0 -1
  83. package/dist/esm/benchmarks/model-comparison.js +0 -434
  84. package/dist/esm/benchmarks/routing-benchmark.d.ts +0 -70
  85. package/dist/esm/benchmarks/routing-benchmark.d.ts.map +0 -1
  86. package/dist/esm/benchmarks/routing-benchmark.js +0 -283
  87. package/dist/esm/contrastive.d.ts +0 -229
  88. package/dist/esm/contrastive.d.ts.map +0 -1
  89. package/dist/esm/contrastive.js +0 -578
  90. package/dist/esm/engine.d.ts +0 -160
  91. package/dist/esm/engine.d.ts.map +0 -1
  92. package/dist/esm/engine.js +0 -548
  93. package/dist/esm/export.d.ts +0 -182
  94. package/dist/esm/export.d.ts.map +0 -1
  95. package/dist/esm/export.js +0 -414
  96. package/dist/esm/federated.d.ts +0 -233
  97. package/dist/esm/federated.d.ts.map +0 -1
  98. package/dist/esm/federated.js +0 -520
  99. package/dist/esm/index.d.ts +0 -68
  100. package/dist/esm/index.d.ts.map +0 -1
  101. package/dist/esm/index.js +0 -84
  102. package/dist/esm/intelligence.d.ts +0 -136
  103. package/dist/esm/intelligence.d.ts.map +0 -1
  104. package/dist/esm/intelligence.js +0 -188
  105. package/dist/esm/lora.d.ts +0 -251
  106. package/dist/esm/lora.d.ts.map +0 -1
  107. package/dist/esm/lora.js +0 -504
  108. package/dist/esm/models.d.ts +0 -129
  109. package/dist/esm/models.d.ts.map +0 -1
  110. package/dist/esm/models.js +0 -282
  111. package/dist/esm/native.d.ts +0 -140
  112. package/dist/esm/native.d.ts.map +0 -1
  113. package/dist/esm/native.js +0 -93
  114. package/dist/esm/session.d.ts +0 -80
  115. package/dist/esm/session.d.ts.map +0 -1
  116. package/dist/esm/session.js +0 -199
  117. package/dist/esm/simd.d.ts +0 -90
  118. package/dist/esm/simd.d.ts.map +0 -1
  119. package/dist/esm/simd.js +0 -205
  120. package/dist/esm/sona.d.ts +0 -223
  121. package/dist/esm/sona.d.ts.map +0 -1
  122. package/dist/esm/sona.js +0 -556
  123. package/dist/esm/streaming.d.ts +0 -55
  124. package/dist/esm/streaming.d.ts.map +0 -1
  125. package/dist/esm/streaming.js +0 -126
  126. package/dist/esm/training.d.ts +0 -312
  127. package/dist/esm/training.d.ts.map +0 -1
  128. package/dist/esm/training.js +0 -603
  129. package/dist/esm/types.d.ts +0 -696
  130. package/dist/esm/types.d.ts.map +0 -1
  131. package/dist/esm/types.js +0 -5
  132. package/scripts/ensemble-model-compare.js +0 -287
  133. package/scripts/fix-esm-extensions.mjs +0 -181
  134. package/scripts/huggingface/README.md +0 -537
  135. package/scripts/huggingface/publish.sh +0 -112
  136. package/scripts/hybrid-model-compare.js +0 -373
  137. package/scripts/improved-model-compare.js +0 -288
  138. package/scripts/optimized-model-compare.js +0 -364
  139. package/scripts/real-model-compare.js +0 -280
  140. package/scripts/smoke-esm.mjs +0 -82
  141. package/scripts/training/agentic-flow-capabilities.json +0 -329
  142. package/scripts/training/claude-code-synth.js +0 -505
  143. package/scripts/training/claude-flow-capabilities.json +0 -1322
  144. package/scripts/training/claude-hard-negatives.js +0 -222
  145. package/scripts/training/contrastive-finetune.js +0 -517
  146. package/scripts/training/ecosystem-triplets.jsonl +0 -1467
  147. package/scripts/training/generate-ecosystem-triplets.js +0 -1324
  148. package/scripts/training/routing-dataset.js +0 -634
  149. package/scripts/training/ruvector-capabilities.json +0 -798
  150. package/scripts/training/validate-ecosystem.js +0 -381
  151. package/scripts/training/validation-results.json +0 -23
@@ -0,0 +1,47 @@
1
+ // The operator supplies this module locally. Secret resolution and provider APIs
2
+ // remain with that host; this loader never fetches credentials or parses stdout.
3
+ import { createHash } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { open } from 'node:fs/promises';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { checkedPath } from './engine.mjs';
8
+
9
+ function failure(code) { const error = new Error(code); error.code = code; return error; }
10
+
11
+ export async function loadHostAdapter({ modulePath, sha256 }) {
12
+ if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) throw failure('HOST_ADAPTER_INVALID_PIN');
13
+ let path, bytes;
14
+ try {
15
+ path = await checkedPath(modulePath);
16
+ if (!path.endsWith('.mjs')) throw failure('HOST_ADAPTER_INVALID_PATH');
17
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
18
+ try {
19
+ const stat = await file.stat();
20
+ if (!stat.isFile() || stat.size > 1024 * 1024 || (stat.mode & 0o077) !== 0
21
+ || typeof process.getuid !== 'function' || stat.uid !== process.getuid()) {
22
+ throw failure('HOST_ADAPTER_UNSAFE_FILE');
23
+ }
24
+ bytes = Buffer.alloc(stat.size);
25
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
26
+ if (bytesRead !== bytes.length) throw failure('HOST_ADAPTER_CHANGED');
27
+ } finally { await file.close(); }
28
+ } catch (error) {
29
+ if (error.code?.startsWith('HOST_ADAPTER_')) throw error;
30
+ throw failure('HOST_ADAPTER_INVALID_PATH');
31
+ }
32
+ if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw failure('HOST_ADAPTER_PIN_MISMATCH');
33
+ let module;
34
+ try {
35
+ // The entry point and its transitive imports are trusted operator code. The
36
+ // digest is a cache identity as well as a pin; never accept tool-supplied code.
37
+ const url = pathToFileURL(path); url.searchParams.set('sha256', sha256);
38
+ module = await import(url.href);
39
+ } catch { throw failure('HOST_ADAPTER_UNAVAILABLE'); }
40
+ if (!['submit', 'inspect', 'cleanup'].every(name => typeof module[name] === 'function')) {
41
+ throw failure('HOST_ADAPTER_INVALID_CONTRACT');
42
+ }
43
+ return Object.freeze(Object.fromEntries(['submit', 'inspect', 'cleanup'].map(name => [name, async input => {
44
+ try { return await module[name](input); }
45
+ catch { throw failure('HOST_ADAPTER_OPERATION_FAILED'); }
46
+ }])));
47
+ }
@@ -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
+ }