mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
POST /api/local-model/install, cancel, resume and runtime, and DELETE /api/local-model. The GET gains an `install` object the window polls. All of it is experimental and admin only in multi-user mode. One job of five steps (runtime, model, verify, agent, connect) driven by a pure reducer over explicit state, step and transition tables. One job at a time (409). The consent is bound to the size shown: `acknowledgedBytes` must be the server's own total (409 size-changed). A job a server restart interrupted comes back paused and never continues without a click. Every failure is a code with one next step. The model is judged against the budget the runtime measured before it is downloaded. A missing coding agent is installed with its stock command through the shared runner, audited. Connect writes one endpoint record under the reserved id through mutateCustomModelHosts() and switches customModelEndpointsEnabled on through mutateSettingsFile(), remembering the flag's value. The model's digest is stored and read again at boot and before every session start on that endpoint; a changed model refuses the launch. Remove answers 409 with the session names while a live session uses the endpoint, undoes the two writes first, restores the flag only when this feature switched it on and no other endpoint is saved, and deletes only inside local-model/. The coding agent stays. The server starts the runtime at boot when a model is installed and stops it at shutdown beside stopDeepSeekWeb(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
/**
|
|
* @fileoverview The one reader and writer of `local-model.json` (src/local-model/state-store.ts).
|
|
*
|
|
* What is pinned:
|
|
* - reading is TOTAL: a missing file, text that does not parse, another schema, an unknown
|
|
* state and a state that lacks a member it needs all read as idle, and a member of the
|
|
* wrong type is dropped rather than trusted;
|
|
* - the file holds no path, so nothing in it can point a delete anywhere;
|
|
* - writes are serialized, go through a temp file, are mode 0600 and stamp `updatedAt`.
|
|
*
|
|
* Port: N/A (no server; the file lives in the test's temp data dir).
|
|
*/
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import { dataPath } from '../src/config/instance.js';
|
|
import {
|
|
IDLE_LOCAL_MODEL_STATE,
|
|
mutateLocalModelState,
|
|
parseLocalModelState,
|
|
readLocalModelState,
|
|
} from '../src/local-model/state-store.js';
|
|
import type { LocalModelStateFile } from '../src/types.js';
|
|
|
|
const FILE = () => dataPath('local-model.json');
|
|
|
|
const INSTALLED: LocalModelStateFile = {
|
|
schema: 1,
|
|
state: 'installed',
|
|
model: {
|
|
id: 'qwen3.6:35b-a3b',
|
|
label: 'Qwen3.6 35B-A3B',
|
|
context: 131072,
|
|
pullBytes: 23_105_798_956,
|
|
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
|
|
},
|
|
cli: { id: 'opencode', label: 'OpenCode' },
|
|
runtime: { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: 40_157_944_217 },
|
|
loaded: { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 },
|
|
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
|
updatedAt: '2026-11-06T10:00:00.000Z',
|
|
};
|
|
|
|
const PAUSED: LocalModelStateFile = {
|
|
schema: 1,
|
|
state: 'paused',
|
|
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: 6_200_000_000, totalBytes: 23_105_798_956 },
|
|
};
|
|
|
|
const FAILED: LocalModelStateFile = {
|
|
...PAUSED,
|
|
state: 'failed',
|
|
step: 'verify',
|
|
error: {
|
|
code: 'no-tool-call',
|
|
vars: { model: 'Qwen3.6 35B-A3B' },
|
|
detail: 'finish_reason stop',
|
|
nextModelId: 'gpt-oss:20b',
|
|
},
|
|
};
|
|
|
|
afterEach(() => {
|
|
rmSync(FILE(), { force: true });
|
|
});
|
|
|
|
describe('parseLocalModelState', () => {
|
|
it('reads back exactly what a state of each kind holds', () => {
|
|
for (const state of [INSTALLED, PAUSED, FAILED, { ...PAUSED, state: 'installing' as const }]) {
|
|
expect(parseLocalModelState(JSON.stringify(state))).toEqual(state);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
['an empty file', ''],
|
|
['text that does not parse', '{"schema":1,"state":"installed",'],
|
|
['a list', '[]'],
|
|
['null', 'null'],
|
|
['another schema', JSON.stringify({ ...INSTALLED, schema: 2 })],
|
|
['no schema', JSON.stringify({ state: 'installed' })],
|
|
['an unknown state', JSON.stringify({ ...INSTALLED, state: 'ready' })],
|
|
['idle', JSON.stringify({ schema: 1, state: 'idle', model: INSTALLED.model })],
|
|
['idle with everything a job has', JSON.stringify({ ...PAUSED, state: 'idle' })],
|
|
['idle with everything an installation has', JSON.stringify({ ...INSTALLED, state: 'idle' })],
|
|
])('%s reads as idle', (_label, raw) => {
|
|
expect(parseLocalModelState(raw)).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
});
|
|
|
|
it('a state that lacks a member it needs is not trusted: it reads as idle', () => {
|
|
const without = (state: LocalModelStateFile, key: keyof LocalModelStateFile) => {
|
|
const copy: Record<string, unknown> = { ...state };
|
|
delete copy[key];
|
|
return JSON.stringify(copy);
|
|
};
|
|
// Installed needs its model, its coding agent and its runtime (the port).
|
|
expect(parseLocalModelState(without(INSTALLED, 'model'))).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(parseLocalModelState(without(INSTALLED, 'cli'))).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(parseLocalModelState(without(INSTALLED, 'runtime'))).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
// A job needs its step and its steps, and a failed one its error.
|
|
expect(parseLocalModelState(without(PAUSED, 'step'))).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(parseLocalModelState(without(PAUSED, 'steps'))).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(parseLocalModelState(without(FAILED, 'error'))).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, step: 'agent', steps: ['runtime', 'model'] }))).toBe(
|
|
IDLE_LOCAL_MODEL_STATE
|
|
);
|
|
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'model'] }))).toBe(
|
|
IDLE_LOCAL_MODEL_STATE
|
|
);
|
|
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'reboot'] }))).toBe(
|
|
IDLE_LOCAL_MODEL_STATE
|
|
);
|
|
expect(parseLocalModelState(JSON.stringify({ ...FAILED, error: { code: 'made-up' } }))).toBe(
|
|
IDLE_LOCAL_MODEL_STATE
|
|
);
|
|
});
|
|
|
|
it('drops a member of the wrong type instead of trusting it', () => {
|
|
const parsed = parseLocalModelState(
|
|
JSON.stringify({
|
|
...INSTALLED,
|
|
model: { ...INSTALLED.model, pullBytes: '23 GB', digest: 12 },
|
|
runtime: { ...INSTALLED.runtime, pid: 'four', measuredTotalBytes: -1 },
|
|
loaded: { context: 131072, sizeBytes: 'big', sizeVramBytes: 1 },
|
|
connection: { endpointId: 'local-ollama', flagWasEnabled: 'no' },
|
|
updatedAt: 5,
|
|
})
|
|
);
|
|
expect(parsed).toEqual({
|
|
schema: 1,
|
|
state: 'installed',
|
|
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 },
|
|
});
|
|
});
|
|
|
|
it('a port that is not a port makes the runtime unknown', () => {
|
|
for (const port of [0, 70_000, 11435.5, '11435', null]) {
|
|
expect(
|
|
parseLocalModelState(JSON.stringify({ ...PAUSED, runtime: { version: '0.40.15', port, enabled: true } }))
|
|
).not.toHaveProperty('runtime');
|
|
}
|
|
});
|
|
|
|
it('only an explicit false switches the runtime off', () => {
|
|
const enabled = (value: unknown) =>
|
|
parseLocalModelState(
|
|
JSON.stringify({ ...INSTALLED, runtime: { version: '0.40.15', port: 11435, enabled: value } })
|
|
).runtime?.enabled;
|
|
expect(enabled(false)).toBe(false);
|
|
expect(enabled(true)).toBe(true);
|
|
expect(enabled(undefined)).toBe(true);
|
|
expect(enabled('false')).toBe(true);
|
|
});
|
|
|
|
it('drops members it does not know, at every level: a state file holds no path', () => {
|
|
const parsed = parseLocalModelState(
|
|
JSON.stringify({
|
|
...INSTALLED,
|
|
root: '/Users/me',
|
|
runtimeDir: '/',
|
|
model: { ...INSTALLED.model, path: '/etc' },
|
|
runtime: { ...INSTALLED.runtime, binary: '/bin/sh' },
|
|
connection: { ...INSTALLED.connection, file: '/etc/passwd' },
|
|
})
|
|
);
|
|
expect(parsed).toEqual(INSTALLED);
|
|
expect(JSON.stringify(parsed)).not.toContain('/');
|
|
});
|
|
|
|
it('keeps the model a replaced job left behind on a job, and never on an installed state', () => {
|
|
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, replacedModelId: 'qwen3.8:27b' })).replacedModelId).toBe(
|
|
'qwen3.8:27b'
|
|
);
|
|
expect(parseLocalModelState(JSON.stringify({ ...INSTALLED, replacedModelId: 'qwen3.8:27b' }))).not.toHaveProperty(
|
|
'replacedModelId'
|
|
);
|
|
});
|
|
|
|
it('an error is kept only on a failed job, and progress and a step only on a job', () => {
|
|
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, error: FAILED.error }))).not.toHaveProperty('error');
|
|
const installed = parseLocalModelState(
|
|
JSON.stringify({
|
|
...INSTALLED,
|
|
error: FAILED.error,
|
|
step: 'connect',
|
|
steps: PAUSED.steps,
|
|
progress: PAUSED.progress,
|
|
})
|
|
);
|
|
expect(installed).toEqual(INSTALLED);
|
|
});
|
|
});
|
|
|
|
describe('readLocalModelState', () => {
|
|
it('is idle when the file is missing, and never throws', async () => {
|
|
expect(existsSync(FILE())).toBe(false);
|
|
expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
});
|
|
|
|
it('is idle for a file that does not parse', async () => {
|
|
writeFileSync(FILE(), 'not json');
|
|
expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
});
|
|
|
|
it('reads what is stored', async () => {
|
|
writeFileSync(FILE(), JSON.stringify(INSTALLED));
|
|
expect(await readLocalModelState()).toEqual(INSTALLED);
|
|
});
|
|
});
|
|
|
|
describe('mutateLocalModelState', () => {
|
|
it('writes the returned state, stamps updatedAt, and answers with before and after', async () => {
|
|
const { updatedAt: _stamp, ...paused } = PAUSED;
|
|
const { before, after } = await mutateLocalModelState(() => paused);
|
|
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(after).toMatchObject(paused);
|
|
expect(Number.isNaN(Date.parse(after!.updatedAt!))).toBe(false);
|
|
expect(await readLocalModelState()).toEqual(after);
|
|
});
|
|
|
|
it('writes nothing when the change returns undefined: no file appears', async () => {
|
|
const { before, after } = await mutateLocalModelState(() => undefined);
|
|
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(after).toBeUndefined();
|
|
expect(existsSync(FILE())).toBe(false);
|
|
});
|
|
|
|
it('the file is mode 0600 and no temp file is left beside it', async () => {
|
|
await mutateLocalModelState(() => PAUSED);
|
|
expect(statSync(FILE()).mode & 0o777).toBe(0o600);
|
|
expect(readdirSync(dirname(FILE())).filter((name) => name.startsWith('local-model.json'))).toEqual([
|
|
'local-model.json',
|
|
]);
|
|
});
|
|
|
|
it('changes started together run one after the other: none is lost', async () => {
|
|
await mutateLocalModelState(() => PAUSED);
|
|
const add = (bytes: number) =>
|
|
mutateLocalModelState((current) => ({
|
|
...current,
|
|
progress: {
|
|
completedBytes: (current.progress?.completedBytes ?? 0) + bytes,
|
|
totalBytes: 23_105_798_956,
|
|
},
|
|
}));
|
|
await Promise.all(Array.from({ length: 10 }, () => add(1_000)));
|
|
expect((await readLocalModelState()).progress?.completedBytes).toBe(6_200_000_000 + 10_000);
|
|
});
|
|
|
|
it('a change that throws rejects its own caller and the next one still runs', async () => {
|
|
const thrown = mutateLocalModelState(() => {
|
|
throw new Error('boom');
|
|
});
|
|
const next = mutateLocalModelState(() => PAUSED);
|
|
await expect(thrown).rejects.toThrow('boom');
|
|
expect((await next).after).toMatchObject({ state: 'paused' });
|
|
});
|
|
|
|
it('replaces a file that does not parse: it holds nothing of the person to lose', async () => {
|
|
writeFileSync(FILE(), '{broken');
|
|
const { before } = await mutateLocalModelState(() => PAUSED);
|
|
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
|
|
expect(JSON.parse(readFileSync(FILE(), 'utf8'))).toMatchObject({ schema: 1, state: 'paused' });
|
|
});
|
|
});
|