mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +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>
764 lines
34 KiB
TypeScript
764 lines
34 KiB
TypeScript
/**
|
|
* @fileoverview The local model install from the first GET to Remove, with the REAL modules
|
|
* joined together: the routes, the install service, the catalogue and `recommend()`, the
|
|
* runtime download and unpack, the supervisor, the pull, the checks, the connection and the
|
|
* state file. Only what would touch the world is a stand-in:
|
|
*
|
|
* - the runtime is the fake HTTP server of test/fixtures/fake-ollama.ts (one instance serves
|
|
* the archive, a second one is what the supervisor "starts" through the fake's `spawn`);
|
|
* - the pin points at the fake's small archive and carries that archive's own sha256, so the
|
|
* real download, the real hash comparison and the real `/usr/bin/tar` run;
|
|
* - the hardware is the owner's MacBook as its `sysctl` reported it (Apple M5 Pro, 48 GB);
|
|
* - the coding agent's installer is a function that says it ran;
|
|
* - the waits between retries are skipped (the retries themselves are the real ones).
|
|
*
|
|
* Nothing is downloaded from the network, no process is started and no fixed port is used
|
|
* (the runtime's port is one the system handed out). Files live in the test's temp data dir.
|
|
*
|
|
* The walks: the whole install and its removal; a damaged runtime download; a model download
|
|
* that fails twice and then succeeds; one that keeps failing; a model that calls no tool;
|
|
* Cancel during the model download; a server restart during the model download; a server
|
|
* that is KILLED during the model download and leaves its runtime running.
|
|
*
|
|
* "A server" here is one install service with its own runtime supervisor (`startServer()`).
|
|
* A restart is `shutdown()` of the one and a new one over the same data dir.
|
|
*
|
|
* The other tests give each module fakes of its neighbours. This one is what fails when two
|
|
* modules that each pass their own tests do not fit together.
|
|
*
|
|
* Port: the fake runtime binds port 0 twice; the routes use app.inject().
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { rm } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
|
import {
|
|
createFakeOllama,
|
|
freeLoopbackPort,
|
|
MACBOOK_MEASURED_TOTAL_BYTES,
|
|
startFakeOllama,
|
|
type FakeOllama,
|
|
} from './fixtures/fake-ollama.js';
|
|
import { dataPath, getDataDir } from '../src/config/instance.js';
|
|
import { customModelHostsPath, readCustomModelHosts } from '../src/custom-model-hosts.js';
|
|
import { createLocalModelService, type LocalModelService } from '../src/local-model/install-job.js';
|
|
import { memClassGb, formatGb } from '../src/local-model/format.js';
|
|
import { pullModel, PULL_RETRIES } from '../src/local-model/ollama-client.js';
|
|
import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js';
|
|
import {
|
|
createRuntimeSupervisor,
|
|
ensureRuntimeInstalled,
|
|
type RuntimeSupervisorDeps,
|
|
} from '../src/local-model/runtime.js';
|
|
import { readLocalModelState } from '../src/local-model/state-store.js';
|
|
import { registerLocalModelRoutes } from '../src/web/routes/local-model-routes.js';
|
|
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
|
import type { Candidate, HardwareFacts, HarnessFact, LocalModelInstallView, LocalModelStatus } from '../src/types.js';
|
|
|
|
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
|
|
|
/** The owner's MacBook, from its sysctl capture of 2026-11-06. */
|
|
const MACBOOK: HardwareFacts = {
|
|
platform: 'darwin',
|
|
arch: 'arm64',
|
|
arm64: true,
|
|
translated: false,
|
|
memBytes: 51539607552,
|
|
memGb: formatGb(51539607552),
|
|
memClassGb: memClassGb(51539607552),
|
|
chip: 'Apple M5 Pro',
|
|
model: 'Mac17,13',
|
|
macosVersion: '27.2',
|
|
macosMajor: 27,
|
|
wiredLimitMb: 0,
|
|
freeDiskBytes: 912308310016,
|
|
freeDiskGb: formatGb(912308310016),
|
|
source: 'sysctl',
|
|
};
|
|
|
|
interface World {
|
|
archive: FakeOllama;
|
|
runtime: FakeOllama | undefined;
|
|
port: number;
|
|
paths: LocalModelPaths;
|
|
agentInstalled: boolean;
|
|
installerRuns: number;
|
|
/** How many of the next archive downloads arrive with one byte changed (the size stays the pinned one). */
|
|
damagedDownloads: number;
|
|
/** The server that is up. A restart replaces it. */
|
|
service: LocalModelService;
|
|
/** Every server of the test, for the clean-up. */
|
|
services: LocalModelService[];
|
|
sessions: Map<string, { name: string; customModel?: { endpointId: string } }>;
|
|
}
|
|
|
|
let world: World;
|
|
|
|
const HOSTS = () => customModelHostsPath(getDataDir());
|
|
const STATE = () => dataPath('local-model.json');
|
|
|
|
const harnessFacts = async (): Promise<HarnessFact[]> => [
|
|
{
|
|
cli: 'opencode',
|
|
label: 'OpenCode',
|
|
installed: world.agentInstalled,
|
|
installable: !world.agentInstalled,
|
|
...(world.agentInstalled ? {} : { installCommand: INSTALL_COMMAND }),
|
|
acceptsModelId: () => true,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The download of the runtime archive: the real `fetch` against the fake's archive. While
|
|
* `world.damagedDownloads` is above 0 the body arrives with its last byte changed, as a
|
|
* download damaged on the way does: the size is still the pinned one, only the hash can tell.
|
|
*/
|
|
const archiveFetch: typeof fetch = async (input, init) => {
|
|
const res = await fetch(input, init);
|
|
if (world.damagedDownloads <= 0 || !res.ok) return res;
|
|
world.damagedDownloads -= 1;
|
|
const body = Buffer.from(await res.arrayBuffer());
|
|
body[body.length - 1] ^= 0xff;
|
|
return new Response(body, { status: res.status, headers: { 'content-length': String(body.length) } });
|
|
};
|
|
|
|
/**
|
|
* One Codeman server as far as this feature goes: an install service with its own runtime
|
|
* supervisor, over the data dir, the pin and the port of the test. It becomes `world.service`,
|
|
* which is the one the routes ask.
|
|
*/
|
|
function startServer(processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = {}): LocalModelService {
|
|
const pin = world.archive.pin();
|
|
const { paths, port } = world;
|
|
const supervisor = createRuntimeSupervisor({
|
|
// "Starting the runtime" makes the second fake listen on the port it was given.
|
|
spawn: (command, args, options) => {
|
|
if (!world.runtime) throw new Error('The test did not prepare a runtime');
|
|
return world.runtime.spawn(command, args, options);
|
|
},
|
|
fetch,
|
|
env: { PATH: process.env.PATH, CODEMAN_PASSWORD: 'must-not-reach-the-runtime', OLLAMA_ORIGINS: '*' },
|
|
commandOfPid: async () => undefined,
|
|
kill: () => {},
|
|
...processes,
|
|
sleep: async () => {},
|
|
pin,
|
|
paths,
|
|
onChange: (status) => service.onRuntimeChange(status),
|
|
});
|
|
const service: LocalModelService = createLocalModelService({
|
|
readHardware: async () => MACBOOK,
|
|
buildHarnessFacts: harnessFacts,
|
|
pin,
|
|
paths,
|
|
// The real download and unpack, told where the network is: the fake's archive.
|
|
ensureRuntimeInstalled: (options) =>
|
|
ensureRuntimeInstalled({ ...options, deps: { fetch: archiveFetch, sleep: async () => {} } }),
|
|
// The real pull with its real retries, without the seconds between them.
|
|
pullModel: (target, modelId, options) => pullModel(target, modelId, { ...options, sleep: async () => {} }),
|
|
chooseRuntimePort: async () => port,
|
|
supervisor,
|
|
recheckCliInstalled: async () => world.agentInstalled,
|
|
tryBeginCliInstall: () => () => {},
|
|
runInstallCommand: async () => {
|
|
world.installerRuns++;
|
|
world.agentInstalled = true;
|
|
return { code: 0, output: 'opencode installed\n', timedOut: false, aborted: false };
|
|
},
|
|
audit: async () => {},
|
|
checkDeveloperTools: async () => 'missing',
|
|
});
|
|
world.service = service;
|
|
world.services.push(service);
|
|
return service;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const archive = await startFakeOllama();
|
|
world = {
|
|
archive,
|
|
runtime: undefined,
|
|
port: await freeLoopbackPort(),
|
|
paths: localModelPaths('0.40.15'),
|
|
agentInstalled: false,
|
|
installerRuns: 0,
|
|
damagedDownloads: 0,
|
|
service: undefined as unknown as LocalModelService,
|
|
services: [],
|
|
sessions: new Map(),
|
|
};
|
|
startServer();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const service of world.services) await service.shutdown();
|
|
await world.archive.close();
|
|
await world.runtime?.close();
|
|
await rm(world.paths.root, { recursive: true, force: true });
|
|
rmSync(STATE(), { force: true });
|
|
rmSync(HOSTS(), { force: true });
|
|
rmSync(SETTINGS_PATH, { force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function routes() {
|
|
const { app } = await createRouteTestHarness((instance) =>
|
|
registerLocalModelRoutes(
|
|
instance,
|
|
{ sessions: world.sessions },
|
|
{ readHardware: async () => MACBOOK, buildHarnessFacts: harnessFacts, service: () => world.service }
|
|
)
|
|
);
|
|
const get = async (): Promise<LocalModelStatus> =>
|
|
(await app.inject({ method: 'GET', url: '/api/local-model' })).json().data;
|
|
/** Polls the GET the way the window does, until the job is no longer running. */
|
|
const settled = async (): Promise<LocalModelInstallView> => {
|
|
let install = (await get()).install;
|
|
await vi.waitFor(
|
|
async () => {
|
|
install = (await get()).install;
|
|
expect(install.state).not.toBe('installing');
|
|
},
|
|
{ timeout: 20_000, interval: 25 }
|
|
);
|
|
return install;
|
|
};
|
|
/** The click on "Download and install" for the row the server recommends, with the size it showed. */
|
|
const install = (pick: Candidate) =>
|
|
app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.installBytes },
|
|
});
|
|
/** Polls until the model download is under way and has moved. */
|
|
const pulling = async (): Promise<void> => {
|
|
await vi.waitFor(
|
|
async () => {
|
|
const status = await get();
|
|
expect(status.install.step).toBe('model');
|
|
expect(status.install.progress?.completedBytes ?? 0).toBeGreaterThan(0);
|
|
},
|
|
{ timeout: 20_000, interval: 20 }
|
|
);
|
|
};
|
|
return { app, get, settled, install, pulling };
|
|
}
|
|
|
|
/** The runtime the supervisor will "start": it has exactly the model the server recommended. */
|
|
function prepareRuntime(pick: Candidate): FakeOllama {
|
|
world.runtime = createFakeOllama({
|
|
model: { id: pick.modelId, bytes: pick.downloadBytes },
|
|
contextLength: pick.context,
|
|
});
|
|
return world.runtime;
|
|
}
|
|
|
|
/** A model download that takes a while: 400 lines, 5 ms apart. */
|
|
function slowPull(runtime: FakeOllama): void {
|
|
runtime.switches.pull.progressLines = 400;
|
|
runtime.switches.pull.lineDelayMs = 5;
|
|
}
|
|
|
|
function quickPull(runtime: FakeOllama): void {
|
|
runtime.switches.pull.progressLines = 20;
|
|
runtime.switches.pull.lineDelayMs = 0;
|
|
}
|
|
|
|
describe('the install from the first GET to Remove, real modules over a fake runtime', () => {
|
|
it('recommends for the MacBook, installs what was confirmed, connects it, refuses a changed model, and removes all of it', async () => {
|
|
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ tmuxHistoryLimit: 5000 }));
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
|
|
// ---- The chooser: what the window shows before anything is installed ----
|
|
const before = await get();
|
|
expect(before.install).toMatchObject({
|
|
state: 'idle',
|
|
running: false,
|
|
developerTools: 'missing',
|
|
actions: ['install'],
|
|
folder: world.paths.root,
|
|
runtime: { version: '0.40.15', installed: false, downloadBytes: world.archive.archive.size },
|
|
});
|
|
expect(before.recommendation.verdict).toBe('ok');
|
|
expect(before.recommendation.budgetSource).toBe('estimated');
|
|
const pick = before.recommendation.pick!;
|
|
expect(pick).toBeDefined();
|
|
// The MacBook's row: the one model that was run on a Mac like it, with its measured speed.
|
|
expect(pick).toMatchObject({
|
|
modelId: 'qwen3.6:35b-a3b',
|
|
context: 131072,
|
|
fit: 'tight',
|
|
needSource: 'measured',
|
|
tested: true,
|
|
tag: { kind: 'tested' },
|
|
});
|
|
// The row carries the coding agent and the literal command the consent screen prints.
|
|
expect(pick.harnesses[0]).toMatchObject({
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
installed: false,
|
|
installCommand: INSTALL_COMMAND,
|
|
});
|
|
// The server's own total: the model plus the runtime archive that is still to download.
|
|
expect(pick.installBytes).toBe(pick.downloadBytes + world.archive.archive.size);
|
|
expect(pick.installGb).toBe(formatGb(pick.installBytes));
|
|
const runtime = prepareRuntime(pick);
|
|
|
|
// ---- Consent is bound to the size shown ----
|
|
const stale = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.downloadBytes },
|
|
});
|
|
expect(stale.statusCode).toBe(409);
|
|
expect(stale.json().data).toMatchObject({ reason: 'size-changed', expectedBytes: pick.installBytes });
|
|
expect(world.archive.state.archiveRequests).toBe(0);
|
|
|
|
// ---- Install ----
|
|
const started = await clickInstall(pick);
|
|
expect(started.statusCode).toBe(200);
|
|
expect(started.json().data).toMatchObject({
|
|
state: 'installing',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
model: { id: pick.modelId, label: pick.modelLabel },
|
|
cli: 'opencode',
|
|
context: pick.context,
|
|
actions: ['cancel'],
|
|
});
|
|
// One job at a time.
|
|
const second = await clickInstall(pick);
|
|
expect(second.statusCode).toBe(409);
|
|
expect(second.json().data).toMatchObject({ reason: 'job-running' });
|
|
|
|
const install = await settled();
|
|
expect(install.error).toBeUndefined();
|
|
expect(install).toMatchObject({
|
|
state: 'installed',
|
|
running: true,
|
|
port: world.port,
|
|
model: { id: pick.modelId, label: pick.modelLabel },
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
context: pick.context,
|
|
endpointId: 'local-ollama',
|
|
folder: world.paths.root,
|
|
loaded: { context: pick.context, onGpu: true },
|
|
actions: ['start-session', 'stop', 'remove'],
|
|
runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) },
|
|
});
|
|
|
|
// ---- What really happened ----
|
|
// The runtime was downloaded once, unpacked, and "started" with the allowlisted environment.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(existsSync(world.paths.binary)).toBe(true);
|
|
expect(existsSync(world.paths.archivePartial)).toBe(false);
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
const env = runtime.state.children[0].env;
|
|
expect(env.OLLAMA_HOST).toBe(`127.0.0.1:${world.port}`);
|
|
expect(env.OLLAMA_CONTEXT_LENGTH).toBe(String(pick.context));
|
|
expect(env.HOME).toBe(world.paths.homeDir);
|
|
expect(env.OLLAMA_MODELS).toBe(world.paths.modelsDir);
|
|
expect(Object.keys(env).filter((key) => key.startsWith('CODEMAN_'))).toEqual([]);
|
|
expect(env).not.toHaveProperty('OLLAMA_ORIGINS');
|
|
// One pull, one coding agent install.
|
|
expect(runtime.state.pulls).toBe(1);
|
|
expect(world.installerRuns).toBe(1);
|
|
|
|
// The state file: the measured budget, the pull's own total, the digest.
|
|
const state = await readLocalModelState();
|
|
expect(state).toMatchObject({
|
|
state: 'installed',
|
|
model: { id: pick.modelId, context: pick.context, pullBytes: runtime.model.bytes, digest: runtime.model.digest },
|
|
runtime: {
|
|
version: '0.40.15',
|
|
port: world.port,
|
|
enabled: true,
|
|
measuredTotalBytes: MACBOOK_MEASURED_TOTAL_BYTES,
|
|
},
|
|
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
|
});
|
|
expect(JSON.stringify(state)).not.toContain(getDataDir());
|
|
|
|
// The two writes outside the folder, and nothing else changed there.
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([
|
|
{
|
|
id: 'local-ollama',
|
|
label: 'This Mac',
|
|
baseUrl: `http://127.0.0.1:${world.port}`,
|
|
authStyle: 'bearer',
|
|
models: [pick.modelId],
|
|
defaultModelId: pick.modelId,
|
|
defaultContextLength: pick.context,
|
|
},
|
|
]);
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({
|
|
tmuxHistoryLimit: 5000,
|
|
customModelEndpointsEnabled: true,
|
|
});
|
|
|
|
// From now on the budget of the GET is the measured one.
|
|
const after = await get();
|
|
expect(after.recommendation.budgetSource).toBe('measured');
|
|
expect(after.recommendation.budgetBytes).toBeLessThanOrEqual(MACBOOK_MEASURED_TOTAL_BYTES);
|
|
|
|
// ---- A session may start, until the model changes under its name ----
|
|
expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBeUndefined();
|
|
runtime.model.digest = 'f'.repeat(64);
|
|
expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBe(
|
|
'The model on this Mac changed since it was installed. Remove it and install it again.'
|
|
);
|
|
expect((await get()).install).toMatchObject({
|
|
state: 'installed',
|
|
error: { code: 'model-changed', next: 'remove' },
|
|
});
|
|
|
|
// ---- Remove ----
|
|
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
|
const refused = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(refused.statusCode).toBe(409);
|
|
expect(refused.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] });
|
|
expect(existsSync(world.paths.binary)).toBe(true);
|
|
|
|
world.sessions.clear();
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(removed.statusCode).toBe(200);
|
|
expect(removed.json().data).toMatchObject({ state: 'idle', running: false, actions: ['install'] });
|
|
// The child was stopped and the whole folder is gone: the runtime, the private home, the models.
|
|
expect(runtime.state.children[0].signals).toContain('SIGTERM');
|
|
expect(runtime.listening).toBe(false);
|
|
expect(existsSync(world.paths.root)).toBe(false);
|
|
// Our record is gone, and the flag this feature switched on is off again.
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({
|
|
tmuxHistoryLimit: 5000,
|
|
customModelEndpointsEnabled: false,
|
|
});
|
|
expect((await readLocalModelState()).state).toBe('idle');
|
|
// The chooser is back, with the runtime download in the total again.
|
|
const again = await get();
|
|
expect(again.install.runtime).toMatchObject({ installed: false, downloadBytes: world.archive.archive.size });
|
|
expect(again.recommendation.pick?.installBytes).toBe(pick.installBytes);
|
|
}, 60_000);
|
|
|
|
it('a runtime download that arrives damaged is deleted before anything is unpacked, and Try again gets it', async () => {
|
|
const { get, settled, install: clickInstall, app } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
world.damagedDownloads = 1;
|
|
|
|
await clickInstall(pick);
|
|
const failed = await settled();
|
|
expect(failed).toMatchObject({
|
|
state: 'failed',
|
|
step: 'runtime',
|
|
error: { code: 'runtime-damaged', next: 'retry' },
|
|
actions: ['resume', 'install', 'remove'],
|
|
runtime: { installed: false },
|
|
});
|
|
expect(failed.error?.detail).toContain(world.archive.archive.sha256);
|
|
// The hash was compared first: the file is gone, nothing was unpacked, nothing was started.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(existsSync(world.paths.archivePartial)).toBe(false);
|
|
expect(existsSync(world.paths.unpackDir)).toBe(false);
|
|
expect(existsSync(world.paths.runtimeDir)).toBe(false);
|
|
expect(runtime.state.children).toHaveLength(0);
|
|
expect(runtime.state.pulls).toBe(0);
|
|
|
|
// "Try again" downloads the archive again, and this time it is the pinned one.
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
expect((await settled()).state).toBe('installed');
|
|
expect(world.archive.state.archiveRequests).toBe(2);
|
|
expect(existsSync(world.paths.binary)).toBe(true);
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
}, 60_000);
|
|
|
|
it('a model download that fails twice is asked again and finishes; Remove leaves another saved endpoint and the models', async () => {
|
|
writeFileSync(HOSTS(), JSON.stringify([{ id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' }]));
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
// The real error line of a pull whose download kept failing, on the first two pulls.
|
|
runtime.switches.pull.fail = 'error-line';
|
|
runtime.switches.pull.failTimes = 2;
|
|
|
|
await clickInstall(pick);
|
|
const install = await settled();
|
|
expect(install.error).toBeUndefined();
|
|
expect(install.state).toBe('installed');
|
|
expect(runtime.state.pulls).toBe(3);
|
|
// The size of the model is the pull's own total.
|
|
expect((await readLocalModelState()).model?.pullBytes).toBe(runtime.model.bytes);
|
|
expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'office' }, { id: 'local-ollama' }]);
|
|
|
|
// Remove without the box: the model store stays for a later install.
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: false } });
|
|
expect(removed.json().data.state).toBe('idle');
|
|
expect(existsSync(world.paths.runtimeRoot)).toBe(false);
|
|
expect(existsSync(world.paths.homeDir)).toBe(false);
|
|
expect(existsSync(world.paths.modelsDir)).toBe(true);
|
|
// The other endpoint stays, and so does the flag it needs, although this feature switched it on.
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([
|
|
{ id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' },
|
|
]);
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(true);
|
|
}, 60_000);
|
|
|
|
it('a model download that keeps failing ends failed after its retries, and Try again continues it', async () => {
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
runtime.switches.pull.fail = 'error-line';
|
|
|
|
await clickInstall(pick);
|
|
const failed = await settled();
|
|
expect(failed).toMatchObject({
|
|
state: 'failed',
|
|
step: 'model',
|
|
error: { code: 'pull-failed', next: 'retry', detail: 'max retries exceeded: local error: tls: bad record MAC' },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
// The first pull and every retry, and no more.
|
|
expect(runtime.state.pulls).toBe(1 + PULL_RETRIES);
|
|
// The download as it stood, in the pull's own figures.
|
|
expect(failed.progress?.totalBytes).toBe(runtime.model.bytes);
|
|
expect(failed.progress?.completedBytes).toBeGreaterThan(0);
|
|
expect(failed.progress?.completedBytes).toBeLessThan(runtime.model.bytes);
|
|
// Nothing was checked, installed or connected.
|
|
expect(runtime.state.installed).toBe(false);
|
|
expect(world.installerRuns).toBe(0);
|
|
expect(existsSync(HOSTS())).toBe(false);
|
|
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
|
|
|
// The network is fine again: "Try again" enters the same step.
|
|
runtime.switches.pull.fail = 'none';
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.json().data).toMatchObject({ state: 'installing', step: 'model' });
|
|
expect((await settled()).state).toBe('installed');
|
|
expect(runtime.state.pulls).toBe(2 + PULL_RETRIES);
|
|
// The runtime was neither downloaded nor started a second time.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
}, 60_000);
|
|
|
|
it('a model that does not call a tool ends failed with the next model to try, and Remove cleans up', async () => {
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
runtime.switches.tool = 'none';
|
|
|
|
await clickInstall(pick);
|
|
const install = await settled();
|
|
expect(install).toMatchObject({
|
|
state: 'failed',
|
|
step: 'verify',
|
|
error: { code: 'no-tool-call', next: 'next-model', message: { vars: { model: pick.modelLabel } } },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
// The model to try next is a row of the list the window has.
|
|
const offered = (await get()).recommendation;
|
|
expect(install.error?.nextModel?.modelId).not.toBe(pick.modelId);
|
|
expect(offered.alternatives.map((row) => row.modelId)).toContain(install.error?.nextModel?.modelId);
|
|
// The coding agent was not installed and nothing was connected.
|
|
expect(world.installerRuns).toBe(0);
|
|
expect(existsSync(HOSTS())).toBe(false);
|
|
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
|
|
|
// "Try again" with a model that now calls the tool finishes the same job.
|
|
runtime.switches.tool = 'call';
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
expect((await settled()).state).toBe('installed');
|
|
// The runtime was not downloaded again and the model was not pulled again.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(runtime.state.pulls).toBe(1);
|
|
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(removed.json().data.state).toBe('idle');
|
|
// This feature switched the flag on and no other endpoint is saved: it is off again.
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(false);
|
|
// The model was not asked to go: the store stays for a later install.
|
|
expect(existsSync(world.paths.runtimeDir)).toBe(false);
|
|
expect(existsSync(world.paths.modelsDir)).toBe(true);
|
|
}, 60_000);
|
|
|
|
it('Cancel during the model download pauses the job, nothing goes on by itself, and Continue finishes it', async () => {
|
|
const { app, get, settled, install: clickInstall, pulling } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
slowPull(runtime);
|
|
|
|
await clickInstall(pick);
|
|
// The window sees the model download move.
|
|
await pulling();
|
|
expect((await get()).install).toMatchObject({ state: 'installing', step: 'model' });
|
|
|
|
const cancelled = await app.inject({ method: 'POST', url: '/api/local-model/cancel' });
|
|
expect(cancelled.statusCode).toBe(200);
|
|
const paused = cancelled.json().data as LocalModelInstallView;
|
|
expect(paused).toMatchObject({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] });
|
|
// The bytes as they stood, and the pull's own total.
|
|
expect(paused.progress?.totalBytes).toBe(runtime.model.bytes);
|
|
expect(paused.progress?.completedBytes).toBeLessThan(runtime.model.bytes);
|
|
expect(paused.progress).not.toHaveProperty('bytesPerSecond');
|
|
expect(runtime.state.installed).toBe(false);
|
|
|
|
// Nothing goes on by itself.
|
|
const pullsAfterCancel = runtime.state.pulls;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(runtime.state.pulls).toBe(pullsAfterCancel);
|
|
expect((await get()).install.state).toBe('paused');
|
|
|
|
quickPull(runtime);
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume' });
|
|
expect(resumed.json().data.state).toBe('installing');
|
|
expect((await settled()).state).toBe('installed');
|
|
expect(runtime.state.pulls).toBe(pullsAfterCancel + 1);
|
|
}, 60_000);
|
|
|
|
it('a server restart during the model download comes back paused, waits for a click, and Continue finishes it', async () => {
|
|
const { app, get, settled, install: clickInstall, pulling } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
slowPull(runtime);
|
|
|
|
await clickInstall(pick);
|
|
await pulling();
|
|
|
|
// ---- The server stops ----
|
|
const first = world.service;
|
|
await first.shutdown();
|
|
// The runtime child was signalled and no longer listens; the download stopped with it.
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
expect(runtime.state.children[0].signals).toContain('SIGTERM');
|
|
expect(runtime.listening).toBe(false);
|
|
expect(runtime.state.installed).toBe(false);
|
|
// Stopping writes nothing: the file still says what the job was doing.
|
|
expect(await readLocalModelState()).toMatchObject({ state: 'installing', step: 'model' });
|
|
const pullsBefore = runtime.state.pulls;
|
|
|
|
// ---- A new server starts over the same data dir ----
|
|
const second = startServer();
|
|
expect(second).not.toBe(first);
|
|
await second.boot();
|
|
const paused = (await get()).install;
|
|
expect(paused).toMatchObject({
|
|
state: 'paused',
|
|
step: 'model',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
model: { id: pick.modelId, label: pick.modelLabel },
|
|
cli: 'opencode',
|
|
context: pick.context,
|
|
port: world.port,
|
|
running: false,
|
|
runtime: { installed: true, downloadBytes: 0 },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
expect(paused.error).toBeUndefined();
|
|
expect(await readLocalModelState()).toMatchObject({ state: 'paused', step: 'model' });
|
|
|
|
// It never continues without a click: no runtime is started, nothing is pulled.
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
expect(runtime.state.pulls).toBe(pullsBefore);
|
|
expect((await get()).install.state).toBe('paused');
|
|
|
|
// ---- Continue ----
|
|
quickPull(runtime);
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
expect(resumed.json().data.state).toBe('installing');
|
|
const install = await settled();
|
|
expect(install.error).toBeUndefined();
|
|
expect(install).toMatchObject({ state: 'installed', running: true, endpointId: 'local-ollama' });
|
|
// The new server started the runtime itself and pulled once more; the archive was not fetched again.
|
|
expect(runtime.state.children).toHaveLength(2);
|
|
expect(runtime.state.pulls).toBe(pullsBefore + 1);
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(world.installerRuns).toBe(1);
|
|
expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'local-ollama', models: [pick.modelId] }]);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => {
|
|
/**
|
|
* Installs up to the model download, then "kills" the server: its job stops and it signals
|
|
* nothing, as a process that got SIGKILL does. Its runtime child goes on listening. Answers
|
|
* what the next server needs to find that process: `ps` and `kill` over the fake child.
|
|
*/
|
|
async function killedDuringTheDownload() {
|
|
const api = await routes();
|
|
const pick = (await api.get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
slowPull(runtime);
|
|
await api.install(pick);
|
|
await api.pulling();
|
|
|
|
const child = runtime.state.children[0];
|
|
const signal = child.kill.bind(child);
|
|
// A killed server sends its child nothing.
|
|
child.kill = () => false;
|
|
await world.service.shutdown();
|
|
expect(runtime.listening).toBe(true);
|
|
// The pid was stored the moment the child was spawned.
|
|
expect((await readLocalModelState()).runtime?.pid).toBe(child.pid);
|
|
|
|
const signals: Array<{ pid: number; signal: string; folderThere: boolean }> = [];
|
|
const processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = {
|
|
// What `ps -o command= -p <pid>` prints for the runtime while it lives.
|
|
commandOfPid: async (pid) => (pid === child.pid && runtime.listening ? `${world.paths.binary} serve` : undefined),
|
|
kill: (pid, sig) => {
|
|
signals.push({ pid, signal: sig, folderThere: existsSync(world.paths.runtimeDir) });
|
|
if (pid === child.pid) signal(sig);
|
|
},
|
|
};
|
|
return { ...api, pick, runtime, child, signals, processes };
|
|
}
|
|
|
|
it('Remove of the job it left ends that runtime BEFORE the folder is deleted', async () => {
|
|
const { app, runtime, child, signals, processes } = await killedDuringTheDownload();
|
|
startServer(processes);
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(removed.statusCode).toBe(200);
|
|
expect(removed.json().data.state).toBe('idle');
|
|
// The process was signalled while its binary was still on disk, and it is gone.
|
|
expect(signals).toEqual([{ pid: child.pid, signal: 'SIGTERM', folderThere: true }]);
|
|
await vi.waitFor(() => expect(runtime.listening).toBe(false));
|
|
expect(existsSync(world.paths.root)).toBe(false);
|
|
expect(await readLocalModelState()).toEqual(expect.objectContaining({ schema: 1, state: 'idle' }));
|
|
}, 60_000);
|
|
|
|
it('the next start of the server ends it too: nothing would use it until a click', async () => {
|
|
const { get, settled, app, runtime, child, signals, processes } = await killedDuringTheDownload();
|
|
const next = startServer(processes);
|
|
await next.boot();
|
|
expect(signals.map((entry) => [entry.pid, entry.signal])).toEqual([[child.pid, 'SIGTERM']]);
|
|
await vi.waitFor(() => expect(runtime.listening).toBe(false));
|
|
expect((await get()).install).toMatchObject({ state: 'paused', step: 'model', running: false });
|
|
|
|
// Continue starts a runtime of its own on the same port and finishes the install.
|
|
quickPull(runtime);
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
const install = await settled();
|
|
expect(install).toMatchObject({ state: 'installed', running: true });
|
|
expect(runtime.state.children).toHaveLength(2);
|
|
}, 60_000);
|
|
|
|
it('a pid that now runs something else is never signalled', async () => {
|
|
const { app, child, signals } = await killedDuringTheDownload();
|
|
// The pid was reused by another program since.
|
|
startServer({
|
|
commandOfPid: async () => '/Applications/Something.app/Contents/MacOS/Something',
|
|
kill: (pid, sig) => signals.push({ pid, signal: sig, folderThere: true }),
|
|
});
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: {} });
|
|
expect(removed.statusCode).toBe(200);
|
|
expect(signals).toEqual([]);
|
|
// Left to the test's own cleanup.
|
|
child.crash(0);
|
|
}, 60_000);
|
|
});
|