mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
The connect step records the connection in the state file BEFORE it writes the endpoint record and the settings flag (the flag's earlier value read by inspectLocalEndpoint()), so a state write that fails or a server killed between the two never leaves a record of ours that the state file does not know; a record under the reserved id that says exactly what ours would say is ours. changePort() writes the state first and puts the old port back when the endpoint move is refused. A job whose end could not be written (a full disk) is kept in memory: the view shows it, Resume and a new install start from it, the next verb writes it, and Cancel never answers a 500. A first install on a full disk is a 507 with the sentence. "installing" in the file while this process runs no job always reads as paused or that failure. An installed model whose runtime does not start says so (runtime-start-failed with Start as the next step, and the launch check says it too); the stored runtime version is started while its folder is unpacked. Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind before any file is deleted. Stop and "Use another port" answer 409 sessions-open while a live session uses the model. A pull that failed because the runtime is gone is runtime-stopped, not a network problem. After "does not fit" only a model that needs less memory is offered. A replaced job's partial blobs are deleted. Every 400 carries its sentence as data.message, and the six inline sentences are labels with zh-CN entries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1009 lines
42 KiB
TypeScript
1009 lines
42 KiB
TypeScript
/**
|
|
* @fileoverview The local model routes: GET /api/local-model (what this Mac is and which local
|
|
* model fits it, plus where the install stands) and the verbs that install, cancel, resume,
|
|
* start, stop and remove.
|
|
*
|
|
* The routes only compose injected functions (the hardware probe, the catalogue getter, the
|
|
* harness facts, the pure `recommend()` and the install service), so every test passes its own
|
|
* and no test depends on what those modules compute. What is pinned here is the routes' own
|
|
* contract: the envelope, the admin gate on EVERY verb in multi-user mode, that an unsupported
|
|
* platform and a failed probe are answers (200) and never a 500, what does answer 500, how a
|
|
* verb's refusal becomes a 400 or a 409 with its conflict, that DELETE hands the live sessions
|
|
* to the service, and that a GET with nothing installed writes nothing.
|
|
*
|
|
* The install service itself is tested in test/local-model-install-service.test.ts.
|
|
*
|
|
* Port: N/A (app.inject()).
|
|
*/
|
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest';
|
|
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { arch, homedir, totalmem } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
|
import {
|
|
defaultLocalModelRouteDeps,
|
|
hardwareFromOs,
|
|
registerLocalModelRoutes,
|
|
type LocalModelRouteDeps,
|
|
} from '../../src/web/routes/local-model-routes.js';
|
|
import { buildHarnessFacts } from '../../src/local-model/harness-facts.js';
|
|
import { formatGb, memClassGb } from '../../src/local-model/format.js';
|
|
import {
|
|
createLocalModelService,
|
|
getLocalModelService,
|
|
type LocalModelService,
|
|
type LocalModelVerbResult,
|
|
} from '../../src/local-model/install-job.js';
|
|
import { CustomModelHostsWriteRefusedError } from '../../src/custom-model-hosts-writer.js';
|
|
import { SettingsWriteRefusedError } from '../../src/web/settings-writer.js';
|
|
import { dataPath, getDataDir } from '../../src/config/instance.js';
|
|
import {
|
|
LOCAL_MODEL_INSTALL_LABELS,
|
|
LOCAL_MODEL_LABELS,
|
|
type HardwareFacts,
|
|
type HarnessFact,
|
|
type LocalModelCatalog,
|
|
type LocalModelInstallView,
|
|
type Recommendation,
|
|
} from '../../src/types.js';
|
|
|
|
const CATALOG: LocalModelCatalog = {
|
|
schema: 1,
|
|
updated: '2026-11-06',
|
|
minMacosMajor: 14,
|
|
models: [],
|
|
harnesses: [
|
|
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
|
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
|
],
|
|
chips: [],
|
|
};
|
|
|
|
/** A 48 GB Apple silicon Mac whose chip is in no chips row, as the probe would report it. */
|
|
const MAC: HardwareFacts = {
|
|
platform: 'darwin',
|
|
arch: 'arm64',
|
|
arm64: true,
|
|
translated: false,
|
|
memBytes: 51539607552,
|
|
memGb: '51.5',
|
|
memClassGb: 48,
|
|
chip: 'Apple M5 Pro',
|
|
model: 'Mac17,13',
|
|
macosVersion: '27.2',
|
|
macosMajor: 27,
|
|
wiredLimitMb: 0,
|
|
freeDiskBytes: 912308310016,
|
|
freeDiskGb: '912',
|
|
source: 'sysctl',
|
|
};
|
|
|
|
/** The same Mac after a `sysctl` spawn that failed: `node:os` supplied what little is known. */
|
|
const MAC_FALLBACK: HardwareFacts = {
|
|
platform: 'darwin',
|
|
arch: 'arm64',
|
|
memBytes: 51539607552,
|
|
memGb: '51.5',
|
|
memClassGb: 48,
|
|
chip: 'Apple M5 Pro',
|
|
source: 'os-fallback',
|
|
};
|
|
|
|
const LINUX: HardwareFacts = {
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
memBytes: 67430404096,
|
|
memGb: '67.4',
|
|
memClassGb: 63,
|
|
source: 'not-probed',
|
|
};
|
|
|
|
const OK: Recommendation = {
|
|
verdict: 'ok',
|
|
headline: { text: LOCAL_MODEL_LABELS.headlineOk },
|
|
notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }],
|
|
pick: {
|
|
modelId: 'qwen3.8:27b',
|
|
modelLabel: 'Qwen3.8 27B',
|
|
license: 'Apache-2.0',
|
|
paramsB: 27.8,
|
|
context: 131072,
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
cliInstalled: true,
|
|
needBytes: 29551160064,
|
|
needGb: '29.6',
|
|
downloadBytes: 17740000000,
|
|
downloadGb: '17.7',
|
|
budgetShare: 0.79,
|
|
fit: 'good',
|
|
fitLabel: LOCAL_MODEL_LABELS.fitGood,
|
|
speed: 'unknown',
|
|
speedLabel: LOCAL_MODEL_LABELS.speedUnknown,
|
|
tested: false,
|
|
needSource: 'estimated',
|
|
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }],
|
|
installBytes: 17740000000,
|
|
installGb: '17.7',
|
|
},
|
|
alternatives: [],
|
|
budgetBytes: 37580963840,
|
|
budgetGb: '37.6',
|
|
budgetSource: 'estimated',
|
|
};
|
|
|
|
const NOT_MACOS: Recommendation = {
|
|
verdict: 'none',
|
|
reason: 'not-macos',
|
|
headline: { text: LOCAL_MODEL_LABELS.noneNotMacos },
|
|
notes: [],
|
|
alternatives: [],
|
|
budgetBytes: 0,
|
|
budgetGb: '0.0',
|
|
budgetSource: 'estimated',
|
|
};
|
|
|
|
const FACTS: HarnessFact[] = [
|
|
{ cli: 'opencode', label: 'OpenCode', installed: true, installable: false, acceptsModelId: () => true },
|
|
];
|
|
|
|
/** The install view of a Mac with nothing installed, as the service would answer it. */
|
|
const IDLE_VIEW: LocalModelInstallView = {
|
|
state: 'idle',
|
|
running: false,
|
|
developerTools: 'missing',
|
|
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
|
|
folder: '/Users/me/.codeman/local-model',
|
|
actions: ['install'],
|
|
};
|
|
|
|
const INSTALLING_VIEW: LocalModelInstallView = {
|
|
...IDLE_VIEW,
|
|
state: 'installing',
|
|
step: 'runtime',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
model: { id: 'qwen3.8:27b', label: 'Qwen3.8 27B' },
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
context: 131072,
|
|
actions: ['cancel'],
|
|
};
|
|
|
|
const OK_RESULT: LocalModelVerbResult = { ok: true, view: INSTALLING_VIEW };
|
|
|
|
type ServiceMock = { [K in keyof LocalModelService]: Mock<LocalModelService[K]> };
|
|
|
|
/** An install service whose every verb answers `OK_RESULT` and whose view is idle. */
|
|
function fakeService(): ServiceMock {
|
|
return {
|
|
view: vi.fn(async () => IDLE_VIEW),
|
|
launchRefusal: vi.fn(async () => undefined),
|
|
launchRefusalMessage: vi.fn(async () => undefined),
|
|
recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })),
|
|
install: vi.fn(async () => OK_RESULT),
|
|
cancel: vi.fn(async () => OK_RESULT),
|
|
resume: vi.fn(async () => OK_RESULT),
|
|
runtime: vi.fn(async () => OK_RESULT),
|
|
remove: vi.fn(async () => OK_RESULT),
|
|
boot: vi.fn(async () => {}),
|
|
shutdown: vi.fn(async () => {}),
|
|
onRuntimeChange: vi.fn(),
|
|
idle: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
interface Fakes {
|
|
deps: LocalModelRouteDeps;
|
|
readHardware: Mock<LocalModelRouteDeps['readHardware']>;
|
|
getCatalog: Mock<LocalModelRouteDeps['getCatalog']>;
|
|
buildHarnessFacts: Mock<LocalModelRouteDeps['buildHarnessFacts']>;
|
|
recommend: Mock<LocalModelRouteDeps['recommend']>;
|
|
service: ServiceMock;
|
|
}
|
|
|
|
function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = OK): Fakes {
|
|
const readHardware = vi.fn<LocalModelRouteDeps['readHardware']>(async () => hardware);
|
|
const getCatalog = vi.fn<LocalModelRouteDeps['getCatalog']>(() => CATALOG);
|
|
const buildFacts = vi.fn<LocalModelRouteDeps['buildHarnessFacts']>(async () => FACTS);
|
|
const recommend = vi.fn<LocalModelRouteDeps['recommend']>(() => recommendation);
|
|
const service = fakeService();
|
|
return {
|
|
deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend, service: () => service },
|
|
readHardware,
|
|
getCatalog,
|
|
buildHarnessFacts: buildFacts,
|
|
recommend,
|
|
service,
|
|
};
|
|
}
|
|
|
|
type AuthUser = { username: string; role: 'admin' | 'user' };
|
|
|
|
/** The live sessions the DELETE looks at: a name, and for some the endpoint their model comes from. */
|
|
type SessionLike = { name: string; customModel?: { endpointId: string } };
|
|
|
|
const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: AuthUser, sessions: SessionLike[] = []) =>
|
|
createRouteTestHarness(
|
|
(app) =>
|
|
registerLocalModelRoutes(app, { sessions: new Map(sessions.map((session, i) => [`s${i}`, session])) }, deps),
|
|
authUser ? { authUser } : undefined
|
|
);
|
|
|
|
const get = async (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) => {
|
|
const { app } = await harness(deps, authUser);
|
|
return app.inject({ method: 'GET', url: '/api/local-model' });
|
|
};
|
|
|
|
/** Every file and folder under `root`, with its size and modification time. */
|
|
function snapshot(root: string): string[] {
|
|
if (!existsSync(root)) return [];
|
|
const rows: string[] = [];
|
|
const walk = (dir: string) => {
|
|
for (const name of readdirSync(dir).sort()) {
|
|
const path = join(dir, name);
|
|
const stat = lstatSync(path);
|
|
rows.push(`${path} ${stat.isDirectory() ? 'dir' : stat.size} ${stat.mtimeMs}`);
|
|
if (stat.isDirectory()) walk(path);
|
|
}
|
|
};
|
|
walk(root);
|
|
return rows;
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete process.env.CODEMAN_MULTIUSER;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('GET /api/local-model', () => {
|
|
it('answers the success envelope with the hardware, the recommendation and the catalogue date', async () => {
|
|
const f = fakes();
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({
|
|
success: true,
|
|
data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06', install: IDLE_VIEW },
|
|
});
|
|
});
|
|
|
|
it('recommends with what the install service knows: the measured budget and the runtime still to download', async () => {
|
|
const f = fakes();
|
|
f.service.recommendOptions.mockResolvedValue({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 });
|
|
await get(f.deps);
|
|
expect(f.service.recommendOptions).toHaveBeenCalledTimes(1);
|
|
expect(f.recommend.mock.calls[0][3]).toEqual({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 });
|
|
});
|
|
|
|
it('carries the install view of the service as it is, in every state', async () => {
|
|
const f = fakes();
|
|
f.service.view.mockResolvedValue(INSTALLING_VIEW);
|
|
const res = await get(f.deps);
|
|
expect(res.json().data.install).toEqual(INSTALLING_VIEW);
|
|
expect(f.service.view).toHaveBeenCalledTimes(1);
|
|
// Looking is not doing: no verb of the service was called.
|
|
for (const verb of ['install', 'cancel', 'resume', 'runtime', 'remove', 'boot', 'shutdown'] as const) {
|
|
expect(f.service[verb]).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('hands recommend() exactly what the probe, the catalogue and the harness facts answered', async () => {
|
|
const f = fakes();
|
|
await get(f.deps);
|
|
expect(f.readHardware).toHaveBeenCalledTimes(1);
|
|
expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1);
|
|
expect(f.buildHarnessFacts).toHaveBeenCalledWith(CATALOG);
|
|
expect(f.recommend).toHaveBeenCalledTimes(1);
|
|
const [hw, catalog, facts] = f.recommend.mock.calls[0];
|
|
expect(hw).toBe(MAC);
|
|
expect(catalog).toBe(CATALOG);
|
|
expect(facts).toBe(FACTS);
|
|
});
|
|
|
|
it('sends the payload as the modules built it: no size is reformatted and no function leaks', async () => {
|
|
const res = await get(fakes().deps);
|
|
const { data } = res.json();
|
|
expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'install', 'recommendation']);
|
|
expect(data.hardware.memGb).toBe('51.5');
|
|
expect(data.hardware.freeDiskGb).toBe('912');
|
|
expect(data.recommendation.pick.needGb).toBe('29.6');
|
|
expect(data.recommendation.budgetGb).toBe('37.6');
|
|
// Harness facts hold a function and stay on the server.
|
|
expect(res.body).not.toContain('acceptsModelId');
|
|
expect(res.body).not.toContain('installable');
|
|
});
|
|
|
|
it('is the only thing a GET does: there is no POST, PUT or PATCH on the same path', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
for (const method of ['POST', 'PUT', 'PATCH'] as const) {
|
|
expect((await app.inject({ method, url: '/api/local-model', payload: {} })).statusCode).toBe(404);
|
|
}
|
|
expect(f.service.install).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('multi-user mode', () => {
|
|
it('refuses a non-admin with 403 before anything is probed', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const res = await get(f.deps, { username: 'bob', role: 'user' });
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
|
|
expect(res.body).not.toContain('Apple M5 Pro');
|
|
expect(f.readHardware).not.toHaveBeenCalled();
|
|
expect(f.getCatalog).not.toHaveBeenCalled();
|
|
expect(f.buildHarnessFacts).not.toHaveBeenCalled();
|
|
expect(f.recommend).not.toHaveBeenCalled();
|
|
expect(f.service.view).not.toHaveBeenCalled();
|
|
expect(f.service.recommendOptions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers an admin', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const res = await get(fakes().deps, { username: 'root', role: 'admin' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.hardware.chip).toBe('Apple M5 Pro');
|
|
});
|
|
|
|
it('is not gated while multi-user mode is off, whoever asks', async () => {
|
|
const res = await get(fakes().deps, { username: 'bob', role: 'user' });
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('an unsupported platform is an answer', () => {
|
|
it('answers 200 with verdict none and reason not-macos on a server that is not a Mac', async () => {
|
|
const f = fakes(LINUX, NOT_MACOS);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
const { success, data } = res.json();
|
|
expect(success).toBe(true);
|
|
expect(data.hardware).toEqual(LINUX);
|
|
expect(data.hardware.source).toBe('not-probed');
|
|
expect(data.recommendation.verdict).toBe('none');
|
|
expect(data.recommendation.reason).toBe('not-macos');
|
|
expect(data.recommendation.pick).toBeUndefined();
|
|
expect(data.recommendation.alternatives).toEqual([]);
|
|
expect(data.recommendation.headline.text).toBe(LOCAL_MODEL_LABELS.noneNotMacos);
|
|
expect(data.catalogUpdated).toBe('2026-11-06');
|
|
expect(f.recommend.mock.calls[0][0]).toBe(LINUX);
|
|
});
|
|
});
|
|
|
|
describe('a failed probe is an answer', () => {
|
|
it('answers 200 when the probe reports that sysctl failed (source os-fallback)', async () => {
|
|
const f = fakes(MAC_FALLBACK, OK);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.hardware).toEqual(MAC_FALLBACK);
|
|
expect(res.json().data.recommendation.verdict).toBe('ok');
|
|
});
|
|
|
|
it('answers 200 from node:os when the probe itself rejects, and still recommends', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const f = fakes();
|
|
f.readHardware.mockRejectedValue(new Error('spawn EAGAIN'));
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
const { success, data } = res.json();
|
|
expect(success).toBe(true);
|
|
expect(data.hardware).toEqual(hardwareFromOs());
|
|
expect(data.hardware.memBytes).toBe(totalmem());
|
|
expect(data.recommendation).toEqual(OK);
|
|
expect(f.recommend).toHaveBeenCalledTimes(1);
|
|
expect(f.recommend.mock.calls[0][0]).toEqual(hardwareFromOs());
|
|
expect(String(warn.mock.calls[0][0])).toContain('spawn EAGAIN');
|
|
});
|
|
|
|
it('hardwareFromOs() is a complete answer of its own, sized by the one formatter', () => {
|
|
const hw = hardwareFromOs();
|
|
expect(hw.platform).toBe(process.platform);
|
|
expect(hw.arch).toBe(arch());
|
|
expect(hw.memBytes).toBe(totalmem());
|
|
expect(hw.memGb).toBe(formatGb(totalmem()));
|
|
expect(hw.memClassGb).toBe(memClassGb(totalmem()));
|
|
expect(hw.source).toBe(process.platform === 'darwin' ? 'os-fallback' : 'not-probed');
|
|
// Nothing that only sysctl or statfs can know is invented.
|
|
expect(Object.keys(hw).sort()).toEqual(['arch', 'memBytes', 'memClassGb', 'memGb', 'platform', 'source']);
|
|
});
|
|
|
|
it('answers 200 when no harness fact could be built (the facts are just empty)', async () => {
|
|
const none: Recommendation = {
|
|
...NOT_MACOS,
|
|
reason: 'no-harness',
|
|
headline: { text: LOCAL_MODEL_LABELS.noneNoHarness },
|
|
};
|
|
const f = fakes(MAC, none);
|
|
f.buildHarnessFacts.mockResolvedValue([]);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.recommendation.reason).toBe('no-harness');
|
|
expect(f.recommend.mock.calls[0][2]).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('a fault that is not a probe is a 500 in the envelope, never a made-up answer', () => {
|
|
it.each([
|
|
[
|
|
'recommend() throws',
|
|
(f: Fakes) =>
|
|
f.recommend.mockImplementation(() => {
|
|
throw new Error('rank table is empty');
|
|
}),
|
|
'rank table is empty',
|
|
],
|
|
[
|
|
'the catalogue getter throws',
|
|
(f: Fakes) =>
|
|
f.getCatalog.mockImplementation(() => {
|
|
throw new Error('catalogue did not parse');
|
|
}),
|
|
'catalogue did not parse',
|
|
],
|
|
[
|
|
'the harness facts reject',
|
|
(f: Fakes) => f.buildHarnessFacts.mockRejectedValue(new Error('registry unreadable')),
|
|
'registry unreadable',
|
|
],
|
|
])('%s', async (_label, breakIt, message) => {
|
|
const f = fakes();
|
|
breakIt(f);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(500);
|
|
const body = res.json();
|
|
expect(body.success).toBe(false);
|
|
expect(body.errorCode).toBe('INTERNAL_ERROR');
|
|
expect(body.error).toContain(message);
|
|
expect(body.data).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('read-only', () => {
|
|
it('writes nothing: the home directory and the data dir are byte for byte what they were', async () => {
|
|
// The real harness facts (they read the CLI registry and run the availability probe),
|
|
// the REAL install service over the real state file (nothing is installed, so its view
|
|
// is idle), the route's own composition, and a hardware answer and a recommendation of
|
|
// the test's.
|
|
const f = fakes();
|
|
const service = createLocalModelService();
|
|
const { app } = await harness({
|
|
...f.deps,
|
|
buildHarnessFacts: (catalog) => buildHarnessFacts(catalog),
|
|
service: () => service,
|
|
});
|
|
|
|
const home = homedir();
|
|
const before = snapshot(home);
|
|
const dataDirExisted = existsSync(getDataDir());
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.install).toMatchObject({ state: 'idle', running: false, actions: ['install'] });
|
|
}
|
|
// A refused request and a failing one write nothing either.
|
|
f.recommend.mockImplementationOnce(() => {
|
|
throw new Error('boom');
|
|
});
|
|
expect((await app.inject({ method: 'GET', url: '/api/local-model' })).statusCode).toBe(500);
|
|
|
|
expect(snapshot(home)).toEqual(before);
|
|
expect(existsSync(getDataDir())).toBe(dataDirExisted);
|
|
expect(existsSync(join(getDataDir(), 'settings.json'))).toBe(false);
|
|
expect(existsSync(join(getDataDir(), 'local-model.json'))).toBe(false);
|
|
expect(existsSync(join(getDataDir(), 'local-models.json'))).toBe(false);
|
|
// The facts really were built from the registry for the catalogue's two harnesses.
|
|
expect(f.recommend.mock.calls[0][2].map((fact) => fact.cli)).toEqual(['opencode', 'claude']);
|
|
|
|
// The comparison above is not vacuous: one new file in the home directory shows up.
|
|
const probe = join(home, 'local-model-route-test-probe');
|
|
writeFileSync(probe, 'x');
|
|
try {
|
|
expect(snapshot(home)).not.toEqual(before);
|
|
} finally {
|
|
rmSync(probe);
|
|
}
|
|
});
|
|
|
|
it('is registered on the real server through the routes barrel', () => {
|
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
|
|
const barrel = readFileSync(join(root, 'src/web/routes/index.ts'), 'utf8');
|
|
expect(barrel).toContain("export { registerLocalModelRoutes } from './local-model-routes.js';");
|
|
// The route context for the live sessions, and no third argument: the server runs the
|
|
// real probe, catalogue, facts, recommend() and install service.
|
|
expect(server).toContain('registerLocalModelRoutes(this.app, ctx);');
|
|
});
|
|
|
|
it('the server starts the install service at boot and stops it at shutdown, outside tests only', () => {
|
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
|
|
const boot = server.indexOf('void getLocalModelService().boot();');
|
|
const shutdown = server.indexOf('void getLocalModelService().shutdown();');
|
|
expect(boot).toBeGreaterThan(-1);
|
|
expect(shutdown).toBeGreaterThan(-1);
|
|
// Each sits in a block that tests never enter: nothing of the runtime runs under vitest.
|
|
for (const at of [boot, shutdown]) {
|
|
const before = server.slice(0, at).trimEnd();
|
|
expect(before.endsWith('if (!this.testMode) {')).toBe(true);
|
|
}
|
|
// Shutdown stands beside the other supervised child's stop.
|
|
const deepseek = server.indexOf('void stopDeepSeekWeb();');
|
|
expect(deepseek).toBeGreaterThan(-1);
|
|
expect(shutdown).toBeGreaterThan(deepseek);
|
|
expect(shutdown - deepseek).toBeLessThan(700);
|
|
});
|
|
|
|
it('the default dependencies are the real modules, read at call time', () => {
|
|
expect(Object.keys(defaultLocalModelRouteDeps).sort()).toEqual([
|
|
'buildHarnessFacts',
|
|
'getCatalog',
|
|
'readHardware',
|
|
'recommend',
|
|
'service',
|
|
]);
|
|
for (const dep of Object.values(defaultLocalModelRouteDeps)) expect(typeof dep).toBe('function');
|
|
// The one install service of the process, the same object every time.
|
|
expect(defaultLocalModelRouteDeps.service()).toBe(getLocalModelService());
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the install verbs', () => {
|
|
const INSTALL_BODY = { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072, acknowledgedBytes: 17926000000 };
|
|
|
|
/** Every verb as a request, with a body the route accepts. */
|
|
const VERBS = [
|
|
{ name: 'install', method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY },
|
|
{ name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined },
|
|
{ name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} },
|
|
{ name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } },
|
|
{ name: 'remove', method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } },
|
|
] as const;
|
|
|
|
const call = (app: Awaited<ReturnType<typeof harness>>['app'], verb: (typeof VERBS)[number]) =>
|
|
app.inject({ method: verb.method, url: verb.url, ...(verb.payload ? { payload: verb.payload } : {}) });
|
|
|
|
describe.each(VERBS)('$method $url', (verb) => {
|
|
it('answers the view after the verb in the success envelope', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({ success: true, data: INSTALLING_VIEW });
|
|
expect(f.service[verb.name]).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('multi-user mode: a non-admin is refused with 403 before the service is asked anything', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
|
|
for (const name of ['install', 'cancel', 'resume', 'runtime', 'remove', 'view', 'recommendOptions'] as const) {
|
|
expect(f.service[name]).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('multi-user mode: the gate comes before the body is even parsed', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
|
|
const res = await app.inject({ method: verb.method, url: verb.url, payload: { nonsense: true } });
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('multi-user mode: an admin is served', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'root', role: 'admin' });
|
|
expect((await call(app, verb)).statusCode).toBe(200);
|
|
expect(f.service[verb.name]).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('is not gated while multi-user mode is off', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
|
|
expect((await call(app, verb)).statusCode).toBe(200);
|
|
});
|
|
|
|
it('a 409 of the service is the error envelope plus the conflict as data', async () => {
|
|
const f = fakes();
|
|
f.service[verb.name].mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'An install is already running.',
|
|
errorCode: 'CONFLICT',
|
|
data: { reason: 'job-running', message: { text: 'An install is already running.' } },
|
|
});
|
|
});
|
|
|
|
it('an exception of the service is a 500 in the envelope', async () => {
|
|
const f = fakes();
|
|
f.service[verb.name].mockRejectedValue(new Error('disk on fire'));
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'INTERNAL_ERROR' });
|
|
expect(res.json().error).toContain('disk on fire');
|
|
});
|
|
|
|
it.each([
|
|
['the endpoint store', new CustomModelHostsWriteRefusedError('custom-model-hosts.json is not a valid JSON list')],
|
|
['the settings file', new SettingsWriteRefusedError('settings.json is not a valid JSON object')],
|
|
])('a refused write of %s is a 409 with the fix and no conflict data', async (_label, error) => {
|
|
const f = fakes();
|
|
f.service[verb.name].mockRejectedValue(error);
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({ success: false, error: error.message, errorCode: 'CONFLICT' });
|
|
});
|
|
});
|
|
|
|
describe('POST /api/local-model/install', () => {
|
|
it('hands the service the four choices and who asked', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' });
|
|
});
|
|
|
|
it('a row the server does not offer is a 400 with the service own sentence, as text and as data', async () => {
|
|
const f = fakes();
|
|
f.service.install.mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(res.statusCode).toBe(400);
|
|
// `error` is the English sentence with its slots filled; `data.message` is the sentence
|
|
// with its slots apart, which the window prints in its own language.
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'OpenCode is not installed and cannot be installed from here.',
|
|
errorCode: 'INVALID_INPUT',
|
|
data: {
|
|
message: { text: '{cli} is not installed and cannot be installed from here.', vars: { cli: 'OpenCode' } },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('a first install on a full disk is a 507 with the sentence, not a 500', async () => {
|
|
const f = fakes();
|
|
f.service.install.mockResolvedValue({
|
|
ok: false,
|
|
status: 507,
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.errDiskFull },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(res.statusCode).toBe(507);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'The disk is full. Free up space, then try again.',
|
|
errorCode: 'OPERATION_FAILED',
|
|
data: { message: { text: 'The disk is full. Free up space, then try again.' } },
|
|
});
|
|
});
|
|
|
|
it('consent bound to the size: the 409 carries the server own total, so the window can ask again', async () => {
|
|
const f = fakes();
|
|
f.service.install.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: {
|
|
reason: 'size-changed',
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.3' } },
|
|
expectedBytes: 23291770627,
|
|
expectedGb: '23.3',
|
|
},
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'The download is 23.3 GB now. Check the sizes and confirm again.',
|
|
errorCode: 'CONFLICT',
|
|
data: {
|
|
reason: 'size-changed',
|
|
message: {
|
|
text: 'The download is {size} GB now. Check the sizes and confirm again.',
|
|
vars: { size: '23.3' },
|
|
},
|
|
expectedBytes: 23291770627,
|
|
expectedGb: '23.3',
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['no body', undefined],
|
|
['a missing size', { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072 }],
|
|
['a model id with a registry host in it', { ...INSTALL_BODY, modelId: 'evil.example/x/y:z' }],
|
|
['a model id without a tag', { ...INSTALL_BODY, modelId: 'qwen3.8' }],
|
|
['an upper-case model id', { ...INSTALL_BODY, modelId: 'Qwen3.8:27B' }],
|
|
['a coding agent id with a space', { ...INSTALL_BODY, cli: 'open code' }],
|
|
['a coding agent id that is a path', { ...INSTALL_BODY, cli: '../bin/sh' }],
|
|
['a context of zero', { ...INSTALL_BODY, context: 0 }],
|
|
['a context that is not a whole number', { ...INSTALL_BODY, context: 1.5 }],
|
|
['a negative size', { ...INSTALL_BODY, acknowledgedBytes: -1 }],
|
|
['a size as text', { ...INSTALL_BODY, acknowledgedBytes: '17926000000' }],
|
|
['a member nobody defined', { ...INSTALL_BODY, url: 'https://example.invalid/ollama.tgz' }],
|
|
])('%s is a 400 before the service is asked', async (_label, payload) => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
...(payload ? { payload } : {}),
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
|
|
expect(f.service.install).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/local-model/resume and /runtime', () => {
|
|
it('resume takes no body, an empty one, or newPort', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume' });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { newPort: true } });
|
|
expect(f.service.resume.mock.calls.map(([body]) => body)).toEqual([{}, {}, { newPort: true }]);
|
|
expect(f.service.resume.mock.calls[0][1]).toEqual({ username: 'admin', ip: '127.0.0.1' });
|
|
});
|
|
|
|
it('resume and runtime hand the service the live sessions on the model, read at the moment of the request', async () => {
|
|
const f = fakes();
|
|
const sessions = new Map<string, SessionLike>([
|
|
['a', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }],
|
|
['b', { name: 'w2-app', customModel: { endpointId: 'office-gpu' } }],
|
|
]);
|
|
const { app } = await createRouteTestHarness((instance) =>
|
|
registerLocalModelRoutes(instance, { sessions }, f.deps)
|
|
);
|
|
await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { newPort: true } });
|
|
sessions.delete('a');
|
|
await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
expect(f.service.runtime.mock.calls.map((args) => args[1])).toEqual([['w1-app'], []]);
|
|
expect(f.service.resume.mock.calls[0][2]).toEqual(['w1-app']);
|
|
});
|
|
|
|
it('a stop while a live session uses the model: 409 with the session names', async () => {
|
|
const f = fakes();
|
|
f.service.runtime.mockImplementation(async (_request, names = []) => ({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: {
|
|
reason: 'sessions-open',
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: names.join(', ') } },
|
|
sessions: [...names],
|
|
},
|
|
}));
|
|
const { app } = await harness(f.deps, undefined, [
|
|
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
|
]);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toMatchObject({
|
|
errorCode: 'CONFLICT',
|
|
error: 'Sessions are still using this model: w1-app. Close them first.',
|
|
data: { reason: 'sessions-open', sessions: ['w1-app'] },
|
|
});
|
|
});
|
|
|
|
it('resume refuses a body it does not know', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { port: 80 } });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.resume).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runtime takes start or stop, and newPort with it', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/runtime',
|
|
payload: { action: 'start', newPort: true },
|
|
});
|
|
await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
expect(f.service.runtime.mock.calls.map(([body]) => body)).toEqual([
|
|
{ action: 'start', newPort: true },
|
|
{ action: 'stop' },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
['no body', undefined],
|
|
['no action', {}],
|
|
['an action that is neither', { action: 'restart' }],
|
|
['a port number of the caller own choosing', { action: 'start', port: 22 }],
|
|
])('runtime with %s is a 400', async (_label, payload) => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/runtime',
|
|
...(payload ? { payload } : {}),
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.runtime).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/local-model', () => {
|
|
const SESSIONS: SessionLike[] = [
|
|
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
|
{ name: 'w2-app' },
|
|
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
|
{ name: 'w4-lib', customModel: { endpointId: 'local-ollama' } },
|
|
];
|
|
|
|
it('hands the service the names of the live sessions on the model, and who asked', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, undefined, SESSIONS);
|
|
await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(f.service.remove).toHaveBeenCalledWith({ removeModel: true }, ['w1-app', 'w4-lib'], {
|
|
username: 'admin',
|
|
ip: '127.0.0.1',
|
|
});
|
|
});
|
|
|
|
it('without a live session on the model the list is empty, and no body means the model stays', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, undefined, [SESSIONS[1], SESSIONS[2]]);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(f.service.remove.mock.calls[0][0]).toEqual({});
|
|
expect(f.service.remove.mock.calls[0][1]).toEqual([]);
|
|
});
|
|
|
|
it('the sessions are read at the moment of the request, not when the routes were registered', async () => {
|
|
const f = fakes();
|
|
const sessions = new Map<string, SessionLike>();
|
|
const { app } = await createRouteTestHarness((instance) =>
|
|
registerLocalModelRoutes(instance, { sessions }, f.deps)
|
|
);
|
|
await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
sessions.set('late', { name: 'w9-late', customModel: { endpointId: 'local-ollama' } });
|
|
await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(f.service.remove.mock.calls.map((args) => args[1])).toEqual([[], ['w9-late']]);
|
|
});
|
|
|
|
it('while a live session uses the model: 409 with the session names', async () => {
|
|
const f = fakes();
|
|
f.service.remove.mockImplementation(async (_request, names) => ({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: {
|
|
reason: 'sessions-open',
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: names.join(', ') } },
|
|
sessions: [...names],
|
|
},
|
|
}));
|
|
const { app } = await harness(f.deps, undefined, SESSIONS);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'Sessions are still using this model: w1-app, w4-lib. Close them first.',
|
|
errorCode: 'CONFLICT',
|
|
data: {
|
|
reason: 'sessions-open',
|
|
message: {
|
|
text: 'Sessions are still using this model: {sessions}. Close them first.',
|
|
vars: { sessions: 'w1-app, w4-lib' },
|
|
},
|
|
sessions: ['w1-app', 'w4-lib'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('refuses a body it does not know', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { path: '/' } });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.remove).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the routes over the real install service', () => {
|
|
const STATE = () => dataPath('local-model.json');
|
|
|
|
afterEach(() => {
|
|
rmSync(STATE(), { force: true });
|
|
});
|
|
|
|
/** The real service with the real state file. Only the hardware is the test's: a server that is not a Mac. */
|
|
function realService(): LocalModelService {
|
|
return createLocalModelService({ readHardware: async () => LINUX });
|
|
}
|
|
|
|
it('on a server that is not a Mac nothing can be installed: the install is a 400 and nothing is written', async () => {
|
|
const service = realService();
|
|
const { app } = await harness({ readHardware: async () => LINUX, service: () => service });
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
payload: { modelId: 'qwen3.6:35b-a3b', cli: 'opencode', context: 131072, acknowledgedBytes: 1 },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
|
|
expect(existsSync(STATE())).toBe(false);
|
|
const get = await app.inject({ method: 'GET', url: '/api/local-model' });
|
|
expect(get.json().data.install).toMatchObject({ state: 'idle', actions: ['install'] });
|
|
expect(get.json().data.recommendation).toMatchObject({ verdict: 'none', reason: 'not-macos' });
|
|
});
|
|
|
|
it('with nothing installed: cancel, resume and a runtime action are 409 wrong-state', async () => {
|
|
const service = realService();
|
|
const { app } = await harness({ service: () => service });
|
|
for (const [url, payload] of [
|
|
['/api/local-model/cancel', undefined],
|
|
['/api/local-model/resume', {}],
|
|
['/api/local-model/runtime', { action: 'start' }],
|
|
] as const) {
|
|
const res = await app.inject({ method: 'POST', url, ...(payload ? { payload } : {}) });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toMatchObject({
|
|
success: false,
|
|
errorCode: 'CONFLICT',
|
|
error: 'That is not possible in the current state.',
|
|
data: { reason: 'wrong-state' },
|
|
});
|
|
}
|
|
expect(existsSync(STATE())).toBe(false);
|
|
});
|
|
|
|
it('a job the state file still calls installing comes back paused on the first GET of a new server', async () => {
|
|
writeFileSync(
|
|
STATE(),
|
|
JSON.stringify({
|
|
schema: 1,
|
|
state: 'installing',
|
|
step: 'model',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
|
|
cli: { id: 'opencode', label: 'OpenCode' },
|
|
runtime: { version: '0.40.15', port: 11435, enabled: true },
|
|
progress: { completedBytes: 6200000000, totalBytes: 23105798956 },
|
|
})
|
|
);
|
|
const service = realService();
|
|
const { app } = await harness({ readHardware: async () => LINUX, service: () => service });
|
|
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
|
|
expect(res.json().data.install).toMatchObject({
|
|
state: 'paused',
|
|
step: 'model',
|
|
running: false,
|
|
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
|
cli: 'opencode',
|
|
port: 11435,
|
|
progress: { completedBytes: 6200000000, totalBytes: 23105798956, completedGb: '6.2', totalGb: '23.1' },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
expect(JSON.parse(readFileSync(STATE(), 'utf8')).state).toBe('paused');
|
|
// A second install while that job is only paused is refused for what it asks, not started.
|
|
const cancel = await app.inject({ method: 'POST', url: '/api/local-model/cancel' });
|
|
expect(cancel.statusCode).toBe(409);
|
|
});
|
|
});
|