94 lines
3.5 KiB
TypeScript
94 lines
3.5 KiB
TypeScript
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import { hostAgentLaneState, type HostAgentLane } from '../../src/lib/hosts.js';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
let fixture: Fixture;
|
|
const inventory = {
|
|
protocolVersion: 1,
|
|
dockerRunning: true,
|
|
directoriesWritable: true,
|
|
storageAvailable: 100 * 1024 ** 3,
|
|
runtimeImages: ['image'],
|
|
libraries: [{ id: 'library', state: 'ready' }]
|
|
};
|
|
const lane: HostAgentLane = {
|
|
name: 'health-1',
|
|
alive: true,
|
|
busy: false,
|
|
lastPollAt: '2026-10-06T12:00:00Z',
|
|
pollAgeSeconds: 5
|
|
};
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
fixture = await newFixture([]);
|
|
});
|
|
afterEach(async () => {
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
it('flags a stopped or stalled health worker despite a live heartbeat and recovers on fresh polls', async () => {
|
|
const api = await import('../../src/lib/server/hosts/index.js');
|
|
const host = api.createHost({ name: 'Worker host' });
|
|
api.enrollHost(api.createEnrollment(host.id).token, inventory);
|
|
for (const broken of [
|
|
{ ...lane, alive: false },
|
|
{ ...lane, busy: true, pollAgeSeconds: 91 }
|
|
]) {
|
|
const updated = api.heartbeat(host.id, {
|
|
...inventory,
|
|
agentLanes: [broken, { ...lane, name: 'health-2' }]
|
|
});
|
|
expect(updated).toMatchObject({
|
|
connected: true,
|
|
connectionState: 'online',
|
|
readiness: 'degraded'
|
|
});
|
|
expect(updated.readinessReasons[0]).toContain('health check 1 worker');
|
|
// Diagnostics must not prevent health polls from recovering the worker.
|
|
const check = api.queueHostOperation(host.id, 'instance.inspect', {});
|
|
expect(api.pendingHostOperations(host.id, 'health').map((op) => op.id)).toContain(check.id);
|
|
}
|
|
expect(api.heartbeat(host.id, { ...inventory, agentLanes: [lane] }).readiness).toBe('ready');
|
|
// Older agents remain compatible without diagnostics.
|
|
expect(api.heartbeat(host.id, inventory).readiness).toBe('ready');
|
|
});
|
|
|
|
it('allows long reads and mutations while detecting stalled idle workers and missing initial polls', () => {
|
|
expect(hostAgentLaneState({ ...lane, lastPollAt: null })).toBe('starting');
|
|
expect(hostAgentLaneState({ ...lane, lastPollAt: null, pollAgeSeconds: 91 })).toBe('stalled');
|
|
expect(hostAgentLaneState({ ...lane, busy: true, pollAgeSeconds: 91 })).toBe('stalled');
|
|
for (const name of ['read', 'write'] as const) {
|
|
expect(hostAgentLaneState({ ...lane, name, busy: true, pollAgeSeconds: 3600 })).toBe(
|
|
'busy'
|
|
);
|
|
expect(hostAgentLaneState({ ...lane, name, busy: false, pollAgeSeconds: 91 })).toBe(
|
|
'stalled'
|
|
);
|
|
expect(hostAgentLaneState({ ...lane, name, alive: false, busy: true })).toBe('stopped');
|
|
}
|
|
});
|
|
|
|
it('sanitizes lane diagnostics without rejecting a heartbeat for one malformed sample', async () => {
|
|
const { validateInventory } = await import('../../src/lib/server/hosts/index.js');
|
|
expect(
|
|
validateInventory({
|
|
...inventory,
|
|
agentLanes: [
|
|
null,
|
|
{ ...lane, secret: 'omit' },
|
|
{ ...lane },
|
|
{ ...lane, name: 'write', pollAgeSeconds: -1 }
|
|
]
|
|
}).agentLanes
|
|
).toEqual([lane]);
|
|
expect(
|
|
validateInventory({
|
|
...inventory,
|
|
agentLanes: [
|
|
{ ...lane, pollAgeSeconds: 'stale' },
|
|
{ ...lane, lastPollAt: 'invalid' }
|
|
]
|
|
}).agentLanes
|
|
).toEqual([]);
|
|
});
|