feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean). wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes. openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
235 lines
9.0 KiB
TypeScript
235 lines
9.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
validateLlamaConfig,
|
|
computeDiff,
|
|
backupFilename,
|
|
applyRemoteConfig,
|
|
healthWait,
|
|
type SshExec,
|
|
type ExecResult,
|
|
} from '../ssh-config.js';
|
|
|
|
// A minimal subset of the llama-swap config schema sufficient for these tests:
|
|
// top-level object with a required non-empty `models` object.
|
|
const SCHEMA = {
|
|
type: 'object',
|
|
required: ['models'],
|
|
properties: {
|
|
models: {
|
|
type: 'object',
|
|
minProperties: 1,
|
|
additionalProperties: {
|
|
type: 'object',
|
|
properties: { cmd: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
const VALID_YAML = `models:\n m1:\n cmd: "llama-server -m m1.gguf"\n`;
|
|
|
|
describe('validateLlamaConfig', () => {
|
|
it('accepts a valid config', () => {
|
|
const r = validateLlamaConfig(VALID_YAML, SCHEMA);
|
|
expect(r.valid).toBe(true);
|
|
expect(r.errors).toEqual([]);
|
|
});
|
|
|
|
it('rejects broken YAML with a parse error', () => {
|
|
const r = validateLlamaConfig('models:\n m1:\n cmd: "x\n : :', SCHEMA);
|
|
expect(r.valid).toBe(false);
|
|
expect(r.errors[0]).toMatch(/YAML parse error/);
|
|
});
|
|
|
|
it('rejects a config missing required models', () => {
|
|
const r = validateLlamaConfig('healthCheckTimeout: 30\n', SCHEMA);
|
|
expect(r.valid).toBe(false);
|
|
expect(r.errors.join(' ')).toMatch(/models/);
|
|
});
|
|
|
|
it('rejects a non-mapping document', () => {
|
|
const r = validateLlamaConfig('- just\n- a\n- list\n', SCHEMA);
|
|
expect(r.valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('computeDiff', () => {
|
|
it('returns empty for identical text', () => {
|
|
expect(computeDiff('a\nb\n', 'a\nb\n')).toBe('');
|
|
});
|
|
it('marks changed lines with -/+', () => {
|
|
const d = computeDiff('a\nb\nc\n', 'a\nX\nc\n');
|
|
expect(d).toContain('- b');
|
|
expect(d).toContain('+ X');
|
|
});
|
|
});
|
|
|
|
describe('backupFilename', () => {
|
|
it('produces a timestamped path', () => {
|
|
const name = backupFilename('/etc/llama/config.yaml', new Date('2026-06-12T03:04:05.678Z'));
|
|
expect(name).toBe('/etc/llama/config.yaml.bak-20260612T030405Z');
|
|
});
|
|
});
|
|
|
|
// ─── apply pipeline failure paths ────────────────────────────────────────────
|
|
|
|
function makeExec(handlers: Record<string, ExecResult>): { exec: SshExec; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const exec: SshExec = async (_t, command) => {
|
|
calls.push(command);
|
|
for (const [pattern, result] of Object.entries(handlers)) {
|
|
if (command.includes(pattern)) return result;
|
|
}
|
|
return { code: 0, stdout: '', stderr: '' };
|
|
};
|
|
return { exec, calls };
|
|
}
|
|
|
|
const target = { host: 'h', user: 'u', keyPath: '/k' };
|
|
const okFetcher = (async () => new Response('{}', { status: 200 })) as unknown as typeof fetch;
|
|
|
|
describe('applyRemoteConfig', () => {
|
|
it('aborts at validate for an invalid config and never touches the host', async () => {
|
|
const { exec, calls } = makeExec({});
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: 'not: valid: yaml: here:::',
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
expect(r.step).toBe('validate');
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it('aborts at validate when the host config is unreadable', async () => {
|
|
const { exec } = makeExec({ "cat '": { code: 1, stdout: '', stderr: 'no such file' } });
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: VALID_YAML,
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
expect(r.step).toBe('validate');
|
|
expect(r.error).toMatch(/read current failed/);
|
|
});
|
|
|
|
it('backs up BEFORE write and aborts on write failure (backup retained)', async () => {
|
|
const { exec, calls } = makeExec({
|
|
"cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' }, // read current
|
|
'cp ': { code: 0, stdout: '', stderr: '' }, // backup
|
|
'cat >': { code: 1, stdout: '', stderr: 'disk full' }, // write fails
|
|
});
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: VALID_YAML,
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
|
|
now: new Date('2026-06-12T00:00:00Z'),
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
expect(r.step).toBe('write');
|
|
expect(r.backupPath).toBe('/c.yaml.bak-20260612T000000Z');
|
|
// backup (cp) must precede write (cat >)
|
|
const cpIdx = calls.findIndex((c) => c.startsWith('cp '));
|
|
const writeIdx = calls.findIndex((c) => c.startsWith('cat >'));
|
|
expect(cpIdx).toBeGreaterThanOrEqual(0);
|
|
expect(writeIdx).toBeGreaterThan(cpIdx);
|
|
});
|
|
|
|
it('aborts at restart on restart failure', async () => {
|
|
const { exec } = makeExec({
|
|
"cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' },
|
|
'cp ': { code: 0, stdout: '', stderr: '' },
|
|
'cat >': { code: 0, stdout: '', stderr: '' },
|
|
restart: { code: 1, stdout: '', stderr: 'service not found' },
|
|
});
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML,
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
expect(r.step).toBe('restart');
|
|
});
|
|
|
|
it('aborts at health when the service never comes back', async () => {
|
|
const { exec } = makeExec({
|
|
"cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' },
|
|
'cp ': { code: 0, stdout: '', stderr: '' },
|
|
'cat >': { code: 0, stdout: '', stderr: '' },
|
|
'restart-svc': { code: 0, stdout: '', stderr: '' },
|
|
});
|
|
const downFetcher = (async () => { throw new Error('refused'); }) as unknown as typeof fetch;
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML,
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: downFetcher,
|
|
healthAttempts: 2, healthDelayMs: 1,
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
expect(r.step).toBe('health');
|
|
});
|
|
|
|
it('succeeds through the full pipeline', async () => {
|
|
const { exec } = makeExec({
|
|
"cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' },
|
|
'cp ': { code: 0, stdout: '', stderr: '' },
|
|
'cat >': { code: 0, stdout: '', stderr: '' },
|
|
'restart-svc': { code: 0, stdout: '', stderr: '' },
|
|
});
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML,
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
|
|
healthAttempts: 1, healthDelayMs: 1,
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
expect(r.step).toBe('done');
|
|
expect(r.backupPath).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('healthWait', () => {
|
|
it('returns true on first OK', async () => {
|
|
const ok = await healthWait('http://h', okFetcher, 3, 1);
|
|
expect(ok).toBe(true);
|
|
});
|
|
it('returns false after exhausting attempts', async () => {
|
|
const downFetcher = (async () => new Response('', { status: 503 })) as unknown as typeof fetch;
|
|
const ok = await healthWait('http://h', downFetcher, 2, 1);
|
|
expect(ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── wrapper mode (forced-command verbs) ─────────────────────────────────────
|
|
|
|
describe('applyRemoteConfig wrapper mode', () => {
|
|
it('sends verbs (not raw shell) and reads the backup path from the backup verb', async () => {
|
|
const { exec, calls } = makeExec({
|
|
read: { code: 0, stdout: 'models:\n old: {}\n', stderr: '' },
|
|
backup: { code: 0, stdout: '/c.yaml.bak-WRAP\n', stderr: '' },
|
|
write: { code: 0, stdout: '', stderr: '' },
|
|
restart: { code: 0, stdout: '', stderr: '' },
|
|
});
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'ignored-in-wrapper', newConfig: VALID_YAML,
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, mode: 'wrapper',
|
|
healthAttempts: 1, healthDelayMs: 1,
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
// backup path comes from the wrapper's stdout, not a client-computed name
|
|
expect(r.backupPath).toBe('/c.yaml.bak-WRAP');
|
|
// verbs only — no cat/cp/cat > shell commands
|
|
expect(calls).toEqual(['read', 'backup', 'write', 'restart']);
|
|
expect(calls.some((c) => c.includes('cat') || c.includes('cp '))).toBe(false);
|
|
});
|
|
|
|
it('aborts at write when the wrapper write verb fails (backup retained)', async () => {
|
|
const { exec } = makeExec({
|
|
read: { code: 0, stdout: 'old\n', stderr: '' },
|
|
backup: { code: 0, stdout: '/c.yaml.bak-WRAP\n', stderr: '' },
|
|
write: { code: 1, stdout: '', stderr: 'denied' },
|
|
});
|
|
const r = await applyRemoteConfig({
|
|
target, configPath: '/c.yaml', restartCmd: 'x', newConfig: VALID_YAML,
|
|
schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, mode: 'wrapper',
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
expect(r.step).toBe('write');
|
|
expect(r.backupPath).toBe('/c.yaml.bak-WRAP');
|
|
});
|
|
});
|