companion-runtime-manager-unit.test.mjs file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
1 /**
2 * Tier 1 β€” UNIT: lib/companion-runtime-manager.mjs
3 *
4 * Smallest behavioural contracts for each exported function in total isolation β€”
5 * no network, no env, no child_process, no filesystem. Each section exercises one
6 * exported function (or one logical control within it) so a failure pinpoints exactly
7 * which invariant broke.
8 *
9 * Reference: docs/COMPANION-APP-PHASE-4-RUNTIME-MANAGER.md (module contract),
10 * docs/COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md Β§4 (loopback controls),
11 * docs/COMPANION-APP-PHASE-1-ADAPTER-SEAM.md Β§1.1 (companionAvailable seam).
12 */
13
14 import { describe, it } from 'node:test';
15 import assert from 'node:assert/strict';
16 import crypto from 'node:crypto';
17
18 import {
19 RUNTIME_MANAGER_REASONS,
20 ALLOWED_SOURCE_SCHEMES,
21 SHA256_HEX_LENGTH,
22 validateSourceUrl,
23 validateIntegritySpec,
24 createIntegrityAccumulator,
25 verifyModelBytes,
26 LIFECYCLE_STATES,
27 LIFECYCLE_EVENTS,
28 createLifecycleState,
29 transitionLifecycle,
30 canServeInference,
31 createAdmissionState,
32 evaluateAdmission,
33 recordInFlight,
34 recordCompletion,
35 recordQueued,
36 recordDequeued,
37 createResourceLimits,
38 evaluateResourceLimits,
39 evaluateRuntimeRequest,
40 } from '../lib/companion-runtime-manager.mjs';
41
42 // ── Shared helpers ──────────────────────────────────────────────────────────
43
44 function makeDigest(data) {
45 return crypto.createHash('sha256').update(data).digest('hex');
46 }
47
48 const ALLOWED_URLS = ['https://models.example.com/'];
49 const VALID_URL = 'https://models.example.com/model.bin';
50 const VALID_DATA = Buffer.from('hello world model data');
51 const VALID_DIGEST = makeDigest(VALID_DATA);
52 const VALID_SIZE = VALID_DATA.length;
53
54 const READY_STATE = { state: LIFECYCLE_STATES.READY };
55
56 const VALID_LIMITS = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 });
57 const VALID_OBS = { ramBytes: 1e9, vramBytes: 0.5e9, cpuPercent: 10 };
58 const VALID_ADMISSION = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
59
60 // ── Β§1 RUNTIME_MANAGER_REASONS ──────────────────────────────────────────────
61
62 describe('RUNTIME_MANAGER_REASONS', () => {
63 it('is frozen (immutable)', () => {
64 assert.ok(Object.isFrozen(RUNTIME_MANAGER_REASONS));
65 });
66
67 it('contains all expected reason keys', () => {
68 const expected = [
69 'OK', 'MALFORMED_SPEC', 'SOURCE_NOT_ALLOWED', 'SCHEME_NOT_ALLOWED',
70 'SIZE_MISMATCH', 'DIGEST_MISMATCH', 'ACCUMULATOR_FINALIZED', 'ACCUMULATOR_ABORTED',
71 'INVALID_TRANSITION', 'NOT_READY', 'UNKNOWN_EVENT', 'UNKNOWN_STATE',
72 'MALFORMED_ADMISSION_STATE', 'AT_CAPACITY', 'QUEUE_FULL', 'NO_IN_FLIGHT_TO_COMPLETE',
73 'MALFORMED_LIMITS', 'MALFORMED_OBSERVATION', 'RAM_OVER_LIMIT', 'VRAM_OVER_LIMIT',
74 'CPU_OVER_LIMIT', 'MALFORMED_REQUEST_PARAMS',
75 ];
76 for (const key of expected) {
77 assert.ok(Object.prototype.hasOwnProperty.call(RUNTIME_MANAGER_REASONS, key), `missing key: ${key}`);
78 }
79 });
80
81 it('all values are non-empty strings', () => {
82 for (const [k, v] of Object.entries(RUNTIME_MANAGER_REASONS)) {
83 assert.equal(typeof v, 'string', `${k} value is not a string`);
84 assert.ok(v.length > 0, `${k} value is empty`);
85 }
86 });
87 });
88
89 // ── Β§2 validateSourceUrl ────────────────────────────────────────────────────
90
91 describe('validateSourceUrl', () => {
92 it('accepts a valid HTTPS URL in the allowlist', () => {
93 const r = validateSourceUrl(VALID_URL, ALLOWED_URLS);
94 assert.equal(r.ok, true);
95 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.OK);
96 });
97
98 it('rejects non-string URL', () => {
99 assert.equal(validateSourceUrl(null, ALLOWED_URLS).ok, false);
100 assert.equal(validateSourceUrl(42, ALLOWED_URLS).ok, false);
101 });
102
103 it('rejects empty string URL', () => {
104 assert.equal(validateSourceUrl('', ALLOWED_URLS).ok, false);
105 });
106
107 it('rejects HTTP URL (scheme not allowed)', () => {
108 const r = validateSourceUrl('http://models.example.com/model.bin', ALLOWED_URLS);
109 assert.equal(r.ok, false);
110 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SCHEME_NOT_ALLOWED);
111 });
112
113 it('rejects FTP URL', () => {
114 const r = validateSourceUrl('ftp://models.example.com/model.bin', ALLOWED_URLS);
115 assert.equal(r.ok, false);
116 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SCHEME_NOT_ALLOWED);
117 });
118
119 it('rejects URL not in allowlist', () => {
120 const r = validateSourceUrl('https://evil.example.com/model.bin', ALLOWED_URLS);
121 assert.equal(r.ok, false);
122 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SOURCE_NOT_ALLOWED);
123 });
124
125 it('rejects empty allowlist', () => {
126 const r = validateSourceUrl(VALID_URL, []);
127 assert.equal(r.ok, false);
128 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SOURCE_NOT_ALLOWED);
129 });
130
131 it('rejects non-array allowlist', () => {
132 const r = validateSourceUrl(VALID_URL, null);
133 assert.equal(r.ok, false);
134 });
135
136 it('rejects unparseable URL', () => {
137 const r = validateSourceUrl('not a url at all \\', ALLOWED_URLS);
138 assert.equal(r.ok, false);
139 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_SPEC);
140 });
141
142 it('ALLOWED_SOURCE_SCHEMES contains only https:', () => {
143 assert.deepEqual([...ALLOWED_SOURCE_SCHEMES], ['https:']);
144 });
145 });
146
147 // ── Β§3 validateIntegritySpec ─────────────────────────────────────────────────
148
149 describe('validateIntegritySpec', () => {
150 it('accepts valid digest and size', () => {
151 const r = validateIntegritySpec(VALID_DIGEST, VALID_SIZE);
152 assert.equal(r.ok, true);
153 });
154
155 it('rejects non-string digest', () => {
156 assert.equal(validateIntegritySpec(null, VALID_SIZE).ok, false);
157 });
158
159 it('rejects digest shorter than 64 chars', () => {
160 assert.equal(validateIntegritySpec('a'.repeat(63), VALID_SIZE).ok, false);
161 });
162
163 it('rejects digest longer than 64 chars', () => {
164 assert.equal(validateIntegritySpec('a'.repeat(65), VALID_SIZE).ok, false);
165 });
166
167 it('rejects uppercase hex chars in digest', () => {
168 assert.equal(validateIntegritySpec('A'.repeat(64), VALID_SIZE).ok, false);
169 });
170
171 it('rejects non-hex chars in digest', () => {
172 assert.equal(validateIntegritySpec('g'.repeat(64), VALID_SIZE).ok, false);
173 });
174
175 it('rejects zero expected size', () => {
176 assert.equal(validateIntegritySpec(VALID_DIGEST, 0).ok, false);
177 });
178
179 it('rejects negative expected size', () => {
180 assert.equal(validateIntegritySpec(VALID_DIGEST, -1).ok, false);
181 });
182
183 it('rejects non-integer expected size', () => {
184 assert.equal(validateIntegritySpec(VALID_DIGEST, 1.5).ok, false);
185 });
186
187 it('SHA256_HEX_LENGTH is 64', () => {
188 assert.equal(SHA256_HEX_LENGTH, 64);
189 });
190 });
191
192 // ── Β§4 createIntegrityAccumulator ───────────────────────────────────────────
193
194 describe('createIntegrityAccumulator', () => {
195 it('throws on malformed spec', () => {
196 assert.throws(() => createIntegrityAccumulator({
197 expectedDigest: 'bad', expectedSizeBytes: VALID_SIZE,
198 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
199 }));
200 });
201
202 it('throws on bad source URL', () => {
203 assert.throws(() => createIntegrityAccumulator({
204 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
205 sourceUrl: 'http://models.example.com/model.bin', allowedSourceUrls: ALLOWED_URLS,
206 }));
207 });
208
209 it('accepts valid inputs and returns object with update/finalize/getReceivedBytes/abort', () => {
210 const acc = createIntegrityAccumulator({
211 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
212 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
213 });
214 assert.equal(typeof acc.update, 'function');
215 assert.equal(typeof acc.finalize, 'function');
216 assert.equal(typeof acc.getReceivedBytes, 'function');
217 assert.equal(typeof acc.abort, 'function');
218 });
219
220 it('returns ok:true for matching data (single chunk)', () => {
221 const acc = createIntegrityAccumulator({
222 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
223 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
224 });
225 acc.update(VALID_DATA);
226 const verdict = acc.finalize();
227 assert.equal(verdict.ok, true, `expected ok, got: ${verdict.reason}`);
228 });
229
230 it('returns ok:true for multi-chunk updates', () => {
231 const data = Buffer.from('abcdefghijklmnop');
232 const digest = makeDigest(data);
233 const acc = createIntegrityAccumulator({
234 expectedDigest: digest, expectedSizeBytes: data.length,
235 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
236 });
237 acc.update(data.subarray(0, 8));
238 acc.update(data.subarray(8));
239 const verdict = acc.finalize();
240 assert.equal(verdict.ok, true);
241 });
242
243 it('returns SIZE_MISMATCH when fewer bytes received', () => {
244 const acc = createIntegrityAccumulator({
245 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
246 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
247 });
248 acc.update(VALID_DATA.subarray(0, VALID_SIZE - 1)); // one byte short
249 const verdict = acc.finalize();
250 assert.equal(verdict.ok, false);
251 assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.SIZE_MISMATCH);
252 });
253
254 it('returns DIGEST_MISMATCH when data is wrong but size matches', () => {
255 const corrupted = Buffer.from('x'.repeat(VALID_SIZE)); // same size, wrong content
256 const acc = createIntegrityAccumulator({
257 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
258 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
259 });
260 acc.update(corrupted);
261 const verdict = acc.finalize();
262 assert.equal(verdict.ok, false);
263 assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH);
264 });
265
266 it('returns ACCUMULATOR_FINALIZED on double finalize', () => {
267 const acc = createIntegrityAccumulator({
268 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
269 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
270 });
271 acc.update(VALID_DATA);
272 acc.finalize();
273 const v2 = acc.finalize();
274 assert.equal(v2.ok, false);
275 assert.equal(v2.reason, RUNTIME_MANAGER_REASONS.ACCUMULATOR_FINALIZED);
276 });
277
278 it('returns ACCUMULATOR_ABORTED after abort()', () => {
279 const acc = createIntegrityAccumulator({
280 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
281 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
282 });
283 acc.abort();
284 const verdict = acc.finalize();
285 assert.equal(verdict.ok, false);
286 assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.ACCUMULATOR_ABORTED);
287 });
288
289 it('getReceivedBytes tracks byte count', () => {
290 const acc = createIntegrityAccumulator({
291 expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
292 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
293 });
294 assert.equal(acc.getReceivedBytes(), 0);
295 acc.update(VALID_DATA.subarray(0, 5));
296 assert.equal(acc.getReceivedBytes(), 5);
297 acc.update(VALID_DATA.subarray(5));
298 assert.equal(acc.getReceivedBytes(), VALID_SIZE);
299 });
300 });
301
302 // ── Β§5 verifyModelBytes ─────────────────────────────────────────────────────
303
304 describe('verifyModelBytes', () => {
305 it('accepts matching data', () => {
306 const r = verifyModelBytes({
307 fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
308 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
309 });
310 assert.equal(r.ok, true);
311 });
312
313 it('rejects wrong digest', () => {
314 const wrong = '0'.repeat(64);
315 const r = verifyModelBytes({
316 fileData: VALID_DATA, expectedDigest: wrong, expectedSizeBytes: VALID_SIZE,
317 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
318 });
319 assert.equal(r.ok, false);
320 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH);
321 });
322
323 it('rejects size mismatch', () => {
324 const r = verifyModelBytes({
325 fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE + 1,
326 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
327 });
328 assert.equal(r.ok, false);
329 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SIZE_MISMATCH);
330 });
331
332 it('rejects HTTP source URL', () => {
333 const r = verifyModelBytes({
334 fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
335 sourceUrl: 'http://models.example.com/model.bin', allowedSourceUrls: ALLOWED_URLS,
336 });
337 assert.equal(r.ok, false);
338 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SCHEME_NOT_ALLOWED);
339 });
340
341 it('rejects non-Buffer fileData', () => {
342 const r = verifyModelBytes({
343 fileData: 'not-a-buffer', expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
344 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
345 });
346 assert.equal(r.ok, false);
347 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_SPEC);
348 });
349 });
350
351 // ── Β§6 Lifecycle state machine ───────────────────────────────────────────────
352
353 describe('createLifecycleState', () => {
354 it('returns stopped state initially', () => {
355 const s = createLifecycleState();
356 assert.equal(s.state, LIFECYCLE_STATES.STOPPED);
357 });
358 });
359
360 describe('transitionLifecycle', () => {
361 it('stopped β†’ starting on start event', () => {
362 const s = createLifecycleState();
363 const r = transitionLifecycle(s, LIFECYCLE_EVENTS.START);
364 assert.equal(r.ok, true);
365 assert.equal(r.newState.state, LIFECYCLE_STATES.STARTING);
366 });
367
368 it('starting β†’ ready on health_ok', () => {
369 const s = { state: LIFECYCLE_STATES.STARTING };
370 const r = transitionLifecycle(s, LIFECYCLE_EVENTS.HEALTH_OK);
371 assert.equal(r.ok, true);
372 assert.equal(r.newState.state, LIFECYCLE_STATES.READY);
373 });
374
375 it('starting β†’ stopped on health_fail', () => {
376 const s = { state: LIFECYCLE_STATES.STARTING };
377 const r = transitionLifecycle(s, LIFECYCLE_EVENTS.HEALTH_FAIL);
378 assert.equal(r.ok, true);
379 assert.equal(r.newState.state, LIFECYCLE_STATES.STOPPED);
380 });
381
382 it('ready β†’ draining on drain', () => {
383 const s = { state: LIFECYCLE_STATES.READY };
384 const r = transitionLifecycle(s, LIFECYCLE_EVENTS.DRAIN);
385 assert.equal(r.ok, true);
386 assert.equal(r.newState.state, LIFECYCLE_STATES.DRAINING);
387 });
388
389 it('draining β†’ stopped on stopped', () => {
390 const s = { state: LIFECYCLE_STATES.DRAINING };
391 const r = transitionLifecycle(s, LIFECYCLE_EVENTS.STOPPED);
392 assert.equal(r.ok, true);
393 assert.equal(r.newState.state, LIFECYCLE_STATES.STOPPED);
394 });
395
396 it('rejects invalid transition (stopped + health_ok)', () => {
397 const s = createLifecycleState();
398 const r = transitionLifecycle(s, LIFECYCLE_EVENTS.HEALTH_OK);
399 assert.equal(r.ok, false);
400 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.INVALID_TRANSITION);
401 });
402
403 it('rejects invalid transition (ready + health_ok)', () => {
404 const r = transitionLifecycle({ state: LIFECYCLE_STATES.READY }, LIFECYCLE_EVENTS.HEALTH_OK);
405 assert.equal(r.ok, false);
406 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.INVALID_TRANSITION);
407 });
408
409 it('rejects unknown event', () => {
410 const s = createLifecycleState();
411 const r = transitionLifecycle(s, 'nonexistent_event');
412 assert.equal(r.ok, false);
413 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.INVALID_TRANSITION);
414 });
415
416 it('rejects null event', () => {
417 const s = createLifecycleState();
418 const r = transitionLifecycle(s, null);
419 assert.equal(r.ok, false);
420 });
421
422 it('rejects malformed state object', () => {
423 const r = transitionLifecycle(null, LIFECYCLE_EVENTS.START);
424 assert.equal(r.ok, false);
425 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.UNKNOWN_STATE);
426 });
427
428 it('does not mutate input state', () => {
429 const s = createLifecycleState();
430 transitionLifecycle(s, LIFECYCLE_EVENTS.START);
431 assert.equal(s.state, LIFECYCLE_STATES.STOPPED); // unchanged
432 });
433 });
434
435 describe('canServeInference', () => {
436 it('returns true only for ready state', () => {
437 assert.equal(canServeInference({ state: LIFECYCLE_STATES.READY }), true);
438 });
439
440 it('returns false for stopped', () => {
441 assert.equal(canServeInference({ state: LIFECYCLE_STATES.STOPPED }), false);
442 });
443
444 it('returns false for starting', () => {
445 assert.equal(canServeInference({ state: LIFECYCLE_STATES.STARTING }), false);
446 });
447
448 it('returns false for draining', () => {
449 assert.equal(canServeInference({ state: LIFECYCLE_STATES.DRAINING }), false);
450 });
451
452 it('returns false for null', () => {
453 assert.equal(canServeInference(null), false);
454 });
455
456 it('returns false for empty object', () => {
457 assert.equal(canServeInference({}), false);
458 });
459
460 it('returns false for unknown state string', () => {
461 assert.equal(canServeInference({ state: 'unknown' }), false);
462 });
463 });
464
465 // ── Β§7 Admission ─────────────────────────────────────────────────────────────
466
467 describe('createAdmissionState', () => {
468 it('creates state with correct fields', () => {
469 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
470 assert.equal(s.maxInFlight, 4);
471 assert.equal(s.queueBound, 8);
472 assert.equal(s.inFlight, 0);
473 assert.equal(s.queued, 0);
474 });
475
476 it('throws on non-integer maxInFlight', () => {
477 assert.throws(() => createAdmissionState({ maxInFlight: 1.5, queueBound: 8 }));
478 });
479
480 it('throws on zero maxInFlight', () => {
481 assert.throws(() => createAdmissionState({ maxInFlight: 0, queueBound: 8 }));
482 });
483
484 it('throws on negative queueBound', () => {
485 assert.throws(() => createAdmissionState({ maxInFlight: 4, queueBound: -1 }));
486 });
487 });
488
489 describe('evaluateAdmission', () => {
490 it('allows when under max in-flight', () => {
491 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
492 const r = evaluateAdmission(s);
493 assert.equal(r.ok, true);
494 });
495
496 it('returns AT_CAPACITY when all slots full but queue has room', () => {
497 let s = createAdmissionState({ maxInFlight: 2, queueBound: 4 });
498 s = { ...s, inFlight: 2 };
499 const r = evaluateAdmission(s);
500 assert.equal(r.ok, false);
501 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.AT_CAPACITY);
502 });
503
504 it('returns QUEUE_FULL when both in-flight and queue are full', () => {
505 let s = createAdmissionState({ maxInFlight: 2, queueBound: 4 });
506 s = { ...s, inFlight: 2, queued: 4 };
507 const r = evaluateAdmission(s);
508 assert.equal(r.ok, false);
509 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.QUEUE_FULL);
510 });
511
512 it('returns MALFORMED_ADMISSION_STATE for null', () => {
513 const r = evaluateAdmission(null);
514 assert.equal(r.ok, false);
515 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_ADMISSION_STATE);
516 });
517 });
518
519 describe('recordInFlight / recordCompletion', () => {
520 it('increments inFlight on recordInFlight', () => {
521 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
522 const s2 = recordInFlight(s);
523 assert.equal(s2.inFlight, 1);
524 assert.equal(s.inFlight, 0); // original unchanged
525 });
526
527 it('decrements inFlight on recordCompletion', () => {
528 let s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
529 s = recordInFlight(s);
530 s = recordCompletion(s);
531 assert.equal(s.inFlight, 0);
532 });
533
534 it('throws on recordCompletion when no in-flight', () => {
535 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
536 assert.throws(() => recordCompletion(s));
537 });
538 });
539
540 describe('recordQueued / recordDequeued', () => {
541 it('increments queued on recordQueued', () => {
542 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
543 const s2 = recordQueued(s);
544 assert.equal(s2.queued, 1);
545 assert.equal(s.queued, 0); // original unchanged
546 });
547
548 it('decrements queued on recordDequeued', () => {
549 let s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
550 s = recordQueued(s);
551 s = recordDequeued(s);
552 assert.equal(s.queued, 0);
553 });
554
555 it('throws on recordDequeued when no queued', () => {
556 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
557 assert.throws(() => recordDequeued(s));
558 });
559 });
560
561 // ── Β§8 Resource limits ───────────────────────────────────────────────────────
562
563 describe('createResourceLimits', () => {
564 it('creates valid limits', () => {
565 const limits = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 });
566 assert.equal(limits.maxRamBytes, 8e9);
567 assert.equal(limits.maxVramBytes, 4e9);
568 assert.equal(limits.maxCpuPercent, 80);
569 });
570
571 it('throws on non-finite RAM limit', () => {
572 assert.throws(() => createResourceLimits({ maxRamBytes: NaN, maxVramBytes: 4e9, maxCpuPercent: 80 }));
573 });
574
575 it('throws on zero VRAM limit', () => {
576 assert.throws(() => createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 0, maxCpuPercent: 80 }));
577 });
578
579 it('throws on CPU percent over 100', () => {
580 assert.throws(() => createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 101 }));
581 });
582
583 it('throws on zero CPU percent', () => {
584 assert.throws(() => createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 0 }));
585 });
586 });
587
588 describe('evaluateResourceLimits', () => {
589 it('allows when all metrics under limits', () => {
590 const r = evaluateResourceLimits(VALID_OBS, VALID_LIMITS);
591 assert.equal(r.ok, true);
592 });
593
594 it('returns RAM_OVER_LIMIT when RAM exceeded', () => {
595 const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 };
596 const r = evaluateResourceLimits(obs, VALID_LIMITS);
597 assert.equal(r.ok, false);
598 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.RAM_OVER_LIMIT);
599 });
600
601 it('returns VRAM_OVER_LIMIT when VRAM exceeded', () => {
602 const obs = { ...VALID_OBS, vramBytes: VALID_LIMITS.maxVramBytes + 1 };
603 const r = evaluateResourceLimits(obs, VALID_LIMITS);
604 assert.equal(r.ok, false);
605 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.VRAM_OVER_LIMIT);
606 });
607
608 it('returns CPU_OVER_LIMIT when CPU exceeded', () => {
609 const obs = { ...VALID_OBS, cpuPercent: VALID_LIMITS.maxCpuPercent + 1 };
610 const r = evaluateResourceLimits(obs, VALID_LIMITS);
611 assert.equal(r.ok, false);
612 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.CPU_OVER_LIMIT);
613 });
614
615 it('returns MALFORMED_LIMITS for null limits', () => {
616 const r = evaluateResourceLimits(VALID_OBS, null);
617 assert.equal(r.ok, false);
618 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_LIMITS);
619 });
620
621 it('returns MALFORMED_OBSERVATION for null observation', () => {
622 const r = evaluateResourceLimits(null, VALID_LIMITS);
623 assert.equal(r.ok, false);
624 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_OBSERVATION);
625 });
626
627 it('allows RAM exactly at limit (not over)', () => {
628 const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes };
629 const r = evaluateResourceLimits(obs, VALID_LIMITS);
630 assert.equal(r.ok, true);
631 });
632 });
633
634 // ── Β§9 evaluateRuntimeRequest ────────────────────────────────────────────────
635
636 describe('evaluateRuntimeRequest', () => {
637 it('allows when all gates pass', () => {
638 const r = evaluateRuntimeRequest({
639 lifecycleState: READY_STATE,
640 admissionState: VALID_ADMISSION,
641 resourceObservation: VALID_OBS,
642 resourceLimits: VALID_LIMITS,
643 });
644 assert.equal(r.ok, true);
645 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.OK);
646 });
647
648 it('returns NOT_READY when lifecycle is stopped', () => {
649 const r = evaluateRuntimeRequest({
650 lifecycleState: createLifecycleState(),
651 admissionState: VALID_ADMISSION,
652 resourceObservation: VALID_OBS,
653 resourceLimits: VALID_LIMITS,
654 });
655 assert.equal(r.ok, false);
656 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.NOT_READY);
657 });
658
659 it('returns NOT_READY when lifecycle is starting', () => {
660 const r = evaluateRuntimeRequest({
661 lifecycleState: { state: LIFECYCLE_STATES.STARTING },
662 admissionState: VALID_ADMISSION,
663 resourceObservation: VALID_OBS,
664 resourceLimits: VALID_LIMITS,
665 });
666 assert.equal(r.ok, false);
667 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.NOT_READY);
668 });
669
670 it('returns QUEUE_FULL when admission gate is exhausted', () => {
671 const fullState = { ...VALID_ADMISSION, inFlight: VALID_ADMISSION.maxInFlight, queued: VALID_ADMISSION.queueBound };
672 const r = evaluateRuntimeRequest({
673 lifecycleState: READY_STATE,
674 admissionState: fullState,
675 resourceObservation: VALID_OBS,
676 resourceLimits: VALID_LIMITS,
677 });
678 assert.equal(r.ok, false);
679 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.QUEUE_FULL);
680 });
681
682 it('returns RAM_OVER_LIMIT when RAM is over limit', () => {
683 const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 };
684 const r = evaluateRuntimeRequest({
685 lifecycleState: READY_STATE,
686 admissionState: VALID_ADMISSION,
687 resourceObservation: obs,
688 resourceLimits: VALID_LIMITS,
689 });
690 assert.equal(r.ok, false);
691 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.RAM_OVER_LIMIT);
692 });
693
694 it('returns MALFORMED_REQUEST_PARAMS when called with no params', () => {
695 const r = evaluateRuntimeRequest();
696 assert.equal(r.ok, false);
697 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_REQUEST_PARAMS);
698 });
699
700 it('returns MALFORMED_REQUEST_PARAMS when any param is null', () => {
701 const r = evaluateRuntimeRequest({
702 lifecycleState: READY_STATE,
703 admissionState: null,
704 resourceObservation: VALID_OBS,
705 resourceLimits: VALID_LIMITS,
706 });
707 assert.equal(r.ok, false);
708 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_REQUEST_PARAMS);
709 });
710
711 it('lifecycle gate is checked before admission gate', () => {
712 const fullAdmission = { ...VALID_ADMISSION, inFlight: VALID_ADMISSION.maxInFlight, queued: VALID_ADMISSION.queueBound };
713 const r = evaluateRuntimeRequest({
714 lifecycleState: { state: LIFECYCLE_STATES.STOPPED },
715 admissionState: fullAdmission,
716 resourceObservation: VALID_OBS,
717 resourceLimits: VALID_LIMITS,
718 });
719 // Should be NOT_READY (lifecycle), not QUEUE_FULL (admission)
720 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.NOT_READY);
721 });
722
723 it('admission gate is checked before resource gate', () => {
724 const fullAdmission = { ...VALID_ADMISSION, inFlight: VALID_ADMISSION.maxInFlight, queued: VALID_ADMISSION.queueBound };
725 const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 };
726 const r = evaluateRuntimeRequest({
727 lifecycleState: READY_STATE,
728 admissionState: fullAdmission,
729 resourceObservation: obs,
730 resourceLimits: VALID_LIMITS,
731 });
732 // Should be QUEUE_FULL (admission), not RAM_OVER_LIMIT (resource)
733 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.QUEUE_FULL);
734 });
735 });