companion-runtime-manager-integration.test.mjs
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | /** |
| 2 | * Tier 2 β INTEGRATION: lib/companion-runtime-manager.mjs |
| 3 | * |
| 4 | * Combined flows through multiple functions β integrity β lifecycle β admission β resource. |
| 5 | * Tests the interactions between sub-systems, ensuring the composition of pure functions |
| 6 | * produces the correct combined behaviour across realistic call sequences. |
| 7 | * |
| 8 | * Reference: docs/COMPANION-APP-PHASE-4-RUNTIME-MANAGER.md Β§3 (lifecycle), Β§4 (backpressure). |
| 9 | */ |
| 10 | |
| 11 | import { describe, it } from 'node:test'; |
| 12 | import assert from 'node:assert/strict'; |
| 13 | import crypto from 'node:crypto'; |
| 14 | |
| 15 | import { |
| 16 | RUNTIME_MANAGER_REASONS, |
| 17 | LIFECYCLE_STATES, |
| 18 | LIFECYCLE_EVENTS, |
| 19 | createIntegrityAccumulator, |
| 20 | verifyModelBytes, |
| 21 | createLifecycleState, |
| 22 | transitionLifecycle, |
| 23 | canServeInference, |
| 24 | createAdmissionState, |
| 25 | evaluateAdmission, |
| 26 | recordInFlight, |
| 27 | recordCompletion, |
| 28 | recordQueued, |
| 29 | recordDequeued, |
| 30 | createResourceLimits, |
| 31 | evaluateResourceLimits, |
| 32 | evaluateRuntimeRequest, |
| 33 | } from '../lib/companion-runtime-manager.mjs'; |
| 34 | |
| 35 | function makeDigest(data) { |
| 36 | return crypto.createHash('sha256').update(data).digest('hex'); |
| 37 | } |
| 38 | |
| 39 | const ALLOWED_URLS = ['https://models.example.com/']; |
| 40 | const VALID_URL = 'https://models.example.com/model.gguf'; |
| 41 | |
| 42 | // ββ Integration: integrity β lifecycle cold-start sequence ββββββββββββββββββ |
| 43 | |
| 44 | describe('integrity verification β lifecycle cold-start success', () => { |
| 45 | it('full happy path: verify β start β health_ok β ready β serve', () => { |
| 46 | // Phase 5 will call this sequence. We simulate it purely. |
| 47 | const modelData = Buffer.from('simulated model binary data for integration test'); |
| 48 | const digest = makeDigest(modelData); |
| 49 | |
| 50 | // 1. Verify model integrity (simulated Phase 5 streaming download) |
| 51 | const acc = createIntegrityAccumulator({ |
| 52 | expectedDigest: digest, expectedSizeBytes: modelData.length, |
| 53 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 54 | }); |
| 55 | // Simulate chunked download |
| 56 | const chunkSize = 8; |
| 57 | for (let i = 0; i < modelData.length; i += chunkSize) { |
| 58 | acc.update(modelData.subarray(i, i + chunkSize)); |
| 59 | } |
| 60 | const integrityVerdict = acc.finalize(); |
| 61 | assert.equal(integrityVerdict.ok, true, 'integrity must pass before lifecycle proceeds'); |
| 62 | |
| 63 | // 2. Only after integrity passes, transition lifecycle |
| 64 | let lifecycle = createLifecycleState(); |
| 65 | assert.equal(lifecycle.state, LIFECYCLE_STATES.STOPPED); |
| 66 | |
| 67 | let tr = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.START); |
| 68 | assert.equal(tr.ok, true); |
| 69 | lifecycle = tr.newState; |
| 70 | assert.equal(lifecycle.state, LIFECYCLE_STATES.STARTING); |
| 71 | |
| 72 | // Simulate health-check pass (Phase 5 health probe succeeds) |
| 73 | tr = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.HEALTH_OK); |
| 74 | assert.equal(tr.ok, true); |
| 75 | lifecycle = tr.newState; |
| 76 | assert.equal(lifecycle.state, LIFECYCLE_STATES.READY); |
| 77 | |
| 78 | // 3. Now admission and resource checks |
| 79 | const admission = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 80 | const limits = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 }); |
| 81 | const obs = { ramBytes: 1e9, vramBytes: 0.5e9, cpuPercent: 10 }; |
| 82 | |
| 83 | const decision = evaluateRuntimeRequest({ |
| 84 | lifecycleState: lifecycle, admissionState: admission, |
| 85 | resourceObservation: obs, resourceLimits: limits, |
| 86 | }); |
| 87 | assert.equal(decision.ok, true); |
| 88 | }); |
| 89 | }); |
| 90 | |
| 91 | describe('integrity failure β lifecycle stays stopped', () => { |
| 92 | it('wrong digest prevents lifecycle start (Phase 5 must not call start on fail)', () => { |
| 93 | const modelData = Buffer.from('good model data'); |
| 94 | const wrongDigest = '0'.repeat(64); |
| 95 | |
| 96 | const r = verifyModelBytes({ |
| 97 | fileData: modelData, expectedDigest: wrongDigest, |
| 98 | expectedSizeBytes: modelData.length, sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 99 | }); |
| 100 | assert.equal(r.ok, false); |
| 101 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH); |
| 102 | |
| 103 | // Phase 5 MUST NOT call transitionLifecycle(START) when integrity fails. |
| 104 | // The lifecycle stays in stopped β no way to reach ready without integrity passing. |
| 105 | const lifecycle = createLifecycleState(); |
| 106 | assert.equal(canServeInference(lifecycle), false); |
| 107 | }); |
| 108 | }); |
| 109 | |
| 110 | // ββ Integration: cold-start failure path ββββββββββββββββββββββββββββββββββββ |
| 111 | |
| 112 | describe('cold-start failure: starting β stopped on health_fail', () => { |
| 113 | it('health_fail returns lifecycle to stopped, inference still denied', () => { |
| 114 | let lifecycle = createLifecycleState(); |
| 115 | |
| 116 | let tr = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.START); |
| 117 | lifecycle = tr.newState; |
| 118 | assert.equal(lifecycle.state, LIFECYCLE_STATES.STARTING); |
| 119 | assert.equal(canServeInference(lifecycle), false); // cannot serve while starting |
| 120 | |
| 121 | tr = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.HEALTH_FAIL); |
| 122 | lifecycle = tr.newState; |
| 123 | assert.equal(lifecycle.state, LIFECYCLE_STATES.STOPPED); |
| 124 | assert.equal(canServeInference(lifecycle), false); |
| 125 | }); |
| 126 | |
| 127 | it('can restart after health_fail (stopped β starting again)', () => { |
| 128 | let lifecycle = createLifecycleState(); |
| 129 | lifecycle = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.START).newState; |
| 130 | lifecycle = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.HEALTH_FAIL).newState; |
| 131 | assert.equal(lifecycle.state, LIFECYCLE_STATES.STOPPED); |
| 132 | |
| 133 | // Can start again |
| 134 | const tr = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.START); |
| 135 | assert.equal(tr.ok, true); |
| 136 | assert.equal(tr.newState.state, LIFECYCLE_STATES.STARTING); |
| 137 | }); |
| 138 | }); |
| 139 | |
| 140 | // ββ Integration: graceful drain sequence ββββββββββββββββββββββββββββββββββββ |
| 141 | |
| 142 | describe('graceful drain: ready β draining β stopped', () => { |
| 143 | it('inference denied in draining state', () => { |
| 144 | let lifecycle = createLifecycleState(); |
| 145 | lifecycle = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.START).newState; |
| 146 | lifecycle = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.HEALTH_OK).newState; |
| 147 | assert.equal(canServeInference(lifecycle), true); |
| 148 | |
| 149 | lifecycle = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.DRAIN).newState; |
| 150 | assert.equal(lifecycle.state, LIFECYCLE_STATES.DRAINING); |
| 151 | assert.equal(canServeInference(lifecycle), false); // no new requests during drain |
| 152 | |
| 153 | lifecycle = transitionLifecycle(lifecycle, LIFECYCLE_EVENTS.STOPPED).newState; |
| 154 | assert.equal(lifecycle.state, LIFECYCLE_STATES.STOPPED); |
| 155 | assert.equal(canServeInference(lifecycle), false); |
| 156 | }); |
| 157 | }); |
| 158 | |
| 159 | // ββ Integration: admission state cycling ββββββββββββββββββββββββββββββββββββ |
| 160 | |
| 161 | describe('admission cycling with recordInFlight/recordCompletion', () => { |
| 162 | it('inFlight returns to 0 after all completions', () => { |
| 163 | let s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 164 | |
| 165 | s = recordInFlight(s); |
| 166 | s = recordInFlight(s); |
| 167 | s = recordInFlight(s); |
| 168 | assert.equal(s.inFlight, 3); |
| 169 | |
| 170 | s = recordCompletion(s); |
| 171 | s = recordCompletion(s); |
| 172 | s = recordCompletion(s); |
| 173 | assert.equal(s.inFlight, 0); |
| 174 | }); |
| 175 | |
| 176 | it('admission allows again after completion brings inFlight below max', () => { |
| 177 | let s = createAdmissionState({ maxInFlight: 2, queueBound: 4 }); |
| 178 | |
| 179 | s = recordInFlight(s); |
| 180 | s = recordInFlight(s); |
| 181 | assert.equal(evaluateAdmission(s).reason, RUNTIME_MANAGER_REASONS.AT_CAPACITY); |
| 182 | |
| 183 | s = recordCompletion(s); |
| 184 | assert.equal(evaluateAdmission(s).ok, true); |
| 185 | }); |
| 186 | |
| 187 | it('queue tracking does not affect inFlight', () => { |
| 188 | let s = createAdmissionState({ maxInFlight: 2, queueBound: 4 }); |
| 189 | s = recordInFlight(s); |
| 190 | s = recordQueued(s); |
| 191 | s = recordQueued(s); |
| 192 | assert.equal(s.inFlight, 1); |
| 193 | assert.equal(s.queued, 2); |
| 194 | s = recordDequeued(s); |
| 195 | assert.equal(s.queued, 1); |
| 196 | assert.equal(s.inFlight, 1); // unchanged |
| 197 | }); |
| 198 | }); |
| 199 | |
| 200 | // ββ Integration: resource + admission combined βββββββββββββββββββββββββββββββ |
| 201 | |
| 202 | describe('resource limits integrated with admission decision', () => { |
| 203 | it('resource over-limit while in-flight slots free β resource gate fails', () => { |
| 204 | const lifecycle = { state: LIFECYCLE_STATES.READY }; |
| 205 | const admission = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 206 | const limits = createResourceLimits({ maxRamBytes: 4e9, maxVramBytes: 4e9, maxCpuPercent: 80 }); |
| 207 | const obs = { ramBytes: 5e9, vramBytes: 0, cpuPercent: 20 }; // RAM over |
| 208 | |
| 209 | const r = evaluateRuntimeRequest({ |
| 210 | lifecycleState: lifecycle, admissionState: admission, |
| 211 | resourceObservation: obs, resourceLimits: limits, |
| 212 | }); |
| 213 | assert.equal(r.ok, false); |
| 214 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.RAM_OVER_LIMIT); |
| 215 | }); |
| 216 | |
| 217 | it('resource within limits + admission within limits + ready β ok', () => { |
| 218 | const lifecycle = { state: LIFECYCLE_STATES.READY }; |
| 219 | const admission = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 220 | const limits = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 }); |
| 221 | const obs = { ramBytes: 1e9, vramBytes: 0, cpuPercent: 20 }; |
| 222 | |
| 223 | const r = evaluateRuntimeRequest({ |
| 224 | lifecycleState: lifecycle, admissionState: admission, |
| 225 | resourceObservation: obs, resourceLimits: limits, |
| 226 | }); |
| 227 | assert.equal(r.ok, true); |
| 228 | }); |
| 229 | }); |
| 230 | |
| 231 | // ββ Integration: streaming integrity accumulator βββββββββββββββββββββββββββββ |
| 232 | |
| 233 | describe('streaming integrity accumulator with many small chunks', () => { |
| 234 | it('matches single-shot verifyModelBytes for identical data', () => { |
| 235 | const data = Buffer.alloc(1024, 0xab); // 1 KB of known bytes |
| 236 | const digest = makeDigest(data); |
| 237 | |
| 238 | // Streaming path (accumulator) |
| 239 | const acc = createIntegrityAccumulator({ |
| 240 | expectedDigest: digest, expectedSizeBytes: data.length, |
| 241 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 242 | }); |
| 243 | for (let i = 0; i < data.length; i++) { |
| 244 | acc.update(data.subarray(i, i + 1)); // 1 byte at a time |
| 245 | } |
| 246 | const streamingVerdict = acc.finalize(); |
| 247 | |
| 248 | // In-memory path |
| 249 | const memoryVerdict = verifyModelBytes({ |
| 250 | fileData: data, expectedDigest: digest, expectedSizeBytes: data.length, |
| 251 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 252 | }); |
| 253 | |
| 254 | assert.equal(streamingVerdict.ok, true); |
| 255 | assert.equal(memoryVerdict.ok, true); |
| 256 | }); |
| 257 | }); |