fix(local-models): use pm pins and resumable component downloads
Use PM for engine binaries, dependent runtime archives, and model files. Keep one operation-owned pause event through installation and download. Report whole-plan bytes and retain paused jobs across desktop remounts. Preserve the completed lm-pm worktree as its own integration parent. The owning session verified focused Python and desktop tests, actual Windows ARM64 CUDA downloads, and rendered pause/resume controls. Combined verification with the safety repairs follows in the merge.
This commit is contained in:
54
apps/desktop/src/api/local-models.test.ts
Normal file
54
apps/desktop/src/api/local-models.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
hermesApi: vi.fn(),
|
||||
profileScoped: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const client = await import('./client')
|
||||
|
||||
const { pauseLocalDownload, resumeLocalDownload } = await import('./local-models')
|
||||
|
||||
const hermesApi = vi.mocked(client.hermesApi)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('local download pause/resume API', () => {
|
||||
it('pauseLocalDownload posts {job_id} to the existing pause route and returns the {ok,paused} contract', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true, paused: true } as never)
|
||||
|
||||
const res = await pauseLocalDownload('job-7')
|
||||
|
||||
expect(hermesApi).toHaveBeenCalledTimes(1)
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
body: { job_id: 'job-7' },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/download/pause'
|
||||
})
|
||||
expect(res).toEqual({ ok: true, paused: true })
|
||||
})
|
||||
|
||||
it('resumeLocalDownload posts {job_id} to the existing resume route and returns the {ok,resumed} contract', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true, resumed: false } as never)
|
||||
|
||||
const res = await resumeLocalDownload('job-8')
|
||||
|
||||
expect(hermesApi).toHaveBeenCalledTimes(1)
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
body: { job_id: 'job-8' },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/download/resume'
|
||||
})
|
||||
// A false resumed flag (no parked download) reaches the caller so the
|
||||
// UI can report "couldn't resume" rather than silently claiming success.
|
||||
expect(res).toEqual({ ok: true, resumed: false })
|
||||
})
|
||||
|
||||
it('a failed pause propagates as a rejection — never a silent success', async () => {
|
||||
hermesApi.mockRejectedValue(new Error('unknown download job'))
|
||||
|
||||
await expect(pauseLocalDownload('gone')).rejects.toThrow('unknown download job')
|
||||
})
|
||||
})
|
||||
@@ -84,6 +84,28 @@ export function getLocalModelsJobs(): Promise<{ jobs: LocalRuntimeJob[] }> {
|
||||
})
|
||||
}
|
||||
|
||||
// Pause/resume a download-phase job (catalog model, quickstart, runtime
|
||||
// install/update, HF-browsed). The backend answers {ok, paused} /
|
||||
// {ok, resumed} — a false flag (no live download handle, e.g. a
|
||||
// quickstart engine leg) is reported to the caller, not treated as success.
|
||||
export function pauseLocalDownload(jobId: string): Promise<{ ok: boolean; paused: boolean }> {
|
||||
return hermesApi<{ ok: boolean; paused: boolean }>({
|
||||
...profileScoped(),
|
||||
body: { job_id: jobId },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/download/pause'
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeLocalDownload(jobId: string): Promise<{ ok: boolean; resumed: boolean }> {
|
||||
return hermesApi<{ ok: boolean; resumed: boolean }>({
|
||||
...profileScoped(),
|
||||
body: { job_id: jobId },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/download/resume'
|
||||
})
|
||||
}
|
||||
|
||||
export function activateLocalModel(modelId: string): Promise<{ job_id: string }> {
|
||||
return hermesApi<{ job_id: string }>({
|
||||
...profileScoped(),
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import type * as RuntimeJobs from '@/store/local-runtime-jobs'
|
||||
import type * as Notifications from '@/store/notifications'
|
||||
import type { LocalRuntimeJob } from '@/types/hermes'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
pauseLocalDownload: vi.fn(),
|
||||
resumeLocalDownload: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', async importOriginal => ({
|
||||
...(await importOriginal<typeof Notifications>()),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/local-runtime-jobs', async importOriginal => ({
|
||||
...(await importOriginal<typeof RuntimeJobs>()),
|
||||
watchLocalRuntimeJobs: vi.fn()
|
||||
}))
|
||||
|
||||
import { pauseLocalDownload, resumeLocalDownload } from '@/hermes'
|
||||
|
||||
import { isDownloadPhase, LocalModelDownloadActions } from './local-model-download-progress'
|
||||
|
||||
function job(overrides: Partial<LocalRuntimeJob>): LocalRuntimeJob {
|
||||
return {
|
||||
detail: '',
|
||||
done_bytes: 40,
|
||||
error: null,
|
||||
job_id: 'j1',
|
||||
kind: 'model-download',
|
||||
model_id: 'm1',
|
||||
phase: 'downloading',
|
||||
status: 'running',
|
||||
target: 'Qwen3.6 27B',
|
||||
total_bytes: 100,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderActions(current: LocalRuntimeJob) {
|
||||
act(() => {
|
||||
render(
|
||||
<I18nProvider configClient={null}>
|
||||
<LocalModelDownloadActions job={current} />
|
||||
</I18nProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(pauseLocalDownload).mockResolvedValue({ ok: true, paused: true })
|
||||
vi.mocked(resumeLocalDownload).mockResolvedValue({ ok: true, resumed: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('LocalModelDownloadActions', () => {
|
||||
it('running download with can_pause:true renders Pause; clicking sends pauseLocalDownload exactly once with the job id', async () => {
|
||||
renderActions(job({ can_pause: true }))
|
||||
|
||||
const pause = screen.getByRole('button', { name: /pause/i })
|
||||
fireEvent.click(pause)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pauseLocalDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(pauseLocalDownload).toHaveBeenCalledWith('j1')
|
||||
expect(resumeLocalDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('running download without explicit can_pause shows NO control (no guessing about old backends)', () => {
|
||||
renderActions(job({}))
|
||||
|
||||
expect(screen.queryByRole('button', { name: /pause/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('can_pause:false hides the control (server start / default assignment)', () => {
|
||||
renderActions(job({ can_pause: false }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: /pause/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('pause_requested renders a disabled pending control, not a vanished one', () => {
|
||||
renderActions(job({ can_pause: false, pause_requested: true }))
|
||||
|
||||
const pending = screen.getByRole('button', { name: /pause/i })
|
||||
expect((pending as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('paused job renders the Paused label + Resume (can_resume:true); clicking sends resumeLocalDownload with the job id', async () => {
|
||||
renderActions(job({ can_resume: true, status: 'paused' }))
|
||||
|
||||
expect(screen.getByText(/paused/i)).toBeTruthy()
|
||||
|
||||
const resume = screen.getByRole('button', { name: /resume/i })
|
||||
fireEvent.click(resume)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeLocalDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(resumeLocalDownload).toHaveBeenCalledWith('j1')
|
||||
expect(pauseLocalDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('paused hides Resume when can_resume is not explicitly true', () => {
|
||||
renderActions(job({ status: 'paused' }))
|
||||
|
||||
expect(screen.getByText(/paused/i)).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /resume/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('a paused:false response is a benign race — truth is re-kicked, no false failure toast', async () => {
|
||||
const { notifyError } = await import('@/store/notifications')
|
||||
const { watchLocalRuntimeJobs } = await import('@/store/local-runtime-jobs')
|
||||
|
||||
// The job may have settled between render and click; the backend says
|
||||
// nothing paused. Refresh the authoritative snapshot; do NOT toast a
|
||||
// download failure the user never saw.
|
||||
vi.mocked(pauseLocalDownload).mockResolvedValue({ ok: true, paused: false })
|
||||
|
||||
renderActions(job({ can_pause: true }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /pause/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchLocalRuntimeJobs).toHaveBeenCalled()
|
||||
})
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a rejected pause propagates to the error path — never a silent success', async () => {
|
||||
const { notifyError } = await import('@/store/notifications')
|
||||
|
||||
vi.mocked(pauseLocalDownload).mockRejectedValue(new Error('unknown download job'))
|
||||
|
||||
renderActions(job({ can_pause: true }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /pause/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('no control on settled jobs', () => {
|
||||
renderActions(job({ can_pause: true, status: 'done' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: /pause|resume/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDownloadPhase', () => {
|
||||
it('download phases cover every job kind that fetches bytes; finalization is not one', () => {
|
||||
expect(isDownloadPhase(job({ kind: 'model-download' }))).toBe(true)
|
||||
expect(isDownloadPhase(job({ kind: 'runtime-install', phase: 'downloading-runtime' }))).toBe(true)
|
||||
expect(isDownloadPhase(job({ kind: 'quickstart', phase: 'downloading' }))).toBe(true)
|
||||
expect(isDownloadPhase(job({ kind: 'quickstart', phase: 'setting-default' }))).toBe(false)
|
||||
expect(isDownloadPhase(job({ kind: 'model-activate', phase: 'loading' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
173
apps/desktop/src/app/settings/local-model-download-progress.tsx
Normal file
173
apps/desktop/src/app/settings/local-model-download-progress.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { pauseLocalDownload, resumeLocalDownload } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2, Pause, Play } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { LocalRuntimeJob } from '@/types/hermes'
|
||||
|
||||
import { Pill } from './primitives'
|
||||
|
||||
interface ProgressBarProps {
|
||||
percent: number | undefined
|
||||
paused?: boolean
|
||||
}
|
||||
|
||||
interface LocalModelDownloadProps {
|
||||
job: LocalRuntimeJob
|
||||
}
|
||||
|
||||
export function ProgressBar({ percent, paused = false }: ProgressBarProps) {
|
||||
const unknown = typeof percent !== 'number'
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
{...(unknown ? {} : { 'aria-valuenow': percent })}
|
||||
className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)"
|
||||
role="progressbar"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
paused ? 'bg-muted-foreground/60' : 'bg-primary transition-[width] duration-300'
|
||||
)}
|
||||
style={{ width: `${Math.max(0, Math.min(100, percent ?? 0))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function gbLabel(bytes: number | null | undefined): string {
|
||||
if (bytes == null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${(bytes / (1 << 30)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
// Phases where bytes are actually moving (or parked mid-move). Quickstart
|
||||
// recomputes percent against EACH stage's own download plan — the counter
|
||||
// resets between stages by design, so it is only shown during a genuine
|
||||
// download phase. Gate of last resort only: the backend's can_pause flag
|
||||
// is the primary control gate.
|
||||
const QUICKSTART_DOWNLOAD_PHASES = new Set([
|
||||
'downloading',
|
||||
'downloading-runtime',
|
||||
'unpacking-runtime',
|
||||
'verifying-runtime'
|
||||
])
|
||||
|
||||
export function isDownloadPhase(job: LocalRuntimeJob): boolean {
|
||||
if (job.kind === 'model-download' || job.kind === 'runtime-install') {
|
||||
return true
|
||||
}
|
||||
|
||||
return job.kind === 'quickstart' && QUICKSTART_DOWNLOAD_PHASES.has(job.phase)
|
||||
}
|
||||
|
||||
// Progress bar + honest byte counter. The counter is suppressed outside
|
||||
// download phases (a stage hand-off would otherwise read as progress loss);
|
||||
// paused rows keep the frozen counter they parked with.
|
||||
export function LocalModelDownloadProgress({ job }: LocalModelDownloadProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.localModels
|
||||
const showCounter = isDownloadPhase(job)
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<ProgressBar paused={job.status === 'paused'} percent={job.percent} />
|
||||
|
||||
<p className="text-[0.68rem] text-muted-foreground">
|
||||
{!showCounter || (!job.done_bytes && job.detail)
|
||||
? job.detail
|
||||
: copy.downloadProgress(gbLabel(job.done_bytes), gbLabel(job.total_bytes))}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LocalModelDownloadActions({ job }: LocalModelDownloadProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.localModels
|
||||
const [busy, setBusy] = useState<boolean>(false)
|
||||
|
||||
const send = async (kind: 'pause' | 'resume'): Promise<void> => {
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
// A false paused/resumed flag is usually a benign race (the job
|
||||
// settled between render and click) — the authoritative refresh
|
||||
// below decides what the row shows; only a real transport failure
|
||||
// surfaces as an error.
|
||||
if (kind === 'pause') {
|
||||
await pauseLocalDownload(job.job_id)
|
||||
} else {
|
||||
await resumeLocalDownload(job.job_id)
|
||||
}
|
||||
|
||||
watchLocalRuntimeJobs()
|
||||
} catch (err) {
|
||||
notifyError(err, copy.downloadFailed(job.target))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === 'paused') {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Pill tone="warn">
|
||||
<Pause className="mr-1 size-3" />
|
||||
{copy.downloadPausedLabel}
|
||||
</Pill>
|
||||
|
||||
{job.can_resume === true && (
|
||||
<Button
|
||||
className={cn(busy && '[&_svg]:animate-spin')}
|
||||
disabled={busy}
|
||||
onClick={() => void send('resume')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 /> : <Play />}
|
||||
{copy.downloadResumeAction}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No gate invented client-side: can_pause is the backend's explicit
|
||||
// verdict; pause_requested keeps the control visible but disabled so a
|
||||
// pending request never reads as "gone".
|
||||
if (job.status !== 'running' || (!job.pause_requested && job.can_pause !== true)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (job.pause_requested) {
|
||||
return (
|
||||
<Button className={cn('[&_svg]:animate-spin')} disabled size="sm" variant="outline">
|
||||
<Loader2 />
|
||||
{copy.downloadPauseAction}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(busy && '[&_svg]:animate-spin')}
|
||||
disabled={busy}
|
||||
onClick={() => void send('pause')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 /> : <Pause />}
|
||||
{copy.downloadPauseAction}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { MemoryRouter, useLocation } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { $localRuntimeJobs } from '@/store/local-runtime-jobs'
|
||||
import { $localRuntimeJobs, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
|
||||
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
|
||||
|
||||
import { LocalModelsSettings } from './local-models-settings'
|
||||
@@ -23,7 +23,9 @@ vi.mock('@/hermes', () => ({
|
||||
getLocalRuntimeJob: vi.fn(),
|
||||
installLocalRuntime: vi.fn(),
|
||||
listHFRepoFiles: vi.fn(),
|
||||
pauseLocalDownload: vi.fn(),
|
||||
quickstartLocalModels: vi.fn(),
|
||||
resumeLocalDownload: vi.fn(),
|
||||
searchHFModels: vi.fn(),
|
||||
sideloadLocalModel: vi.fn()
|
||||
}))
|
||||
@@ -116,9 +118,21 @@ function renderPane() {
|
||||
// is one 'Configure…' click away. Render and click through.
|
||||
async function renderFullPane() {
|
||||
const result = renderPane()
|
||||
const configure = await screen.findByRole('button', { name: /configure/i })
|
||||
|
||||
fireEvent.click(configure)
|
||||
// Wait for status to load: the pane either shows the setup card (click
|
||||
// through to the full pane) or, when an active runtime/model job routes
|
||||
// straight to the full pane, the runtime section directly.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
Boolean(screen.queryByRole('button', { name: /configure/i })) || screen.queryAllByText(/this machine/i).length > 0
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
const configure = screen.queryByRole('button', { name: /configure/i })
|
||||
|
||||
if (configure) {
|
||||
fireEvent.click(configure)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -127,12 +141,18 @@ beforeEach(() => {
|
||||
mocked.getLocalModelsStatus.mockResolvedValue(BASE_STATUS)
|
||||
mocked.getLocalHardware.mockResolvedValue(BASE_HARDWARE)
|
||||
mocked.getLocalCatalog.mockResolvedValue({ models: [FITTING_MODEL, SPILLED_MODEL, REFUSED_MODEL] })
|
||||
mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] })
|
||||
// The backend mock ECHOES the atom: the watcher's immediate poll reads
|
||||
// seeded jobs instead of wiping them with a default {jobs:[]}.
|
||||
mocked.getLocalModelsJobs.mockImplementation(async () => ({ jobs: [...$localRuntimeJobs.get()] }))
|
||||
$localRuntimeJobs.set([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] })
|
||||
await act(async () => {
|
||||
watchLocalRuntimeJobs()
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -554,3 +574,200 @@ describe('quickstart completion navigation', () => {
|
||||
expect(routeProbe).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pause / resume integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(hermes.pauseLocalDownload).mockResolvedValue({ ok: true, paused: true })
|
||||
vi.mocked(hermes.resumeLocalDownload).mockResolvedValue({ ok: true, resumed: true })
|
||||
})
|
||||
|
||||
it('a running catalog download shows Pause; clicking sends the job id once', async () => {
|
||||
mocked.getLocalModelsStatus.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
runtime_installed: true,
|
||||
runtime_backend: 'cuda'
|
||||
})
|
||||
$localRuntimeJobs.set([
|
||||
{
|
||||
job_id: 'j1',
|
||||
kind: 'model-download',
|
||||
target: 'Qwen3.6 27B',
|
||||
model_id: FITTING_MODEL.id,
|
||||
status: 'running',
|
||||
phase: 'downloading',
|
||||
detail: 'Qwen3.6 27B — 17.6 GB',
|
||||
total_bytes: 100,
|
||||
can_pause: true,
|
||||
done_bytes: 40,
|
||||
percent: 40,
|
||||
error: null
|
||||
}
|
||||
])
|
||||
|
||||
await renderFullPane()
|
||||
await screen.findByText('Qwen3.6 27B')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pause/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hermes.pauseLocalDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('j1')
|
||||
expect(hermes.resumeLocalDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a paused catalog download keeps its row: Paused label, Resume button, progress bar retained', async () => {
|
||||
mocked.getLocalModelsStatus.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
runtime_installed: true,
|
||||
runtime_backend: 'cuda'
|
||||
})
|
||||
$localRuntimeJobs.set([
|
||||
{
|
||||
job_id: 'j1',
|
||||
kind: 'model-download',
|
||||
target: 'Qwen3.6 27B',
|
||||
model_id: FITTING_MODEL.id,
|
||||
can_resume: true,
|
||||
phase: 'downloading',
|
||||
status: 'paused',
|
||||
detail: 'Qwen3.6 27B — 17.6 GB',
|
||||
total_bytes: 100,
|
||||
done_bytes: 40,
|
||||
percent: 40,
|
||||
error: null
|
||||
}
|
||||
])
|
||||
|
||||
await renderFullPane()
|
||||
await screen.findByText('Qwen3.6 27B')
|
||||
|
||||
// Row survives with an honest parked label and a way forward.
|
||||
expect(screen.getAllByText(/paused/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: /resume/i })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /resume/i }))
|
||||
await waitFor(() => {
|
||||
expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('j1')
|
||||
})
|
||||
|
||||
// The watcher re-kicked: an authoritative re-read happens after resume.
|
||||
await waitFor(() => {
|
||||
expect(mocked.getLocalModelsJobs.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('a paused quickstart stays pinned in the hero with a Resume control', async () => {
|
||||
$localRuntimeJobs.set([
|
||||
{
|
||||
job_id: 'q1',
|
||||
kind: 'quickstart',
|
||||
target: 'Qwen3.6 27B',
|
||||
model_id: 'qwen3.6-27b',
|
||||
can_resume: true,
|
||||
phase: 'downloading',
|
||||
status: 'paused',
|
||||
detail: '',
|
||||
total_bytes: 100,
|
||||
done_bytes: 30,
|
||||
percent: 30,
|
||||
error: null
|
||||
}
|
||||
])
|
||||
|
||||
renderPane()
|
||||
await screen.findAllByText(/paused/i)
|
||||
|
||||
expect(screen.getByRole('button', { name: /resume/i })).toBeTruthy()
|
||||
// Hero is pinned — no quickstart action button competes with it.
|
||||
expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /resume/i }))
|
||||
await waitFor(() => {
|
||||
expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('q1')
|
||||
})
|
||||
})
|
||||
|
||||
it('a running quickstart hero shows Pause during the download stage', async () => {
|
||||
$localRuntimeJobs.set([
|
||||
{
|
||||
job_id: 'q1',
|
||||
kind: 'quickstart',
|
||||
target: 'Qwen3.6 27B',
|
||||
model_id: 'qwen3.6-27b',
|
||||
can_pause: true,
|
||||
phase: 'downloading',
|
||||
status: 'running',
|
||||
detail: '',
|
||||
total_bytes: 100,
|
||||
done_bytes: 30,
|
||||
percent: 30,
|
||||
error: null
|
||||
}
|
||||
])
|
||||
|
||||
renderPane()
|
||||
await screen.findByText('Qwen3.6 27B')
|
||||
|
||||
expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running runtime install row shows Pause when the engine leg can park', async () => {
|
||||
mocked.getLocalModelsStatus.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
runtime_installed: false,
|
||||
update_available: false
|
||||
})
|
||||
$localRuntimeJobs.set([
|
||||
{
|
||||
job_id: 'r1',
|
||||
kind: 'runtime-install',
|
||||
target: 'llamacpp',
|
||||
model_id: null,
|
||||
can_pause: true,
|
||||
phase: 'downloading-runtime',
|
||||
status: 'running',
|
||||
detail: 'llamacpp b10290',
|
||||
total_bytes: 100,
|
||||
done_bytes: 10,
|
||||
percent: 10,
|
||||
error: null
|
||||
}
|
||||
])
|
||||
|
||||
renderPane()
|
||||
await screen.findByText(/installing/i)
|
||||
|
||||
const pause = screen.getByRole('button', { name: /pause/i })
|
||||
fireEvent.click(pause)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('r1')
|
||||
})
|
||||
})
|
||||
|
||||
it('quickstart hero suppresses the byte counter outside download phases', async () => {
|
||||
$localRuntimeJobs.set([
|
||||
{
|
||||
job_id: 'q1',
|
||||
kind: 'quickstart',
|
||||
target: 'Qwen3.6 27B',
|
||||
model_id: 'qwen3.6-27b',
|
||||
status: 'running',
|
||||
phase: 'installing-runtime',
|
||||
detail: 'Unpacking runtime',
|
||||
total_bytes: 100,
|
||||
done_bytes: 100,
|
||||
percent: 100,
|
||||
error: null
|
||||
}
|
||||
])
|
||||
|
||||
renderPane()
|
||||
await screen.findByText('Unpacking runtime')
|
||||
|
||||
// Stage detail yes; byte counter no — a 100% counter on an install
|
||||
// phase would lie about the model leg still ahead.
|
||||
expect(screen.queryByText(/of/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Loader2,
|
||||
Monitor,
|
||||
Package,
|
||||
Pause,
|
||||
Search,
|
||||
StopFilled,
|
||||
Trash2,
|
||||
@@ -47,29 +48,17 @@ import {
|
||||
watchLocalRuntimeJobs
|
||||
} from '@/store/local-runtime-jobs'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus } from '@/types/hermes'
|
||||
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
gbLabel,
|
||||
isDownloadPhase,
|
||||
LocalModelDownloadActions,
|
||||
LocalModelDownloadProgress,
|
||||
ProgressBar
|
||||
} from './local-model-download-progress'
|
||||
import { ListRow, Pill, SettingsContent, SettingsSection, SettingsSkeleton } from './primitives'
|
||||
|
||||
function ProgressBar({ percent }: { percent: number | undefined }) {
|
||||
return (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(2, Math.min(100, percent ?? 2))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function gbLabel(bytes: number | null | undefined): string {
|
||||
if (!bytes) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${(bytes / (1 << 30)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
// Catalog display order: what runs well leads. Resident (all on GPU)
|
||||
// first, then spilled (works, slower), then doesn't-fit; catalog order
|
||||
// (recommended first) holds within each band.
|
||||
@@ -85,6 +74,12 @@ function fitRank(model: LocalCatalogModel): number {
|
||||
return 2
|
||||
}
|
||||
|
||||
// Still on its way (or parked mid-way): rows/hero stay visible while
|
||||
// paused — a paused download vanishing reads as progress loss.
|
||||
function isActiveStatus(status: LocalRuntimeJob['status']): boolean {
|
||||
return status === 'paused' || status === 'running'
|
||||
}
|
||||
|
||||
export function LocalModelsSettings() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.localModels
|
||||
@@ -265,7 +260,7 @@ export function LocalModelsSettings() {
|
||||
const navigate = useNavigate()
|
||||
const seenQuickstarts = useRef(new Set<string>())
|
||||
|
||||
const runningQuickstart = jobs.find(j => j.kind === 'quickstart' && j.status === 'running')
|
||||
const runningQuickstart = jobs.find(j => j.kind === 'quickstart' && isActiveStatus(j.status))
|
||||
|
||||
useEffect(() => {
|
||||
// Event detection, not value mirroring: the ref only remembers which
|
||||
@@ -306,7 +301,14 @@ export function LocalModelsSettings() {
|
||||
const needsSetup = !status.runtime_installed || status.models.length === 0
|
||||
const heroModel = catalog.find(c => c.recommended && c.fits) ?? catalog.find(c => c.fits) ?? null
|
||||
|
||||
if (qJob || (needsSetup && !configure && heroModel)) {
|
||||
// An active runtime install/update or model download needs the FULL pane
|
||||
// (its row lives there with its controls) — the setup hero must not hide
|
||||
// it on a remount.
|
||||
const otherActiveJob = jobs.some(
|
||||
j => (j.kind === 'runtime-install' || j.kind === 'model-download') && isActiveStatus(j.status)
|
||||
)
|
||||
|
||||
if ((qJob || (needsSetup && !configure && heroModel)) && !otherActiveJob) {
|
||||
// Stage rail derived from the job phase: engine -> model -> finish.
|
||||
const phase = qJob?.phase ?? ''
|
||||
|
||||
@@ -314,23 +316,17 @@ export function LocalModelsSettings() {
|
||||
|
||||
const stages = [copy.quickstartStageEngine, copy.quickstartStageModel, copy.quickstartStageFinish]
|
||||
|
||||
// The model-download leg blanks job.detail on purpose (pane rows
|
||||
// render their own byte counter) — compose one here instead of
|
||||
// falling back to runtime copy that would misname the stage.
|
||||
const liveDetail =
|
||||
qJob &&
|
||||
(qJob.detail ||
|
||||
(qJob.total_bytes
|
||||
? copy.downloadProgress(gbLabel(qJob.done_bytes), gbLabel(qJob.total_bytes))
|
||||
: copy.installing))
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="flex min-h-[60dvh] items-center justify-center">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-5 flex size-14 items-center justify-center rounded-2xl bg-primary/10">
|
||||
{qJob ? (
|
||||
<Loader2 className="size-7 animate-spin text-primary" />
|
||||
qJob.status === 'paused' ? (
|
||||
<Pause className="size-7 text-muted-foreground" />
|
||||
) : (
|
||||
<Loader2 className="size-7 animate-spin text-primary" />
|
||||
)
|
||||
) : (
|
||||
<Cpu className="size-7 text-primary" />
|
||||
)}
|
||||
@@ -342,10 +338,28 @@ export function LocalModelsSettings() {
|
||||
|
||||
{qJob ? (
|
||||
<>
|
||||
<p className="mt-2 min-h-10 text-[0.8rem] leading-5 text-muted-foreground">{liveDetail}</p>
|
||||
{/* Byte counter only while bytes actually move: each stage
|
||||
recomputes percent against ITS OWN download plan, and a
|
||||
stage hand-off resets the counter by design — showing it
|
||||
across a hand-off would read as progress loss. Paused
|
||||
rows keep the frozen counter they parked with. */}
|
||||
<p className="mt-2 min-h-10 text-[0.8rem] leading-5 text-muted-foreground">
|
||||
{qJob.status === 'paused'
|
||||
? copy.downloadPausedLabel
|
||||
: isDownloadPhase(qJob) && (qJob.detail || qJob.total_bytes)
|
||||
? qJob.detail || copy.downloadProgress(gbLabel(qJob.done_bytes), gbLabel(qJob.total_bytes))
|
||||
: qJob.detail || copy.installing}
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<ProgressBar percent={qJob.percent} />
|
||||
<ProgressBar paused={qJob.status === 'paused'} percent={qJob.percent} />
|
||||
</div>
|
||||
|
||||
{/* Pause / Resume — the backend's can_pause/can_resume gate
|
||||
when controls exist (engine legs, server start report
|
||||
false); the paused state always offers Resume. */}
|
||||
<div className="mt-5 flex items-center justify-center gap-3">
|
||||
<LocalModelDownloadActions job={qJob} />
|
||||
</div>
|
||||
|
||||
{/* Stage rail: engine -> model -> finish. */}
|
||||
@@ -456,11 +470,16 @@ export function LocalModelsSettings() {
|
||||
/>
|
||||
) : rJob ? (
|
||||
<ListRow
|
||||
below={<ProgressBar percent={rJob.percent} />}
|
||||
action={<LocalModelDownloadActions job={rJob} />}
|
||||
below={<LocalModelDownloadProgress job={rJob} />}
|
||||
description={rJob.detail || copy.installing}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{rJob.status === 'paused' ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{copy.installing}
|
||||
</span>
|
||||
}
|
||||
@@ -493,11 +512,16 @@ export function LocalModelsSettings() {
|
||||
|
||||
{rJob && status.runtime_installed && (
|
||||
<ListRow
|
||||
below={<ProgressBar percent={rJob.percent} />}
|
||||
action={<LocalModelDownloadActions job={rJob} />}
|
||||
below={<LocalModelDownloadProgress job={rJob} />}
|
||||
description={rJob.detail || copy.updating}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{rJob.status === 'paused' ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{copy.updating}
|
||||
</span>
|
||||
}
|
||||
@@ -554,7 +578,9 @@ export function LocalModelsSettings() {
|
||||
<div className="grid gap-1">
|
||||
{sortedCatalog.map(model => {
|
||||
const dJob = runningDownloadFor(jobs, model.id)
|
||||
const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && j.status === 'running')
|
||||
|
||||
const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && isActiveStatus(j.status))
|
||||
|
||||
const activateTarget = model.downloaded_model_id ?? model.model_id
|
||||
const isActive = Boolean(activateTarget && status.active_model_id === activateTarget)
|
||||
const residency = activateTarget ? status.loaded_models[activateTarget] : undefined
|
||||
@@ -634,7 +660,9 @@ export function LocalModelsSettings() {
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
) : dJob ? undefined : (
|
||||
) : dJob ? (
|
||||
<LocalModelDownloadActions job={dJob} />
|
||||
) : (
|
||||
<Button
|
||||
disabled={!model.fits || anyDownloadRunning || !status.runtime_installed}
|
||||
onClick={() => void handleDownload(model)}
|
||||
@@ -649,12 +677,14 @@ export function LocalModelsSettings() {
|
||||
below={
|
||||
dJob ? (
|
||||
<div className="mt-2 grid gap-1">
|
||||
<ProgressBar percent={dJob.percent} />
|
||||
<ProgressBar paused={dJob.status === 'paused'} percent={dJob.percent} />
|
||||
|
||||
<p className="text-[0.68rem] text-muted-foreground">
|
||||
{!dJob.done_bytes && dJob.detail
|
||||
? dJob.detail
|
||||
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
|
||||
{dJob.status === 'paused'
|
||||
? copy.downloadPausedLabel
|
||||
: !dJob.done_bytes && dJob.detail
|
||||
? dJob.detail
|
||||
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -1067,13 +1097,17 @@ function BrowseSection({ onChanged }: { onChanged: () => void }) {
|
||||
|
||||
{dJob ? (
|
||||
<>
|
||||
<ProgressBar percent={dJob.percent} />
|
||||
<ProgressBar paused={dJob.status === 'paused'} percent={dJob.percent} />
|
||||
|
||||
<span className="text-[0.68rem] text-muted-foreground">
|
||||
{!dJob.done_bytes && dJob.detail
|
||||
? dJob.detail
|
||||
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
|
||||
{dJob.status === 'paused'
|
||||
? copy.downloadPausedLabel
|
||||
: !dJob.done_bytes && dJob.detail
|
||||
? dJob.detail
|
||||
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
|
||||
</span>
|
||||
|
||||
<LocalModelDownloadActions job={dJob} />
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -682,10 +682,19 @@ const LOCAL_PROVIDER_SLUG = 'llamacpp'
|
||||
// above subscribes to download identity, not progress.
|
||||
function DownloadingModelRow({ jobId, target }: { jobId: string; target: string }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.modelPicker
|
||||
const copyPicker = t.modelPicker
|
||||
const copyLocal = t.settings.localModels
|
||||
|
||||
// The row's own scalar slice: percent for live rows, status for the
|
||||
// paused fork (a paused download must stay listed with its Paused pill,
|
||||
// not vanish — progress loss is information loss).
|
||||
const percent = useStoreSelector($localRuntimeJobs, jobs => jobs.find(job => job.job_id === jobId)?.percent ?? null)
|
||||
|
||||
const paused = useStoreSelector(
|
||||
$localRuntimeJobs,
|
||||
jobs => jobs.find(job => job.job_id === jobId)?.status === 'paused'
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'opacity-60')}
|
||||
@@ -694,15 +703,25 @@ function DownloadingModelRow({ jobId, target }: { jobId: string; target: string
|
||||
textValue=""
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{target}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5" title={copy.downloading}>
|
||||
<span
|
||||
className="ml-auto flex shrink-0 items-center gap-1.5"
|
||||
title={paused ? copyLocal.downloadPausedLabel : copyPicker.downloading}
|
||||
>
|
||||
<span className="h-1 w-14 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
className={cn(
|
||||
'block h-full rounded-full',
|
||||
paused ? 'bg-muted-foreground/60' : 'bg-primary transition-[width] duration-500'
|
||||
)}
|
||||
style={{ width: `${Math.max(2, percent ?? 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[0.62rem] tabular-nums text-(--ui-text-tertiary)">
|
||||
{typeof percent === 'number' ? `${percent}%` : copy.downloading}
|
||||
{paused
|
||||
? copyLocal.downloadPausedLabel
|
||||
: typeof percent === 'number'
|
||||
? `${percent}%`
|
||||
: copyPicker.downloading}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -8,12 +8,12 @@ import { modelSearchText } from '@/lib/model-search-text'
|
||||
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||
import { foldIncludes, normalize } from '@/lib/text'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $localModelsEnabled } from '@/store/local-models-flag'
|
||||
import { $localRuntimeJobs, runningModelDownloads, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
|
||||
import type { LocalModelLoadProgress, ModelOptionProvider, ModelPricing } from '@/types/hermes'
|
||||
|
||||
import type { HermesGateway } from '../hermes'
|
||||
import { cn } from '../lib/utils'
|
||||
import { startManualOnboarding } from '../store/onboarding'
|
||||
|
||||
import { InlineNotice } from './notifications'
|
||||
@@ -387,21 +387,33 @@ const LOCAL_PROVIDER_SLUG = 'llamacpp'
|
||||
function DownloadingModelRow({ jobId, target }: { jobId: string; target: string }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.modelPicker
|
||||
const copyLocal = t.settings.localModels
|
||||
|
||||
const percent = useStoreSelector($localRuntimeJobs, jobs => jobs.find(job => job.job_id === jobId)?.percent ?? null)
|
||||
|
||||
const paused = useStoreSelector(
|
||||
$localRuntimeJobs,
|
||||
jobs => jobs.find(job => job.job_id === jobId)?.status === 'paused'
|
||||
)
|
||||
|
||||
return (
|
||||
<CommandItem className="flex items-center gap-2 pl-6 font-mono opacity-60" disabled value={`downloading:${jobId}`}>
|
||||
<span className="min-w-0 flex-1 truncate">{target}</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5" title={copy.downloading}>
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
title={paused ? copyLocal.downloadPausedLabel : copy.downloading}
|
||||
>
|
||||
<span className="h-1 w-16 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
className={cn(
|
||||
'block h-full rounded-full',
|
||||
paused ? 'bg-muted-foreground/60' : 'bg-primary transition-[width] duration-500'
|
||||
)}
|
||||
style={{ width: `${Math.max(2, percent ?? 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[0.62rem] tabular-nums text-muted-foreground">
|
||||
{typeof percent === 'number' ? `${percent}%` : copy.downloading}
|
||||
{paused ? copyLocal.downloadPausedLabel : typeof percent === 'number' ? `${percent}%` : copy.downloading}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
|
||||
201
apps/desktop/src/store/local-runtime-jobs.test.ts
Normal file
201
apps/desktop/src/store/local-runtime-jobs.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LocalRuntimeJob } from '@/types/hermes'
|
||||
|
||||
// The BACKEND is the authority: a staged registry the poll reads from, so
|
||||
// transitions arrive the way production sees them — via a poll response,
|
||||
// never by mutating the cache directly.
|
||||
const backend = vi.hoisted(() => ({ jobs: [] as LocalRuntimeJob[] }))
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getLocalModelsJobs: vi.fn(async () => ({ jobs: backend.jobs })),
|
||||
getLocalModelsStatus: vi.fn(async () => ({ enabled: true, update_available: false }))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
translateNow: (key: string, ...args: unknown[]) => (args.length ? `${key}:${args.join(',')}` : key)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
const { $localRuntimeJobs, watchLocalRuntimeJobs } = await import('./local-runtime-jobs')
|
||||
const { getLocalModelsJobs } = await import('@/hermes')
|
||||
const { notify, notifyError } = await import('@/store/notifications')
|
||||
|
||||
function job(overrides: Partial<LocalRuntimeJob>): LocalRuntimeJob {
|
||||
return {
|
||||
detail: '',
|
||||
done_bytes: 0,
|
||||
error: null,
|
||||
job_id: 'j1',
|
||||
kind: 'model-download',
|
||||
model_id: 'm1',
|
||||
phase: 'downloading',
|
||||
status: 'running',
|
||||
target: 'Qwen3.6 27B',
|
||||
total_bytes: 100,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
backend.jobs = []
|
||||
$localRuntimeJobs.set([])
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
backend.jobs = []
|
||||
watchLocalRuntimeJobs()
|
||||
await vi.waitFor(() => expect($localRuntimeJobs.get()).toEqual([]))
|
||||
})
|
||||
|
||||
const settle = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// Drive the poller: each advance lets the pending tick fire, the fetch read
|
||||
// the staged backend snapshot, and the next tick re-arm.
|
||||
// Stage-then-poll: the kick starts the loop AND the loop only re-arms while
|
||||
// work is active, so each tick re-kicks (idempotent) then waits for the
|
||||
// fetch to land the staged snapshot.
|
||||
async function pollTick() {
|
||||
watchLocalRuntimeJobs()
|
||||
await settle(150)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('local runtime jobs store — pause/settle contract', () => {
|
||||
it('running→paused settles NOTHING: no error toast, job stays visible', async () => {
|
||||
backend.jobs = [job({ done_bytes: 40 })]
|
||||
await pollTick()
|
||||
expect($localRuntimeJobs.get()[0]?.status).toBe('running')
|
||||
|
||||
// Backend pauses the job (status='paused', error=null).
|
||||
backend.jobs = [job({ done_bytes: 40, status: 'paused' })]
|
||||
await pollTick()
|
||||
await pollTick()
|
||||
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
|
||||
const snapshot = $localRuntimeJobs.get()
|
||||
expect(snapshot).toHaveLength(1)
|
||||
expect(snapshot[0]?.status).toBe('paused')
|
||||
})
|
||||
|
||||
it('a paused job that resumes and finishes notifies done exactly once (paused→done still notifies)', async () => {
|
||||
backend.jobs = [job({ done_bytes: 40 })]
|
||||
await pollTick()
|
||||
|
||||
backend.jobs = [job({ done_bytes: 40, status: 'paused' })]
|
||||
await pollTick()
|
||||
|
||||
backend.jobs = [job({ done_bytes: 60 })]
|
||||
await pollTick()
|
||||
|
||||
backend.jobs = [job({ done_bytes: 100, percent: 100, status: 'done', total_bytes: 100 })]
|
||||
await pollTick()
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(notify).mock.calls[0][0]).toMatchObject({ kind: 'success' })
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('running→error still toasts exactly once', async () => {
|
||||
backend.jobs = [job({ done_bytes: 10, job_id: 'j-err' })]
|
||||
await pollTick()
|
||||
|
||||
backend.jobs = [job({ done_bytes: 10, error: 'disk full', job_id: 'j-err', status: 'error' })]
|
||||
await pollTick()
|
||||
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps polling while a job is paused (a resume from another surface is witnessed)', async () => {
|
||||
backend.jobs = [job({ status: 'paused' })]
|
||||
await pollTick()
|
||||
|
||||
const callsAfterPause = vi.mocked(getLocalModelsJobs).mock.calls.length
|
||||
expect(callsAfterPause).toBeGreaterThan(0)
|
||||
|
||||
// The paused cadence is slower (3s), not dead: wait past it and the
|
||||
// poll fires again WITHOUT any new kick.
|
||||
await settle(3_400)
|
||||
|
||||
expect(vi.mocked(getLocalModelsJobs).mock.calls.length).toBeGreaterThan(callsAfterPause)
|
||||
|
||||
// And a backend-side resume IS picked up with no local kick at all.
|
||||
backend.jobs = [job({ done_bytes: 90 })]
|
||||
await pollTick()
|
||||
expect($localRuntimeJobs.get()[0]?.status).toBe('running')
|
||||
})
|
||||
|
||||
it('coalesces refresh requests while one backend read is in flight', async () => {
|
||||
let release: (value: { jobs: LocalRuntimeJob[] }) => void = () => {}
|
||||
|
||||
const pending = new Promise<{ jobs: LocalRuntimeJob[] }>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
vi.mocked(getLocalModelsJobs).mockReturnValueOnce(pending)
|
||||
watchLocalRuntimeJobs()
|
||||
watchLocalRuntimeJobs()
|
||||
watchLocalRuntimeJobs()
|
||||
expect(getLocalModelsJobs).toHaveBeenCalledTimes(1)
|
||||
backend.jobs = [job({ done_bytes: 40 })]
|
||||
release({ jobs: [] })
|
||||
await vi.waitFor(() => expect(getLocalModelsJobs).toHaveBeenCalledTimes(2))
|
||||
expect($localRuntimeJobs.get()[0]?.done_bytes).toBe(40)
|
||||
})
|
||||
|
||||
it('does not re-set the atom when a poll returns the identical payload', async () => {
|
||||
backend.jobs = [job({ done_bytes: 40 })]
|
||||
await pollTick()
|
||||
expect($localRuntimeJobs.get()).toHaveLength(1)
|
||||
|
||||
const reference = $localRuntimeJobs.get()
|
||||
await pollTick()
|
||||
await pollTick()
|
||||
|
||||
// Same reference preserved — no-op polls never hand React fresh arrays.
|
||||
expect($localRuntimeJobs.get()).toBe(reference)
|
||||
})
|
||||
|
||||
it('equality covers control flags, ranges, percent, detail and error — not just done_bytes', async () => {
|
||||
backend.jobs = [job({ done_bytes: 40, ranges: { 'model.gguf': [[0, 100]] } })]
|
||||
await pollTick()
|
||||
const first = $localRuntimeJobs.get()
|
||||
|
||||
// Backend flips can_pause:false (e.g. a phase change) with identical bytes.
|
||||
backend.jobs = [job({ can_pause: false, done_bytes: 40, ranges: { 'model.gguf': [[0, 100]] } })]
|
||||
await pollTick()
|
||||
|
||||
const second = $localRuntimeJobs.get()
|
||||
expect(second).not.toBe(first)
|
||||
expect(second[0]?.can_pause).toBe(false)
|
||||
|
||||
// A ranges-only change re-publishes too.
|
||||
const before = $localRuntimeJobs.get()
|
||||
backend.jobs = [
|
||||
job({
|
||||
can_pause: false,
|
||||
done_bytes: 40,
|
||||
ranges: {
|
||||
'model.gguf': [
|
||||
[0, 100],
|
||||
[100, 200]
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
await pollTick()
|
||||
expect($localRuntimeJobs.get()).not.toBe(before)
|
||||
expect($localRuntimeJobs.get()[0]?.ranges?.['model.gguf']).toEqual([
|
||||
[0, 100],
|
||||
[100, 200]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -15,33 +15,78 @@ import type { LocalRuntimeJob } from '@/types/hermes'
|
||||
export const $localRuntimeJobs = atom<readonly LocalRuntimeJob[]>([])
|
||||
|
||||
const POLL_ACTIVE_MS = 700
|
||||
const POLL_PAUSED_MS = 3_000
|
||||
let timer: null | number = null
|
||||
let polling = false
|
||||
// Jobs we've already toasted for, so a poll race can't double-notify.
|
||||
const settledNotified = new Set<string>()
|
||||
|
||||
function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) {
|
||||
if (a.length !== b.length) {
|
||||
// The active set: running OR paused. Paused is NOT settled — it is user
|
||||
// intent, not failure — and a paused job that later runs again must stay
|
||||
// tracked so its eventual done/error notifies exactly once.
|
||||
function isActive(status: LocalRuntimeJob['status']): boolean {
|
||||
return status === 'paused' || status === 'running'
|
||||
}
|
||||
|
||||
// A job is the same observation unless ANY user-visible payload changed —
|
||||
// bytes, percent, phase, detail, error, control flags, and the per-file
|
||||
// ranges. The backend re-sorts jobs per poll, so compare by id, not index.
|
||||
function jobEqual(a: LocalRuntimeJob, b: LocalRuntimeJob): boolean {
|
||||
return (
|
||||
a.job_id === b.job_id &&
|
||||
a.status === b.status &&
|
||||
a.phase === b.phase &&
|
||||
a.detail === b.detail &&
|
||||
a.done_bytes === b.done_bytes &&
|
||||
a.total_bytes === b.total_bytes &&
|
||||
a.percent === b.percent &&
|
||||
a.error === b.error &&
|
||||
a.can_pause === b.can_pause &&
|
||||
a.can_resume === b.can_resume &&
|
||||
a.pause_requested === b.pause_requested &&
|
||||
rangesEqual(a.ranges, b.ranges)
|
||||
)
|
||||
}
|
||||
|
||||
function rangesEqual(
|
||||
a: Record<string, [number, number][]> | undefined,
|
||||
b: Record<string, [number, number][]> | undefined
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.every((job, i) => {
|
||||
const other = b[i]
|
||||
const keysA = Object.keys(a)
|
||||
const keysB = Object.keys(b)
|
||||
|
||||
return (
|
||||
job.job_id === other.job_id &&
|
||||
job.status === other.status &&
|
||||
job.phase === other.phase &&
|
||||
job.done_bytes === other.done_bytes
|
||||
)
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return keysA.every(key => {
|
||||
const ra = a[key]
|
||||
const rb = b[key]
|
||||
|
||||
if (!ra || !rb || ra.length !== rb.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ra.every((range, i) => range[0] === rb[i]?.[0] && range[1] === rb[i]?.[1])
|
||||
})
|
||||
}
|
||||
|
||||
function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) {
|
||||
return a.length === b.length && a.every(job => b.some(other => jobEqual(job, other)))
|
||||
}
|
||||
|
||||
function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly LocalRuntimeJob[]) {
|
||||
const wasRunning = new Set(previous.filter(j => j.status === 'running').map(j => j.job_id))
|
||||
const wasActive = new Set(previous.filter(j => isActive(j.status)).map(j => j.job_id))
|
||||
|
||||
for (const job of next) {
|
||||
if (job.status === 'running' || !wasRunning.has(job.job_id) || settledNotified.has(job.job_id)) {
|
||||
if (isActive(job.status) || !wasActive.has(job.job_id) || settledNotified.has(job.job_id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -76,7 +121,13 @@ function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly Loca
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
let inFlight = false
|
||||
let refreshRequested = false
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
timer = null
|
||||
inFlight = true
|
||||
|
||||
try {
|
||||
const { jobs } = await getLocalModelsJobs()
|
||||
const previous = $localRuntimeJobs.get()
|
||||
@@ -86,57 +137,71 @@ async function poll() {
|
||||
$localRuntimeJobs.set(jobs)
|
||||
}
|
||||
} catch {
|
||||
// Backend unreachable — keep the last snapshot; the next poll retries.
|
||||
// Preserve the last snapshot while the backend is unreachable.
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
|
||||
const anyRunning = $localRuntimeJobs.get().some(j => j.status === 'running')
|
||||
if (refreshRequested) {
|
||||
refreshRequested = false
|
||||
void poll()
|
||||
|
||||
if (anyRunning) {
|
||||
timer = window.setTimeout(() => void poll(), POLL_ACTIVE_MS)
|
||||
} else {
|
||||
polling = false
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent kick: start (or keep) the poll loop while work is in flight.
|
||||
// Call after starting a job AND on app boot (to rediscover work started
|
||||
// before a reload).
|
||||
export function watchLocalRuntimeJobs() {
|
||||
if (polling) {
|
||||
return
|
||||
}
|
||||
|
||||
polling = true
|
||||
const jobs = $localRuntimeJobs.get()
|
||||
const anyRunning = jobs.some(job => job.status === 'running')
|
||||
|
||||
if (anyRunning || jobs.some(job => job.status === 'paused')) {
|
||||
timer = window.setTimeout(() => void poll(), anyRunning ? POLL_ACTIVE_MS : POLL_PAUSED_MS)
|
||||
}
|
||||
}
|
||||
|
||||
export function watchLocalRuntimeJobs(): void {
|
||||
if (inFlight) {
|
||||
refreshRequested = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
void poll()
|
||||
}
|
||||
|
||||
// Selector: the running download job for a catalog model id, if any.
|
||||
// Selector: the in-flight (running or paused) download job for a catalog
|
||||
// model id, if any. Paused stays visible — the row parks, it doesn't
|
||||
// vanish (progress loss is information loss).
|
||||
export function runningDownloadFor(jobs: readonly LocalRuntimeJob[], modelId: string): LocalRuntimeJob | null {
|
||||
return jobs.find(j => j.kind === 'model-download' && j.status === 'running' && j.model_id === modelId) ?? null
|
||||
return jobs.find(j => j.kind === 'model-download' && isActive(j.status) && j.model_id === modelId) ?? null
|
||||
}
|
||||
|
||||
// Selector: every model on its way to the library right now — plain
|
||||
// downloads plus quickstart runs while they are still fetching bytes
|
||||
// (later quickstart phases mean the model is staged and activating).
|
||||
// The model picker renders these as disabled progress rows.
|
||||
const DOWNLOAD_PHASES = new Set(['starting', 'installing-runtime', 'downloading'])
|
||||
// (later quickstart phases mean the model is staged and activating),
|
||||
// paused ones included. The model picker renders these as disabled
|
||||
// progress rows.
|
||||
const DOWNLOAD_PHASES = new Set([
|
||||
'starting',
|
||||
'installing-runtime',
|
||||
'downloading-runtime',
|
||||
'unpacking-runtime',
|
||||
'verifying-runtime',
|
||||
'downloading'
|
||||
])
|
||||
|
||||
export function runningModelDownloads(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob[] {
|
||||
return jobs.filter(
|
||||
j =>
|
||||
j.status === 'running' &&
|
||||
(j.kind === 'model-download' || (j.kind === 'quickstart' && DOWNLOAD_PHASES.has(j.phase)))
|
||||
isActive(j.status) && (j.kind === 'model-download' || (j.kind === 'quickstart' && DOWNLOAD_PHASES.has(j.phase)))
|
||||
)
|
||||
}
|
||||
|
||||
export function runningRuntimeInstall(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob | null {
|
||||
return jobs.find(j => j.kind === 'runtime-install' && j.status === 'running') ?? null
|
||||
return jobs.find(j => j.kind === 'runtime-install' && isActive(j.status)) ?? null
|
||||
}
|
||||
|
||||
// One engine-update toast per app session: checked at boot (after the
|
||||
|
||||
@@ -1349,13 +1349,23 @@ export interface LocalRuntimeJob {
|
||||
kind: 'model-activate' | 'model-download' | 'quickstart' | 'runtime-install'
|
||||
target: string
|
||||
model_id: string | null
|
||||
status: 'running' | 'done' | 'error'
|
||||
status: 'done' | 'error' | 'paused' | 'running'
|
||||
phase: string
|
||||
detail: string
|
||||
total_bytes: number | null
|
||||
done_bytes: number
|
||||
percent?: number
|
||||
error: string | null
|
||||
/** Which file ranges of the source plan are fetched vs banked — present
|
||||
* while a plan (multi-file or cached+fresh mix) is in flight. */
|
||||
ranges?: Record<string, [number, number][]>
|
||||
/** Control flags: false while the job is in a phase that cannot park
|
||||
* (server start, default assignment, non-download quickstart legs). */
|
||||
can_pause?: boolean
|
||||
can_resume?: boolean
|
||||
/** The backend has accepted a pause request; the status flips when the
|
||||
* downloader actually parks. */
|
||||
pause_requested?: boolean
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
|
||||
@@ -2335,8 +2335,7 @@ DEFAULT_CONFIG = {
|
||||
"local_runtime": {
|
||||
# Off = detection-only (Hermes still finds an external llama-server you run).
|
||||
"enabled": False,
|
||||
# Pinned llama.cpp release tag; bumped by Hermes releases after validation.
|
||||
"tag": "b10679",
|
||||
# Engine versions and every dependent library are pinned by pm/lock.json.
|
||||
# auto = CUDA on NVIDIA, Metal on macOS, Vulkan on other GPUs, else CPU. Explicit:
|
||||
# cuda|metal|vulkan|hip|cpu.
|
||||
"backend": "auto",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Managed llama.cpp runtime.
|
||||
|
||||
``binaries`` resolves/downloads/verifies official llama.cpp release zips into
|
||||
``$HERMES_HOME/runtimes/llamacpp/<tag>/``; ``supervisor`` spawns and supervises one llama-server in
|
||||
router mode (readiness is a touch generation, never health-200 alone); ``detect`` finds an
|
||||
already-running llama-server (external or ours).
|
||||
``binaries`` answers which backend this machine uses and which PM-pinned
|
||||
engine is installed (the engine bytes live in pm's machine-wide store);
|
||||
``bootstrap`` boots the managed server from what is installed — never a
|
||||
download; ``supervisor`` spawns and supervises one llama-server in router
|
||||
mode (readiness is a touch generation, never health-200 alone); ``detect``
|
||||
finds an already-running llama-server (external or ours).
|
||||
"""
|
||||
|
||||
from hermes_cli.local_runtime.binaries import ( # noqa: F401
|
||||
BinaryResolutionError, ensure_runtime_installed, resolve_assets, select_backend)
|
||||
BACKEND_PACKAGES, BinaryResolutionError, Engine, ensure_engine,
|
||||
installed_engine, pinned_tag, resolve_backend, select_backend)
|
||||
from hermes_cli.local_runtime.bootstrap import ensure_local_runtime, shutdown_local_runtime # noqa: F401
|
||||
from hermes_cli.local_runtime.context_policy import ( # noqa: F401
|
||||
FLOOR, growth_decision, initial_window, ladder, launch_args)
|
||||
|
||||
@@ -1,304 +1,128 @@
|
||||
"""Binary acquisition for the managed llama.cpp runtime."""
|
||||
"""Hardware selection for PM-owned llama.cpp binaries; mutable runtime state stays separate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pm
|
||||
from pm.downloader import ProgressFn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RELEASE_URL = "https://github.com/ggml-org/llama.cpp/releases/download/{tag}/{asset}"
|
||||
|
||||
# Windows CUDA zips ship per CUDA major; the runtime zip must be paired with its cudart zip so
|
||||
# end users need no toolkit. 13.3 verified on 13.1 and 13.2 drivers.
|
||||
_WIN_CUDA_VERSION = "13.3"
|
||||
# arm64 Windows CUDA prebuilts landed upstream (~b1036x) on CUDA 13.4. Tags at or before b10290
|
||||
# don't have them; resolution succeeds and the download 404s honestly if a user pins backward.
|
||||
_WIN_CUDA_VERSION_ARM64 = "13.4"
|
||||
|
||||
|
||||
def default_tag() -> str:
|
||||
"""Fallback when the config section is missing entirely; DEFAULT_CONFIG owns the shipped tag."""
|
||||
from hermes_cli.config_defaults import DEFAULT_CONFIG
|
||||
|
||||
return DEFAULT_CONFIG["local_runtime"]["tag"]
|
||||
BACKEND_PACKAGES = {
|
||||
"cuda": "llamacpp-cuda",
|
||||
"vulkan": "llamacpp-vulkan",
|
||||
"metal": "llamacpp-metal",
|
||||
"hip": "llamacpp-hip",
|
||||
"cpu": "llamacpp-cpu",
|
||||
}
|
||||
|
||||
|
||||
class BinaryResolutionError(RuntimeError):
|
||||
"""No usable asset combination for this platform/backend."""
|
||||
"""The requested backend has no pinned or installed engine."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetPlan:
|
||||
"""The exact zips one runtime install needs, in extraction order."""
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Engine:
|
||||
backend: str
|
||||
tag: str
|
||||
backend: str # cuda | metal | vulkan | hip | cpu
|
||||
assets: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def install_dir(self) -> Path:
|
||||
return runtimes_root() / self.tag / self.backend
|
||||
binary: Path
|
||||
|
||||
|
||||
def runtimes_root() -> Path:
|
||||
"""Machine-scoped, deliberately NOT profile-scoped: engine binaries, presets and server state
|
||||
describe this machine's hardware and its one managed server (stable port) — a second profile
|
||||
re-downloading the engine or fighting over the port would be the bug. Profile-scoped things
|
||||
(default model, enabled) live in each profile's config.yaml."""
|
||||
"""Machine-scoped presets and server state. Binaries belong to PM's store."""
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
return get_default_hermes_root() / "runtimes" / "llamacpp"
|
||||
|
||||
|
||||
def manifest_verified(manifest: Path) -> bool:
|
||||
"""True when an install manifest records a verified_version (missing/damaged -> False)."""
|
||||
try:
|
||||
return bool(json.loads(manifest.read_text(encoding="utf-8-sig")).get("verified_version"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _release_number(tag: str) -> int:
|
||||
digits = "".join(ch for ch in tag if ch.isdigit())
|
||||
return int(digits) if digits else 0
|
||||
|
||||
|
||||
def installed_tags() -> list[str]:
|
||||
"""Tags with a verified install, newest first by release number. The boot ladder and the
|
||||
update check both read installed-ness from here — one resolver, every caller."""
|
||||
root = runtimes_root()
|
||||
if not root.exists():
|
||||
return []
|
||||
found = {entry.name for entry in root.iterdir()
|
||||
if entry.is_dir() and entry.name != "downloads"
|
||||
and any(manifest_verified(m) for m in entry.glob("*/manifest.json"))}
|
||||
return sorted(found, key=_release_number, reverse=True)
|
||||
|
||||
|
||||
def _host_os_arch() -> tuple[str, str]:
|
||||
"""(os, arch) normalized to release-asset vocabulary. PITFALL: PROCESSOR_ARCHITECTURE lies
|
||||
under x64 emulation on ARM64 Windows, and platform.machine() reads the same env on some
|
||||
Pythons — so on Windows prefer PROCESSOR_IDENTIFIER's text when present."""
|
||||
system = platform.system().lower()
|
||||
os_name = {"windows": "win", "darwin": "macos", "linux": "ubuntu"}.get(system, system)
|
||||
arch = "arm64" if platform.machine().lower() in ("arm64", "aarch64") else "x64"
|
||||
if os_name == "win":
|
||||
ident = os.environ.get("PROCESSOR_IDENTIFIER", "").lower()
|
||||
if "armv8" in ident or "arm " in ident:
|
||||
arch = "arm64"
|
||||
return os_name, arch
|
||||
def pinned_tag(backend: str) -> str:
|
||||
version = pm.Lockfile(pm.paths.lockfile_path()).version(BACKEND_PACKAGES[backend])
|
||||
if version is None:
|
||||
raise BinaryResolutionError(f"llama.cpp {backend} has no PM version pin")
|
||||
return f"b{version}"
|
||||
|
||||
|
||||
def select_backend(gpu_vendor: str | None, os_name: str | None = None) -> str:
|
||||
"""CUDA if NVIDIA, Metal on macOS, Vulkan if a non-NVIDIA GPU is present, else CPU.
|
||||
``--list-devices`` validates post-install; the supervisor's touch generation is ground truth."""
|
||||
if os_name is None:
|
||||
os_name, _ = _host_os_arch()
|
||||
os_name = "macos" if pm.current_target().startswith("darwin-") else "other"
|
||||
if os_name == "macos":
|
||||
return "metal"
|
||||
vendor = (gpu_vendor or "").lower()
|
||||
if "nvidia" in vendor:
|
||||
return "cuda"
|
||||
if vendor in ("amd", "intel") or "radeon" in vendor or "arc" in vendor:
|
||||
if any(name in vendor for name in ("amd", "intel", "radeon", "arc")):
|
||||
return "vulkan"
|
||||
return "cpu"
|
||||
|
||||
|
||||
# Per-OS (human label, {backend: asset-name templates}). Windows CUDA pairs the runtime zip with
|
||||
# its cudart zip; ubuntu ships tarballs, win ships zips.
|
||||
_ASSET_TEMPLATES = {
|
||||
"ubuntu": ("linux", {
|
||||
"vulkan": ["llama-{tag}-bin-ubuntu-vulkan-{arch}.tar.gz"],
|
||||
"hip": ["llama-{tag}-bin-ubuntu-rocm-7.2-{arch}.tar.gz"],
|
||||
"cpu": ["llama-{tag}-bin-ubuntu-{arch}.tar.gz"],
|
||||
}),
|
||||
"win": ("windows", {
|
||||
"cuda": ["llama-{tag}-bin-win-cuda-{cuda_ver}-{arch}.zip",
|
||||
"cudart-llama-bin-win-cuda-{cuda_ver}-{arch}.zip"],
|
||||
"vulkan": ["llama-{tag}-bin-win-vulkan-x64.zip"],
|
||||
"hip": ["llama-{tag}-bin-win-hip-radeon-x64.zip"],
|
||||
"cpu": ["llama-{tag}-bin-win-cpu-{arch}.zip"],
|
||||
}),
|
||||
}
|
||||
def _candidates(requested: str, gpu_vendor: str | None, target: str) -> tuple[str, ...]:
|
||||
if requested != "auto":
|
||||
if requested not in BACKEND_PACKAGES:
|
||||
raise BinaryResolutionError(f"unknown backend {requested}")
|
||||
return (requested,)
|
||||
preferred = select_backend(gpu_vendor, "macos" if target.startswith("darwin-") else "other")
|
||||
fallback = ("vulkan", "cpu") if gpu_vendor else ("cpu",)
|
||||
return tuple(dict.fromkeys((preferred, *fallback)))
|
||||
|
||||
|
||||
def resolve_assets(tag: str, backend: str, os_name: str | None = None,
|
||||
arch: str | None = None) -> AssetPlan:
|
||||
"""Compose the asset list for (tag, backend, platform). Raises BinaryResolutionError for pairs
|
||||
the release ships no artifact for; callers fall back down the ladder cuda -> vulkan -> cpu."""
|
||||
host_os, host_arch = _host_os_arch()
|
||||
os_name = os_name or host_os
|
||||
arch = arch or host_arch
|
||||
if os_name == "macos":
|
||||
# macOS tarballs are unified (Metal built in).
|
||||
return AssetPlan(tag, backend, [f"llama-{tag}-bin-macos-{arch}.tar.gz"])
|
||||
if os_name not in _ASSET_TEMPLATES:
|
||||
raise BinaryResolutionError(f"unsupported platform {os_name}-{arch}")
|
||||
if os_name == "ubuntu" and backend == "cuda":
|
||||
# No prebuilt Linux CUDA zips at current tags — Linux CUDA users build from source or
|
||||
# use vulkan; the resolver is honest about it.
|
||||
raise BinaryResolutionError(
|
||||
f"no prebuilt linux CUDA asset at {tag}; use vulkan/cpu or a source build")
|
||||
if os_name == "win" and backend == "vulkan" and arch == "arm64":
|
||||
raise BinaryResolutionError(f"no win-vulkan-arm64 asset at {tag}")
|
||||
label, templates = _ASSET_TEMPLATES[os_name]
|
||||
if backend not in templates:
|
||||
raise BinaryResolutionError(f"unsupported {label} backend {backend}")
|
||||
cuda_ver = _WIN_CUDA_VERSION_ARM64 if arch == "arm64" else _WIN_CUDA_VERSION
|
||||
return AssetPlan(tag, backend, [t.format(tag=tag, arch=arch, cuda_ver=cuda_ver)
|
||||
for t in templates[backend]])
|
||||
def unavailable_reason(backend: str, target: str | None = None) -> str | None:
|
||||
name = BACKEND_PACKAGES.get(backend)
|
||||
if name is None:
|
||||
return f"unknown backend {backend}"
|
||||
target = target or pm.current_target()
|
||||
reason = pm.get_package(name).missing_reason(target)
|
||||
if reason:
|
||||
return reason
|
||||
lock = pm.Lockfile(pm.paths.lockfile_path())
|
||||
if not lock.version(name) or not lock.artifacts(name, target):
|
||||
return f"{name} is not pinned for {target}"
|
||||
return None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 22), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
def resolve_backend(requested: str = "auto", *, gpu_vendor: str | None = None,
|
||||
target: str | None = None) -> str:
|
||||
"""Explicit choices are strict. Auto only falls back to compatible pinned builds."""
|
||||
if requested == "auto" and target is None and gpu_vendor is None:
|
||||
from hermes_cli.local_runtime.bootstrap import _detect_gpu_vendor
|
||||
|
||||
gpu_vendor = _detect_gpu_vendor()
|
||||
target = target or pm.current_target()
|
||||
reasons = []
|
||||
for backend in _candidates(requested, gpu_vendor, target):
|
||||
reason = unavailable_reason(backend, target)
|
||||
if reason is None:
|
||||
return backend
|
||||
reasons.append(reason)
|
||||
raise BinaryResolutionError("; ".join(reasons))
|
||||
|
||||
|
||||
def _download(url: str, dest: Path,
|
||||
progress: "Callable[[int, int], None] | None" = None) -> None:
|
||||
"""Stream url -> dest. ``progress(done_bytes, total_bytes)`` ticks per chunk (total 0 when
|
||||
the server sends no Content-Length) — a several-hundred-MB archive must never look hung."""
|
||||
logger.info("downloading %s", url)
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as f:
|
||||
total = int(r.headers.get("Content-Length") or 0)
|
||||
done = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
done += len(chunk)
|
||||
if progress is not None:
|
||||
progress(done, total)
|
||||
tmp.replace(dest)
|
||||
def installed_engine(backend: str = "auto", *, allow_outdated: bool = True) -> Engine | None:
|
||||
"""Boot may retain a prior PM pin, but never installs or adopts unmanaged bytes."""
|
||||
vendor = None
|
||||
if backend == "auto":
|
||||
from hermes_cli.local_runtime.bootstrap import _detect_gpu_vendor
|
||||
|
||||
vendor = _detect_gpu_vendor()
|
||||
for candidate in _candidates(backend, vendor, pm.current_target()):
|
||||
found = pm.installed_package(BACKEND_PACKAGES[candidate], allow_outdated=allow_outdated)
|
||||
if found is not None and found.binary is not None:
|
||||
return Engine(candidate, f"b{found.version}", found.binary)
|
||||
return None
|
||||
|
||||
|
||||
def _extract(archive: Path, dest: Path,
|
||||
progress: "Callable[[int, int], None] | None" = None) -> None:
|
||||
"""Extract member by member so ``progress(done, total)`` can tick in uncompressed bytes."""
|
||||
if archive.name.endswith(".zip"):
|
||||
opener, list_members, size = zipfile.ZipFile, "infolist", "file_size"
|
||||
kwargs = {}
|
||||
else:
|
||||
import tarfile
|
||||
opener, list_members, size = tarfile.open, "getmembers", "size"
|
||||
kwargs = {"filter": "data"}
|
||||
with opener(archive) as ar:
|
||||
members = getattr(ar, list_members)()
|
||||
total = sum(getattr(m, size) for m in members)
|
||||
done = 0
|
||||
for m in members:
|
||||
ar.extract(m, dest, **kwargs)
|
||||
done += getattr(m, size)
|
||||
if progress is not None:
|
||||
progress(done, total)
|
||||
|
||||
|
||||
def server_binary(install_dir: Path) -> Path:
|
||||
"""Locate llama-server within an extracted runtime (zips differ in whether they nest a
|
||||
build/bin directory)."""
|
||||
names = ("llama-server.exe", "llama-server")
|
||||
for name in names:
|
||||
direct = install_dir / name
|
||||
if direct.exists():
|
||||
return direct
|
||||
for name in names:
|
||||
hits = sorted(install_dir.rglob(name))
|
||||
if hits:
|
||||
return hits[0]
|
||||
raise BinaryResolutionError(f"llama-server not found under {install_dir}")
|
||||
|
||||
|
||||
def verify_install(install_dir: Path, tag: str) -> str:
|
||||
"""Run --version; require the tag's build number in the output (printed WITHOUT the 'b')."""
|
||||
exe = server_binary(install_dir)
|
||||
out = subprocess.run([str(exe), "--version"], capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
timeout=60, cwd=str(exe.parent))
|
||||
text = (out.stdout + out.stderr).strip()
|
||||
if tag.lstrip("b") not in text:
|
||||
raise BinaryResolutionError(
|
||||
f"version check failed for {exe}: expected {tag}, got: {text[:120]}")
|
||||
return text.splitlines()[0] if text else ""
|
||||
|
||||
|
||||
def prune_old_tags(keep: list[str]) -> None:
|
||||
"""Retain only the tags in ``keep`` (current + previous — N-1 rollback). The shared
|
||||
``downloads/`` archive cache is not a tag and always survives."""
|
||||
root = runtimes_root()
|
||||
if not root.exists():
|
||||
return
|
||||
for entry in root.iterdir():
|
||||
if entry.is_dir() and entry.name != "downloads" and entry.name not in keep:
|
||||
shutil.rmtree(entry, ignore_errors=True)
|
||||
logger.info("pruned old runtime %s", entry.name)
|
||||
|
||||
|
||||
def ensure_runtime_installed(tag: str, backend: str,
|
||||
expected_sha256: dict[str, str] | None = None,
|
||||
progress: "Callable[[str, int, int, str], None] | None" = None) -> Path:
|
||||
"""Idempotent: resolve, download, verify, extract, version-check; returns the install dir.
|
||||
``expected_sha256`` pins hashes per asset; without pins the computed hash is recorded in the
|
||||
manifest (trust on first download, verified on every reinstall). ``progress(stage, done,
|
||||
total, label)`` ticks through download/extract/verify."""
|
||||
plan = resolve_assets(tag, backend)
|
||||
install_dir = plan.install_dir
|
||||
manifest_path = install_dir / "manifest.json"
|
||||
if manifest_verified(manifest_path):
|
||||
return install_dir
|
||||
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
downloads = runtimes_root() / "downloads"
|
||||
downloads.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def stage_progress(stage: str, label: str):
|
||||
if progress is None:
|
||||
return None
|
||||
tick = progress
|
||||
return lambda d, t: tick(stage, d, t, label)
|
||||
|
||||
recorded: dict[str, str] = {}
|
||||
n_assets = len(plan.assets)
|
||||
for i, asset in enumerate(plan.assets, 1):
|
||||
label = f"{i}/{n_assets}" if n_assets > 1 else ""
|
||||
archive = downloads / asset
|
||||
if not archive.exists():
|
||||
_download(RELEASE_URL.format(tag=tag, asset=asset), archive,
|
||||
progress=stage_progress("download", label))
|
||||
if progress is not None:
|
||||
progress("verify", 0, 0, label)
|
||||
digest = _sha256(archive)
|
||||
expected = (expected_sha256 or {}).get(asset)
|
||||
if expected and digest != expected:
|
||||
archive.unlink(missing_ok=True)
|
||||
raise BinaryResolutionError(
|
||||
f"sha256 mismatch for {asset}: expected {expected}, got {digest}")
|
||||
recorded[asset] = digest
|
||||
_extract(archive, install_dir, progress=stage_progress("extract", label))
|
||||
|
||||
if progress is not None:
|
||||
progress("verify", 0, 0, "")
|
||||
version = verify_install(install_dir, tag)
|
||||
manifest_path.write_text(json.dumps({"tag": tag, "backend": plan.backend, "assets": recorded,
|
||||
"verified_version": version}, indent=2), encoding="utf-8")
|
||||
logger.info("installed llama.cpp %s (%s): %s", tag, backend, version)
|
||||
return install_dir
|
||||
def ensure_engine(backend: str, *, progress: Callable[[str, int, int, str], None] | None = None,
|
||||
pause_event: threading.Event | None = None,
|
||||
download_progress: ProgressFn | None = None) -> Engine:
|
||||
"""Only deliberate install/update jobs call this. PM verifies every archive and publishes."""
|
||||
resolved = resolve_backend(backend)
|
||||
pm.ensure(BACKEND_PACKAGES[resolved], explicit=True, progress=progress,
|
||||
pause_event=pause_event, download_progress=download_progress)
|
||||
engine = installed_engine(resolved, allow_outdated=False)
|
||||
if engine is None:
|
||||
raise BinaryResolutionError(f"llama.cpp {resolved} install has no usable pinned binary")
|
||||
return engine
|
||||
|
||||
|
||||
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
||||
|
||||
@@ -206,35 +206,19 @@ def ensure_local_runtime(config: dict, force: bool = False) -> "object | None":
|
||||
_stop_state_server(state)
|
||||
|
||||
try:
|
||||
from hermes_cli.local_runtime.binaries import (
|
||||
default_tag, ensure_runtime_installed, installed_tags, select_backend)
|
||||
from hermes_cli.local_runtime.binaries import installed_engine
|
||||
from hermes_cli.local_runtime.supervisor import LlamaServerSupervisor
|
||||
|
||||
backend = section.get("backend", "auto")
|
||||
if backend == "auto":
|
||||
backend = select_backend(_detect_gpu_vendor())
|
||||
# Boot ladder: serve what is INSTALLED, never download here. The configured tag is
|
||||
# preferred; when it isn't installed yet, the newest installed tag serves and the status
|
||||
# endpoint reports the pending update — the download is a deliberate click in the pane,
|
||||
# not a boot-path surprise (a multi-minute inline download here is exactly how the
|
||||
# onboarding bounce returns).
|
||||
tag = section.get("tag") or default_tag()
|
||||
have = installed_tags()
|
||||
if tag not in have:
|
||||
if not have:
|
||||
logger.info("local runtime enabled but no build installed; "
|
||||
"install happens in the Local Models pane")
|
||||
return None
|
||||
logger.info("configured tag %s not installed; serving %s "
|
||||
"(update is a click in Local Models)", tag, have[0])
|
||||
tag = have[0]
|
||||
install_dir = ensure_runtime_installed(tag, backend)
|
||||
engine = installed_engine(section.get("backend", "auto"))
|
||||
if engine is None:
|
||||
logger.info("local runtime enabled but no PM engine installed; use the Local Models pane")
|
||||
return None
|
||||
|
||||
mdir = models_dir()
|
||||
mdir.mkdir(parents=True, exist_ok=True)
|
||||
preset_path = _generate_presets(mdir, runtimes_root() / "presets.ini")
|
||||
|
||||
sup = LlamaServerSupervisor(install_dir, mdir, preset_path=preset_path,
|
||||
sup = LlamaServerSupervisor(engine.binary, mdir, preset_path=preset_path,
|
||||
models_max=int(section.get("models_max", 4)),
|
||||
port=int(section.get("port", 0)) or None)
|
||||
try:
|
||||
@@ -246,7 +230,7 @@ def ensure_local_runtime(config: dict, force: bool = False) -> "object | None":
|
||||
sup.stop()
|
||||
raise
|
||||
_SUPERVISOR = sup
|
||||
logger.info("managed llama-server up at %s (backend=%s tag=%s)", sup.base_url, backend, tag)
|
||||
logger.info("managed llama-server up at %s (backend=%s tag=%s)", sup.base_url, engine.backend, engine.tag)
|
||||
_start_idle_sweeper(sup)
|
||||
return sup
|
||||
except Exception as exc: # noqa: BLE001 — never break session start
|
||||
|
||||
@@ -140,20 +140,12 @@ def _kick_managed_boot(config: dict | None) -> None:
|
||||
|
||||
|
||||
def _boot_in_flight(config: dict | None) -> bool:
|
||||
"""True when the managed runtime is enabled and installed (a verified-manifest scan under
|
||||
runtimes_root(), NOT a bare ``server_binary()`` call — that needs an install_dir, and calling
|
||||
it bare once made this gate throw-and-return False forever, disabling the boot wait)."""
|
||||
"""Wait only when an enabled runtime has a PM-installed engine to boot."""
|
||||
with suppress(Exception):
|
||||
config = _load_config_if_none(config)
|
||||
if not ((config or {}).get("local_runtime") or {}).get("enabled"):
|
||||
return False
|
||||
from hermes_cli.local_runtime.binaries import manifest_verified, runtimes_root
|
||||
from hermes_cli.local_runtime.binaries import installed_engine
|
||||
|
||||
for manifest in runtimes_root().glob("*/*/manifest.json"):
|
||||
try:
|
||||
if manifest_verified(manifest):
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
return False
|
||||
return installed_engine(config["local_runtime"].get("backend", "auto")) is not None
|
||||
return False
|
||||
|
||||
@@ -198,15 +198,13 @@ def _engine_device_pool() -> "tuple[int, bool | None] | None":
|
||||
fallback when the driver API is unreachable: asks the exact binary that will do the
|
||||
allocating. Carries no integrated verdict — callers must gate it."""
|
||||
with suppress(Exception): # a probe miss must never block budgeting
|
||||
from hermes_cli.local_runtime.binaries import installed_tags, runtimes_root, server_binary
|
||||
from hermes_cli.config import get_config_value
|
||||
from hermes_cli.local_runtime.binaries import installed_engine
|
||||
|
||||
tags = installed_tags()
|
||||
if not tags:
|
||||
engine = installed_engine(get_config_value("local_runtime.backend", "auto"))
|
||||
if engine is None:
|
||||
return None
|
||||
backend_dirs = [d for d in (runtimes_root() / tags[0]).iterdir() if d.is_dir()]
|
||||
if not backend_dirs:
|
||||
return None
|
||||
exe = server_binary(backend_dirs[0])
|
||||
exe = engine.binary
|
||||
out = subprocess.run([str(exe), "--list-devices"], capture_output=True,
|
||||
text=True, timeout=30, cwd=str(exe.parent))
|
||||
if out.returncode != 0:
|
||||
|
||||
@@ -21,7 +21,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.local_runtime.binaries import server_binary, runtimes_root
|
||||
from hermes_cli.local_runtime.binaries import runtimes_root
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -99,12 +99,15 @@ class LlamaServerSupervisor:
|
||||
# comes back to.
|
||||
IDLE_UNLOAD_S = 15 * 60
|
||||
|
||||
def __init__(self, install_dir: Path, models_dir: Path, *,
|
||||
def __init__(self, binary: Path, models_dir: Path, *,
|
||||
models_max: int = 4, port: int | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
log_path: Path | None = None,
|
||||
preset_path: Path | None = None):
|
||||
self.install_dir = Path(install_dir)
|
||||
# The exact engine binary (PM store path, backend-selected), handed
|
||||
# in by boot — the supervisor never discovers binaries itself: a
|
||||
# legacy-directory scan could resurrect bytes pm did not pin.
|
||||
self.binary = Path(binary)
|
||||
self.models_dir = Path(models_dir)
|
||||
self.models_max = models_max
|
||||
self.port = port or _stable_port()
|
||||
@@ -146,7 +149,7 @@ class LlamaServerSupervisor:
|
||||
# ── lifecycle ────────────────────────────────────────────
|
||||
|
||||
def _spawn(self) -> None:
|
||||
exe = server_binary(self.install_dir)
|
||||
exe = self.binary
|
||||
cmd = [
|
||||
str(exe),
|
||||
"--host", "127.0.0.1",
|
||||
@@ -278,7 +281,7 @@ class LlamaServerSupervisor:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
exe = str(server_binary(self.install_dir))
|
||||
exe = str(self.binary)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
own_pid = self.proc.pid if self.proc is not None else None
|
||||
|
||||
@@ -50,9 +50,7 @@ _RUNNING: Dict[str, Dict[str, Any]] = {}
|
||||
_QUICKSTART_LOCK = threading.Lock()
|
||||
_LLAMACPP_PROVIDERS = ("llamacpp", "llama.cpp", "llama-cpp")
|
||||
_SPLIT_PART_RE = r"-\d{5}-of-\d{5}"
|
||||
# One TCP stream to a CDN rarely fills a fast line; 8 ranged connections into a preallocated file saturate gigabit.
|
||||
_DOWNLOAD_CONNECTIONS = 8
|
||||
_CHUNK = 4 << 20
|
||||
_DOWNLOAD_PHASES = frozenset({"starting", "installing-runtime", "downloading-runtime", "downloading"})
|
||||
_SERVER_START_FAILED = "The local server could not start — check the runtime is installed"
|
||||
|
||||
|
||||
@@ -128,7 +126,7 @@ def _job(kind: str, target: str, model_id: str | None = None) -> Dict[str, Any]:
|
||||
job = {
|
||||
"job_id": uuid.uuid4().hex[:12], "kind": kind, "target": target,
|
||||
"model_id": model_id, # catalog id for downloads; None otherwise
|
||||
"status": "running", # running | done | error
|
||||
"status": "running", # running | paused | done | error
|
||||
"phase": "starting", # human-readable step name
|
||||
"detail": "", "total_bytes": None, "done_bytes": 0, "started_at": time.time(), "error": None,
|
||||
}
|
||||
@@ -139,14 +137,29 @@ def _job(kind: str, target: str, model_id: str | None = None) -> Dict[str, Any]:
|
||||
|
||||
def _job_view(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(job)
|
||||
running = _RUNNING.get(job["job_id"], {})
|
||||
pause = running.get("pause")
|
||||
out["pause_requested"] = bool(pause is not None and pause.is_set() and out["status"] == "running")
|
||||
out["can_pause"] = bool(pause is not None and out["status"] == "running"
|
||||
and out["phase"] in _DOWNLOAD_PHASES and not out["pause_requested"])
|
||||
out["can_resume"] = out["status"] == "paused" and "resume" in running
|
||||
if out["total_bytes"]:
|
||||
out["percent"] = min(100, round(out["done_bytes"] / out["total_bytes"] * 100))
|
||||
return out
|
||||
|
||||
|
||||
def _check_job_pause(job: Dict[str, Any]) -> None:
|
||||
pause = _RUNNING.get(job["job_id"], {}).get("pause")
|
||||
if pause is not None and pause.is_set():
|
||||
raise DownloadPaused("download paused")
|
||||
|
||||
|
||||
def _step(job: Dict[str, Any], phase: str, detail: str) -> None:
|
||||
job["phase"] = phase
|
||||
job["detail"] = detail
|
||||
with _JOBS_LOCK:
|
||||
if job.get("phase") in _DOWNLOAD_PHASES and phase not in _DOWNLOAD_PHASES:
|
||||
_check_job_pause(job)
|
||||
job["phase"] = phase
|
||||
job["detail"] = detail
|
||||
|
||||
|
||||
def _finish(job: Dict[str, Any], detail: str) -> None:
|
||||
@@ -159,6 +172,7 @@ def _spawn_job(job: Dict[str, Any], name: str, body: Callable[[], None], *, fail
|
||||
resumable: bool = False) -> None:
|
||||
"""One worker per job. Pauses retain ownership; terminal outcomes release it."""
|
||||
guard = threading.Lock()
|
||||
pause = threading.Event()
|
||||
|
||||
def _run():
|
||||
status = "done"
|
||||
@@ -176,22 +190,25 @@ def _spawn_job(job: Dict[str, Any], name: str, body: Callable[[], None], *, fail
|
||||
job["error"] = str(exc)
|
||||
finally:
|
||||
try:
|
||||
if status != "paused":
|
||||
_RUNNING.pop(job["job_id"], None)
|
||||
if on_exit is not None:
|
||||
on_exit()
|
||||
if status != "paused" and on_exit is not None:
|
||||
on_exit()
|
||||
finally:
|
||||
job["status"] = status
|
||||
guard.release()
|
||||
with _JOBS_LOCK:
|
||||
if status != "paused":
|
||||
_RUNNING.pop(job["job_id"], None)
|
||||
job["status"] = status
|
||||
guard.release()
|
||||
|
||||
def start(*, initial: bool = False) -> bool:
|
||||
if not guard.acquire(blocking=False):
|
||||
return False
|
||||
if not initial and job["status"] != "paused":
|
||||
guard.release()
|
||||
return False
|
||||
job["status"] = "running"
|
||||
job["error"] = None
|
||||
with _JOBS_LOCK:
|
||||
if not initial and job["status"] != "paused":
|
||||
guard.release()
|
||||
return False
|
||||
pause.clear()
|
||||
job["status"] = "running"
|
||||
job["error"] = None
|
||||
try:
|
||||
threading.Thread(target=_run, daemon=True, name=name).start()
|
||||
except Exception:
|
||||
@@ -204,7 +221,7 @@ def _spawn_job(job: Dict[str, Any], name: str, body: Callable[[], None], *, fail
|
||||
return True
|
||||
|
||||
if resumable:
|
||||
_RUNNING[job["job_id"]] = {"resume": start}
|
||||
_RUNNING[job["job_id"]] = {"resume": start, "pause": pause}
|
||||
start(initial=True)
|
||||
|
||||
|
||||
@@ -244,30 +261,22 @@ def _set_runtime_enabled(enabled: bool) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _runtime_target(requested: str | None = None) -> "tuple[str, str]":
|
||||
"""(tag, backend) the runtime routes act on: configured tag or release default; ``auto`` -> detected GPU vendor."""
|
||||
def _runtime_target(requested: str | None = None) -> tuple[str, str]:
|
||||
"""Resolve only reviewed PM artifacts; hardware work stays off the event loop."""
|
||||
section = _runtime_section()
|
||||
tag = section.get("tag") or binaries.default_tag()
|
||||
backend = requested or section.get("backend", "auto")
|
||||
if backend == "auto":
|
||||
backend = binaries.select_backend(bootstrap._detect_gpu_vendor())
|
||||
return tag, backend
|
||||
|
||||
|
||||
def _resolve_assets_or_400(tag: str, backend: str):
|
||||
"""Resolve first so an impossible combination fails the POST, not the job."""
|
||||
with _http_error(400):
|
||||
return binaries.resolve_assets(tag, backend)
|
||||
backend = binaries.resolve_backend(backend)
|
||||
return binaries.pinned_tag(backend), backend
|
||||
|
||||
|
||||
def _engine_too_old(min_engine: str) -> bool:
|
||||
"""True when the installed llama.cpp predates a model's requirement. Tags are release numbers (b10362);
|
||||
no engine installed compares as too old only when the model states a requirement."""
|
||||
def newest_installed() -> int:
|
||||
tags = binaries.installed_tags() or [binaries.default_tag()]
|
||||
return max(int(t.lstrip("b")) for t in tags if t.lstrip("b").isdigit())
|
||||
|
||||
return bool(min_engine) and _quiet(lambda: newest_installed() < int(min_engine.lstrip("b")), False)
|
||||
if not min_engine:
|
||||
return False
|
||||
requested = _runtime_section().get("backend", "auto")
|
||||
engine = binaries.installed_engine(requested)
|
||||
tag = engine.tag if engine is not None else _runtime_target()[0]
|
||||
return int(tag.removeprefix("b")) < int(min_engine.removeprefix("b"))
|
||||
|
||||
|
||||
def _eligible_entries():
|
||||
@@ -331,94 +340,6 @@ def _variant_files_on_disk(model_id: str) -> "list[Path]":
|
||||
return files
|
||||
|
||||
|
||||
def _probe_range_support(url: str) -> int:
|
||||
"""Total size when the server honors Range requests, else 0. 401/403 = gated repo or wrong catalog
|
||||
repo — raise a plain-language message, not a bare status."""
|
||||
req = urllib.request.Request(url, headers={"Range": "bytes=0-0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
content_range = r.headers.get("Content-Range", "") if r.status == 206 else ""
|
||||
if "/" in content_range:
|
||||
return int(content_range.rsplit("/", 1)[1])
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (401, 403):
|
||||
raise RuntimeError("The model host refused the download (gated or moved). "
|
||||
"This is a catalog problem, not yours — please report it.") from exc
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def download_file(url: str, dest: Path, job: Dict[str, Any], *, base_done: int = 0, keep_totals: bool = False) -> None:
|
||||
"""Download url -> dest with byte progress on ``job``; ranged-parallel when the server supports it,
|
||||
single-stream otherwise. Never leaves a .part. Completeness is checked only against what the SERVER
|
||||
declared (range-probe total / Content-Length), never the CATALOG (its sizes may lag a re-upload), so a
|
||||
dropped connection still errors instead of staging a truncated file. Multi-file variants: ``base_done``
|
||||
offsets progress onto earlier files; ``keep_totals=True`` keeps the per-file size from overwriting the
|
||||
variant's total."""
|
||||
tmp = dest.with_suffix(".part")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_done = [0]
|
||||
progress_lock = threading.Lock()
|
||||
errors: list[Exception] = []
|
||||
|
||||
def pump(r, f) -> None:
|
||||
for chunk in iter(lambda: r.read(_CHUNK), b""):
|
||||
f.write(chunk)
|
||||
with progress_lock:
|
||||
file_done[0] += len(chunk)
|
||||
job["done_bytes"] = base_done + file_done[0]
|
||||
|
||||
def fetch_range(start: int, end: int) -> None:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Range": f"bytes={start}-{end}"})
|
||||
with urllib.request.urlopen(req, timeout=120) as r, open(tmp, "r+b") as f:
|
||||
f.seek(start)
|
||||
pump(r, f)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(exc)
|
||||
|
||||
try:
|
||||
# Probe and preallocation take real seconds on a 20+ GB file — narrate them, or the pane shows a dead '— of X GB'.
|
||||
job["detail"] = "Connecting"
|
||||
total = _probe_range_support(url)
|
||||
if total:
|
||||
if not keep_totals:
|
||||
job["total_bytes"] = total
|
||||
# Preallocate so each worker writes at its own offset.
|
||||
job["detail"] = f"Reserving {_human_gb(total)} of disk space"
|
||||
with open(tmp, "wb") as f:
|
||||
f.truncate(total)
|
||||
job["detail"] = ""
|
||||
n = _DOWNLOAD_CONNECTIONS
|
||||
threads = [threading.Thread(target=fetch_range, daemon=True, name=f"lm-dl-{i}",
|
||||
args=(i * total // n, (i + 1) * total // n - 1)) for i in range(n)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
if errors:
|
||||
raise errors[0]
|
||||
if file_done[0] != total:
|
||||
raise RuntimeError(f"download incomplete ({file_done[0]} of {total} bytes)")
|
||||
else:
|
||||
# No range support: single stream; completeness judged by the server's
|
||||
# own Content-Length when it sent one — never the catalog.
|
||||
with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as f:
|
||||
length = int(r.headers.get("Content-Length") or 0)
|
||||
if length and not keep_totals:
|
||||
job["total_bytes"] = length
|
||||
pump(r, f)
|
||||
if length and file_done[0] != length:
|
||||
raise RuntimeError(f"Download ended at {file_done[0]:,} bytes but the server "
|
||||
f"said {length:,} — connection dropped? Removed; try again")
|
||||
shutil.move(str(tmp), str(dest))
|
||||
except Exception:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _download_plan(entry, variant) -> list:
|
||||
"""Everything a variant needs: split parts + mmproj/draft assets, as (url, dest, bytes) tuples."""
|
||||
plan = [(_hf_url(entry.repo, a.path), bootstrap.models_dir() / a.local_name, a.size_bytes) for a in variant.files]
|
||||
@@ -428,47 +349,23 @@ def _download_plan(entry, variant) -> list:
|
||||
|
||||
|
||||
def _run_download_plan(job: Dict[str, Any], plan: list, label: str) -> None:
|
||||
"""Download every missing file in ``plan`` via pm.downloader (one resumable
|
||||
Download per job); already-present files count toward progress without a
|
||||
transfer. Raises DownloadPaused straight through: the caller's worker
|
||||
parks the job — the sequence must never advance past a pause. Handle
|
||||
lifecycle (dl registration, resume handle, _RUNNING cleanup) belongs to
|
||||
_spawn_job, not here."""
|
||||
_step(job, "downloading", f"{label} — {_human_gb(sum(p[2] for p in plan))}")
|
||||
done_before = 0
|
||||
for url, dest, size in plan:
|
||||
if not dest.exists():
|
||||
_download_job(job, [(url, dest, size)])
|
||||
job["phase"] = "downloading"
|
||||
done_before += size
|
||||
job["done_bytes"] = done_before
|
||||
"""The downloader counts completed files and every part of this plan."""
|
||||
_step(job, "downloading", f"Downloading {label}")
|
||||
_download_job(job, plan)
|
||||
|
||||
|
||||
def _download_progress_hook(job: Dict[str, Any]):
|
||||
def tick(done: int, total: int, ranges: dict) -> None:
|
||||
with _JOBS_LOCK:
|
||||
job.update(done_bytes=done, total_bytes=total or None, ranges=ranges)
|
||||
return tick
|
||||
|
||||
|
||||
def _download_job(job: Dict[str, Any], plan) -> None:
|
||||
"""Run a plan of (url, dest, size) downloads as ONE resumable Download.
|
||||
|
||||
Completion and range progress feed the job dict (done_bytes /
|
||||
total_bytes / ranges — the bar reads the aggregate, the detail view
|
||||
reads the bitmap; both come from the same callback). Sources carry NO
|
||||
hash by design: catalog sizes may lag an upstream re-upload, so
|
||||
completeness is judged by the downloader against the server's declared
|
||||
total, never the catalog. On pause the downloader raises
|
||||
DownloadPaused and this propagates to the caller: the worker parks the
|
||||
job 'paused' with its partials intact for a later resume. The live
|
||||
handle is kept OFF the job dict (which stays JSON-serializable for the
|
||||
poll route) under _RUNNING.
|
||||
"""
|
||||
dl = Download([Source(url, dest) for url, dest, _ in plan])
|
||||
_RUNNING.setdefault(job["job_id"], {})["dl"] = dl
|
||||
|
||||
def tick(done: int, total: int, ranges: dict) -> None:
|
||||
job["done_bytes"] = done
|
||||
job["total_bytes"] = total
|
||||
job["ranges"] = ranges
|
||||
|
||||
try:
|
||||
dl.run(progress=tick)
|
||||
finally:
|
||||
_RUNNING.get(job["job_id"], {}).pop("dl", None)
|
||||
"""Model bytes use the same resumable transfer as pinned PM archives."""
|
||||
running = _RUNNING.get(job["job_id"], {})
|
||||
dl = Download([Source(url, dest) for url, dest, _ in plan], pause_event=running.get("pause"))
|
||||
dl.run(progress=_download_progress_hook(job))
|
||||
|
||||
|
||||
def _loaded_models(running: Dict[str, Any]) -> "tuple[Dict[str, str], Dict[str, Any]]":
|
||||
@@ -495,13 +392,6 @@ def _loaded_models(running: Dict[str, Any]) -> "tuple[Dict[str, str], Dict[str,
|
||||
return loaded, placement
|
||||
|
||||
|
||||
def _installed_backend(tag: str) -> str | None:
|
||||
"""Name of the first backend dir under ``tag`` with a working server binary."""
|
||||
root = binaries.runtimes_root() / tag
|
||||
dirs = sorted(p for p in root.iterdir() if p.is_dir()) if root.exists() else []
|
||||
return next((d.name for d in dirs if _quiet(lambda: binaries.server_binary(d), None) is not None), None)
|
||||
|
||||
|
||||
def _staged_row(gguf: Path) -> Dict[str, Any]:
|
||||
model_id = _model_id_for(gguf)
|
||||
# Split models: report the whole variant's bytes, not one part's.
|
||||
@@ -527,11 +417,13 @@ def local_models_status():
|
||||
"""Cheap, immediate: config state + installed runtime + staged models + supervisor state (GPU facts live
|
||||
in /hardware). Sync def on purpose: blocking urlopen/scans run in the threadpool."""
|
||||
section = _runtime_section()
|
||||
configured_tag = section.get("tag") or binaries.default_tag()
|
||||
have = binaries.installed_tags()
|
||||
# The tag actually serving (boot ladder: configured if installed, else newest installed).
|
||||
tag = configured_tag if configured_tag in have else (have[0] if have else configured_tag)
|
||||
runtime_backend = _installed_backend(tag)
|
||||
engine = binaries.installed_engine(section.get("backend", "auto"))
|
||||
runtime_backend = engine.backend if engine is not None else None
|
||||
configured_tag = binaries.pinned_tag(runtime_backend) if runtime_backend else _runtime_target()[0]
|
||||
tag = engine.tag if engine is not None else configured_tag
|
||||
import pm
|
||||
|
||||
current = pm.installed_package(binaries.BACKEND_PACKAGES[runtime_backend]) if runtime_backend else None
|
||||
mdir = bootstrap.models_dir()
|
||||
running = _state_endpoint()
|
||||
# Resident models from the live router ({} when down): Loaded pills + eject. A failed read is never
|
||||
@@ -540,9 +432,7 @@ def local_models_status():
|
||||
lambda: _loaded_models(running), ({}, {}), warn="loaded-models read failed: %r")
|
||||
return {
|
||||
"enabled": bool(section.get("enabled")), "tag": tag, "configured_tag": configured_tag,
|
||||
# Update pending = engine in use (enabled + something installed) and the configured tag
|
||||
# (pinned or release default) isn't on disk. The download is a button click, never automatic.
|
||||
"update_available": bool(section.get("enabled") and have and configured_tag not in have),
|
||||
"update_available": bool(section.get("enabled") and engine is not None and current is None),
|
||||
"runtime_installed": runtime_backend is not None, "runtime_backend": runtime_backend,
|
||||
"server_running": running is not None, "server_base_url": (running or {}).get("base_url"),
|
||||
"active_model_id": _active_llamacpp_model_id(), "loaded_models": loaded,
|
||||
@@ -596,7 +486,8 @@ _QUANT_REASON_COMPACT = "Compact build sized for this machine ({quant}) — larg
|
||||
def _catalog_row(entry, budget, recommended, recommended_reason, staged_ids) -> Dict[str, Any]:
|
||||
choice = catalog.select_variant(entry, budget)
|
||||
# Any variant of this family on disk counts as downloaded.
|
||||
dl = next((v for v in entry.variants if v.model_id in staged_ids), None)
|
||||
dl = next((v for v in entry.variants if v.model_id in staged_ids
|
||||
and all(dest.is_file() for _, dest, _ in _download_plan(entry, v))), None)
|
||||
row: Dict[str, Any] = {
|
||||
"id": entry.id, "display_name": entry.display_name, "description": entry.description,
|
||||
"native_context": entry.n_ctx_train, "native_context_label": _k_label(entry.n_ctx_train),
|
||||
@@ -669,68 +560,43 @@ def local_models_catalog():
|
||||
|
||||
# ── runtime install (job) ────────────────────────────────────
|
||||
def _runtime_progress_hook(job: Dict[str, Any]):
|
||||
"""Adapter: ensure_runtime_installed's progress stream -> job fields, throttled to ~4 updates/s. Byte
|
||||
counters are CUMULATIVE across the plan (a multi-asset engine reads as one growing download, total
|
||||
growing as each asset's size becomes known); unpack/verify keep the counters — a bar bouncing back to
|
||||
zero after the bytes finished reads as failure."""
|
||||
state = {"last": 0.0, "banked": 0, "asset": None, "asset_total": 0}
|
||||
"""PM owns byte accounting; stages describe work without resetting it."""
|
||||
phases = {
|
||||
"download": ("downloading-runtime", "Downloading the local engine"),
|
||||
"unpack": ("unpacking-runtime", "Unpacking the local engine"),
|
||||
"verify": ("verifying-runtime", "Verifying the local engine"),
|
||||
}
|
||||
|
||||
def hook(stage: str, done: int, total: int, label: str) -> None:
|
||||
now = time.monotonic()
|
||||
if now - state["last"] < 0.25 and done < total:
|
||||
return
|
||||
state["last"] = now
|
||||
suffix = f" ({label})" if label else ""
|
||||
if stage == "download":
|
||||
if label != state["asset"]:
|
||||
# Previous asset finished: bank its bytes so the counters keep climbing instead of restarting.
|
||||
state["banked"] += state["asset_total"]
|
||||
state["asset"] = label
|
||||
state["asset_total"] = total or done
|
||||
plan_done = state["banked"] + done
|
||||
plan_total = state["banked"] + (total or 0)
|
||||
_step(job, "downloading-runtime", f"Downloading the local engine{suffix} — {_human_gb(plan_done)}"
|
||||
+ (f" of {_human_gb(plan_total)}" if total else ""))
|
||||
job["done_bytes"] = plan_done
|
||||
job["total_bytes"] = plan_total or None
|
||||
elif stage == "extract":
|
||||
pct = f" — {min(100, round(done / total * 100))}%" if total else ""
|
||||
_step(job, "unpacking-runtime", f"Unpacking the engine{suffix}{pct}")
|
||||
else: # verify
|
||||
_step(job, "verifying-runtime", f"Verifying the engine{suffix}")
|
||||
|
||||
phase, detail = phases[stage]
|
||||
_step(job, phase, detail + (f" ({label})" if label else ""))
|
||||
return hook
|
||||
|
||||
|
||||
def _restart_on_new_tag(job: Dict[str, Any], tag: str, previous: list) -> bool:
|
||||
"""Engine update path: a server already running on an older tag moves to the new one now — the click was
|
||||
the consent. Fresh installs (no server) skip this; Use/boot handles their start."""
|
||||
if bootstrap.get_supervisor() is None or not previous or tag in previous:
|
||||
return False
|
||||
_step(job, "restarting", "Switching the running server to the new build")
|
||||
bootstrap.shutdown_local_runtime()
|
||||
bootstrap.ensure_local_runtime(_load_config(), force=True)
|
||||
return True
|
||||
def _install_engine_job(job: Dict[str, Any], backend: str):
|
||||
_step(job, "installing-runtime", "Preparing the pinned local engine")
|
||||
return binaries.ensure_engine(
|
||||
backend, progress=_runtime_progress_hook(job),
|
||||
download_progress=_download_progress_hook(job),
|
||||
pause_event=_RUNNING[job["job_id"]]["pause"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/local-models/runtime/install")
|
||||
async def local_models_runtime_install(body: RuntimeInstallBody):
|
||||
def local_models_runtime_install(body: RuntimeInstallBody):
|
||||
tag, backend = _runtime_target(body.backend)
|
||||
plan = _resolve_assets_or_400(tag, backend)
|
||||
job = _job("runtime-install", f"llama.cpp {tag} ({backend})")
|
||||
|
||||
def _run():
|
||||
previous = binaries.installed_tags()
|
||||
_step(job, "downloading", f"Fetching {len(plan.assets)} package(s) for {backend}")
|
||||
binaries.ensure_runtime_installed(tag, backend, progress=_runtime_progress_hook(job))
|
||||
# Restart failure is logged only: the new build is installed either way and the next boot serves it.
|
||||
restarted = _quiet(lambda: _restart_on_new_tag(job, tag, previous), False, warn="post-update restart skipped: %s")
|
||||
# N-1 retention, only after the new tag verified: keep it + the newest previous build as the rollback pin target.
|
||||
_quiet(lambda: binaries.prune_old_tags([tag] + [t for t in previous if t != tag][:1]), None,
|
||||
warn="runtime prune skipped: %s")
|
||||
_finish(job, f"llama.cpp {tag} ready ({backend})" + (" — server restarted on the new build" if restarted else ""))
|
||||
def run():
|
||||
previous = binaries.installed_engine(backend)
|
||||
engine = _install_engine_job(job, backend)
|
||||
running = bootstrap.get_supervisor()
|
||||
if running is not None and previous != engine:
|
||||
_step(job, "restarting", "Switching the running server to the pinned build")
|
||||
bootstrap.refresh_local_runtime()
|
||||
_finish(job, f"llama.cpp {tag} ready ({backend})")
|
||||
|
||||
_spawn_job(job, "lr-runtime-install", _run, fail_msg="runtime install failed: %s")
|
||||
_spawn_job(job, "lr-runtime-install", run, fail_msg="runtime install failed: %s", resumable=True)
|
||||
return {"job_id": job["job_id"], "backend": backend, "tag": tag}
|
||||
|
||||
|
||||
@@ -751,19 +617,15 @@ def _download_target(model_id: str):
|
||||
|
||||
|
||||
@router.post("/api/local-models/download")
|
||||
async def local_models_download(body: ModelDownloadBody):
|
||||
def local_models_download(body: ModelDownloadBody):
|
||||
"""Accepts either a family id (downloads this machine's selected variant) or an exact variant model_id."""
|
||||
entry, variant = _download_target(body.model_id)
|
||||
if variant.model_id in bootstrap.staged_model_ids():
|
||||
return {"job_id": None, "already_downloaded": True, "model_id": variant.model_id}
|
||||
plan = _download_plan(entry, variant)
|
||||
if plan and all(dest.is_file() for _, dest, _ in plan):
|
||||
return {"job_id": None, "already_downloaded": True, "model_id": variant.model_id}
|
||||
job = _job("model-download", f"{entry.display_name} ({variant.quant})", model_id=entry.id)
|
||||
job["total_bytes"] = sum(p[2] for p in plan)
|
||||
|
||||
def _run():
|
||||
job["phase"] = "downloading"
|
||||
job["detail"] = f"{entry.display_name} — {_human_gb(job['total_bytes'])}"
|
||||
_download_job(job, plan)
|
||||
_run_download_plan(job, plan, entry.display_name)
|
||||
_finish(job, f"{entry.display_name} ready")
|
||||
_refresh_runtime("post-download runtime refresh skipped")
|
||||
|
||||
@@ -773,29 +635,24 @@ async def local_models_download(body: ModelDownloadBody):
|
||||
|
||||
@router.post("/api/local-models/download/pause")
|
||||
async def local_models_download_pause(body: JobIdBody):
|
||||
"""Pause a running model download. The downloader's live handle is
|
||||
checked off the job dict (which stays JSON-serializable for the poll
|
||||
route); when none is active (already done/paused) this is a no-op."""
|
||||
running = _RUNNING.get(body.job_id)
|
||||
if running is None:
|
||||
"""Pause the download phase of a model, component or quickstart job."""
|
||||
job = _JOBS.get(body.job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="unknown download job")
|
||||
dl = running.get("dl")
|
||||
if dl is None:
|
||||
return {"ok": True, "paused": False}
|
||||
dl.pause()
|
||||
with _JOBS_LOCK:
|
||||
if not _job_view(job)["can_pause"]:
|
||||
return {"ok": True, "paused": False}
|
||||
_RUNNING[body.job_id]["pause"].set()
|
||||
return {"ok": True, "paused": True}
|
||||
|
||||
|
||||
@router.post("/api/local-models/download/resume")
|
||||
async def local_models_download_resume(body: JobIdBody):
|
||||
"""Resume a paused model download."""
|
||||
running = _RUNNING.get(body.job_id)
|
||||
if running is None:
|
||||
"""Restart the same job; PM reuses verified files and durable ranges."""
|
||||
if body.job_id not in _JOBS:
|
||||
raise HTTPException(status_code=404, detail="unknown download job")
|
||||
resume = running.get("resume")
|
||||
if resume is None:
|
||||
return {"ok": True, "resumed": False}
|
||||
return {"ok": True, "resumed": resume()}
|
||||
resume = _RUNNING.get(body.job_id, {}).get("resume")
|
||||
return {"ok": True, "resumed": bool(resume and resume())}
|
||||
|
||||
|
||||
@router.delete("/api/local-models/models/{model_id}")
|
||||
@@ -833,34 +690,33 @@ def _quickstart_target(body: QuickstartBody, budget):
|
||||
|
||||
|
||||
@router.post("/api/local-models/quickstart")
|
||||
async def local_models_quickstart(body: QuickstartBody):
|
||||
def local_models_quickstart(body: QuickstartBody):
|
||||
"""One job: install the runtime (if missing), download this machine's build of the recommended model (if
|
||||
missing), make it the default. Each leg is the same code the individual routes run, so 'Configure' and
|
||||
quickstart can never disagree. Preflight rejects (no servable entry, engine too old) fail the POST
|
||||
synchronously so the button can explain itself; everything slow runs in the job with phase/byte progress."""
|
||||
entry, variant = _quickstart_target(body, hardware.probe_budget(planning=True))
|
||||
tag, backend = _runtime_target()
|
||||
need_runtime = not binaries.installed_tags()
|
||||
if need_runtime:
|
||||
_resolve_assets_or_400(tag, backend)
|
||||
need_download = variant.model_id not in bootstrap.staged_model_ids()
|
||||
download_plan = _download_plan(entry, variant) if need_download else []
|
||||
need_runtime = binaries.installed_engine(backend) is None
|
||||
download_plan = _download_plan(entry, variant)
|
||||
need_download = any(not dest.is_file() for _, dest, _ in download_plan)
|
||||
if not need_download:
|
||||
download_plan = []
|
||||
download_bytes = sum(p[2] for p in download_plan)
|
||||
if not _QUICKSTART_LOCK.acquire(blocking=False):
|
||||
raise HTTPException(status_code=409, detail="Setup is already running")
|
||||
job = _job("quickstart", entry.display_name, model_id=entry.id)
|
||||
job["total_bytes"] = download_bytes or None
|
||||
|
||||
def _run():
|
||||
if need_runtime:
|
||||
_step(job, "installing-runtime", "Installing the local engine")
|
||||
binaries.ensure_runtime_installed(tag, backend, progress=_runtime_progress_hook(job))
|
||||
if need_runtime and binaries.installed_engine(backend) is None:
|
||||
_install_engine_job(job, backend)
|
||||
if need_download:
|
||||
# The runtime leg repurposed the byte counters for its own stages — reset them to the model plan.
|
||||
job["done_bytes"] = 0
|
||||
job["total_bytes"] = download_bytes
|
||||
# Each phase has its own complete download plan. Resume within a
|
||||
# phase retains counters until PM reports the durable bytes.
|
||||
if job["phase"] != "downloading":
|
||||
with _JOBS_LOCK:
|
||||
job.update(done_bytes=0, total_bytes=None, ranges={})
|
||||
_run_download_plan(job, download_plan, entry.display_name)
|
||||
# Activate: same sequence as /activate's job body.
|
||||
_step(job, "starting-server", "Starting the local server")
|
||||
_ensure_server(job, _set_runtime_enabled(True), variant.model_id,
|
||||
fail_detail="The local server could not start — open Local Models for details",
|
||||
skip_msg="quickstart rescan check skipped")
|
||||
@@ -958,17 +814,19 @@ async def local_models_activate(body: ModelActivateBody):
|
||||
async def local_models_jobs():
|
||||
"""All recent jobs, running first — the pane and app-level poller rediscover in-flight work here after a remount."""
|
||||
with _JOBS_LOCK:
|
||||
jobs = sorted(_JOBS.values(), key=lambda j: (j["status"] != "running", -j["started_at"]))
|
||||
return {"jobs": [_job_view(job) for job in jobs[:20]]}
|
||||
jobs = sorted(_JOBS.values(), key=lambda job: -job["started_at"])
|
||||
active = [job for job in jobs if job["status"] in ("running", "paused")]
|
||||
recent = [job for job in jobs if job["status"] in ("done", "error")][:20]
|
||||
return {"jobs": [_job_view(job) for job in [*active, *recent]]}
|
||||
|
||||
|
||||
@router.get("/api/local-models/jobs/{job_id}")
|
||||
async def local_models_job(job_id: str):
|
||||
with _JOBS_LOCK:
|
||||
job = _JOBS.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
return _job_view(job)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
return _job_view(job)
|
||||
|
||||
|
||||
# ── Hugging Face browser: search, repo files, arbitrary download ─
|
||||
@@ -1003,17 +861,13 @@ async def local_models_download_browsed(body: BrowsedDownloadBody):
|
||||
return {"job_id": None, "already_downloaded": True, "model_id": model_id}
|
||||
job = _job("model-download", f"{model_id} (from {body.repo})", model_id=model_id)
|
||||
|
||||
def _fetch():
|
||||
job["phase"] = "downloading"
|
||||
for p in paths:
|
||||
dest = bootstrap.models_dir() / p.rsplit("/", 1)[-1]
|
||||
if dest.exists():
|
||||
continue
|
||||
download_file(_hf_url(body.repo, urllib.parse.quote(p)), dest, job,
|
||||
base_done=int(job.get("done_bytes") or 0), keep_totals=bool(job.get("total_bytes")))
|
||||
job["phase"] = "downloading"
|
||||
plan = [(_hf_url(body.repo, urllib.parse.quote(path)),
|
||||
bootstrap.models_dir() / path.rsplit("/", 1)[-1], 0) for path in paths]
|
||||
|
||||
_spawn_job(job, "lm-download-browsed", _fetch, download_label=model_id)
|
||||
def _fetch():
|
||||
_run_download_plan(job, plan, model_id)
|
||||
|
||||
_spawn_job(job, "lm-download-browsed", _fetch, download_label=model_id, resumable=True)
|
||||
return {"job_id": job["job_id"], "model_id": model_id}
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from pm.ensure import (
|
||||
ensure,
|
||||
env_for,
|
||||
is_installed,
|
||||
installed_package,
|
||||
lazy_installs_allowed,
|
||||
sync_venv,
|
||||
uv,
|
||||
@@ -33,6 +34,7 @@ __all__ = [
|
||||
"ensure",
|
||||
"env_for",
|
||||
"is_installed",
|
||||
"installed_package",
|
||||
"adopt",
|
||||
"check",
|
||||
"activate",
|
||||
|
||||
121
pm/downloader.py
121
pm/downloader.py
@@ -14,7 +14,7 @@ exactly the ranges the bitmap says are missing.
|
||||
|
||||
The progress callback reports the whole job AND the per-dest bitmap:
|
||||
``progress(overall_done, overall_total, ranges)`` where ``ranges`` maps
|
||||
``dest.name`` to half-open [start, end) runs. ``done_bytes`` is the sum;
|
||||
the destination path to half-open [start, end) runs. ``done_bytes`` is the sum;
|
||||
``ranges`` is the shape — one datum, two resolutions.
|
||||
"""
|
||||
|
||||
@@ -176,6 +176,10 @@ def gc_protected_names(
|
||||
protected.add(entry.name)
|
||||
except OSError:
|
||||
protected.add(entry.name) # unreadable → assume live
|
||||
for name in tuple(protected):
|
||||
path = Path(name)
|
||||
if path.suffix in (".part", ".ranges"):
|
||||
protected.update({path.with_suffix(".part").name, path.with_suffix(".ranges").name})
|
||||
return protected
|
||||
|
||||
|
||||
@@ -202,6 +206,7 @@ class Download:
|
||||
resume: bool = True,
|
||||
connections: int = CONNECTIONS,
|
||||
partials_dir: Optional[Path] = None,
|
||||
pause_event: Optional[threading.Event] = None,
|
||||
):
|
||||
self.sources = [Source(s.url, Path(s.dest), s.sha256) for s in sources]
|
||||
self.resume = resume
|
||||
@@ -212,7 +217,8 @@ class Download:
|
||||
from pm import paths
|
||||
|
||||
self.partials_dir = paths.partials_root()
|
||||
self._paused = threading.Event()
|
||||
self._owns_pause_event = pause_event is None
|
||||
self._paused = pause_event if pause_event is not None else threading.Event()
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Request a stop between chunks; run() raises DownloadPaused."""
|
||||
@@ -220,56 +226,76 @@ class Download:
|
||||
|
||||
def run(self, progress: Optional[ProgressFn] = None) -> list[Path]:
|
||||
"""Fetch every source; return the moved destination paths."""
|
||||
self._paused.clear()
|
||||
if self._owns_pause_event:
|
||||
self._paused.clear()
|
||||
self._check_pause()
|
||||
self.partials_dir.mkdir(parents=True, exist_ok=True)
|
||||
for source in self.sources:
|
||||
if not (source.url.startswith("https://")
|
||||
or source.url.startswith(_LOOPBACK)):
|
||||
raise ValueError(f"refusing non-https url: {source.url}")
|
||||
|
||||
# Probe every source up front so overall_total is the whole job.
|
||||
probed = [] # (source, total, range_supported)
|
||||
# Probe the whole plan before reporting a denominator. A missing
|
||||
# Content-Length keeps the bar indeterminate until that file ends.
|
||||
probed = []
|
||||
totals: dict[str, int] = {}
|
||||
unknown: set[str] = set()
|
||||
coverage: dict[str, _Ranges] = {}
|
||||
for source in self.sources:
|
||||
if _existing_dest_ok(source):
|
||||
probed.append((source, source.dest.stat().st_size, True))
|
||||
continue
|
||||
# A pre-existing dest that fails its pinned hash is stale — clear
|
||||
# it so the fetch below replaces it rather than trusting it.
|
||||
if source.dest.exists():
|
||||
source.dest.unlink(missing_ok=True)
|
||||
total, supported = self._probe(source.url)
|
||||
probed.append((source, total, supported))
|
||||
overall_total = sum(t for _, t, _ in probed)
|
||||
self._check_pause()
|
||||
key = str(source.dest)
|
||||
cached = _existing_dest_ok(source)
|
||||
if cached:
|
||||
total, supported = source.dest.stat().st_size, True
|
||||
covered = [(0, total)]
|
||||
else:
|
||||
if source.dest.exists():
|
||||
source.dest.unlink(missing_ok=True)
|
||||
total, supported = self._probe(source.url)
|
||||
covered = self._partial_ranges(source.url, total) if supported else []
|
||||
if not total:
|
||||
unknown.add(key)
|
||||
totals[key] = total
|
||||
coverage[key] = _coalesce(covered)
|
||||
probed.append((source, total, supported, cached))
|
||||
|
||||
progress_lock = threading.Lock()
|
||||
|
||||
def report(key: str, written: _Ranges) -> None:
|
||||
with progress_lock:
|
||||
# The last key identifies the source reporting this tick.
|
||||
coverage.pop(key, None)
|
||||
coverage[key] = list(written)
|
||||
if progress is not None:
|
||||
done = sum(b - a for rows in coverage.values() for a, b in rows)
|
||||
progress(done, 0 if unknown else sum(totals.values()), dict(coverage))
|
||||
|
||||
moved: list[Path] = []
|
||||
done_base = 0
|
||||
completed: dict = {}
|
||||
for source, total, supported in probed:
|
||||
if _existing_dest_ok(source):
|
||||
size = source.dest.stat().st_size
|
||||
completed[source.dest.name] = [(0, size)]
|
||||
done_base += size
|
||||
moved.append(source.dest)
|
||||
continue
|
||||
for source, total, supported, cached in probed:
|
||||
self._check_pause()
|
||||
key = str(source.dest)
|
||||
report(key, coverage[key])
|
||||
if not cached:
|
||||
def tick(written: _Ranges) -> None:
|
||||
report(key, written)
|
||||
|
||||
def tick(written: _Ranges) -> None:
|
||||
if progress is not None:
|
||||
ranges = dict(completed)
|
||||
ranges[source.dest.name] = written
|
||||
progress(done_base + sum(b - a for a, b in written),
|
||||
overall_total, ranges)
|
||||
|
||||
if total and supported:
|
||||
self._fetch_ranged(source, total, tick)
|
||||
else:
|
||||
self._fetch_single(source, total, tick)
|
||||
done_base += total
|
||||
completed[source.dest.name] = [(0, total)]
|
||||
if total and supported:
|
||||
self._fetch_ranged(source, total, tick)
|
||||
else:
|
||||
self._fetch_single(source, total, tick)
|
||||
size = source.dest.stat().st_size
|
||||
totals[key] = size
|
||||
unknown.discard(key)
|
||||
report(key, [(0, size)])
|
||||
moved.append(source.dest)
|
||||
return moved
|
||||
|
||||
# ── internals ─────────────────────────────────────────────
|
||||
|
||||
def _check_pause(self) -> None:
|
||||
if self._paused.is_set():
|
||||
raise DownloadPaused("download paused")
|
||||
|
||||
@staticmethod
|
||||
def _probe(url: str) -> tuple[int, bool]:
|
||||
"""(total bytes, range_supported). A server that ignores Range
|
||||
@@ -306,6 +332,17 @@ class Download:
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
def _partial_ranges(self, url: str, total: int) -> _Ranges:
|
||||
key = self._key(url)
|
||||
part = self.partials_dir / f"{key}.part"
|
||||
if not part.is_file():
|
||||
return []
|
||||
size = part.stat().st_size
|
||||
covered = self._load_sidecar(self.partials_dir / f"{key}.ranges")
|
||||
if any(a < 0 or b < a or b > min(size, total) for a, b in covered):
|
||||
return []
|
||||
return _coalesce(covered)
|
||||
|
||||
@staticmethod
|
||||
def _write_sidecar(side: Path, covered: _Ranges) -> None:
|
||||
side.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -315,9 +352,7 @@ class Download:
|
||||
key = self._key(source.url)
|
||||
part = self.partials_dir / f"{key}.part"
|
||||
side = self.partials_dir / f"{key}.ranges"
|
||||
covered = self._load_sidecar(side)
|
||||
if any(b > total for _, b in covered):
|
||||
covered = [] # server now serves a smaller file: stale partial
|
||||
covered = self._partial_ranges(source.url, total)
|
||||
# Create the file WITHOUT truncating an existing partial (resume),
|
||||
# then size it to total (extends with zeros / shrinks stale tails).
|
||||
open(part, "ab").close()
|
||||
@@ -358,7 +393,7 @@ class Download:
|
||||
with lock:
|
||||
covered[:] = _coalesce(covered + [(start, pos)])
|
||||
written[0] += len(chunk)
|
||||
tick(list(covered))
|
||||
tick(list(covered))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
with lock:
|
||||
errors.append(exc)
|
||||
@@ -402,6 +437,7 @@ class Download:
|
||||
self._write_sidecar(side, covered)
|
||||
raise DownloadError(
|
||||
f"download incomplete ({written[0]} of {total} bytes)")
|
||||
self._write_sidecar(side, covered)
|
||||
self._finalize(source, part, side)
|
||||
|
||||
def _fetch_single(self, source: Source, total: int, tick) -> None:
|
||||
@@ -443,9 +479,11 @@ class Download:
|
||||
except Exception:
|
||||
self._write_sidecar(side, covered)
|
||||
raise
|
||||
self._write_sidecar(side, covered)
|
||||
self._finalize(source, part, side)
|
||||
|
||||
def _finalize(self, source: Source, part: Path, side: Path) -> None:
|
||||
self._check_pause()
|
||||
if source.sha256:
|
||||
actual = _sha256_file(part)
|
||||
if actual != source.sha256:
|
||||
@@ -454,6 +492,7 @@ class Download:
|
||||
raise HashError(
|
||||
f"sha256 mismatch for {source.url}: pinned "
|
||||
f"{source.sha256}, got {actual}")
|
||||
self._check_pause()
|
||||
source.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(part), str(source.dest))
|
||||
side.unlink(missing_ok=True)
|
||||
|
||||
78
pm/ensure.py
78
pm/ensure.py
@@ -6,10 +6,13 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pm import paths
|
||||
from pm.downloader import DownloadPaused, ProgressFn
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.package import InstallError, Package, Runner, StatePackage, compose_env
|
||||
from pm.registry import get_package, walk
|
||||
@@ -42,17 +45,49 @@ def _store() -> Store:
|
||||
return Store(paths.store_root())
|
||||
|
||||
|
||||
def _installed_location(package: Package, lockfile: Lockfile, target: str, *, verify: bool = False):
|
||||
"""Select matching shipped bytes first, then an added pinned store entry."""
|
||||
def _installed_location(package: Package, lockfile: Lockfile, target: str, *,
|
||||
verify: bool = False, allow_outdated: bool = False):
|
||||
"""Prefer the current pin. An explicit read may retain a prior PM install."""
|
||||
roots = dict.fromkeys([paths.store_root(), paths.writable_store_root()])
|
||||
fallback = None
|
||||
for root in roots:
|
||||
store = Store(root)
|
||||
facts = _facts() if root == paths.store_root() else Facts(root / "facts.json")
|
||||
fact = facts.get(package.name)
|
||||
if not fact or not facts.installed(package.name, None, root):
|
||||
continue
|
||||
binary = package.binary(store.entry(fact["entry"]), target)
|
||||
if binary is not None and not binary.is_file():
|
||||
continue
|
||||
if verify and not _entry_verified(package, fact, store, target):
|
||||
continue
|
||||
if facts.installed(package.name, lockfile.version(package.name), root,
|
||||
_identity(lockfile, package.name, target)):
|
||||
if not verify or _entry_verified(package, facts.get(package.name), store, target):
|
||||
return facts, store
|
||||
return None
|
||||
return facts, store
|
||||
if (allow_outdated and fact.get("target") == target
|
||||
and fact.get("artifacts") and fact.get("digest")):
|
||||
fallback = facts, store
|
||||
return fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledPackage:
|
||||
path: Path
|
||||
version: str
|
||||
binary: Path | None
|
||||
|
||||
|
||||
def installed_package(name: str, *, allow_outdated: bool = False) -> InstalledPackage | None:
|
||||
"""Read the selected PM entry without installing or changing its facts."""
|
||||
package = get_package(name)
|
||||
target = current_target()
|
||||
location = _installed_location(package, _lockfile(), target, allow_outdated=allow_outdated)
|
||||
if location is None:
|
||||
return None
|
||||
facts, store = location
|
||||
fact = facts.get(name)
|
||||
entry = store.entry(fact["entry"])
|
||||
return InstalledPackage(entry, fact["version"], package.binary(entry, target))
|
||||
|
||||
|
||||
def _identity(lockfile: Lockfile, name: str, target: str):
|
||||
@@ -179,6 +214,8 @@ def _install(
|
||||
store: Store,
|
||||
target: str,
|
||||
progress=None,
|
||||
pause_event: threading.Event | None = None,
|
||||
download_progress: ProgressFn | None = None,
|
||||
) -> None:
|
||||
version = lockfile.version(package.name)
|
||||
if version is None:
|
||||
@@ -194,6 +231,8 @@ def _install(
|
||||
entry_name = package.store_entry(version, target)
|
||||
|
||||
with store.install_lock():
|
||||
if pause_event is not None and pause_event.is_set():
|
||||
raise DownloadPaused("install paused")
|
||||
facts.reload()
|
||||
entry = store.entry(entry_name)
|
||||
previous_entry = store.entry(f".previous-{entry_name}")
|
||||
@@ -219,12 +258,20 @@ def _install(
|
||||
staged = scratch / "tree"
|
||||
previous = facts.get(package.name)
|
||||
try:
|
||||
for index, artifact in enumerate(artifacts):
|
||||
def tick(done, total, ranges):
|
||||
if progress is not None:
|
||||
active = next(reversed(ranges))
|
||||
index = next(i for i, artifact in enumerate(artifacts) if artifact["url"] == active)
|
||||
label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else ""
|
||||
progress("download", done, total, label)
|
||||
if download_progress is not None:
|
||||
download_progress(done, total, ranges)
|
||||
|
||||
archives = store.fetch_many(artifacts, scratch, progress=tick, pause_event=pause_event)
|
||||
for index, archive in enumerate(archives):
|
||||
if pause_event is not None and pause_event.is_set():
|
||||
raise DownloadPaused("install paused")
|
||||
label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else ""
|
||||
archive = store.fetch(
|
||||
artifact["url"], artifact["sha256"], scratch,
|
||||
progress=_artifact_progress(progress, index, len(artifacts)),
|
||||
)
|
||||
if progress is not None:
|
||||
progress("unpack", 0, 0, label)
|
||||
if index == 0:
|
||||
@@ -235,7 +282,11 @@ def _install(
|
||||
extra = scratch / f"extra-{index}"
|
||||
package.unpack(archive, extra, target)
|
||||
merge_tree(extra, staged)
|
||||
if pause_event is not None and pause_event.is_set():
|
||||
raise DownloadPaused("install paused")
|
||||
package.stage(store, staged, version, target)
|
||||
if progress is not None:
|
||||
progress("verify", 0, 0, "")
|
||||
reason = package.verify(staged, target)
|
||||
if reason:
|
||||
raise InstallError(package.name, f"staged entry failed verification: {reason}")
|
||||
@@ -257,7 +308,7 @@ def _install(
|
||||
raise
|
||||
if previous_entry.exists():
|
||||
_remove_entry(store, previous_entry.name)
|
||||
except InstallError:
|
||||
except (InstallError, DownloadPaused):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise InstallError(package.name, f"install failed: {e}") from e
|
||||
@@ -393,6 +444,8 @@ def ensure(
|
||||
base_env: Optional[dict] = None,
|
||||
explicit: bool = False,
|
||||
progress=None,
|
||||
pause_event: threading.Event | None = None,
|
||||
download_progress: ProgressFn | None = None,
|
||||
) -> Runner:
|
||||
"""``explicit`` marks a deliberate install command (`hermes pm
|
||||
install`, `hermes pm bundle`) — those ARE the remedy the lazy-install
|
||||
@@ -415,7 +468,8 @@ def ensure(
|
||||
store = Store(paths.writable_store_root())
|
||||
facts = _facts() if store.root == paths.store_root() else Facts(store.root / "facts.json")
|
||||
for package in missing:
|
||||
_install(package, lockfile, facts, store, target, progress=progress)
|
||||
_install(package, lockfile, facts, store, target, progress=progress,
|
||||
pause_event=pause_event, download_progress=download_progress)
|
||||
return Runner(name, env_for(name, base_env=base_env))
|
||||
|
||||
|
||||
|
||||
13
pm/lock.json
13
pm/lock.json
@@ -225,6 +225,19 @@
|
||||
},
|
||||
"version": "10362"
|
||||
},
|
||||
"llamacpp-hip": {
|
||||
"artifacts": {
|
||||
"linux-x64": {
|
||||
"sha256": "8df777b54d88405fb5d8bb8207b80ae0d2051cb03f5a7dd69f4b1f67f94f3f2b",
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-ubuntu-rocm-7.14-x64.tar.gz"
|
||||
},
|
||||
"win32-x64": {
|
||||
"sha256": "fcd1899e9577aa8805e761eb8feb8fde028644975b8c7270edd66c60bcceceb4",
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-win-rocm-7.14-x64.zip"
|
||||
}
|
||||
},
|
||||
"version": "10362"
|
||||
},
|
||||
"llamacpp-metal": {
|
||||
"artifacts": {
|
||||
"darwin-arm64": {
|
||||
|
||||
@@ -1102,6 +1102,16 @@ class LlamaCppVulkan(LlamaCpp):
|
||||
}
|
||||
|
||||
|
||||
@register
|
||||
class LlamaCppHip(LlamaCpp):
|
||||
name = "llamacpp-hip"
|
||||
backend = "hip"
|
||||
assets = {
|
||||
"win32-x64": "win-rocm-7.14-x64",
|
||||
"linux-x64": "ubuntu-rocm-7.14-x64",
|
||||
}
|
||||
|
||||
|
||||
@register
|
||||
class LlamaCppMetal(LlamaCpp):
|
||||
"""macOS archives are unified builds with Metal compiled in."""
|
||||
|
||||
83
pm/store.py
83
pm/store.py
@@ -7,6 +7,7 @@ import platform
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import tempfile
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
@@ -127,24 +128,6 @@ def hash_url(url: str) -> str:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download(url: str, dest: Path, sha256: str, progress=None) -> Path:
|
||||
"""Fetch url into dest dir, hash-verified, via the resumable downloader.
|
||||
The digest is proven before the caller ever sees the file.
|
||||
``progress(done, total)`` ticks per chunk — a several-hundred-MB engine
|
||||
archive on a slow line must never look hung. Partial state lives in
|
||||
the downloader's writable partials area (outside scratch, keyed by
|
||||
sha256(url)), so an interrupted = failed fetch resumes on the next
|
||||
call instead of re-fetching the whole archive. Non-https/non-loopback
|
||||
urls are refused by Download itself (ValueError)."""
|
||||
from pm.downloader import Download, Source
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
archive = dest / url.rsplit("/", 1)[-1]
|
||||
p = (lambda d, t, r: progress(d, t)) if progress is not None else None
|
||||
Download([Source(url, archive, sha256)]).run(progress=p)
|
||||
return archive
|
||||
|
||||
|
||||
def extract(archive: Path, dest: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
@@ -272,26 +255,50 @@ class Store:
|
||||
def published(self, name: str) -> bool:
|
||||
return self.entry(name).is_dir()
|
||||
|
||||
def fetch(self, url: str, sha256: str, scratch: Path, progress=None) -> Path:
|
||||
"""Verified archive for url, from the store if already fetched.
|
||||
The cache entry is `fetch-<full sha256>/` holding the single file.
|
||||
The cached file is RE-HASHED against the requested digest before
|
||||
it is returned: the cache lives on a mutable disk, so trust is
|
||||
re-proven, not assumed. On mismatch the entry is deleted and the
|
||||
archive re-downloaded (the 100MB+ re-hash is install-time only)."""
|
||||
entry_name = f"fetch-{sha256}"
|
||||
entry = self.entry(entry_name)
|
||||
if entry.is_dir():
|
||||
files = [p for p in entry.iterdir() if p.is_file()]
|
||||
if len(files) == 1 and sha256_file(files[0]) == sha256:
|
||||
return files[0]
|
||||
shutil.rmtree(entry, ignore_errors=True)
|
||||
archive = download(url, scratch, sha256, progress=progress)
|
||||
staged = scratch / entry_name
|
||||
staged.mkdir(parents=True)
|
||||
archive.rename(staged / archive.name)
|
||||
published = self.publish(staged, entry_name)
|
||||
return published / archive.name
|
||||
def fetch(self, url: str, sha256: str, scratch: Path, progress=None, *, pause_event: threading.Event | None = None) -> Path:
|
||||
tick = (lambda done, total, ranges: progress(done, total)) if progress is not None else None
|
||||
return self.fetch_many([{"url": url, "sha256": sha256}], scratch,
|
||||
progress=tick, pause_event=pause_event)[0]
|
||||
|
||||
def fetch_many(self, artifacts: list[dict], scratch: Path, *, progress=None,
|
||||
pause_event: threading.Event | None = None) -> list[Path]:
|
||||
"""Fetch a pinned plan with one aggregate progress stream.
|
||||
|
||||
Completed archives enter the cache even if a later source pauses.
|
||||
The downloader owns partial bytes outside this disposable scratch.
|
||||
"""
|
||||
from pm.downloader import Download, DownloadPaused, Source
|
||||
|
||||
sources = []
|
||||
for artifact in artifacts:
|
||||
if pause_event is not None and pause_event.is_set():
|
||||
raise DownloadPaused("download paused")
|
||||
url, digest = artifact["url"], artifact["sha256"]
|
||||
entry_name = f"fetch-{digest}"
|
||||
entry = self.entry(entry_name)
|
||||
files = list(entry.iterdir()) if entry.is_dir() else []
|
||||
if len(files) == 1 and files[0].is_file() and sha256_file(files[0]) == digest:
|
||||
destination = files[0]
|
||||
else:
|
||||
if entry.exists():
|
||||
shutil.rmtree(entry)
|
||||
destination = scratch / entry_name / url.rsplit("/", 1)[-1]
|
||||
sources.append(Source(url, destination, digest))
|
||||
|
||||
urls = {str(source.dest): source.url for source in sources}
|
||||
|
||||
def tick(done, total, ranges):
|
||||
if progress is not None:
|
||||
progress(done, total, {urls[key]: rows for key, rows in ranges.items()})
|
||||
|
||||
try:
|
||||
Download(sources, pause_event=pause_event).run(progress=tick)
|
||||
finally:
|
||||
# Only finalized, hash-verified files can exist at these paths.
|
||||
for source in sources:
|
||||
if source.dest.is_relative_to(scratch) and source.dest.is_file():
|
||||
self.publish(source.dest.parent, f"fetch-{source.sha256}")
|
||||
return [self.entry(f"fetch-{source.sha256}") / source.dest.name for source in sources]
|
||||
|
||||
@contextmanager
|
||||
def scratch(self):
|
||||
|
||||
@@ -83,7 +83,7 @@ def test_boot_replaces_incumbent_with_stale_presets(hermes_home, monkeypatch):
|
||||
|
||||
# Fail fast once boot proper begins — reaching it IS the assertion.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.ensure_runtime_installed", fake_boot)
|
||||
"hermes_cli.local_runtime.binaries.installed_engine", fake_boot)
|
||||
|
||||
result = boot.ensure_local_runtime({"local_runtime": {"enabled": True}})
|
||||
assert stopped.get("pid") == 12345, "stale incumbent was not stopped"
|
||||
|
||||
@@ -111,8 +111,11 @@ def test_loader_ignores_unknown_fields():
|
||||
def test_min_engine_gate(monkeypatch):
|
||||
from hermes_cli.web_routers.local_models import _engine_too_old
|
||||
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_tags",
|
||||
lambda: ["b10362"])
|
||||
from hermes_cli.local_runtime.binaries import Engine
|
||||
from pathlib import Path
|
||||
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_engine",
|
||||
lambda *args, **kwargs: Engine("cpu", "b10362", Path("unused")))
|
||||
assert _engine_too_old("") is False, "no requirement, no gate"
|
||||
assert _engine_too_old("b10000") is False, "installed engine suffices"
|
||||
assert _engine_too_old("b10363") is True, "newer requirement gates"
|
||||
|
||||
@@ -102,24 +102,9 @@ def test_browsed_download_stages_and_bounces(client, tmp_path, monkeypatch):
|
||||
bounce the router — the seam that makes it a NORMAL model."""
|
||||
body = b"GGUF" + b"\x00" * 60
|
||||
|
||||
class FakeResponse:
|
||||
headers = {"Content-Length": str(len(body))}
|
||||
from tests.hermes_cli.test_local_models_routes import _FakeRangeOpener
|
||||
|
||||
def __init__(self):
|
||||
self._data = body
|
||||
|
||||
def read(self, n=-1):
|
||||
out, self._data = self._data, b""
|
||||
return out
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen",
|
||||
lambda *a, **k: FakeResponse())
|
||||
monkeypatch.setattr("pm.downloader._OPENER", _FakeRangeOpener(body))
|
||||
bounced = {}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.bootstrap.refresh_local_runtime",
|
||||
|
||||
@@ -202,11 +202,8 @@ def test_reap_orphans_kills_only_our_parentless_binaries(tmp_path, monkeypatch):
|
||||
AccessDenied=_NoSuch,
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "psutil", fake_psutil)
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.supervisor.server_binary",
|
||||
lambda install_dir: exe)
|
||||
|
||||
sup = LlamaServerSupervisor.__new__(LlamaServerSupervisor)
|
||||
sup.install_dir = tmp_path
|
||||
sup.binary = exe
|
||||
sup.proc = None
|
||||
sup._reap_orphaned_children()
|
||||
|
||||
|
||||
101
tests/hermes_cli/test_local_component_jobs.py
Normal file
101
tests/hermes_cli/test_local_component_jobs.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Desktop component jobs pause real PM archive transfers before activation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
import pm
|
||||
from pm import paths, registry
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.packages import LlamaCppCpu
|
||||
from tests.hermes_cli.test_local_download_jobs import client, poll # noqa: F401
|
||||
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
||||
from tests.pm.test_install_download_control import archive
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["runtime/install", "quickstart"])
|
||||
def test_component_job_pause_retains_pin_and_stops_sequence(client, monkeypatch, tmp_path, dl_server, endpoint):
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
class ArchiveEngine(LlamaCppCpu):
|
||||
probe_version = False
|
||||
|
||||
package = ArchiveEngine()
|
||||
monkeypatch.setitem(registry._packages, package.name, package)
|
||||
root = tmp_path / "store"
|
||||
lock_path = tmp_path / "lock.json"
|
||||
monkeypatch.setattr(paths, "store_root", lambda: root)
|
||||
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
|
||||
monkeypatch.setattr(lm, "_runtime_section", lambda: {"backend": "cpu"})
|
||||
target = pm.current_target()
|
||||
binary_name = package.binary(root, target).name
|
||||
files = {binary_name: b"archive engine fixture", "runtime.dll": bytes(range(256)) * (32 * 1024)}
|
||||
pins = []
|
||||
for index, (name, body) in enumerate(files.items()):
|
||||
payload = archive({name: body})
|
||||
path = f"/component-{index}.zip"
|
||||
RangeHandler.payloads[path] = payload
|
||||
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
|
||||
lock = Lockfile(lock_path)
|
||||
lock.set_pin(package.name, "10362", {target: pins})
|
||||
lock.save()
|
||||
|
||||
entry = lm.catalog.CATALOG[0]
|
||||
variant = entry.variants[0]
|
||||
monkeypatch.setattr(lm, "_quickstart_target", lambda *args: (entry, variant))
|
||||
model = lm.bootstrap.models_dir() / "test.gguf"
|
||||
RangeHandler.payloads["/model"] = b"model fixture"
|
||||
monkeypatch.setattr(lm, "_download_plan", lambda *args: [(url(dl_server, "/model"), model, 13)])
|
||||
calls = []
|
||||
monkeypatch.setattr(lm, "_ensure_server", lambda *args, **kwargs: calls.append("server"))
|
||||
monkeypatch.setattr(lm, "_assign_default", lambda *args: calls.append("default"))
|
||||
monkeypatch.setattr(lm.bootstrap, "get_supervisor", lambda: None)
|
||||
|
||||
flowing, release = threading.Event(), threading.Event()
|
||||
real_hook = lm._runtime_progress_hook
|
||||
|
||||
def observed_hook(job):
|
||||
hook = real_hook(job)
|
||||
first_archive_bytes = 0
|
||||
|
||||
def tick(stage, done, total, label):
|
||||
nonlocal first_archive_bytes
|
||||
hook(stage, done, total, label)
|
||||
if stage == "download" and label == "1/2":
|
||||
first_archive_bytes = done
|
||||
if stage == "download" and label == "2/2" and first_archive_bytes < done < total and not flowing.is_set():
|
||||
flowing.set()
|
||||
assert release.wait(15)
|
||||
return tick
|
||||
|
||||
monkeypatch.setattr(lm, "_runtime_progress_hook", observed_hook)
|
||||
response = client.post(f"/api/local-models/{endpoint}", json={"backend": "cpu"} if endpoint == "runtime/install" else {})
|
||||
assert response.status_code == 200, response.text
|
||||
job_id = response.json()["job_id"]
|
||||
try:
|
||||
assert flowing.wait(5), "runtime did not enter PM's pinned archive transfer"
|
||||
paused = client.post("/api/local-models/download/pause", json={"job_id": job_id})
|
||||
assert paused.json()["paused"], paused.text
|
||||
finally:
|
||||
release.set()
|
||||
job = poll(client, job_id)
|
||||
assert job["status"] == "paused", job
|
||||
assert Facts(paths.facts_path()).get(package.name) is None
|
||||
assert calls == [] and not model.exists()
|
||||
assert job["can_resume"]
|
||||
assert list(paths.partials_root().glob("*.ranges"))
|
||||
|
||||
assert client.post("/api/local-models/download/resume", json={"job_id": job_id}).json()["resumed"]
|
||||
job = poll(client, job_id, until=("done", "error"))
|
||||
assert job["status"] == "done", job
|
||||
selected = pm.installed_package(package.name)
|
||||
assert selected is not None
|
||||
for name, body in files.items():
|
||||
assert (selected.path / name).read_bytes() == body
|
||||
assert Facts(paths.facts_path()).get(package.name)["artifacts"] == [p["sha256"] for p in pins]
|
||||
if endpoint == "quickstart":
|
||||
assert calls == ["server", "default"] and model.read_bytes() == RangeHandler.payloads["/model"]
|
||||
assert job_id not in lm._RUNNING
|
||||
117
tests/hermes_cli/test_local_download_jobs.py
Normal file
117
tests/hermes_cli/test_local_download_jobs.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Local-model jobs use PM transfers for every entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
monkeypatch.setattr(lm, "_JOBS", {})
|
||||
monkeypatch.setattr(lm, "_RUNNING", {})
|
||||
monkeypatch.setattr(lm.bootstrap, "refresh_local_runtime", lambda: False)
|
||||
app = FastAPI()
|
||||
app.include_router(lm.router)
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def poll(client, job_id, until=("done", "error", "paused")):
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
response = client.get(f"/api/local-models/jobs/{job_id}")
|
||||
assert response.status_code == 200, response.text
|
||||
job = response.json()
|
||||
if job["status"] in until:
|
||||
return job
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f"job did not settle: {job}")
|
||||
|
||||
|
||||
def test_browsed_download_can_pause_and_resume_pm_transfer(client, monkeypatch, dl_server):
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
payload = bytes(range(256)) * (32 * 1024)
|
||||
RangeHandler.payloads["/test.gguf"] = payload
|
||||
monkeypatch.setattr(lm, "_hf_url", lambda repo, path: url(dl_server, "/test.gguf"))
|
||||
flowing, release = threading.Event(), threading.Event()
|
||||
real_download = lm.Download
|
||||
|
||||
class ObservedDownload(real_download):
|
||||
def run(self, progress=None):
|
||||
def tick(done, total, ranges):
|
||||
if progress is not None:
|
||||
progress(done, total, ranges)
|
||||
if 0 < done < total and not flowing.is_set():
|
||||
flowing.set()
|
||||
assert release.wait(15)
|
||||
return super().run(progress=tick)
|
||||
|
||||
monkeypatch.setattr(lm, "Download", ObservedDownload)
|
||||
response = client.post("/api/local-models/download-browsed", json={"repo": "a/b", "paths": ["test.gguf"]})
|
||||
job_id = response.json()["job_id"]
|
||||
try:
|
||||
assert flowing.wait(5), "browsed download bypassed PM's downloader"
|
||||
assert client.post("/api/local-models/download/pause", json={"job_id": job_id}).json()["paused"]
|
||||
finally:
|
||||
release.set()
|
||||
job = poll(client, job_id)
|
||||
assert job["status"] == "paused", job
|
||||
assert job["can_resume"] and not job["can_pause"]
|
||||
assert job["error"] is None
|
||||
before = job["done_bytes"]
|
||||
assert before > 0
|
||||
assert client.post("/api/local-models/download/resume", json={"job_id": job_id}).json()["resumed"]
|
||||
job = poll(client, job_id, until=("done", "error"))
|
||||
assert job["status"] == "done", job
|
||||
assert job["done_bytes"] == job["total_bytes"] == len(payload)
|
||||
assert (lm.bootstrap.models_dir() / "test.gguf").read_bytes() == payload
|
||||
assert not job["can_pause"] and not job["can_resume"]
|
||||
|
||||
|
||||
def test_quickstart_plan_progress_uses_actual_whole_plan_bytes(client, monkeypatch, dl_server, tmp_path):
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
bodies = {"first": b"first", "second": b"second"}
|
||||
RangeHandler.payloads = {"/" + name: body for name, body in bodies.items()}
|
||||
plan = [(url(dl_server, "/" + name), tmp_path / (name + ".gguf"), 1000) for name in bodies]
|
||||
job = lm._job("quickstart", "test")
|
||||
lm._run_download_plan(job, plan, "test")
|
||||
expected = sum(map(len, bodies.values()))
|
||||
assert job["done_bytes"] == job["total_bytes"] == expected
|
||||
assert sum(end - start for rows in job["ranges"].values() for start, end in rows) == expected
|
||||
|
||||
|
||||
def test_existing_weights_do_not_skip_missing_companion_download(client, monkeypatch, dl_server, tmp_path):
|
||||
from dataclasses import replace
|
||||
from hermes_cli.local_runtime.catalog import AssetFile
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
entry = replace(lm.catalog.CATALOG[0], mmproj=AssetFile("projector.gguf", 1000), draft=None)
|
||||
variant = entry.variants[0]
|
||||
monkeypatch.setattr(lm, "_download_target", lambda model_id: (entry, variant))
|
||||
monkeypatch.setattr(lm.bootstrap, "staged_model_ids", lambda: [variant.model_id])
|
||||
weights = tmp_path / "weights.gguf"
|
||||
weights.write_bytes(b"already downloaded")
|
||||
companion = tmp_path / "assets" / "projector.gguf"
|
||||
RangeHandler.payloads["/projector"] = b"projector bytes"
|
||||
plan = [(url(dl_server, "/weights"), weights, 1000),
|
||||
(url(dl_server, "/projector"), companion, 1000)]
|
||||
monkeypatch.setattr(lm, "_download_plan", lambda *args: plan)
|
||||
response = client.post("/api/local-models/download", json={"model_id": entry.id})
|
||||
assert response.json().get("job_id"), "weights-only staging incorrectly skipped the projector"
|
||||
job = poll(client, response.json()["job_id"])
|
||||
assert job["status"] == "done", job
|
||||
assert companion.read_bytes() == RangeHandler.payloads["/projector"]
|
||||
assert job["done_bytes"] == weights.stat().st_size + companion.stat().st_size
|
||||
37
tests/hermes_cli/test_local_job_registry.py
Normal file
37
tests/hermes_cli/test_local_job_registry.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Paused work remains discoverable, and terminal phase changes honor pause intent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from pm.downloader import DownloadPaused
|
||||
from tests.hermes_cli.test_local_download_jobs import client # noqa: F401
|
||||
|
||||
|
||||
def test_jobs_keep_every_active_download_ahead_of_bounded_history(client):
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
paused = lm._job("runtime-install", "engine")
|
||||
paused["status"] = "paused"
|
||||
for index in range(25):
|
||||
job = lm._job("model-download", f"model-{index}")
|
||||
job["status"] = "done"
|
||||
active = [lm._job("model-download", f"active-{index}") for index in range(25)]
|
||||
response = client.get("/api/local-models/jobs")
|
||||
ids = {job["job_id"] for job in response.json()["jobs"]}
|
||||
assert {paused["job_id"], *(job["job_id"] for job in active)} <= ids
|
||||
|
||||
|
||||
def test_pause_request_prevents_transition_into_activation(client):
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
job = lm._job("quickstart", "model")
|
||||
job["phase"] = "downloading"
|
||||
pause = threading.Event()
|
||||
lm._RUNNING[job["job_id"]] = {"pause": pause}
|
||||
assert client.post("/api/local-models/download/pause", json={"job_id": job["job_id"]}).json()["paused"]
|
||||
with pytest.raises(DownloadPaused):
|
||||
lm._step(job, "starting-server", "Starting")
|
||||
assert job["phase"] == "downloading"
|
||||
@@ -231,7 +231,10 @@ def test_download_already_downloaded_short_circuits(client, monkeypatch):
|
||||
lambda **kw: budget)
|
||||
choice = select_variant(CATALOG[0], budget)
|
||||
assert choice is not None
|
||||
_write_fake_gguf(models_dir() / choice.variant.files[0].local_name)
|
||||
from hermes_cli.web_routers.local_models import _download_plan
|
||||
|
||||
for _, dest, _ in _download_plan(CATALOG[0], choice.variant):
|
||||
_write_fake_gguf(dest)
|
||||
r = client.post("/api/local-models/download", json={"model_id": CATALOG[0].id})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["already_downloaded"] is True
|
||||
@@ -253,11 +256,15 @@ def test_runtime_install_rejects_impossible_combo(client, monkeypatch):
|
||||
"""Impossible platform/backend combos fail the POST itself with the
|
||||
resolver's honest message — not a background job that dies silently.
|
||||
(win-arm64-vulkan; the old cuda case became real upstream at ~b1036x.)"""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries._host_os_arch", lambda: ("win", "arm64"))
|
||||
r = client.post("/api/local-models/runtime/install", json={"backend": "vulkan"})
|
||||
from pm import paths
|
||||
from pm.lock import Lockfile
|
||||
|
||||
lock_path = Path(paths.partials_root()).parent / "unavailable-lock.json"
|
||||
Lockfile(lock_path).save()
|
||||
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
|
||||
r = client.post("/api/local-models/runtime/install", json={"backend": "cpu"})
|
||||
assert r.status_code == 400
|
||||
assert "arm64" in r.json()["detail"]
|
||||
assert "not pinned" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_job_poll_unknown_404s(client):
|
||||
@@ -630,8 +637,10 @@ def test_quickstart_pause_stops_the_sequence(client, monkeypatch, dl_server,
|
||||
|
||||
_serve_plan(monkeypatch, dl_server, tmp_path / "partials",
|
||||
{"QsPartA": _BIG_BODY, "QsPartB": _BIG_BODY})
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_tags",
|
||||
lambda: ["b99999"]) # runtime leg already satisfied
|
||||
from hermes_cli.local_runtime.binaries import Engine
|
||||
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_engine",
|
||||
lambda *args, **kwargs: Engine("cpu", "b99999", Path("unused")))
|
||||
monkeypatch.setattr(lm, "_runtime_target",
|
||||
lambda requested=None: ("b1", "cpu"))
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli.local_runtime.binaries import Engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -67,10 +68,10 @@ def test_quickstart_runs_all_three_legs(client, monkeypatch, tmp_path):
|
||||
|
||||
# Leg 1: no runtime installed yet; install is the stubbed binaries call.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.installed_tags", lambda: [])
|
||||
"hermes_cli.local_runtime.binaries.installed_engine", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
|
||||
lambda tag, backend, progress=None: calls.append("install"))
|
||||
"hermes_cli.local_runtime.binaries.ensure_engine",
|
||||
lambda backend, **kwargs: calls.append("install"))
|
||||
|
||||
# Leg 2: nothing staged; the download writes the files the plan names.
|
||||
def _fake_download(job, plan):
|
||||
@@ -120,17 +121,20 @@ def test_quickstart_skips_satisfied_legs(client, monkeypatch):
|
||||
calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.installed_tags", lambda: ["b10362"])
|
||||
"hermes_cli.local_runtime.binaries.installed_engine", lambda *args, **kwargs: Engine("cpu", "b10362", Path("unused")))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
|
||||
lambda tag, backend, progress=None: calls.append("install"))
|
||||
"hermes_cli.local_runtime.binaries.ensure_engine",
|
||||
lambda backend, **kwargs: calls.append("install"))
|
||||
|
||||
# Every catalog variant reads as staged.
|
||||
# Every model file and companion is already present.
|
||||
from hermes_cli.local_runtime.catalog import CATALOG
|
||||
from hermes_cli.web_routers.local_models import _download_plan
|
||||
|
||||
all_ids = {v.model_id for e in CATALOG for v in e.variants}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.bootstrap.staged_model_ids", lambda: all_ids)
|
||||
for entry in CATALOG:
|
||||
for variant in entry.variants:
|
||||
for _, dest, _ in _download_plan(entry, variant):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(b"downloaded fixture")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.web_routers.local_models._download_job",
|
||||
lambda *a, **k: calls.append("download"))
|
||||
@@ -166,7 +170,7 @@ def quickstart_ready(monkeypatch):
|
||||
from hermes_cli.local_runtime.catalog import VariantChoice
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.installed_tags", lambda: ["b10362"])
|
||||
"hermes_cli.local_runtime.binaries.installed_engine", lambda *args, **kwargs: Engine("cpu", "b10362", Path("unused")))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.catalog.select_variant",
|
||||
lambda entry, budget: VariantChoice(variant=entry.variants[0],
|
||||
|
||||
@@ -17,12 +17,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.local_runtime.binaries import (
|
||||
AssetPlan,
|
||||
BinaryResolutionError,
|
||||
resolve_assets,
|
||||
select_backend,
|
||||
)
|
||||
from hermes_cli.local_runtime.binaries import BinaryResolutionError, resolve_backend, select_backend
|
||||
from hermes_cli.local_runtime.detect import DetectedServer, probe_port
|
||||
|
||||
|
||||
@@ -169,53 +164,23 @@ def test_probe_dead_port_returns_none():
|
||||
# ── binary resolver (Rollout 2) ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("os_name,arch,backend,ok", [
|
||||
("win", "x64", "cuda", True),
|
||||
("win", "x64", "vulkan", True),
|
||||
("win", "x64", "cpu", True),
|
||||
("win", "arm64", "cpu", True),
|
||||
("win", "arm64", "cuda", True), # upstream ships these since ~b1036x (CUDA 13.4)
|
||||
("win", "arm64", "vulkan", False),
|
||||
("macos", "arm64", "metal", True),
|
||||
("ubuntu", "x64", "vulkan", True),
|
||||
("ubuntu", "x64", "cpu", True),
|
||||
("ubuntu", "x64", "cuda", False), # no prebuilt linux CUDA
|
||||
@pytest.mark.parametrize("target,backend,ok", [
|
||||
("win32-x64", "cuda", True),
|
||||
("win32-x64", "vulkan", True),
|
||||
("win32-arm64", "cpu", True),
|
||||
("win32-arm64", "cuda", True),
|
||||
("win32-arm64", "vulkan", False),
|
||||
("darwin-arm64", "metal", True),
|
||||
("linux-x64", "vulkan", True),
|
||||
("linux-x64", "cpu", True),
|
||||
("linux-x64", "cuda", False),
|
||||
])
|
||||
def test_resolver_platform_matrix(os_name, arch, backend, ok):
|
||||
def test_resolver_platform_matrix(target, backend, ok):
|
||||
if ok:
|
||||
plan = resolve_assets("b10290", backend, os_name=os_name, arch=arch)
|
||||
assert plan.assets, "resolvable combination must yield assets"
|
||||
# Invariant: every asset names the tag or is a paired runtime zip.
|
||||
for asset in plan.assets:
|
||||
assert "b10290" in asset or asset.startswith("cudart-")
|
||||
assert resolve_backend(backend, target=target) == backend
|
||||
else:
|
||||
with pytest.raises(BinaryResolutionError):
|
||||
resolve_assets("b10290", backend, os_name=os_name, arch=arch)
|
||||
|
||||
|
||||
def test_windows_cuda_pairs_cudart():
|
||||
"""Windows CUDA must ship the runtime zip — users have no toolkit."""
|
||||
plan = resolve_assets("b10290", "cuda", os_name="win", arch="x64")
|
||||
assert any(a.startswith("cudart-") for a in plan.assets)
|
||||
|
||||
|
||||
def test_windows_cuda_arm64_pairs_cudart_on_its_own_version():
|
||||
"""arm64 CUDA rides its own CUDA line (13.4 at b10362, verified live):
|
||||
both zips must agree on version and name the arch."""
|
||||
plan = resolve_assets("b10362", "cuda", os_name="win", arch="arm64")
|
||||
assert len(plan.assets) == 2
|
||||
assert all("arm64" in a for a in plan.assets)
|
||||
versions = {a.split("cuda-")[1].split("-")[0] for a in plan.assets}
|
||||
assert len(versions) == 1, f"paired zips disagree on CUDA version: {plan.assets}"
|
||||
assert any(a.startswith("cudart-") for a in plan.assets)
|
||||
assert any(a.startswith("llama-") for a in plan.assets)
|
||||
|
||||
|
||||
def test_install_dir_is_profile_scoped(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
plan = AssetPlan(tag="b10290", backend="cuda")
|
||||
assert str(tmp_path) in str(plan.install_dir)
|
||||
assert "runtimes" in plan.install_dir.parts
|
||||
resolve_backend(backend, target=target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vendor,os_name,expected", [
|
||||
@@ -232,28 +197,6 @@ def test_backend_selection(vendor, os_name, expected):
|
||||
assert select_backend(vendor, os_name=os_name) == expected
|
||||
|
||||
|
||||
def test_sha256_mismatch_rejects(tmp_path, monkeypatch):
|
||||
"""A pinned hash that doesn't match the download must hard-fail."""
|
||||
from hermes_cli.local_runtime import binaries
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
# Pre-place a wrong-content "download" so no network is touched. The
|
||||
# asset name is host-dependent (win/.zip, ubuntu/.tar.gz, macos/.zip)
|
||||
# — resolve it the way the installer will, so the poisoned file is the
|
||||
# one it verifies on every CI platform.
|
||||
plan = binaries.resolve_assets("b10290", "cpu")
|
||||
asset = plan.assets[0]
|
||||
downloads = binaries.runtimes_root() / "downloads"
|
||||
downloads.mkdir(parents=True)
|
||||
(downloads / asset).write_bytes(b"not the real archive")
|
||||
with pytest.raises(BinaryResolutionError, match="sha256 mismatch"):
|
||||
binaries.ensure_runtime_installed(
|
||||
"b10290", "cpu",
|
||||
expected_sha256={asset: "0" * 64})
|
||||
# The poisoned download must not survive for a retry to trust.
|
||||
assert not (downloads / asset).exists()
|
||||
|
||||
|
||||
# ── supervisor contracts (stubbed; no GPU) ───────────────────
|
||||
|
||||
|
||||
@@ -262,7 +205,7 @@ def _make_supervisor(tmp_path, port):
|
||||
from hermes_cli.local_runtime.supervisor import LlamaServerSupervisor
|
||||
|
||||
sup = LlamaServerSupervisor(
|
||||
install_dir=tmp_path, models_dir=tmp_path, port=port)
|
||||
binary=tmp_path / "llama-server", models_dir=tmp_path, port=port)
|
||||
return sup
|
||||
|
||||
|
||||
@@ -519,25 +462,17 @@ def test_resolution_kicks_boot_when_no_thread_is_booting(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_boot_in_flight_real_gate(tmp_path, monkeypatch):
|
||||
"""_boot_in_flight exercised FOR REAL (the previous regression test
|
||||
monkeypatched it — and the real one threw TypeError on every call,
|
||||
silently disabling the boot wait). Enabled + verified manifest on
|
||||
disk -> True; either missing -> False."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
import pm
|
||||
from hermes_cli.local_runtime import endpoint as ep
|
||||
from hermes_cli.local_runtime.binaries import runtimes_root
|
||||
from tests.hermes_cli.test_local_runtime_updates import record_engine
|
||||
|
||||
enabled = {"local_runtime": {"enabled": True}}
|
||||
# Not installed yet -> False.
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
store = tmp_path / "store"
|
||||
monkeypatch.setattr(pm.paths, "store_root", lambda: store)
|
||||
enabled = {"local_runtime": {"enabled": True, "backend": "cpu"}}
|
||||
assert ep._boot_in_flight(enabled) is False
|
||||
# Verified install manifest -> True.
|
||||
install = runtimes_root() / "b10290" / "cuda"
|
||||
install.mkdir(parents=True)
|
||||
(install / "manifest.json").write_text(
|
||||
json.dumps({"tag": "b10290", "verified_version": "5015 (abc)"}),
|
||||
encoding="utf-8")
|
||||
record_engine(store, "10290")
|
||||
assert ep._boot_in_flight(enabled) is True
|
||||
# Disabled -> False even when installed.
|
||||
assert ep._boot_in_flight({"local_runtime": {"enabled": False}}) is False
|
||||
|
||||
|
||||
@@ -625,7 +560,7 @@ def test_bootstrap_skips_boot_with_no_staged_models(tmp_path, monkeypatch):
|
||||
called["spawn"] = True
|
||||
raise AssertionError("must not reach install/spawn")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.binaries.ensure_runtime_installed", _boom)
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_engine", _boom)
|
||||
result = bs.ensure_local_runtime({"local_runtime": {"enabled": True}})
|
||||
assert result is None
|
||||
assert called["spawn"] is False
|
||||
@@ -759,7 +694,7 @@ def test_local_runtime_config_defaults_shape():
|
||||
|
||||
cfg = DEFAULT_CONFIG["local_runtime"]
|
||||
assert cfg["enabled"] is False
|
||||
assert isinstance(cfg["tag"], str) and cfg["tag"].startswith("b")
|
||||
assert "tag" not in cfg # PM owns the version pin.
|
||||
forbidden = [k for k in cfg if "context" in k or "ctx" in k or "vram" in k or "kv" in k]
|
||||
assert forbidden == [], f"policy constants leaked into config: {forbidden}"
|
||||
|
||||
@@ -793,7 +728,7 @@ def test_bootstrap_reuses_running_server(tmp_path, monkeypatch, stub_server):
|
||||
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
|
||||
"hermes_cli.local_runtime.binaries.installed_engine",
|
||||
lambda *a, **k: called.append(1))
|
||||
assert bootstrap.ensure_local_runtime({"local_runtime": {"enabled": True}}) is None
|
||||
assert called == []
|
||||
@@ -812,6 +747,6 @@ def test_bootstrap_failure_never_raises(tmp_path, monkeypatch):
|
||||
raise RuntimeError("no network")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.ensure_runtime_installed", boom)
|
||||
"hermes_cli.local_runtime.binaries.installed_engine", boom)
|
||||
result = bootstrap.ensure_local_runtime({"local_runtime": {"enabled": True}})
|
||||
assert result is None # no exception escaped
|
||||
|
||||
34
tests/hermes_cli/test_local_runtime_pm.py
Normal file
34
tests/hermes_cli/test_local_runtime_pm.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Backend selection uses reviewed target pins and never installs on lookup."""
|
||||
|
||||
import pytest
|
||||
|
||||
import pm
|
||||
from hermes_cli.local_runtime import binaries
|
||||
from pm import paths
|
||||
from pm.lock import Lockfile
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target,vendor,expected", [
|
||||
("linux-x64", "nvidia", "vulkan"),
|
||||
("linux-arm64", "nvidia", "vulkan"),
|
||||
("win32-arm64", "nvidia", "cuda"),
|
||||
("win32-arm64", "AMD Radeon", "cpu"),
|
||||
("win32-x64", "AMD Radeon", "vulkan"),
|
||||
("win32-x64", None, "cpu"),
|
||||
("darwin-arm64", None, "metal"),
|
||||
])
|
||||
def test_auto_backend_uses_only_compatible_pins(target, vendor, expected):
|
||||
assert binaries.resolve_backend("auto", gpu_vendor=vendor, target=target) == expected
|
||||
with pytest.raises(binaries.BinaryResolutionError):
|
||||
binaries.resolve_backend("cuda", target="linux-x64")
|
||||
|
||||
|
||||
def test_missing_pin_is_refused_instead_of_constructing_download_url(tmp_path, monkeypatch):
|
||||
lock_path = tmp_path / "lock.json"
|
||||
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
|
||||
lock = Lockfile(lock_path)
|
||||
lock.set_pin("llamacpp-cpu", "123", {})
|
||||
lock.save()
|
||||
assert binaries.pinned_tag("cpu") == "b123"
|
||||
with pytest.raises(binaries.BinaryResolutionError, match="not pinned"):
|
||||
binaries.resolve_backend("cpu", target=pm.current_target())
|
||||
@@ -1,123 +1,86 @@
|
||||
"""Engine-update contracts (Rollout 4 follow-up):
|
||||
|
||||
- default tag flows from DEFAULT_CONFIG unless the user pinned;
|
||||
- boot serves what is INSTALLED, never downloads (the ladder);
|
||||
- update_available only when the local engine is enabled AND installed
|
||||
AND the configured tag is missing on disk;
|
||||
- the update itself is a button-driven job, and prune keeps N-1.
|
||||
"""
|
||||
"""Engine updates read PM pins; boot retains installed bytes without downloading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import pm
|
||||
from pm import paths
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.store import tree_digest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
def runtime_env(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
store = home / "tools"
|
||||
lock_path = tmp_path / "lock.json"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
monkeypatch.setattr(paths, "store_root", lambda: store)
|
||||
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
|
||||
lock = Lockfile(lock_path)
|
||||
lock.set_pin("llamacpp-cpu", "10412", {pm.current_target(): {
|
||||
"url": "https://example.invalid/cpu.zip", "sha256": "a" * 64,
|
||||
}})
|
||||
lock.save()
|
||||
return store, lock
|
||||
|
||||
|
||||
def _install_fake_tag(home, tag: str, backend: str = "cuda") -> None:
|
||||
d = home / "runtimes" / "llamacpp" / tag / backend
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.json").write_text(json.dumps({
|
||||
"tag": tag, "backend": backend, "assets": {},
|
||||
"verified_version": f"version: {tag.lstrip('b')}",
|
||||
}), encoding="utf-8")
|
||||
# server_binary() looks for the executable name per-OS; give it both.
|
||||
(d / "llama-server.exe").write_bytes(b"MZ fake")
|
||||
(d / "llama-server").write_bytes(b"\x7fELF fake")
|
||||
def record_engine(store, version, digest="b" * 64):
|
||||
package = pm.get_package("llamacpp-cpu")
|
||||
target = pm.current_target()
|
||||
entry = store / package.store_entry(version, target)
|
||||
entry.mkdir(parents=True, exist_ok=True)
|
||||
binary = package.binary(entry, target)
|
||||
binary.write_bytes(b"installed engine fixture")
|
||||
Facts(store / "facts.json").record(package.name, version, entry.name, {}, store,
|
||||
target=target, artifacts=[digest], digest=tree_digest(entry))
|
||||
return binary
|
||||
|
||||
|
||||
def test_installed_tags_newest_first(hermes_home):
|
||||
from hermes_cli.local_runtime.binaries import installed_tags
|
||||
def test_status_uses_pm_pin_and_ignores_legacy_tag(runtime_env, monkeypatch):
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
assert installed_tags() == []
|
||||
_install_fake_tag(hermes_home, "b10290")
|
||||
_install_fake_tag(hermes_home, "b10412")
|
||||
assert installed_tags() == ["b10412", "b10290"]
|
||||
store, lock = runtime_env
|
||||
section = {"enabled": True, "backend": "cpu", "tag": "b99999"}
|
||||
monkeypatch.setattr(lm, "_runtime_section", lambda: section)
|
||||
monkeypatch.setattr(lm, "_state_endpoint", lambda: None)
|
||||
record_engine(store, "10362")
|
||||
status = lm.local_models_status()
|
||||
assert status["tag"] == "b10362"
|
||||
assert status["configured_tag"] == "b" + lock.version("llamacpp-cpu")
|
||||
assert status["runtime_installed"] and status["update_available"]
|
||||
section["enabled"] = False
|
||||
assert not lm.local_models_status()["update_available"]
|
||||
section["enabled"] = True
|
||||
record_engine(store, lock.version("llamacpp-cpu"), "a" * 64)
|
||||
assert not lm.local_models_status()["update_available"]
|
||||
|
||||
|
||||
def test_default_tag_flows_from_default_config(hermes_home):
|
||||
"""Unpinned users inherit the Hermes-release default (deep-merge);
|
||||
the shipped default must be a plausible rolling tag."""
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.config_defaults import DEFAULT_CONFIG
|
||||
def test_boot_uses_previous_pm_engine_without_installing(runtime_env, monkeypatch):
|
||||
from hermes_cli.local_runtime import bootstrap, endpoint, supervisor
|
||||
|
||||
default_tag = DEFAULT_CONFIG["local_runtime"]["tag"]
|
||||
assert default_tag.startswith("b") and default_tag.lstrip("b").isdigit()
|
||||
assert load_config()["local_runtime"]["tag"] == default_tag
|
||||
store, _ = runtime_env
|
||||
binary = record_engine(store, "10362")
|
||||
monkeypatch.setattr(bootstrap, "_SUPERVISOR", None)
|
||||
monkeypatch.setattr(endpoint, "_state_endpoint", lambda: None)
|
||||
monkeypatch.setattr(bootstrap, "_generate_presets", lambda *args: None)
|
||||
monkeypatch.setattr(bootstrap, "_start_idle_sweeper", lambda *args: None)
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("boot attempted a PM install")
|
||||
|
||||
def test_update_available_requires_enabled_and_installed(hermes_home, monkeypatch):
|
||||
"""The flag's truth table: enabled+installed+configured-missing only."""
|
||||
from fastapi.testclient import TestClient
|
||||
monkeypatch.setattr(pm, "ensure", forbidden)
|
||||
spawned = []
|
||||
|
||||
from hermes_cli import web_server
|
||||
def start(self):
|
||||
spawned.append(self.binary)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
# Same auth pattern as the other local-models route tests.
|
||||
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
|
||||
def status():
|
||||
r = client.get("/api/local-models/status")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
import hermes_cli.web_routers.local_models as lm
|
||||
|
||||
# Case 1: enabled, configured newer than installed -> update available.
|
||||
monkeypatch.setattr(lm, "_runtime_section",
|
||||
lambda: {"enabled": True, "tag": "b10412"})
|
||||
_install_fake_tag(hermes_home, "b10290")
|
||||
s = status()
|
||||
assert s["update_available"] is True
|
||||
assert s["configured_tag"] == "b10412"
|
||||
assert s["tag"] == "b10290" # serving what's installed
|
||||
|
||||
# Case 2: configured tag installed -> no update.
|
||||
_install_fake_tag(hermes_home, "b10412")
|
||||
s = status()
|
||||
assert s["update_available"] is False
|
||||
assert s["tag"] == "b10412"
|
||||
|
||||
# Case 3: disabled -> never flagged, even with a mismatch.
|
||||
monkeypatch.setattr(lm, "_runtime_section",
|
||||
lambda: {"enabled": False, "tag": "b10999"})
|
||||
assert status()["update_available"] is False
|
||||
|
||||
|
||||
def test_boot_never_downloads_missing_tag(hermes_home, monkeypatch):
|
||||
"""The ladder: configured-but-not-installed serves the newest installed
|
||||
tag; nothing installed means no boot (and NO download either way)."""
|
||||
from hermes_cli.local_runtime import bootstrap
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
|
||||
lambda tag, backend, **kw: calls.append(tag) or (_ for _ in ()).throw(
|
||||
AssertionError("boot must not reach install for missing tags")))
|
||||
|
||||
# Nothing installed: returns None before any install attempt.
|
||||
cfg = {"local_runtime": {"enabled": True, "tag": "b10412"}}
|
||||
assert bootstrap.ensure_local_runtime(cfg) is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_prune_keeps_n_minus_one(hermes_home):
|
||||
from hermes_cli.local_runtime.binaries import installed_tags, prune_old_tags
|
||||
|
||||
for tag in ("b10100", "b10200", "b10290"):
|
||||
_install_fake_tag(hermes_home, tag)
|
||||
prune_old_tags(["b10290", "b10200"])
|
||||
assert installed_tags() == ["b10290", "b10200"]
|
||||
# downloads/ cache dir must survive pruning when present.
|
||||
downloads = hermes_home / "runtimes" / "llamacpp" / "downloads"
|
||||
downloads.mkdir(exist_ok=True)
|
||||
prune_old_tags(["b10290"])
|
||||
assert downloads.exists()
|
||||
monkeypatch.setattr(supervisor.LlamaServerSupervisor, "start", start)
|
||||
config = {"local_runtime": {"enabled": True, "backend": "cpu", "tag": "b99999"}}
|
||||
assert bootstrap.ensure_local_runtime(config, force=True) is not None
|
||||
assert spawned == [binary]
|
||||
monkeypatch.setattr(bootstrap, "_SUPERVISOR", None)
|
||||
binary.unlink()
|
||||
assert bootstrap.ensure_local_runtime(config, force=True) is None
|
||||
assert spawned == [binary]
|
||||
|
||||
@@ -1,166 +1,23 @@
|
||||
"""Runtime install progress: the slow legs must tick, never hang silently.
|
||||
"""PM download bytes and install phases remain separate, faithful UI facts."""
|
||||
|
||||
The incident: 'Installing runtime…' sat frozen for minutes on a slow
|
||||
line — ensure_runtime_installed downloaded and extracted with no
|
||||
progress stream, so both the quickstart hero and the pane's install row
|
||||
showed a dead bar. Contract: _download and _extract tick per chunk /
|
||||
per member, ensure_runtime_installed forwards a staged stream, and the
|
||||
router's hook translates it into live job fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_cli.local_runtime.binaries as binaries
|
||||
from hermes_cli.web_routers.local_models import _job, _runtime_progress_hook
|
||||
from hermes_cli.web_routers.local_models import _download_progress_hook, _job, _runtime_progress_hook
|
||||
|
||||
|
||||
def _make_zip(path: Path, names_sizes: dict[str, int]) -> None:
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
for name, size in names_sizes.items():
|
||||
z.writestr(name, b"x" * size)
|
||||
|
||||
|
||||
def test_extract_ticks_per_member(tmp_path):
|
||||
archive = tmp_path / "runtime.zip"
|
||||
_make_zip(archive, {"a.bin": 1000, "b.bin": 3000, "c.bin": 500})
|
||||
ticks: list[tuple[int, int]] = []
|
||||
binaries._extract(archive, tmp_path / "out",
|
||||
progress=lambda d, t: ticks.append((d, t)))
|
||||
assert len(ticks) == 3
|
||||
total = 4500
|
||||
assert all(t == total for _, t in ticks)
|
||||
assert [d for d, _ in ticks] == sorted(d for d, _ in ticks)
|
||||
assert ticks[-1][0] == total
|
||||
assert (tmp_path / "out" / "b.bin").stat().st_size == 3000
|
||||
|
||||
|
||||
def test_download_ticks_with_content_length(tmp_path, monkeypatch):
|
||||
payload = b"y" * (3 << 20) # 3 MiB -> several 1 MiB chunks
|
||||
|
||||
class _Resp(io.BytesIO):
|
||||
headers = {"Content-Length": str(len(payload))}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(binaries.urllib.request, "urlopen",
|
||||
lambda url, timeout=0: _Resp(payload))
|
||||
ticks: list[tuple[int, int]] = []
|
||||
dest = tmp_path / "asset.zip"
|
||||
binaries._download("http://x/asset.zip", dest,
|
||||
progress=lambda d, t: ticks.append((d, t)))
|
||||
assert dest.read_bytes() == payload
|
||||
assert len(ticks) >= 3
|
||||
assert ticks[-1] == (len(payload), len(payload))
|
||||
|
||||
|
||||
def test_ensure_runtime_installed_forwards_staged_progress(tmp_path, monkeypatch):
|
||||
"""The full install path emits download -> verify -> extract stages
|
||||
(per asset) and a final verify, all through one callback."""
|
||||
monkeypatch.setattr(binaries, "runtimes_root", lambda: tmp_path)
|
||||
|
||||
class _Plan:
|
||||
assets = ["a.zip", "b.zip"]
|
||||
backend = "cuda"
|
||||
install_dir = tmp_path / "b1" / "cuda"
|
||||
|
||||
monkeypatch.setattr(binaries, "resolve_assets", lambda tag, backend: _Plan())
|
||||
monkeypatch.setattr(binaries, "verify_install", lambda d, t: "ok")
|
||||
|
||||
def _fake_download(url, dest, progress=None):
|
||||
_make_zip(dest, {"f.bin": 2048})
|
||||
if progress is not None:
|
||||
progress(1024, 2048)
|
||||
progress(2048, 2048)
|
||||
|
||||
monkeypatch.setattr(binaries, "_download", _fake_download)
|
||||
|
||||
events: list[tuple[str, str]] = []
|
||||
binaries.ensure_runtime_installed(
|
||||
"b1", "cuda",
|
||||
progress=lambda stage, d, t, label: events.append((stage, label)))
|
||||
|
||||
stages = [s for s, _ in events]
|
||||
assert "download" in stages and "extract" in stages and "verify" in stages
|
||||
# Two assets -> per-asset labels on the slow stages.
|
||||
assert ("download", "1/2") in events and ("download", "2/2") in events
|
||||
assert ("extract", "1/2") in events and ("extract", "2/2") in events
|
||||
# Stage order per asset: download before extract.
|
||||
assert stages.index("download") < stages.index("extract")
|
||||
|
||||
|
||||
def test_progress_hook_translates_stages_to_job_fields():
|
||||
job = _job("quickstart", "Test Model")
|
||||
hook = _runtime_progress_hook(job)
|
||||
|
||||
hook("download", 5 << 20, 100 << 20, "1/2")
|
||||
def test_runtime_stages_preserve_pm_whole_plan_progress():
|
||||
job = _job("runtime-install", "engine")
|
||||
stage = _runtime_progress_hook(job)
|
||||
transfer = _download_progress_hook(job)
|
||||
stage("download", 40, 100, "1/2")
|
||||
transfer(40, 100, {"engine.zip": [(0, 40)], "dlls.zip": []})
|
||||
assert job["phase"] == "downloading-runtime"
|
||||
assert "1/2" in job["detail"]
|
||||
assert job["done_bytes"] == 5 << 20
|
||||
assert job["total_bytes"] == 100 << 20
|
||||
|
||||
# Rapid second tick inside the throttle window is dropped...
|
||||
hook("download", 6 << 20, 100 << 20, "1/2")
|
||||
assert job["done_bytes"] == 5 << 20
|
||||
# ...but a terminal tick (done == total) always lands.
|
||||
hook("download", 100 << 20, 100 << 20, "1/2")
|
||||
assert job["done_bytes"] == 100 << 20
|
||||
|
||||
hook("extract", 10, 100, "")
|
||||
assert job["phase"] in ("downloading-runtime", "unpacking-runtime")
|
||||
|
||||
job2 = _job("runtime-install", "x")
|
||||
hook2 = _runtime_progress_hook(job2)
|
||||
hook2("extract", 100, 100, "")
|
||||
assert job2["phase"] == "unpacking-runtime"
|
||||
hook2("verify", 0, 0, "")
|
||||
assert job2["phase"] == "verifying-runtime"
|
||||
assert job2["total_bytes"] is None # indeterminate bar, not a stuck 0%
|
||||
|
||||
|
||||
def test_progress_hook_accumulates_across_assets(monkeypatch):
|
||||
"""A two-asset engine reads as ONE growing download: the second asset's
|
||||
bytes stack on the first's instead of restarting the bar at zero, and
|
||||
unpack/verify leave the finished download's counters standing."""
|
||||
# Drive the throttle's clock so every tick lands (the real hook drops
|
||||
# sub-250ms non-terminal ticks; this test is about arithmetic, not
|
||||
# pacing — pacing has its own assertions above).
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
clock = {"now": 0.0}
|
||||
|
||||
def fake_monotonic():
|
||||
clock["now"] += 1.0
|
||||
return clock["now"]
|
||||
|
||||
monkeypatch.setattr(lm.time, "monotonic", fake_monotonic)
|
||||
|
||||
job = _job("runtime-install", "engine")
|
||||
hook = _runtime_progress_hook(job)
|
||||
|
||||
hook("download", 40 << 20, 40 << 20, "1/2")
|
||||
assert job["done_bytes"] == 40 << 20
|
||||
assert job["total_bytes"] == 40 << 20
|
||||
|
||||
# Second asset starts: counters continue from the first asset's total.
|
||||
hook("download", 0, 60 << 20, "2/2")
|
||||
assert job["done_bytes"] == 40 << 20
|
||||
assert job["total_bytes"] == 100 << 20
|
||||
|
||||
hook("download", 60 << 20, 60 << 20, "2/2")
|
||||
assert job["done_bytes"] == 100 << 20
|
||||
assert job["total_bytes"] == 100 << 20
|
||||
|
||||
# Unpack and verify narrate without rewinding the finished bar.
|
||||
hook("extract", 1, 100, "2/2")
|
||||
stage("download", 50, 100, "2/2")
|
||||
transfer(50, 100, {"engine.zip": [(0, 40)], "dlls.zip": [(0, 10)]})
|
||||
assert job["done_bytes"] == 50 and job["total_bytes"] == 100
|
||||
transfer(100, 100, {"engine.zip": [(0, 40)], "dlls.zip": [(0, 60)]})
|
||||
stage("unpack", 0, 0, "2/2")
|
||||
assert job["phase"] == "unpacking-runtime"
|
||||
assert job["done_bytes"] == 100 << 20
|
||||
hook("verify", 0, 0, "")
|
||||
assert job["done_bytes"] == job["total_bytes"] == 100
|
||||
stage("verify", 0, 0, "")
|
||||
assert job["phase"] == "verifying-runtime"
|
||||
assert job["done_bytes"] == 100 << 20
|
||||
assert sum(end - start for rows in job["ranges"].values() for start, end in rows) == 100
|
||||
|
||||
109
tests/integration/test_local_runtime_pm_download.py
Normal file
109
tests/integration/test_local_runtime_pm_download.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Opt-in live acceptance: download the actual pinned CUDA engine and run it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import pm
|
||||
from hermes_cli.local_runtime import binaries
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
from pm import paths
|
||||
from pm.lock import Facts, Lockfile
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows", arch="arm64")
|
||||
def test_live_pinned_cuda_download_pause_resume_and_execute(tmp_path, monkeypatch, record_property):
|
||||
home = tmp_path / "home"
|
||||
store = home / "tools"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(paths, "store_root", lambda: store)
|
||||
monkeypatch.setattr(lm, "_JOBS", {})
|
||||
monkeypatch.setattr(lm, "_RUNNING", {})
|
||||
app = FastAPI()
|
||||
app.include_router(lm.router)
|
||||
pinned = Lockfile(paths.lockfile_path()).artifacts("llamacpp-cuda", pm.current_target())
|
||||
flowing = threading.Event()
|
||||
release = threading.Event()
|
||||
snapshots = []
|
||||
stage_hook = lm._runtime_progress_hook
|
||||
byte_hook = lm._download_progress_hook
|
||||
|
||||
def observed_progress(job):
|
||||
original = byte_hook(job)
|
||||
|
||||
def tick(done, total, ranges):
|
||||
original(done, total, ranges)
|
||||
snapshots.append((done, total))
|
||||
return tick
|
||||
|
||||
def observed_stage(job):
|
||||
original = stage_hook(job)
|
||||
first_archive_bytes = 0
|
||||
|
||||
def tick(stage, done, total, label):
|
||||
nonlocal first_archive_bytes
|
||||
original(stage, done, total, label)
|
||||
if stage == "download" and label == "1/2":
|
||||
first_archive_bytes = done
|
||||
if stage == "download" and label == "2/2" and first_archive_bytes < done < total and not flowing.is_set():
|
||||
flowing.set()
|
||||
assert release.wait(30)
|
||||
return tick
|
||||
|
||||
monkeypatch.setattr(lm, "_runtime_progress_hook", observed_stage)
|
||||
monkeypatch.setattr(lm, "_download_progress_hook", observed_progress)
|
||||
|
||||
def wait_job(client, job_id, states, timeout=240):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
row = client.get(f"/api/local-models/jobs/{job_id}").json()
|
||||
if row["status"] in states:
|
||||
return row
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(row)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/local-models/runtime/install", json={"backend": "cuda"})
|
||||
assert response.status_code == 200, response.text
|
||||
job_id = response.json()["job_id"]
|
||||
try:
|
||||
assert flowing.wait(240), client.get(f"/api/local-models/jobs/{job_id}").text
|
||||
assert client.post("/api/local-models/download/pause", json={"job_id": job_id}).json()["paused"]
|
||||
finally:
|
||||
release.set()
|
||||
paused = wait_job(client, job_id, {"paused", "done", "error"})
|
||||
assert paused["status"] == "paused", paused
|
||||
assert binaries.installed_engine("cuda") is None
|
||||
assert client.post("/api/local-models/download/resume", json={"job_id": job_id}).json()["resumed"]
|
||||
completed = wait_job(client, job_id, {"done", "error"})
|
||||
assert completed["status"] == "done", completed
|
||||
|
||||
engine = binaries.installed_engine("cuda", allow_outdated=False)
|
||||
assert engine is not None
|
||||
fact = Facts(paths.facts_path()).get("llamacpp-cuda")
|
||||
assert fact["artifacts"] == [asset["sha256"] for asset in pinned]
|
||||
assert [done for done, _ in snapshots] == sorted(done for done, _ in snapshots)
|
||||
assert completed["done_bytes"] == completed["total_bytes"] > 0
|
||||
receipt = {"target": pm.current_target(), "tag": engine.tag,
|
||||
"download_bytes": completed["done_bytes"], "pause_bytes": paused["done_bytes"],
|
||||
"artifacts": pinned, "binary": str(engine.binary), "commands": {}}
|
||||
for argument in ("--version", "--list-devices"):
|
||||
run = subprocess.run([str(engine.binary), argument], cwd=engine.binary.parent,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
receipt["commands"][argument] = run.stdout + run.stderr
|
||||
dlls = sorted(engine.binary.parent.glob("*.dll"))
|
||||
assert dlls
|
||||
receipt["dlls"] = [dll.name for dll in dlls]
|
||||
evidence = tmp_path / "acceptance.json"
|
||||
evidence.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
|
||||
record_property("acceptance", json.dumps(receipt))
|
||||
print(json.dumps(receipt, indent=2))
|
||||
34
tests/pm/test_download_partial_integrity.py
Normal file
34
tests/pm/test_download_partial_integrity.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""A resume bitmap is usable only while its partial file still contains those bytes."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from pm.downloader import Download, Source, gc_protected_names
|
||||
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
||||
|
||||
|
||||
def test_missing_partial_cannot_turn_a_bitmap_into_completed_model(tmp_path, dl_server):
|
||||
payload = b"real model bytes"
|
||||
RangeHandler.payloads["/model"] = payload
|
||||
source_url = url(dl_server, "/model")
|
||||
partials = tmp_path / "partials"
|
||||
partials.mkdir()
|
||||
key = hashlib.sha256(source_url.encode()).hexdigest()
|
||||
(partials / f"{key}.ranges").write_text(json.dumps([[0, len(payload)]]), encoding="utf-8")
|
||||
dest = tmp_path / "model.gguf"
|
||||
Download([Source(source_url, dest)], partials_dir=partials).run()
|
||||
assert dest.read_bytes() == payload
|
||||
|
||||
|
||||
def test_gc_protects_both_halves_of_live_partial(tmp_path):
|
||||
partials = tmp_path / "partials"
|
||||
partials.mkdir()
|
||||
part = partials / "key.part"
|
||||
side = partials / "key.ranges"
|
||||
part.write_bytes(b"partial")
|
||||
side.write_text("[]", encoding="utf-8")
|
||||
old = time.time() - 100
|
||||
os.utime(side, (old, old))
|
||||
assert {part.name, side.name} <= gc_protected_names(partials, grace_seconds=30)
|
||||
43
tests/pm/test_download_progress.py
Normal file
43
tests/pm/test_download_progress.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Download progress describes actual whole-plan bytes on fresh and resumed runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pm.downloader import Download, Source
|
||||
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
||||
|
||||
|
||||
def test_progress_counts_cached_files_and_distinguishes_equal_basenames(tmp_path, dl_server):
|
||||
bodies = {"/a": b"first", "/b": b"second-file"}
|
||||
RangeHandler.payloads = bodies
|
||||
sources = [Source(url(dl_server, path), tmp_path / path[1:] / "model.bin") for path in bodies]
|
||||
expected = sum(map(len, bodies.values()))
|
||||
for attempt in range(2):
|
||||
ticks = []
|
||||
Download(sources, partials_dir=tmp_path / "partials").run(
|
||||
progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
|
||||
assert ticks, f"attempt {attempt} did not report its completed files"
|
||||
done, total, ranges = ticks[-1]
|
||||
assert done == total == expected
|
||||
assert len(ranges) == len(sources)
|
||||
assert sum(end - start for rows in ranges.values() for start, end in rows) == done
|
||||
|
||||
|
||||
def test_unknown_length_sources_do_not_lose_completed_bytes(tmp_path, dl_server, monkeypatch):
|
||||
RangeHandler.payloads = {"/a": b"first", "/b": b"second-file"}
|
||||
RangeHandler.no_range = True
|
||||
send_header = RangeHandler.send_header
|
||||
|
||||
def without_length(self, name, value):
|
||||
if name.lower() != "content-length":
|
||||
send_header(self, name, value)
|
||||
|
||||
monkeypatch.setattr(RangeHandler, "send_header", without_length)
|
||||
ticks = []
|
||||
sources = [Source(url(dl_server, path), tmp_path / path[1:]) for path in RangeHandler.payloads]
|
||||
Download(sources, partials_dir=tmp_path / "partials").run(
|
||||
progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
|
||||
expected = sum(source.dest.stat().st_size for source in sources)
|
||||
assert ticks[-1][:2] == (expected, expected)
|
||||
assert all(sum(end - start for rows in ranges.values() for start, end in rows) == done
|
||||
for done, _, ranges in ticks)
|
||||
assert [done for done, _, _ in ticks] == sorted(done for done, _, _ in ticks)
|
||||
@@ -60,8 +60,8 @@ def test_progress_carries_overall_and_ranges(dl_server, tmp_path):
|
||||
final_d, final_t, final_r = seen[-1]
|
||||
assert final_d == 3000
|
||||
assert final_t == 3000
|
||||
assert final_r["a.bin"] == [(0, 1000)]
|
||||
assert final_r["b.bin"] == [(0, 2000)]
|
||||
assert final_r[str(d1)] == [(0, 1000)]
|
||||
assert final_r[str(d2)] == [(0, 2000)]
|
||||
|
||||
|
||||
# ── optional hash ─────────────────────────────────────────────
|
||||
@@ -260,7 +260,7 @@ def test_no_range_fallback_downloads_full_body(dl_server, tmp_path):
|
||||
assert _Handler.ranges_seen == [] # never used Range
|
||||
d, t, r = seen[-1]
|
||||
assert d == t == len(payload)
|
||||
assert r["nr.bin"] == [(0, len(payload))]
|
||||
assert r[str(dest)] == [(0, len(payload))]
|
||||
|
||||
|
||||
def test_no_range_short_body_raises_and_leaves_no_dest(dl_server, tmp_path):
|
||||
@@ -343,7 +343,7 @@ def test_resume_first_progress_shows_durable_prefix(dl_server, tmp_path):
|
||||
d0, t0, r0 = seen[0]
|
||||
assert d0 > 0 # overall_done starts above zero (durable prefix + new)
|
||||
assert t0 == total
|
||||
ranges = r0["rp.bin"]
|
||||
ranges = r0[str(dest)]
|
||||
assert len(ranges) == 1 # coalesced
|
||||
assert ranges[0][0] == 0 # the durable prefix is a covered range
|
||||
assert ranges[0][1] > 0
|
||||
|
||||
104
tests/pm/test_install_download_control.py
Normal file
104
tests/pm/test_install_download_control.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Install controls reach real archive transfers without publishing partial packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import threading
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
import pm
|
||||
from pm import paths, registry
|
||||
from pm.downloader import DownloadPaused
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.package import Package
|
||||
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
||||
|
||||
|
||||
class ComponentPackage(Package):
|
||||
name = "download-components"
|
||||
|
||||
|
||||
def archive(files: dict[str, bytes]) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w") as stream:
|
||||
for name, body in files.items():
|
||||
stream.writestr(name, body)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server):
|
||||
root = tmp_path / "store"
|
||||
lock_path = tmp_path / "lock.json"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setattr(paths, "store_root", lambda: root)
|
||||
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
|
||||
monkeypatch.setitem(registry._packages, ComponentPackage.name, ComponentPackage())
|
||||
contents = {"engine.dat": b"engine component", "runtime.dll": bytes(range(256)) * (64 * 1024)}
|
||||
payloads = [archive({name: body}) for name, body in contents.items()]
|
||||
pins = []
|
||||
for index, payload in enumerate(payloads):
|
||||
path = f"/component-{index}.zip"
|
||||
RangeHandler.payloads[path] = payload
|
||||
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
|
||||
lock = Lockfile(lock_path)
|
||||
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
|
||||
lock.save()
|
||||
pause = threading.Event()
|
||||
|
||||
def progress(stage, done, total, label):
|
||||
if stage == "download" and label == "2/2" and len(payloads[0]) < done < total:
|
||||
pause.set()
|
||||
|
||||
with pytest.raises(DownloadPaused):
|
||||
pm.ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause)
|
||||
assert Facts(paths.facts_path()).get(ComponentPackage.name) is None
|
||||
assert list(paths.partials_root().glob("*.ranges"))
|
||||
first_requests = [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"]
|
||||
assert first_requests
|
||||
assert (root / f"fetch-{pins[0]['sha256']}").is_dir()
|
||||
|
||||
pause.clear()
|
||||
pm.ensure(ComponentPackage.name, explicit=True, pause_event=pause)
|
||||
fact = Facts(paths.facts_path()).get(ComponentPackage.name)
|
||||
assert fact["artifacts"] == [pin["sha256"] for pin in pins]
|
||||
for name, body in contents.items():
|
||||
assert (root / fact["entry"] / name).read_bytes() == body
|
||||
assert [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] == first_requests
|
||||
assert not list(paths.partials_root().glob("*.part"))
|
||||
|
||||
|
||||
def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server):
|
||||
root = tmp_path / "store"
|
||||
lock_path = tmp_path / "lock.json"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setattr(paths, "store_root", lambda: root)
|
||||
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
|
||||
monkeypatch.setitem(registry._packages, ComponentPackage.name, ComponentPackage())
|
||||
payloads = [archive({"engine.dat": b"engine"}), archive({"runtime.dll": b"runtime"})]
|
||||
pins = []
|
||||
for index, payload in enumerate(payloads):
|
||||
path = f"/component-{index}.zip"
|
||||
RangeHandler.payloads[path] = payload
|
||||
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
|
||||
lock = Lockfile(lock_path)
|
||||
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
|
||||
lock.save()
|
||||
store = pm.Store(root)
|
||||
with store.scratch() as scratch:
|
||||
store.fetch(pins[0]["url"], pins[0]["sha256"], scratch)
|
||||
ticks = []
|
||||
stages = []
|
||||
pm.ensure(ComponentPackage.name, explicit=True,
|
||||
progress=lambda *args: stages.append(args),
|
||||
download_progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
|
||||
expected = sum(map(len, payloads))
|
||||
assert ticks and all(total == expected for _, total, _ in ticks)
|
||||
assert ticks[0][0] == len(payloads[0])
|
||||
assert ticks[-1][0] == expected
|
||||
assert all(sum(end - start for rows in ranges.values() for start, end in rows) == done
|
||||
for done, _, ranges in ticks)
|
||||
assert [done for done, _, _ in ticks] == sorted(done for done, _, _ in ticks)
|
||||
assert {stage for stage, *_ in stages} >= {"download", "unpack", "verify"}
|
||||
30
tests/pm/test_installed_package.py
Normal file
30
tests/pm/test_installed_package.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Installed runtime lookup uses the same shipped/addition precedence as ensure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pm
|
||||
from pm import paths
|
||||
from pm.lock import Lockfile
|
||||
from tests.pm.test_pm_authority import pm_env, served # noqa: F401
|
||||
|
||||
|
||||
def test_installed_lookup_prefers_current_pin_then_recorded_fallback(pm_env, tmp_path, monkeypatch):
|
||||
pm.ensure("faketool", explicit=True)
|
||||
original = pm.installed_package("faketool")
|
||||
assert original is not None and original.binary.is_file()
|
||||
|
||||
lock = Lockfile(pm_env["lockfile_path"])
|
||||
lock.set_pin("faketool", "2.0", {"any": {
|
||||
"url": pm_env["base_url"] + "/faketool-2.0.tar.gz", "sha256": "b" * 64,
|
||||
}})
|
||||
lock.save()
|
||||
assert pm.installed_package("faketool") is None
|
||||
assert pm.installed_package("faketool", allow_outdated=True) == original
|
||||
|
||||
shipped = tmp_path / "sealed" / "tools"
|
||||
shipped.mkdir(parents=True)
|
||||
monkeypatch.setattr(paths, "store_root", lambda: shipped)
|
||||
monkeypatch.setattr(paths, "writable_store_root", lambda: pm_env["runtime"])
|
||||
assert pm.installed_package("faketool", allow_outdated=True) == original
|
||||
original.binary.unlink()
|
||||
assert pm.installed_package("faketool", allow_outdated=True) is None
|
||||
26
tests/pm/test_llamacpp_pins.py
Normal file
26
tests/pm/test_llamacpp_pins.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Every supported local engine backend installs only reviewed PM artifacts."""
|
||||
|
||||
import pm
|
||||
from pm import paths
|
||||
from pm.lock import Lockfile
|
||||
from pm.store import ALL_TARGETS
|
||||
|
||||
|
||||
def test_llamacpp_backends_have_pins_for_their_supported_targets():
|
||||
lock = Lockfile(paths.lockfile_path())
|
||||
for backend in ("cpu", "cuda", "vulkan", "metal", "hip"):
|
||||
name = f"llamacpp-{backend}"
|
||||
package = pm.get_package(name)
|
||||
version = lock.version(name)
|
||||
assert version
|
||||
for target in ALL_TARGETS:
|
||||
artifacts = lock.artifacts(name, target)
|
||||
if package.missing_reason(target):
|
||||
assert not artifacts
|
||||
continue
|
||||
assert artifacts
|
||||
assert [a["url"] for a in artifacts] == package.fetch_urls(version, target)
|
||||
assert all(len(bytes.fromhex(a["sha256"])) == 32 for a in artifacts)
|
||||
if backend == "cuda":
|
||||
assert len(artifacts) == 2
|
||||
assert any("cudart-" in a["url"] for a in artifacts)
|
||||
@@ -886,6 +886,9 @@ def test_store_path_dirs_include_node_npm_when_installed(tmp_path, monkeypatch):
|
||||
entry = f"{name}-1"
|
||||
entry_dir = store / entry
|
||||
entry_dir.mkdir(parents=True, exist_ok=True)
|
||||
binary = registry.get_package(name).binary(entry_dir, current_target())
|
||||
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||
binary.write_bytes(b"installed tool")
|
||||
facts.record(
|
||||
name, "1", entry,
|
||||
{"PATH": ["{store}/" + entry]}, store_root=store,
|
||||
|
||||
@@ -21,9 +21,20 @@ builds require the `--local` launch flag. A runtime can already be bundled;
|
||||
its absence triggers the managed-tool install path, not an arbitrary latest
|
||||
llama.cpp download.
|
||||
|
||||
Model downloads support pause and resume. Partial downloads use PM's writable
|
||||
`cache/partials` area, outside a signed app package. Model weights and managed
|
||||
engine binaries are separate downloads.
|
||||
Use **Pause** and **Resume** on engine installs, engine updates, catalog models,
|
||||
Hugging Face downloads, and quickstart. Paused jobs remain visible when you
|
||||
leave and reopen Local Models. Pause stops the transfer at a chunk boundary;
|
||||
unpacking, verification, and server activation are separate phases.
|
||||
|
||||
PM downloads every engine component, including CUDA runtime DLLs, from the
|
||||
URLs and SHA-256 pins in `pm/lock.json`. Model weights, split GGUF parts,
|
||||
vision projectors, and draft models use the same downloader. Byte progress
|
||||
covers the whole download plan, including completed files and resumed ranges.
|
||||
Quickstart labels engine and model progress as separate stages.
|
||||
|
||||
Partial downloads use PM's writable `cache/partials` area, outside a signed
|
||||
app package. Range-capable hosts resume missing bytes. Hosts without Range
|
||||
support restart the current file. Completed files are reused.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -123,23 +134,27 @@ local_runtime:
|
||||
enabled: false # true = start the managed server with Hermes.
|
||||
# The desktop "Use" button sets this automatically.
|
||||
backend: auto # auto | cuda | metal | vulkan | hip | cpu
|
||||
tag: b10362 # pinned llama.cpp release; Hermes updates it with
|
||||
# each release after re-validation
|
||||
```
|
||||
|
||||
Models and runtime builds live under the Hermes home directory
|
||||
(`models/` and `runtimes/llamacpp/`). Selecting a local model as your
|
||||
main model uses the standard `model.provider: llamacpp` +
|
||||
`model.default` settings — the same shape as every other provider.
|
||||
Engine versions come only from PM's lockfile, not a `local_runtime.tag`
|
||||
override. Boot uses an installed PM engine without downloading. When a new
|
||||
pin is available, install it with the desktop update button.
|
||||
|
||||
Models live in the machine-shared `models/` directory. Engine binaries live
|
||||
in PM's store; `runtimes/llamacpp/` holds mutable presets and server state.
|
||||
Selecting a local model as your main model uses the standard
|
||||
`model.provider: llamacpp` + `model.default` settings.
|
||||
|
||||
## Requirements and limits
|
||||
|
||||
- **Windows and Linux:** NVIDIA GPU (CUDA) or CPU. **macOS:** Apple
|
||||
Silicon (Metal). Vulkan builds serve AMD GPUs.
|
||||
- **Windows:** CUDA on supported NVIDIA targets, Vulkan on x64, or CPU.
|
||||
**Linux:** Vulkan or CPU; the pinned release has no prebuilt CUDA archive.
|
||||
**macOS:** Metal or CPU. HIP/ROCm is an explicit choice on supported x64
|
||||
targets. Unsupported backend/target pairs fail before any download.
|
||||
- A GPU with 8 GB+ of memory runs the small catalog models comfortably;
|
||||
16 GB+ runs the 27–35B models at high quality.
|
||||
- Model downloads are byte-size checked against the catalog during the
|
||||
transfer; an incomplete download is deleted and reported, never
|
||||
half-used. (Only the runtime engine zips are SHA-256 verified.)
|
||||
- Model completeness is checked against the server's response, not catalog
|
||||
size estimates. Interrupted transfers retain partials for resume; incomplete
|
||||
files are not published. Engine archives are SHA-256 verified before use.
|
||||
- Deleting a model removes every file it staged, including vision
|
||||
adapters and speculative-decoding companions.
|
||||
|
||||
Reference in New Issue
Block a user