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 ? (