Files
orca/src/main/node-runtime/managed-runtime-installation.test.ts
T
Brennan Benson 99788a91fa feat(acp): a managed runtime installs only when asked, with progress and cancel (#29023)
* 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
2026-10-16 17:38:02 -07:00

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'])
})