From e1e2eb50904ce4901b3ea7e501dc8e73da47ec70 Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 17 Oct 2026 16:04:04 +0200 Subject: [PATCH] feat(323): Start Ollama from the banner and the page, through this machine's executor A runtime on this machine that does not answer offers Start Ollama wherever the machine's executor is paired, in the chat's readiness banner and on This Computer, in place of the instruction to start it by hand. - The request travels a per-machine channel (hostRuntimeRequestChannel), published only by the operator's local-models route on a space admin's request; it names nothing to run but the port. - The executor starts Ollama as installed: a Homebrew service through `brew services`, another launch agent through `launchctl`, a Linux user unit, the macOS app, and only then `ollama serve`, detached and without the executor's environment. A service first, because its definition carries the runtime's settings: Homebrew's sets the q8_0 cache and flash attention the machine was measured with, which a bare serve drops. A system service is left to the administrator, with the command. - The server never starts it: in the appliance it runs in a container. Helmsman cannot either, and needs not: it cannot run without its runtime. - The publish-and-wait exchange the browser settings used moves into @aflow/redis (askHost) and serves both; probeEnv is shared by the host's three callers. - An action's refusal now shows on This Computer as well as in the banner. Proven on a Homebrew install: stopped with `brew services stop`, started by this code in about a second, with its environment intact. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/harnessBoundary.test.ts | 6 +- .../src/__tests__/localRuntimeStart.test.ts | 161 +++++++++++++ .../src/harnessDiscovery.ts | 12 +- apps/aflow-executor-host/src/index.ts | 37 +++ .../src/localRuntimeStart.ts | 223 ++++++++++++++++++ apps/aflow-executor-host/src/probeEnv.ts | 13 + apps/aflow-executor-host/src/runtimes.ts | 12 +- .../323-models-that-run-on-this-computer.md | 1 + .../src/localRuntimeDiagnostics.test.ts | 15 ++ .../ai-client/src/localRuntimeDiagnostics.ts | 6 +- .../src/localModels/__tests__/actions.test.ts | 1 + .../src/localModels/__tests__/start.test.ts | 90 +++++++ .../src/localModels/actions.ts | 8 +- .../src/localModels/diagnose.ts | 15 +- .../src/localModels/runtime.ts | 10 +- .../src/localModels/start.ts | 67 ++++++ packages/redis/src/hostInventory.ts | 55 ++++- packages/redis/src/hostRequests.ts | 72 ++++++ packages/redis/src/index.ts | 1 + .../credentials/localRuntimeDiagnostics.ts | 4 + .../server-runtime/src/lib/hostExchange.ts | 14 ++ .../src/routes/hostBrowserSettings.ts | 70 +----- .../src/routes/localModels.test.ts | 1 + .../server-runtime/src/routes/localModels.ts | 6 +- .../credentials/LlmReadinessBanner.tsx | 24 +- .../ui/components/host/HostLocalModels.tsx | 1 + 26 files changed, 836 insertions(+), 89 deletions(-) create mode 100644 apps/aflow-executor-host/src/__tests__/localRuntimeStart.test.ts create mode 100644 apps/aflow-executor-host/src/localRuntimeStart.ts create mode 100644 apps/aflow-executor-host/src/probeEnv.ts create mode 100644 packages/cybernetic-runtime/src/localModels/__tests__/start.test.ts create mode 100644 packages/cybernetic-runtime/src/localModels/start.ts create mode 100644 packages/redis/src/hostRequests.ts create mode 100644 packages/server-runtime/src/lib/hostExchange.ts diff --git a/apps/aflow-executor-host/src/__tests__/harnessBoundary.test.ts b/apps/aflow-executor-host/src/__tests__/harnessBoundary.test.ts index 9df17238..cdf4229a 100644 --- a/apps/aflow-executor-host/src/__tests__/harnessBoundary.test.ts +++ b/apps/aflow-executor-host/src/__tests__/harnessBoundary.test.ts @@ -200,7 +200,10 @@ describe('one spawn path', () => { // while a step runs, a system call by another name that reads and runs no // workload. machine.ts asks Metal (through `swift`) or `nvidia-smi` how much // GPU memory a model runtime on this machine has, once, and runs nothing it - // was handed. + // was handed. localRuntimeStart.ts starts the operator's own Ollama as it + // is installed — its service manager, its app, or its binary — when the + // operator asks from the workspace; the request names nothing to run, and + // a runtime confined to a job's sandbox could not serve the machine. // Named rather than matched, so a new unconfined spawn is argued for here. expect(importers).toEqual([ 'browser/harnessBrowser.ts', @@ -208,6 +211,7 @@ describe('one spawn path', () => { 'folderPicker.ts', 'harnessDiscovery.ts', 'keepAwake.ts', + 'localRuntimeStart.ts', 'machine.ts', 'orphans.ts', 'runtimes.ts', diff --git a/apps/aflow-executor-host/src/__tests__/localRuntimeStart.test.ts b/apps/aflow-executor-host/src/__tests__/localRuntimeStart.test.ts new file mode 100644 index 00000000..b6197dbd --- /dev/null +++ b/apps/aflow-executor-host/src/__tests__/localRuntimeStart.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { startLocalRuntime, type RuntimeStartDeps } from '../localRuntimeStart.js'; + +interface Machine { + files?: string[]; + agents?: string[]; + running?: boolean; + /** Commands that fail, by their first argument. */ + failing?: string[]; +} + +/** A machine with the given files, whose runtime answers once something has started it. */ +function machine(setup: Machine & Partial = {}) { + const files = new Set(setup.files ?? []); + let running = setup.running ?? false; + const ran: string[] = []; + const served: Array<{ binary: string; env: Record }> = []; + const starts = (command: string, args: readonly string[]) => + command === 'open' || + (command === 'launchctl' && args[0] === 'kickstart') || + command === 'systemctl' || + command.endsWith('/brew'); + const deps: RuntimeStartDeps = { + platform: 'darwin', + home: '/Users/op', + uid: 501, + searchPath: ['/opt/homebrew/bin', '/usr/local/bin'], + exists: async (path) => files.has(path), + executable: async (path) => files.has(path), + list: async (dir) => (dir === '/Users/op/Library/LaunchAgents' ? (setup.agents ?? []) : []), + exec: async (command, args) => { + ran.push([command, ...args].join(' ')); + if (setup.failing?.includes(args[0] ?? command)) throw new Error(`${command} failed`); + if (starts(command, args)) running = true; + }, + serve: (binary, env) => { + served.push({ binary, env }); + running = true; + }, + answers: async () => running, + sleep: async () => undefined, + waitMs: 2_000, + ...setup, + }; + return { deps, ran, served }; +} + +describe('starting Ollama on this machine', () => { + it('starts a Homebrew install through Homebrew, which reinstalls the agent it removed on stop', async () => { + const { deps, ran, served } = machine({ + files: [ + '/opt/homebrew/opt/ollama/bin/ollama', + '/opt/homebrew/bin/brew', + '/opt/homebrew/bin/ollama', + '/Applications/Ollama.app', + ], + }); + + const answer = await startLocalRuntime(11434, deps); + + expect(answer).toEqual({ + kind: 'started', + message: 'Started the ollama Homebrew service.', + }); + expect(ran).toEqual(['/opt/homebrew/bin/brew services start ollama']); + expect(served).toHaveLength(0); + }); + + it('starts an installed launch agent as that service, so its settings hold', async () => { + const { deps, ran, served } = machine({ + agents: ['sh.brew.ollama.plist', 'com.other.plist'], + files: ['/Applications/Ollama.app', '/opt/homebrew/bin/ollama'], + }); + + const answer = await startLocalRuntime(11434, deps); + + expect(answer).toEqual({ kind: 'started', message: 'Started the sh.brew.ollama service.' }); + expect(ran).toEqual([ + 'launchctl bootstrap gui/501 /Users/op/Library/LaunchAgents/sh.brew.ollama.plist', + 'launchctl kickstart gui/501/sh.brew.ollama', + ]); + expect(served).toHaveLength(0); + }); + + it('starts a launch agent that is already loaded, whose bootstrap fails', async () => { + const { deps } = machine({ agents: ['sh.brew.ollama.plist'], failing: ['bootstrap'] }); + + expect(await startLocalRuntime(11434, deps)).toMatchObject({ kind: 'started' }); + }); + + it('opens the macOS app where no service is installed', async () => { + const { deps, ran } = machine({ files: ['/Applications/Ollama.app'] }); + + expect(await startLocalRuntime(11434, deps)).toEqual({ + kind: 'started', + message: 'Opened the Ollama app.', + }); + expect(ran).toEqual(['open -g -a /Applications/Ollama.app']); + }); + + it('starts a Linux user unit through systemd', async () => { + const { deps, ran } = machine({ + platform: 'linux', + files: ['/Users/op/.config/systemd/user/ollama.service', '/usr/local/bin/ollama'], + }); + + expect(await startLocalRuntime(11434, deps)).toMatchObject({ kind: 'started' }); + expect(ran).toEqual(['systemctl --user start ollama']); + }); + + it('leaves a system service to the administrator, and says how', async () => { + const { deps, served } = machine({ + platform: 'linux', + files: ['/etc/systemd/system/ollama.service', '/usr/local/bin/ollama'], + }); + + const answer = await startLocalRuntime(11434, deps); + + expect(answer).toMatchObject({ kind: 'refused' }); + expect(answer.message).toContain('sudo systemctl start ollama'); + expect(served).toHaveLength(0); + }); + + it('runs `ollama serve` without the executor’s own credentials where nothing else is installed', async () => { + process.env['REDIS_URL'] = 'redis://:secret@127.0.0.1:6379'; + const { deps, served } = machine({ platform: 'linux', files: ['/usr/local/bin/ollama'] }); + + const answer = await startLocalRuntime(11500, deps); + + expect(answer.kind).toBe('started'); + expect(served[0]?.binary).toBe('/usr/local/bin/ollama'); + expect(served[0]?.env['REDIS_URL']).toBeUndefined(); + expect(served[0]?.env['OLLAMA_HOST']).toBe('127.0.0.1:11500'); + delete process.env['REDIS_URL']; + }); + + it('points at the download where Ollama is not installed', async () => { + const answer = await startLocalRuntime(11434, machine().deps); + + expect(answer).toMatchObject({ kind: 'refused' }); + expect(answer.message).toContain('https://ollama.com/download'); + }); + + it('starts nothing when the runtime already answers', async () => { + const { deps, ran, served } = machine({ running: true, agents: ['sh.brew.ollama.plist'] }); + + expect(await startLocalRuntime(11434, deps)).toMatchObject({ kind: 'started' }); + expect(ran).toHaveLength(0); + expect(served).toHaveLength(0); + }); + + it('says where to look when what it started never answers', async () => { + const { deps } = machine({ files: ['/Applications/Ollama.app'], answers: async () => false }); + + const answer = await startLocalRuntime(11434, deps); + + expect(answer).toMatchObject({ kind: 'refused' }); + expect(answer.message).toContain('~/.ollama/logs'); + }); +}); diff --git a/apps/aflow-executor-host/src/harnessDiscovery.ts b/apps/aflow-executor-host/src/harnessDiscovery.ts index adcda07b..0687b811 100644 --- a/apps/aflow-executor-host/src/harnessDiscovery.ts +++ b/apps/aflow-executor-host/src/harnessDiscovery.ts @@ -17,20 +17,12 @@ import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { probeEnv } from './probeEnv.js'; + import type { HarnessOutputFormat } from './harnessProfiles.js'; const run = promisify(execFile); -/** As in `runtimes.ts`: discovery runs operator-controlled programs. */ -function probeEnv(): Record { - const env: Record = {}; - for (const name of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ'] as const) { - const value = process.env[name]; - if (value !== undefined) env[name] = value; - } - return env; -} - const PROBE_TIMEOUT_MS = 5_000; /** diff --git a/apps/aflow-executor-host/src/index.ts b/apps/aflow-executor-host/src/index.ts index 13e3a1b6..b9dd05b5 100644 --- a/apps/aflow-executor-host/src/index.ts +++ b/apps/aflow-executor-host/src/index.ts @@ -34,6 +34,10 @@ import { HOST_MACHINES_KEY, hostBrowserAnswerChannel, hostBrowserRequestChannel, + hostRuntimeAnswerChannel, + hostRuntimeRequestChannel, + readHostRuntimeRequest, + type HostRuntimeAnswer, HOST_WITHDRAWAL_CHANNEL, hostInventoryKey, type HostInventory, @@ -85,6 +89,7 @@ import { createMachineReader } from './machine.js'; import { publishingFolders } from './pushApproval.js'; import { startUnderSignals } from './startUnderSignals.js'; import { createStartupTimings } from './startupTimings.js'; +import { startLocalRuntime } from './localRuntimeStart.js'; import { type BackgroundTaskLogger, createBackgroundTaskRunner } from '@aflow/lib'; const log = createServiceLogger('host-executor'); @@ -635,11 +640,43 @@ async function main(): Promise { }); }); }; + // The operator asking from the workspace for Ollama to start here. Two asks + // at once share one start, so a double click does not race two launches. + const runtimeChannel = hostRuntimeRequestChannel(hostname); + await hostChannels.subscribe(runtimeChannel).catch((error: unknown) => { + log.warn('Could not subscribe to model runtime requests from the workspace', { error }); + }); + let starting: Promise | undefined; + const runtimeAsked = (raw: string): void => { + const request = readHostRuntimeRequest(raw, hostname); + if (request === undefined) return; + starting ??= startLocalRuntime(request.port).finally(() => { + starting = undefined; + }); + void starting + .then(async (answer) => { + log.info('Asked from the workspace to start the model runtime', { + port: request.port, + outcome: answer.kind, + message: answer.message, + }); + await redis.publish(hostRuntimeAnswerChannel(request.answerId), JSON.stringify(answer)); + }) + .catch((error: unknown) => { + log.warn('Could not answer a model runtime start asked for from the workspace', { + error: error instanceof Error ? error.message : String(error), + }); + }); + }; hostChannels.on('message', (channel: string, raw: string) => { if (channel === requestChannel) { askedFromWorkspace(raw); return; } + if (channel === runtimeChannel) { + runtimeAsked(raw); + return; + } if (channel !== HOST_WITHDRAWAL_CHANNEL) return; void (async () => { let notice: HostWithdrawalNotice; diff --git a/apps/aflow-executor-host/src/localRuntimeStart.ts b/apps/aflow-executor-host/src/localRuntimeStart.ts new file mode 100644 index 00000000..40dab33f --- /dev/null +++ b/apps/aflow-executor-host/src/localRuntimeStart.ts @@ -0,0 +1,223 @@ +/** + * Starting the model runtime on this machine when the operator asks from the + * workspace (Plan 323 D15). + * + * The request names nothing to run: this starts Ollama the way the machine has + * it installed, and only that. A service the operator installed — a launch + * agent, a user unit — is started as that service, because its definition + * carries the runtime's settings (a cache type, flash attention) that a bare + * `ollama serve` would drop, and the machine's measured footprint with them. + * Then the macOS app; then `ollama serve` as the operator, detached so it + * outlives this executor's restarts. A system service is the administrator's + * to start, and the answer says how. + */ +import { spawn, execFile } from 'node:child_process'; +import { constants, openSync } from 'node:fs'; +import { access, mkdir, readdir } from 'node:fs/promises'; +import { homedir, userInfo } from 'node:os'; +import { basename, delimiter, join } from 'node:path'; +import { promisify } from 'node:util'; + +import type { HostRuntimeAnswer } from '@aflow/redis'; + +import { probeEnv } from './probeEnv.js'; + +const run = promisify(execFile); + +export const OLLAMA_DEFAULT_PORT = 11_434; +/** An app's first launch after an update unpacks before it listens. */ +export const RUNTIME_START_WAIT_MS = 30_000; +const ANSWER_POLL_MS = 500; +const DOWNLOAD = 'https://ollama.com/download'; +const HOMEBREW_PREFIXES = ['/opt/homebrew', '/usr/local']; +const SYSTEM_UNITS = ['/etc/systemd/system/ollama.service', '/lib/systemd/system/ollama.service']; + +export interface RuntimeStartDeps { + platform: NodeJS.Platform; + home: string; + uid: number; + searchPath: readonly string[]; + exists(path: string): Promise; + executable(path: string): Promise; + /** The names in a directory, or none where it does not exist. */ + list(dir: string): Promise; + exec(command: string, args: readonly string[]): Promise; + serve(binary: string, env: Record): void; + answers(port: number): Promise; + sleep(ms: number): Promise; + waitMs: number; +} + +/** Where a package manager or the installer puts the binary, when the executor's PATH is a service's. */ +const COMMON_BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']; + +function defaultDeps(): RuntimeStartDeps { + return { + platform: process.platform, + home: homedir(), + uid: userInfo().uid, + searchPath: [ + ...(process.env['PATH'] ?? '').split(delimiter).filter(Boolean), + ...COMMON_BIN_DIRS, + ], + exists: (path) => + access(path).then( + () => true, + () => false, + ), + executable: (path) => + access(path, constants.X_OK).then( + () => true, + () => false, + ), + list: (dir) => readdir(dir).catch(() => []), + exec: async (command, args) => { + await run(command, [...args], { timeout: 30_000, env: probeEnv() }); + }, + serve: (binary, env) => { + // Ollama's own log, where an operator who goes looking will look. + const logs = join(env['HOME'] ?? homedir(), '.ollama', 'logs'); + void mkdir(logs, { recursive: true }) + .catch(() => undefined) + .then(() => { + const out = openSync(join(logs, 'server.log'), 'a'); + spawn(binary, ['serve'], { detached: true, stdio: ['ignore', out, out], env }).unref(); + }); + }, + answers: async (port) => { + try { + const res = await fetch(`http://127.0.0.1:${port}/api/version`, { + signal: AbortSignal.timeout(1_000), + }); + return res.ok; + } catch { + return false; + } + }, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + waitMs: RUNTIME_START_WAIT_MS, + }; +} + +/** The runtime's own settings from this executor's environment, which a service manager may have set. */ +function serveEnv(port: number): Record { + const env = probeEnv(); + for (const [name, value] of Object.entries(process.env)) { + if (name.startsWith('OLLAMA_') && value !== undefined) env[name] = value; + } + if (port !== OLLAMA_DEFAULT_PORT) env['OLLAMA_HOST'] = `127.0.0.1:${port}`; + return env; +} + +async function findBinary(deps: RuntimeStartDeps): Promise { + for (const dir of deps.searchPath) { + const candidate = join(dir, 'ollama'); + if (await deps.executable(candidate)) return candidate; + } + return undefined; +} + +async function waitForAnswer(deps: RuntimeStartDeps, port: number): Promise { + for (let waited = 0; waited < deps.waitMs; waited += ANSWER_POLL_MS) { + if (await deps.answers(port)) return true; + await deps.sleep(ANSWER_POLL_MS); + } + return deps.answers(port); +} + +const errorText = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +interface Started { + readonly how: string; + readonly log: string; +} + +/** Starts the runtime the way this machine has it installed, or says why it cannot. */ +async function start(port: number, deps: RuntimeStartDeps): Promise { + if (deps.platform === 'darwin') { + // Homebrew removes its launch agent when the service is stopped and keeps + // the definition in the formula, so only its own command starts it again. + for (const prefix of HOMEBREW_PREFIXES) { + const brew = join(prefix, 'bin', 'brew'); + if ( + !(await deps.executable(join(prefix, 'opt', 'ollama', 'bin', 'ollama'))) || + !(await deps.executable(brew)) + ) { + continue; + } + try { + await deps.exec(brew, ['services', 'start', 'ollama']); + } catch (error) { + return { refused: `Homebrew did not start the ollama service: ${errorText(error)}.` }; + } + return { how: 'Started the ollama Homebrew service', log: `${prefix}/var/log/ollama.log` }; + } + const agentsDir = join(deps.home, 'Library', 'LaunchAgents'); + const agent = (await deps.list(agentsDir)).find( + (name) => /ollama/i.test(name) && name.endsWith('.plist'), + ); + if (agent !== undefined) { + const label = basename(agent, '.plist'); + const domain = `gui/${deps.uid}`; + // Loaded unless it was stopped through its manager, which unloads it; + // loading one already loaded fails, and the start below is what counts. + await deps + .exec('launchctl', ['bootstrap', domain, join(agentsDir, agent)]) + .catch(() => undefined); + try { + await deps.exec('launchctl', ['kickstart', `${domain}/${label}`]); + } catch (error) { + return { refused: `The ${label} service did not start: ${errorText(error)}.` }; + } + return { how: `Started the ${label} service`, log: 'the service’s log' }; + } + for (const app of ['/Applications/Ollama.app', join(deps.home, 'Applications', 'Ollama.app')]) { + if (!(await deps.exists(app))) continue; + try { + await deps.exec('open', ['-g', '-a', app]); + } catch (error) { + return { refused: `The Ollama app did not open: ${errorText(error)}.` }; + } + return { how: 'Opened the Ollama app', log: '~/.ollama/logs' }; + } + } + if (deps.platform === 'linux') { + if (await deps.exists(join(deps.home, '.config', 'systemd', 'user', 'ollama.service'))) { + try { + await deps.exec('systemctl', ['--user', 'start', 'ollama']); + } catch (error) { + return { refused: `The ollama user service did not start: ${errorText(error)}.` }; + } + return { how: 'Started the ollama user service', log: '`journalctl --user -u ollama`' }; + } + for (const unit of SYSTEM_UNITS) { + if (await deps.exists(unit)) { + return { + refused: + 'Ollama is installed here as a system service, which only an administrator starts: run `sudo systemctl start ollama`.', + }; + } + } + } + const binary = await findBinary(deps); + if (binary === undefined) { + return { refused: `Ollama is not installed on this computer. Get it from ${DOWNLOAD}.` }; + } + deps.serve(binary, serveEnv(port)); + return { how: `Started \`ollama serve\` (${binary})`, log: '~/.ollama/logs/server.log' }; +} + +export async function startLocalRuntime( + port: number, + deps: RuntimeStartDeps = defaultDeps(), +): Promise { + if (await deps.answers(port)) return { kind: 'started', message: 'Ollama is already running.' }; + const started = await start(port, deps); + if ('refused' in started) return { kind: 'refused', message: started.refused }; + if (await waitForAnswer(deps, port)) return { kind: 'started', message: `${started.how}.` }; + return { + kind: 'refused', + message: `${started.how}, and it has not answered on port ${port} after ${deps.waitMs / 1000} seconds. Its log is ${started.log}.`, + }; +} diff --git a/apps/aflow-executor-host/src/probeEnv.ts b/apps/aflow-executor-host/src/probeEnv.ts new file mode 100644 index 00000000..56d4fa37 --- /dev/null +++ b/apps/aflow-executor-host/src/probeEnv.ts @@ -0,0 +1,13 @@ +/** + * Enough to find and run one of the operator's programs, and nothing this + * executor holds: its environment carries the Redis credential pairing wrote, + * and a program found on `PATH` has no use for it. + */ +export function probeEnv(): Record { + const env: Record = {}; + for (const name of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ'] as const) { + const value = process.env[name]; + if (value !== undefined) env[name] = value; + } + return env; +} diff --git a/apps/aflow-executor-host/src/runtimes.ts b/apps/aflow-executor-host/src/runtimes.ts index 0f670cba..ff556397 100644 --- a/apps/aflow-executor-host/src/runtimes.ts +++ b/apps/aflow-executor-host/src/runtimes.ts @@ -10,17 +10,9 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -const run = promisify(execFile); +import { probeEnv } from './probeEnv.js'; -/** Enough to find and run a version command, and nothing this executor holds. */ -function probeEnv(): Record { - const env: Record = {}; - for (const name of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ'] as const) { - const value = process.env[name]; - if (value !== undefined) env[name] = value; - } - return env; -} +const run = promisify(execFile); /** Probed by asking for a version, which is the one flag they agree on. */ const PROBES: ReadonlyArray<{ name: string; command: string; args: string[] }> = [ diff --git a/docs/plans/323-models-that-run-on-this-computer.md b/docs/plans/323-models-that-run-on-this-computer.md index e9afcb59..a56831b8 100644 --- a/docs/plans/323-models-that-run-on-this-computer.md +++ b/docs/plans/323-models-that-run-on-this-computer.md @@ -390,6 +390,7 @@ _Built:_ - **"Use for every role":** writes the space's directives through the space update the agent settings already use. It sets the default and clears every role override, and puts the Clerk on `space_default`. - **Onboarding (D18):** offers the models on this computer beside the hosted ones. Choosing one puts every role on it, adds an Ollama connection unless one already resolves, and skips the key step. - **Cost:** a run whose every model call ran here reads _Cost: local_, and so does each such step. +- **Starting Ollama:** a runtime on this machine that does not answer offers **Start Ollama**, in the chat's banner and on the page, wherever this machine's executor is paired. The request travels the per-machine channel the browser's requests already use (`hostRuntimeRequestChannel`), published only by the operator's route on a space admin's request, and names nothing to run but the port. The executor starts Ollama the way the machine has it installed: a Homebrew service through `brew services`, another launch agent through `launchctl`, a Linux user unit through `systemctl --user`, then the macOS app, and only then `ollama serve` as the operator, detached and without the executor's own environment. A service comes first because its definition carries the runtime's settings — Homebrew's sets the `q8_0` cache and flash attention G6 was measured with — which a bare `ollama serve` drops. It answers once the runtime does. A runtime installed as a system service is left to the administrator, with the command that starts it. The server never starts it: in the appliance it runs in a container that reaches the machine's Ollama but cannot start a program there. Helmsman has no way to start it, and needs none: it cannot run while its model's runtime is down. _Not built:_ diff --git a/packages/ai-client/src/localRuntimeDiagnostics.test.ts b/packages/ai-client/src/localRuntimeDiagnostics.test.ts index 5d7db2b2..d907e9a5 100644 --- a/packages/ai-client/src/localRuntimeDiagnostics.test.ts +++ b/packages/ai-client/src/localRuntimeDiagnostics.test.ts @@ -54,6 +54,21 @@ describe('diagnoseLocalRuntime', () => { expect(d.issues[0]!.remedy).toMatchObject({ kind: 'instruction' }); }); + it('offers to start the runtime where this machine’s executor can start it', () => { + const d = diagnoseLocalRuntime({ + observation: observe({ version: null, installed: null, loaded: null }), + connection: { machine: M3_24GB }, + models: [gemma], + roles: { default: gemma.id }, + startable: true, + }); + expect(d.issues[0]!.remedy).toEqual({ + kind: 'action', + action: { action: 'start_runtime' }, + label: 'Start Ollama', + }); + }); + it('offers the download, with its size, for a role whose model is not downloaded', () => { const d = diagnose({ machine: M4_64GB }, { installed: [] }); expect(d.issues).toEqual([ diff --git a/packages/ai-client/src/localRuntimeDiagnostics.ts b/packages/ai-client/src/localRuntimeDiagnostics.ts index 6f0e116d..7991aaaf 100644 --- a/packages/ai-client/src/localRuntimeDiagnostics.ts +++ b/packages/ai-client/src/localRuntimeDiagnostics.ts @@ -48,6 +48,8 @@ export function diagnoseLocalRuntime(input: { roles: Partial>; freeDiskBytes?: number | undefined; pulls?: Readonly> | undefined; + /** Whether this machine's paired executor can start the runtime, which only it can reach. */ + startable?: boolean | undefined; }): LocalRuntimeDiagnostics { const { observation, connection } = input; const reachable = observation.version !== null; @@ -120,7 +122,9 @@ export function diagnoseLocalRuntime(input: { code: 'runtime_unreachable', severity: 'error', cause: `Ollama is not answering at ${observation.baseUrl}.`, - remedy: { kind: 'instruction', text: START_OLLAMA }, + remedy: input.startable + ? { kind: 'action', action: { action: 'start_runtime' }, label: 'Start Ollama' } + : { kind: 'instruction', text: START_OLLAMA }, }, ], }; diff --git a/packages/cybernetic-runtime/src/localModels/__tests__/actions.test.ts b/packages/cybernetic-runtime/src/localModels/__tests__/actions.test.ts index 2a4de7ad..c27c78c4 100644 --- a/packages/cybernetic-runtime/src/localModels/__tests__/actions.test.ts +++ b/packages/cybernetic-runtime/src/localModels/__tests__/actions.test.ts @@ -45,6 +45,7 @@ function runtime(freeDiskBytes: number | undefined) { freeDiskBytes === undefined ? undefined : { totalMemoryBytes: 24 * GIB, unifiedMemory: true, freeDiskBytes }, + hostname: undefined, }; return { local, diff --git a/packages/cybernetic-runtime/src/localModels/__tests__/start.test.ts b/packages/cybernetic-runtime/src/localModels/__tests__/start.test.ts new file mode 100644 index 00000000..5559c984 --- /dev/null +++ b/packages/cybernetic-runtime/src/localModels/__tests__/start.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import type { HostExchangeConnections, HostRuntimeRequest } from '@aflow/redis'; +import { startSpaceLocalRuntime } from '../start.js'; +import type { SpaceLocalRuntime } from '../runtime.js'; + +function runtime(overrides: Partial = {}): SpaceLocalRuntime { + return { + config: {}, + client: {} as SpaceLocalRuntime['client'], + runtimeId: 'this-machine:11434', + credential: { id: 'c', scope: 'user', scopeId: 'u' }, + hostMachine: undefined, + hostname: 'studio', + ...overrides, + }; +} + +/** An executor that answers each request with `answer`, or that nobody runs. */ +function machine(answer: unknown | 'nobody') { + const asked: Array<{ channel: string; request: HostRuntimeRequest }> = []; + let deliver: ((channel: string, message: string) => void) | undefined; + let answerChannel = ''; + const connections: HostExchangeConnections = { + publish: async (channel, message) => { + if (answer === 'nobody') return 0; + asked.push({ channel, request: JSON.parse(message) as HostRuntimeRequest }); + queueMicrotask(() => deliver?.(answerChannel, JSON.stringify(answer))); + return 1; + }, + subscriber: () => ({ + on: (_event, listener) => { + deliver = listener; + }, + subscribe: async (channel) => { + answerChannel = channel; + }, + disconnect: () => undefined, + }), + }; + return { connections, asked }; +} + +describe('starting the runtime from the workspace', () => { + it('asks the executor of the machine it runs on, for the port the connection names', async () => { + const { connections, asked } = machine({ kind: 'started', message: 'Opened the Ollama app.' }); + + const outcome = await startSpaceLocalRuntime( + runtime({ runtimeId: 'this-machine:11500' }), + connections, + ); + + expect(outcome).toEqual({ ok: true, message: 'Opened the Ollama app.' }); + expect(asked[0]?.channel).toBe('aflow:pubsub:host-runtime:studio'); + expect(asked[0]?.request).toMatchObject({ kind: 'start', hostname: 'studio', port: 11500 }); + }); + + it('carries the executor’s refusal in its own words', async () => { + const { connections } = machine({ kind: 'refused', message: 'Ollama is not installed.' }); + + expect(await startSpaceLocalRuntime(runtime(), connections)).toEqual({ + ok: false, + reason: 'runtime', + message: 'Ollama is not installed.', + }); + }); + + it('says no executor is listening rather than waiting for an answer that cannot come', async () => { + const { connections } = machine('nobody'); + + expect(await startSpaceLocalRuntime(runtime(), connections)).toMatchObject({ + ok: false, + reason: 'host', + }); + }); + + it('refuses a runtime on another machine, or one with no executor paired here', async () => { + const { connections, asked } = machine({ kind: 'started', message: '' }); + + for (const elsewhere of [ + runtime({ runtimeId: 'gpu-box.lan:11434' }), + runtime({ hostname: undefined }), + ]) { + expect(await startSpaceLocalRuntime(elsewhere, connections)).toMatchObject({ + ok: false, + reason: 'not_here', + }); + } + expect(asked).toHaveLength(0); + }); +}); diff --git a/packages/cybernetic-runtime/src/localModels/actions.ts b/packages/cybernetic-runtime/src/localModels/actions.ts index caf99737..53b850bd 100644 --- a/packages/cybernetic-runtime/src/localModels/actions.ts +++ b/packages/cybernetic-runtime/src/localModels/actions.ts @@ -12,10 +12,12 @@ import { endLocalPull, recordLocalPullProgress, requestLocalPullCancel, + type HostExchangeConnections, type Redis, } from '@aflow/redis'; import { THIS_MACHINE_RUNTIME_HOST, type LocalModelAction, type TenantId } from '@aflow/schemas'; import type { SpaceLocalRuntime } from './runtime.js'; +import { startSpaceLocalRuntime } from './start.js'; /** Room a download leaves on the disk: the runtime writes a manifest and temporary parts beside the layers. */ export const PULL_DISK_HEADROOM_BYTES = 2 * 1024 ** 3; @@ -26,7 +28,7 @@ export type LocalModelActionOutcome = | { ok: true; message: string } | { ok: false; - reason: 'not_local' | 'no_disk' | 'runtime' | 'not_here'; + reason: 'not_local' | 'no_disk' | 'runtime' | 'not_here' | 'host'; message: string; }; @@ -38,6 +40,8 @@ export interface LocalModelActionContext { tenantId: string; runtime: SpaceLocalRuntime; log?: { warn(message: string, fields?: Record): void }; + /** How the operator's route asks this machine's executor; absent where nothing can. */ + hostExchange?: HostExchangeConnections; } export async function applyLocalModelAction( @@ -45,6 +49,8 @@ export async function applyLocalModelAction( action: LocalModelAction, ): Promise { if (action.action === 'set_context') return setContext(ctx, action.contextTokens); + if (action.action === 'start_runtime') + return startSpaceLocalRuntime(ctx.runtime, ctx.hostExchange); if (action.action === 'use_for_every_role') { return { ok: false, diff --git a/packages/cybernetic-runtime/src/localModels/diagnose.ts b/packages/cybernetic-runtime/src/localModels/diagnose.ts index 14084d60..004cbf78 100644 --- a/packages/cybernetic-runtime/src/localModels/diagnose.ts +++ b/packages/cybernetic-runtime/src/localModels/diagnose.ts @@ -70,7 +70,7 @@ export async function diagnoseSpaceLocalRuntime(input: { if (pull) pulls[model.id] = pull; } // This machine's free disk says nothing about a runtime on another one. - const onThisMachine = runtime.runtimeId.startsWith(`${THIS_MACHINE_RUNTIME_HOST}:`); + const onThisMachine = isOnThisMachine(runtime); return diagnoseLocalRuntime({ observation: { baseUrl: runtime.config.baseUrl ?? OLLAMA_DEFAULT_BASE_URL, @@ -83,5 +83,18 @@ export async function diagnoseSpaceLocalRuntime(input: { roles, freeDiskBytes: onThisMachine ? runtime.hostMachine?.freeDiskBytes : undefined, pulls, + startable: localRuntimeStartable(runtime), }); } + +function isOnThisMachine(runtime: SpaceLocalRuntime): boolean { + return runtime.runtimeId.startsWith(`${THIS_MACHINE_RUNTIME_HOST}:`); +} + +/** + * Whether the runtime can be started from the workspace: it is on this + * machine, and this machine's executor is paired to start it. + */ +export function localRuntimeStartable(runtime: SpaceLocalRuntime): boolean { + return isOnThisMachine(runtime) && runtime.hostname !== undefined; +} diff --git a/packages/cybernetic-runtime/src/localModels/runtime.ts b/packages/cybernetic-runtime/src/localModels/runtime.ts index 93e63e3b..e18f023a 100644 --- a/packages/cybernetic-runtime/src/localModels/runtime.ts +++ b/packages/cybernetic-runtime/src/localModels/runtime.ts @@ -11,7 +11,7 @@ import { createProviderCredentialDbLoader, providerConfigFromCredential, } from '@aflow/credential-resolver'; -import { readLocalMachine, type HostMachine, type Redis } from '@aflow/redis'; +import { readLocalHost, type HostMachine, type Redis } from '@aflow/redis'; import { localRuntimeId, type CredentialScope } from '@aflow/schemas'; export interface SpaceLocalRuntime { @@ -22,6 +22,8 @@ export interface SpaceLocalRuntime { credential: { id: string; scope: CredentialScope; scopeId: string }; /** This machine as its host executor last published it; absent where none is paired. */ hostMachine: HostMachine | undefined; + /** The name that executor listens under, for asking it to start the runtime. */ + hostname: string | undefined; } export interface SpaceLocalRuntimeScope { @@ -44,9 +46,8 @@ export async function resolveSpaceLocalRuntime( }) .catch(() => null); if (!resolved) return null; - const hostMachine = scope.redis - ? await readLocalMachine(scope.redis).catch(() => undefined) - : undefined; + const host = scope.redis ? await readLocalHost(scope.redis).catch(() => undefined) : undefined; + const hostMachine = host?.machine; const config = providerConfigFromCredential('ollama', resolved, { machine: hostMachine }); return { config, @@ -54,5 +55,6 @@ export async function resolveSpaceLocalRuntime( runtimeId: localRuntimeId(config.baseUrl), credential: { id: resolved.credentialId, scope: resolved.scope, scopeId: resolved.scopeId }, hostMachine, + hostname: host?.hostname, }; } diff --git a/packages/cybernetic-runtime/src/localModels/start.ts b/packages/cybernetic-runtime/src/localModels/start.ts new file mode 100644 index 00000000..8736bfa6 --- /dev/null +++ b/packages/cybernetic-runtime/src/localModels/start.ts @@ -0,0 +1,67 @@ +/** + * Starting the space's runtime from the workspace (Plan 323 D15), which only + * this machine's paired executor can do: the server may run in a container + * that reaches the machine's Ollama but cannot start a program there. + */ +import { + askHost, + hostRuntimeAnswerChannel, + hostRuntimeRequestChannel, + HostRuntimeAnswerSchema, + newHostAnswerId, + type HostExchangeConnections, + type HostRuntimeRequest, +} from '@aflow/redis'; +import { localRuntimeStartable } from './diagnose.js'; +import type { LocalModelActionOutcome } from './actions.js'; +import type { SpaceLocalRuntime } from './runtime.js'; + +/** Past the executor's own wait for the runtime to answer, so its answer arrives first. */ +export const RUNTIME_START_ANSWER_TIMEOUT_MS = 40_000; + +export async function startSpaceLocalRuntime( + runtime: SpaceLocalRuntime, + connections: HostExchangeConnections | undefined, +): Promise { + const hostname = runtime.hostname; + if (!localRuntimeStartable(runtime) || hostname === undefined || connections === undefined) { + return { + ok: false, + reason: 'not_here', + message: + 'Only a runtime on this computer, with its executor paired, can be started from here. Start Ollama on the machine it runs on.', + }; + } + const answerId = newHostAnswerId(); + const request: HostRuntimeRequest = { + kind: 'start', + hostname, + answerId, + port: Number(runtime.runtimeId.split(':').at(-1)), + }; + const exchanged = await askHost(connections, { + requestChannel: hostRuntimeRequestChannel(hostname), + answerChannel: hostRuntimeAnswerChannel(answerId), + request, + parse: (value) => HostRuntimeAnswerSchema.safeParse(value).data, + timeoutMs: RUNTIME_START_ANSWER_TIMEOUT_MS, + }); + if (exchanged.status === 'not_listening') { + return { + ok: false, + reason: 'host', + message: `No executor on ${hostname} is listening. Restart it there, or start Ollama yourself.`, + }; + } + if (exchanged.status === 'no_answer') { + return { + ok: false, + reason: 'host', + message: `The executor on ${hostname} took the request and has not answered after ${RUNTIME_START_ANSWER_TIMEOUT_MS / 1000} seconds. Its log says whether Ollama started.`, + }; + } + const { answer } = exchanged; + return answer.kind === 'started' + ? { ok: true, message: answer.message } + : { ok: false, reason: 'runtime', message: answer.message }; +} diff --git a/packages/redis/src/hostInventory.ts b/packages/redis/src/hostInventory.ts index 65574d9c..d4921200 100644 --- a/packages/redis/src/hostInventory.ts +++ b/packages/redis/src/hostInventory.ts @@ -90,6 +90,52 @@ export function hostBrowserAnswerChannel(answerId: string): string { return `aflow:pubsub:host-browser-answer:${answerId}`; } +/** + * The operator asking, from the workspace, for the model runtime on this + * machine to start (Plan 323 D15). Published only by the operator's + * local-models route on a space admin's request, and acted on only by the + * executor of the machine it names, which starts the one program it knows + * and nothing the request could name. + */ +export function hostRuntimeRequestChannel(hostname: string): string { + return `aflow:pubsub:host-runtime:${hostname}`; +} + +export function hostRuntimeAnswerChannel(answerId: string): string { + return `aflow:pubsub:host-runtime-answer:${answerId}`; +} + +export const HostRuntimeRequestSchema = z.object({ + kind: z.literal('start'), + hostname: z.string().min(1), + answerId: z.string().regex(/^[A-Za-z0-9_-]{16,64}$/), + /** The loopback port the space's connection names, which the runtime is waited for on. */ + port: z.number().int().min(1).max(65_535), +}); +export type HostRuntimeRequest = z.infer; + +export const HostRuntimeAnswerSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('started'), message: z.string() }), + z.object({ kind: z.literal('refused'), message: z.string() }), +]); +export type HostRuntimeAnswer = z.infer; + +/** The request, when it is well formed and names `hostname`. */ +export function readHostRuntimeRequest( + raw: string, + hostname: string, +): HostRuntimeRequest | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + const request = HostRuntimeRequestSchema.safeParse(parsed); + if (!request.success || request.data.hostname !== hostname) return undefined; + return request.data; +} + const SettingWordSchema = z.string().max(2048); export const HostBrowserSettingSchema = z.discriminatedUnion('kind', [ @@ -327,8 +373,15 @@ export async function readLiveHostInventories( export async function readLocalMachine( redis: HostInventoryReader, ): Promise { + return (await readLocalHost(redis))?.machine; +} + +/** The same machine, with the name its executor listens under. */ +export async function readLocalHost( + redis: HostInventoryReader, +): Promise<{ hostname: string; machine: HostMachine } | undefined> { for (const inventory of await readLiveHostInventories(redis)) { - if (inventory.machine) return inventory.machine; + if (inventory.machine) return { hostname: inventory.hostname, machine: inventory.machine }; } return undefined; } diff --git a/packages/redis/src/hostRequests.ts b/packages/redis/src/hostRequests.ts new file mode 100644 index 00000000..ad06ce17 --- /dev/null +++ b/packages/redis/src/hostRequests.ts @@ -0,0 +1,72 @@ +/** + * The workspace asking one machine's executor for something and waiting for + * its answer: a request published on the machine's own channel, answered on a + * channel named for the request. + */ +import { randomBytes } from 'node:crypto'; + +/** The connections the exchange runs on: a subscriber of its own, and any client to publish with. */ +export interface HostExchangeConnections { + publish(channel: string, message: string): Promise; + subscriber(): { + on(event: 'message', listener: (channel: string, message: string) => void): unknown; + subscribe(channel: string): Promise; + disconnect(): void; + }; +} + +export type HostExchange = + | { readonly status: 'answered'; readonly answer: A } + /** Nobody subscribes to the machine's channel: no executor of it is running. */ + | { readonly status: 'not_listening' } + | { readonly status: 'no_answer' }; + +export function newHostAnswerId(): string { + return randomBytes(18).toString('base64url'); +} + +export async function askHost( + connections: HostExchangeConnections, + exchange: { + requestChannel: string; + answerChannel: string; + request: unknown; + /** The answer, or undefined for a message that is not one. */ + parse: (value: unknown) => A | undefined; + timeoutMs: number; + }, +): Promise> { + const answers = connections.subscriber(); + let timer: NodeJS.Timeout | undefined; + try { + const answered = new Promise((resolve) => { + answers.on('message', (from: string, raw: string) => { + if (from !== exchange.answerChannel) return; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + const answer = exchange.parse(parsed); + if (answer !== undefined) resolve(answer); + }); + timer = setTimeout(() => { + resolve(undefined); + }, exchange.timeoutMs); + }); + // Subscribed before the request goes out, so an executor that answers at + // once is heard. + await answers.subscribe(exchange.answerChannel); + const receivers = await connections.publish( + exchange.requestChannel, + JSON.stringify(exchange.request), + ); + if (receivers === 0) return { status: 'not_listening' }; + const answer = await answered; + return answer === undefined ? { status: 'no_answer' } : { status: 'answered', answer }; + } finally { + clearTimeout(timer); + answers.disconnect(); + } +} diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 70c27066..78d46163 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -524,6 +524,7 @@ export { } from './computeBudget.js'; export * from './hostInventory.js'; +export * from './hostRequests.js'; export * from './browserHandoff.js'; export * from './sessionResidue.js'; export { diff --git a/packages/schemas/src/credentials/localRuntimeDiagnostics.ts b/packages/schemas/src/credentials/localRuntimeDiagnostics.ts index ea9287d8..e71d61e4 100644 --- a/packages/schemas/src/credentials/localRuntimeDiagnostics.ts +++ b/packages/schemas/src/credentials/localRuntimeDiagnostics.ts @@ -38,6 +38,9 @@ const setContext = z .describe('The window every request gets, or null for the largest that fits the machine.'), }) .describe("Set the context window on the space's Ollama connection."); +const startRuntime = z + .object({ action: z.literal('start_runtime') }) + .describe('Start Ollama on this computer, through its paired executor.'); const useForEveryRole = z .object({ action: z.literal('use_for_every_role'), model }) .describe('Assign one model to every role, so the runtime never switches models between them.'); @@ -67,6 +70,7 @@ export const LocalModelActionSchema = z.discriminatedUnion('action', [ warm, unload, setContext, + startRuntime, useForEveryRole, ]); export type LocalModelAction = z.infer; diff --git a/packages/server-runtime/src/lib/hostExchange.ts b/packages/server-runtime/src/lib/hostExchange.ts new file mode 100644 index 00000000..c1a761fa --- /dev/null +++ b/packages/server-runtime/src/lib/hostExchange.ts @@ -0,0 +1,14 @@ +import { + createSubscriberConnection, + getRedisConfig, + getRedisConnection, + type HostExchangeConnections, +} from '@aflow/redis'; + +/** The server's own connections for asking a machine's executor something. */ +export function hostExchangeConnections(connectionName: string): HostExchangeConnections { + return { + publish: (channel, message) => getRedisConnection().publish(channel, message), + subscriber: () => createSubscriberConnection({ ...getRedisConfig(), connectionName }), + }; +} diff --git a/packages/server-runtime/src/routes/hostBrowserSettings.ts b/packages/server-runtime/src/routes/hostBrowserSettings.ts index 35b35743..a0cd2346 100644 --- a/packages/server-runtime/src/routes/hostBrowserSettings.ts +++ b/packages/server-runtime/src/routes/hostBrowserSettings.ts @@ -10,11 +10,8 @@ * executor writes the policy file through the command's own writer and answers * with the profile as it now is, or with its refusal. */ -import { randomBytes } from 'node:crypto'; - import { - createSubscriberConnection, - getRedisConfig, + askHost, getRedisConnection, hostBrowserAnswerChannel, hostBrowserRequestChannel, @@ -22,6 +19,8 @@ import { type HostBrowserSetting, type HostBrowserSettingAnswer, HostBrowserSettingAnswerSchema, + type HostExchange, + newHostAnswerId, readHostInventory, } from '@aflow/redis'; import { describeStackPortOwner, stackOwnPorts } from '@aflow/lib'; @@ -35,6 +34,7 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod'; import { isInteractiveUser } from '../utils/interactiveUser.js'; +import { hostExchangeConnections } from '../lib/hostExchange.js'; /** Past the executor republishing its inventory, which it does before it answers. */ export const SETTING_ANSWER_TIMEOUT_MS = 20_000; @@ -61,64 +61,20 @@ const NOT_A_PERSON = { const Hostname = z.string().min(1).max(256); const Params = z.object({ profileId: BrowserProfileIdSchema }); -type Relayed = - | { readonly status: 'answered'; readonly answer: HostBrowserSettingAnswer } - | { readonly status: 'not_listening' } - | { readonly status: 'no_answer' }; - async function relay( hostname: string, profileId: string, setting: HostBrowserSetting, -): Promise { - const answerId = randomBytes(18).toString('base64url'); - const answers = createSubscriberConnection({ - ...getRedisConfig(), - connectionName: 'host-browser-answer', +): Promise> { + const answerId = newHostAnswerId(); + const request: HostBrowserRequest = { kind: 'setting', hostname, profileId, answerId, setting }; + return await askHost(hostExchangeConnections('host-browser-answer'), { + requestChannel: hostBrowserRequestChannel(hostname), + answerChannel: hostBrowserAnswerChannel(answerId), + request, + parse: (value) => HostBrowserSettingAnswerSchema.safeParse(value).data, + timeoutMs: SETTING_ANSWER_TIMEOUT_MS, }); - try { - const channel = hostBrowserAnswerChannel(answerId); - let timer: NodeJS.Timeout | undefined; - const answered = new Promise((resolve) => { - answers.on('message', (from: string, raw: string) => { - if (from !== channel) return; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return; - } - const answer = HostBrowserSettingAnswerSchema.safeParse(parsed); - if (answer.success) resolve(answer.data); - }); - timer = setTimeout(() => { - resolve(undefined); - }, SETTING_ANSWER_TIMEOUT_MS); - }); - // Subscribed before the request goes out, so an executor that answers at - // once is heard. - await answers.subscribe(channel); - const request: HostBrowserRequest = { - kind: 'setting', - hostname, - profileId, - answerId, - setting, - }; - const receivers = await getRedisConnection().publish( - hostBrowserRequestChannel(hostname), - JSON.stringify(request), - ); - if (receivers === 0) { - clearTimeout(timer); - return { status: 'not_listening' }; - } - const answer = await answered; - clearTimeout(timer); - return answer === undefined ? { status: 'no_answer' } : { status: 'answered', answer }; - } finally { - answers.disconnect(); - } } /** Why a port asked for is never opened to a profile, when this process can tell. */ diff --git a/packages/server-runtime/src/routes/localModels.test.ts b/packages/server-runtime/src/routes/localModels.test.ts index c0cb2aa0..f66d82ff 100644 --- a/packages/server-runtime/src/routes/localModels.test.ts +++ b/packages/server-runtime/src/routes/localModels.test.ts @@ -21,6 +21,7 @@ vi.mock('@aflow/cybernetic-runtime', async (importOriginal) => { runtimeId: 'this-machine:11434', credential: { id: 'c', scope: 'user', scopeId: 'u' }, hostMachine: undefined, + hostname: undefined, } : null, }; diff --git a/packages/server-runtime/src/routes/localModels.ts b/packages/server-runtime/src/routes/localModels.ts index 6561b3f7..3872ecd4 100644 --- a/packages/server-runtime/src/routes/localModels.ts +++ b/packages/server-runtime/src/routes/localModels.ts @@ -23,17 +23,19 @@ import { type SpaceLocalRuntime, } from '@aflow/cybernetic-runtime'; import { LocalModelActionSchema, LocalRuntimeDiagnosticsSchema } from '@aflow/schemas'; +import { hostExchangeConnections } from '../lib/hostExchange.js'; const ErrorSchema = z.object({ error: z.string(), message: z.string() }); const OUTCOME_STATUS: Record< Extract['reason'], - 400 | 409 | 502 + 400 | 409 | 502 | 503 > = { not_local: 400, not_here: 400, no_disk: 409, runtime: 502, + host: 503, }; const spaceAuthz = (action: 'read' | 'admin') => ({ @@ -133,6 +135,7 @@ export const localModelRoutes: FastifyPluginAsync = async (fastify) => { 404: ErrorSchema, 409: ErrorSchema, 502: ErrorSchema, + 503: ErrorSchema, }, }, }, @@ -148,6 +151,7 @@ export const localModelRoutes: FastifyPluginAsync = async (fastify) => { tenantId: resolved.tenantId, runtime: resolved.runtime, log: request.log, + hostExchange: hostExchangeConnections('host-runtime-answer'), }, request.body, ); diff --git a/packages/web-product/src/ui/components/credentials/LlmReadinessBanner.tsx b/packages/web-product/src/ui/components/credentials/LlmReadinessBanner.tsx index c6802384..7619f078 100644 --- a/packages/web-product/src/ui/components/credentials/LlmReadinessBanner.tsx +++ b/packages/web-product/src/ui/components/credentials/LlmReadinessBanner.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import Link from 'next/link'; import { Button, Icon, Row, Text } from '@aflow/design-system'; import { useSpaceLlmReadiness, type SpaceLlmReadiness } from '../../hooks/useSpaceLlmReadiness.js'; +import { useLocalModels } from '../../hooks/useLocalModels.js'; import { useSpaceFromRoute } from '../providers.js'; import { spaceRoute } from '../../lib/space-routes.js'; @@ -44,6 +45,7 @@ export function LlmReadinessBanner({ onReviewModels: () => void; }) { const { readiness } = useSpaceLlmReadiness(spaceId); + const { act } = useLocalModels(spaceId, false); const routeSpace = useSpaceFromRoute(); const autoOpenedSpace = useRef(null); @@ -86,8 +88,15 @@ export function LlmReadinessBanner({ const localOnly = local !== null && missing.length === 0 && errored.length === 0 && !unknownMessage; + // A remedy the banner runs itself, where it needs nothing the page holds. + const startAction = + local?.remedy.kind === 'action' && local.remedy.action.action === 'start_runtime' + ? local.remedy + : null; const localMessage = local - ? `${local.cause} ${local.remedy.kind === 'instruction' ? local.remedy.text : `${local.remedy.label} on This Computer.`}` + ? startAction + ? `${local.cause}${act.error ? ` ${act.error.message}` : ''}` + : `${local.cause} ${local.remedy.kind === 'instruction' ? local.remedy.text : `${local.remedy.label} on This Computer.`}` : null; return ( @@ -116,7 +125,18 @@ export function LlmReadinessBanner({ ? `To run agents, ${subject} requires ${providerKey}. Add ${oneProvider ? 'the key' : 'them'}, or choose ${modelAlternative} from ${providerAlternative}.` : `${oneProvider ? `The ${names} key used by ${subject} was rejected` : `The keys for ${names} used by ${subject} were rejected`}. Update ${oneProvider ? 'the key' : 'them'}, or choose ${modelAlternative} from ${providerAlternative}.`)} - {localOnly ? ( + {localOnly && startAction ? ( + + ) : localOnly ? (