mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* feat(acp): a managed runtime installs only when asked, with progress and cancel One installation per runtime owns every change to its releases: it reclaims old ones once, first, then installs one at a time. An install checks free space, downloads with byte progress, extracts, hashes each member (abortable), writes the receipt, then publishes by rename, the commit point: a cancel after it reports success. Reinstall rehashes the pinned release without downloading. A status read waits, bounded, for a newer revision and rereads the installed release from disk. * fix(runtime): Install repairs a damaged copy, says when unzip is missing before downloading, and reads a failed download plainly - Install over a damaged pinned copy downloads and verifies a fresh one, then swaps the damaged folder into staging, so there is no dead end; status reports the damaged fact. - A host that cannot unpack the archive (no unzip) reports it in status and refuses Install without downloading. - A network failure while downloading reads "Couldn't download …"; the raw error goes to the log. - Reclamation skips the whole pass when a release folder could not be read. * fix(runtime): a status read that can't read the installed files fails instead of saying not installed, and extraction uses the extractor the preflight found * fix(runtime): a host that can't report free disk space still installs, and says so in the log * fix(runtime): a download that can't be fixed by retrying says so instead of blaming the network * fix(runtime): Install unpacks the release itself, checks it starts before publishing, and Cancel answers with the outcome - The managed install unzips in-process (fflate, bundled into main), so a host without unzip can install; the unzip preflight and the missingProgram status field are gone. The Node runtime download keeps its own extractor. - A runtime may provide validate(): Install starts the downloaded copy once before publishing it, and an existing copy before reusing it; a failure reads "<agent> couldn't start on this host". - No 'cancelling' phase: Cancel resolves once the work has unwound and returns cancelled, or succeeded when it had already committed. - Byte progress every 250 ms; status rereads disk and PATH only on a first read or a phase change. - The declared download size is checked only for an uncompressed body. - An existing copy whose files hash correctly but whose receipt is missing gets its receipt rewritten instead of reading as damaged forever. * refactor(runtime): move the steps that put one release on disk out of the installer The installer class now holds only its lifecycle: the shared operation state, the one-at-a-time queue, install, cancel, status, reclaim and progress publishing. Reusing a whole copy, the free space check, the download, unpacking, checking each file, the start check, the receipt, the publishing rename and the failure sentences move unchanged into managed-runtime-release-install.ts, which gets the pin, folder, label, signal and two callbacks (progress, and the commit point right after the rename) instead of the class. No behavior change; this keeps the installer well under the file-length limit once Remove lands in it. * chore(deps): lock fflate for the in-process runtime unzip * fix(runtime): a failed download blames the release only for a proven mismatch, so a refused proxy reads as a network problem * fix(runtime): runtime downloads skip the HTTP cache, so Remove frees the space and nothing is stored twice
136 lines
5.6 KiB
TypeScript
136 lines
5.6 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { strToU8, zipSync } from 'fflate'
|
|
import { afterEach, expect, it, vi } from 'vitest'
|
|
import type * as ReleaseModule from './managed-runtime-release'
|
|
import { reclaimRuntimeReleases, RUNTIME_RELEASE_RECEIPT } from './managed-runtime-release'
|
|
import { ManagedRuntimeInstallation } from './managed-runtime-installation'
|
|
|
|
vi.mock('./managed-runtime-release', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof ReleaseModule>()
|
|
return { ...actual, reclaimRuntimeReleases: vi.fn(actual.reclaimRuntimeReleases) }
|
|
})
|
|
|
|
const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
|
const ARCHIVE = zipSync({ server: strToU8('server bytes') })
|
|
const roots: string[] = []
|
|
afterEach(() => Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true }))))
|
|
|
|
async function rig(bodies: ('hang' | 'whole')[]) {
|
|
const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-runtime-install-'))
|
|
roots.push(dataDirectory)
|
|
const root = join(dataDirectory, 'agent-runtimes', 'agent')
|
|
const pin = {
|
|
version: '1.0',
|
|
url: 'https://example.test/agent-1.0.zip',
|
|
archiveSha256: sha256(ARCHIVE),
|
|
archiveBytes: ARCHIVE.length,
|
|
members: [{ path: 'server', sha256: sha256('server bytes'), bytes: 12 }]
|
|
}
|
|
const runtime = { label: 'Agent', root: () => root, pin: () => pin }
|
|
const fetcher = async () => {
|
|
const body = bodies.shift()
|
|
return new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(ARCHIVE.slice(0, 5))
|
|
if (body === 'whole') {
|
|
controller.enqueue(ARCHIVE.slice(5))
|
|
controller.close()
|
|
}
|
|
}
|
|
})
|
|
)
|
|
}
|
|
const host = () =>
|
|
new ManagedRuntimeInstallation('agent', runtime, {
|
|
dataDirectory,
|
|
findExternal: async () => false,
|
|
fetcher
|
|
})
|
|
const phase = async (installation: ManagedRuntimeInstallation) =>
|
|
(await installation.status()).operation?.phase
|
|
return { root, host, phase }
|
|
}
|
|
|
|
it('installs only when asked, with progress, cancel, retry, and a status read from disk', async () => {
|
|
const { root, host, phase } = await rig(['hang', 'whole'])
|
|
const installation = host()
|
|
expect(await installation.status()).toMatchObject({ installedVersion: null, source: null })
|
|
const started = installation.install()
|
|
await vi.waitFor(async () =>
|
|
expect((await installation.status()).operation?.downloadedBytes).toBe(5)
|
|
)
|
|
expect(installation.install().id).toBe(started.id)
|
|
expect(await installation.cancel(started.id)).toMatchObject({ phase: 'cancelled' })
|
|
expect(await readdir(root)).toEqual([])
|
|
|
|
expect(installation.install().id).not.toBe(started.id)
|
|
await vi.waitFor(async () => expect(await phase(installation)).toBe('succeeded'))
|
|
expect(await host().status()).toMatchObject({
|
|
installedVersion: '1.0',
|
|
installedCurrent: true,
|
|
source: 'managed',
|
|
operation: null
|
|
})
|
|
expect(await readdir(root)).toEqual(['1.0'])
|
|
|
|
// Whole files without a receipt looped as damaged; Install now rewrites it, with no download.
|
|
await rm(join(root, '1.0', RUNTIME_RELEASE_RECEIPT))
|
|
expect(await installation.status()).toMatchObject({ damaged: true })
|
|
const rewrite = installation.install()
|
|
await vi.waitFor(async () =>
|
|
expect((await installation.status()).operation).toMatchObject({
|
|
id: rewrite.id,
|
|
phase: 'succeeded'
|
|
})
|
|
)
|
|
expect(await installation.status()).toMatchObject({ installedCurrent: true })
|
|
})
|
|
|
|
it('publishes an update only once the old releases were reclaimed', async () => {
|
|
const { root, host, phase } = await rig(['whole'])
|
|
for (const version of ['0.8', '0.9']) {
|
|
await mkdir(join(root, version), { recursive: true })
|
|
await writeFile(join(root, version, 'server'), 'old server', { mode: 0o755 })
|
|
const members = [{ path: 'server', bytes: 10 }]
|
|
const receipt = { releaseId: sha256(version), version, members }
|
|
await writeFile(
|
|
join(root, version, RUNTIME_RELEASE_RECEIPT),
|
|
JSON.stringify({ ...receipt, platform: process.platform, arch: process.arch })
|
|
)
|
|
}
|
|
let resume = (): void => {}
|
|
const reclaim = vi.mocked(reclaimRuntimeReleases)
|
|
const paused = new Promise<void>((resolve) => (resume = resolve))
|
|
const { reclaimRuntimeReleases: reclaimNow } = await vi.importActual<typeof ReleaseModule>(
|
|
'./managed-runtime-release'
|
|
)
|
|
reclaim.mockImplementationOnce(async (...args) => paused.then(() => reclaimNow(...args)))
|
|
const installation = host()
|
|
installation.install()
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
expect((await installation.status()).operation).toMatchObject({ downloadedBytes: 0 })
|
|
resume()
|
|
await vi.waitFor(async () => expect(await phase(installation)).toBe('succeeded'))
|
|
expect((await readdir(root)).sort()).toEqual(['0.9', '1.0'])
|
|
})
|
|
|
|
it('repairs a damaged copy when Install is clicked again', async () => {
|
|
const { root, host, phase } = await rig(['whole', 'whole'])
|
|
const installation = host()
|
|
installation.install()
|
|
await vi.waitFor(async () => expect(await phase(installation)).toBe('succeeded'))
|
|
await writeFile(join(root, '1.0', 'server'), 'cut')
|
|
expect(await installation.status()).toMatchObject({ installedVersion: null, damaged: true })
|
|
|
|
installation.install()
|
|
await vi.waitFor(async () => expect(await phase(installation)).toBe('succeeded'))
|
|
const repaired = await installation.status()
|
|
expect(repaired).toMatchObject({ installedVersion: '1.0', installedCurrent: true })
|
|
expect(repaired.damaged).toBeUndefined()
|
|
expect(await readdir(root)).toEqual(['1.0'])
|
|
})
|