Loading files
59e2a9d01This file is pinned to the selected release. Download the original to open it in your design tool.
import { prepareHarnessLaunch } from './runtime.js'
import { updateDsh } from './update.js'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { execFileSync } from 'node:child_process'
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { env } from '../config/env.js'
import { dshInstallDir, installedDsh, invalidateInstalledDsh, readInstalledIndex, upsertInstalledRecord } from './installed.js'
import { installDsh, removeDsh, type DshInstallProgress } from './install.js'
import { materializeWorkspace } from './materialize.js'
import { dshUpdateInfo, samePackageSource } from './updates.js'
import { type DshRegistryEntry } from './registry.js'
import { dshListRows } from './wire.js'
const faults = vi.hoisted(() => ({ cleanup: false, place: false }))
vi.mock('node:fs', async original => {
const fs = await original<typeof import('node:fs')>()
return { ...fs,
renameSync: (...args: Parameters<typeof fs.renameSync>) => {
if (faults.place && String(args[0]).includes('/.tmp-') && String(args[1]).endsWith('/acme/thing')) throw new Error('disk full while placing update')
return fs.renameSync(...args)
},
rmSync: (...args: Parameters<typeof fs.rmSync>) => {
if (faults.cleanup && String(args[0]).includes('/.previous-')) throw new Error('backup is busy')
return fs.rmSync(...args)
},
}
})
describe('package updates', { timeout: 30_000 }, () => {
let root: string
let savedRoot: string
const id = 'acme/thing'
const manifest = { spec: 1, id, name: 'Thing', engine: 'claude',
agent: { instructions: 'AGENTS.md', skills: ['skills'] },
workspace: { template: 'template', marker: 'project.txt' },
toolchain: { setup: './setup.sh', doctor: './doctor.sh' } }
const git = (repo: string, ...args: string[]): string => execFileSync('git', ['-C', repo, ...args], {
encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@example.test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@example.test' },
}).trim()
const write = (dir: string, files: Record<string, string>): void => {
for (const [name, body] of Object.entries(files)) {
mkdirSync(join(dir, name, '..'), { recursive: true })
writeFileSync(join(dir, name), body, { mode: name.endsWith('.sh') ? 0o755 : 0o644 })
}
}
const commit = (repo: string): string => { git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'package version'); return git(repo, 'rev-parse', 'HEAD') }
const create = (path?: string): string => {
const repo = join(root, 'repo')
const dir = path ? join(repo, path) : repo
write(dir, {
'harness.json': JSON.stringify(manifest), 'AGENTS.md': '# Original instructions\n',
'skills/task/SKILL.md': '# Original skill\n', 'template/project.txt': 'template\n',
'setup.sh': '#!/bin/sh\npwd > setup-path\n', 'doctor.sh': '#!/bin/sh\ntest -f setup-path\n',
})
git(repo, 'init', '-q', '-b', 'main'); commit(repo)
return repo
}
const catalog = (repo: string, ref: string, path?: string): DshRegistryEntry => ({
id, name: 'Thing', engine: 'claude', repo, ref, path,
revision: git(repo, 'rev-parse', path ? `${ref}:${path}` : `${ref}^{tree}`),
})
const update = (entry?: DshRegistryEntry, progress?: DshInstallProgress[]) => updateDsh({ id,
registry: candidate => candidate === id ? entry : undefined, onProgress: p => progress?.push(p) })
beforeEach(() => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-update-')))
const shell = join(root, 'test-shell')
writeFileSync(shell, '#!/bin/sh\nexec /bin/sh -c "$2"\n', { mode: 0o755 })
vi.stubEnv('SHELL', shell)
savedRoot = env.DSH_DIR
env.DSH_DIR = join(root, 'installed')
invalidateInstalledDsh()
})
afterEach(() => {
faults.cleanup = false
faults.place = false
vi.unstubAllEnvs()
env.DSH_DIR = savedRoot
invalidateInstalledDsh()
rmSync(root, { recursive: true, force: true })
})
it('refreshes the default catalog and treats the same recorded commit as a no-op for legacy installs', async () => {
const repo = create()
await installDsh({ source: repo })
const [record] = readInstalledIndex()
delete record.revision
upsertInstalledRecord(record)
const lines: string[] = []
expect((await updateDsh({ id, onLine: line => lines.push(line) })).ok).toBe(true)
expect(lines).toContain(`${id} is already up to date`)
})
it('repairs a missing installed directory and a record with unknown commit/ref', async () => {
const repo = create()
await installDsh({ source: repo })
const [record] = readInstalledIndex()
upsertInstalledRecord({ ...record, commit: null, revision: null })
rmSync(record.dir, { recursive: true })
expect((await update()).ok).toBe(true)
expect(installedDsh(id)?.commit).toBe(git(repo, 'rev-parse', 'HEAD'))
})
it('rejects a kind change, but updates viewer packages independently', async () => {
const repo = create()
await installDsh({ source: repo })
const viewer = { spec: 1, id, kind: 'viewer', name: 'Viewer', viewer: { command: './viewer.sh', url: 'http://127.0.0.1:${port}/' } }
write(repo, { 'harness.json': JSON.stringify(viewer) })
expect(await update(catalog(repo, commit(repo)))).toMatchObject({ ok: false, error: 'PACKAGE_KIND_MISMATCH' })
removeDsh(id)
await installDsh({ source: repo })
write(repo, { 'harness.json': JSON.stringify({ ...viewer, name: 'Updated viewer' }) })
expect((await update(catalog(repo, commit(repo)))).ok).toBe(true)
expect(installedDsh(id)?.manifest.name).toBe('Updated viewer')
})
it('refuses a symlink even if its installed record incorrectly calls it a clone', async () => {
const repo = create()
await installDsh({ source: repo, link: true })
const [record] = readInstalledIndex()
upsertInstalledRecord({ ...record, linked: false })
write(repo, { 'AGENTS.md': '# Changed checkout' })
expect(await update(catalog(repo, commit(repo)))).toMatchObject({ ok: false, error: 'LINKED_INSTALL' })
expect(lstatSync(record.dir).isSymbolicLink()).toBe(true)
})
it('restores the old package if moving the new files fails and retains a busy backup after success', async () => {
const repo = create()
await installDsh({ source: repo })
const before = readInstalledIndex()
write(repo, { 'AGENTS.md': '# Updated' })
const entry = catalog(repo, commit(repo))
faults.place = true
expect(await update(entry)).toMatchObject({ ok: false, error: 'UPDATE_FAILED', detail: 'disk full while placing update' })
expect(readInstalledIndex()).toEqual(before)
expect(readFileSync(join(dshInstallDir(id), 'AGENTS.md'), 'utf8')).toBe('# Original instructions\n')
faults.place = false
faults.cleanup = true
const lines: string[] = []
expect((await updateDsh({ id, registry: () => entry, onLine: line => lines.push(line) })).ok).toBe(true)
expect(lines.some(line => line.startsWith('Previous package retained at'))).toBe(true)
expect(installedDsh(id)?.commit).toBe(entry.ref)
})
it('reports non-Error failures and releases its lock', async () => {
const repo = create()
await installDsh({ source: repo })
const result = await updateDsh({ id, registry: () => { throw 'connection closed' } })
expect(result).toMatchObject({ ok: false, error: 'UPDATE_FAILED', detail: 'connection closed' })
expect((await update()).ok).toBe(true)
})
for (const path of [undefined, 'store/agents/thing']) {
it(`updates ${path ?? 'a whole repo'} at the catalog commit, preserving workspace files and skill links`, async () => {
const repo = create(path)
expect((await installDsh({ source: repo, ref: 'main', path })).ok).toBe(true)
const before = installedDsh(id)!
const workspace = join(root, 'workspace')
mkdirSync(workspace)
await materializeWorkspace(before, workspace)
const launch = prepareHarnessLaunch(before, workspace, 'claude', 'session')
write(workspace, { 'project.txt': 'my finished work\n', 'AGENTS.md': 'my custom instructions\n', '.harness/verdict.json': '{"ready":true}', 'notes.md': 'my notes' })
const skill = join(launch.env.HARNESS_SKILLS_DIR!, 'task')
const link = readlinkSync(skill)
const dir = path ? join(repo, path) : repo
write(dir, { 'skills/task/SKILL.md': '# Updated skill\n', 'AGENTS.md': '# New instructions\n', 'template/project.txt': 'new template\n' })
const ref = commit(repo)
const entry = catalog(repo, ref, path)
expect(dshListRows([before], [entry])[0]).toMatchObject({ installedCommit: before.commit, availableCommit: ref, updateAvailable: true })
write(dir, { 'skills/task/SKILL.md': '# Unpublished skill\n' }); commit(repo)
const phases: DshInstallProgress[] = []
const result = await update(entry, phases)
expect(result.ok, JSON.stringify(result)).toBe(true)
const after = installedDsh(id)!
expect(after).toMatchObject({ commit: ref, revision: entry.revision, dir: before.dir, installedAt: before.installedAt, updatedAt: expect.any(Number) })
expect(readFileSync(join(after.dir, 'setup-path'), 'utf8').trim()).toBe(after.dir)
expect(readFileSync(join(workspace, 'project.txt'), 'utf8')).toBe('my finished work\n')
expect(readFileSync(join(workspace, 'AGENTS.md'), 'utf8')).toBe('my custom instructions\n')
expect(readFileSync(join(workspace, '.harness/verdict.json'), 'utf8')).toBe('{"ready":true}')
expect(readFileSync(join(workspace, 'notes.md'), 'utf8')).toBe('my notes')
expect(readlinkSync(skill)).toBe(link)
expect(readFileSync(join(skill, 'SKILL.md'), 'utf8')).toBe('# Updated skill\n')
expect(dshUpdateInfo(after, entry).updateAvailable).toBe(false)
expect(phases.map(p => p.phase)).toEqual(['clone', 'setup', 'doctor', 'done'])
expect(readdirSync(env.DSH_DIR).filter(name => name.startsWith('.'))).toEqual([])
})
}
for (const failure of ['setup', 'doctor']) {
it(`restores the complete old package and index when ${failure} fails, then supports retry`, async () => {
const repo = create()
await installDsh({ source: repo, ref: 'main' })
const before = readInstalledIndex()
write(dshInstallDir(id), { 'local-cache': 'keep me' })
write(repo, { [`${failure}.sh`]: '#!/bin/sh\necho "miss broken update"\nexit 1\n', 'AGENTS.md': '# Bad version' })
const entry = catalog(repo, commit(repo))
expect(await update(entry)).toMatchObject({ ok: false, error: `${failure.toUpperCase()}_FAILED` })
expect(readInstalledIndex()).toEqual(before)
expect(readFileSync(join(dshInstallDir(id), 'AGENTS.md'), 'utf8')).toBe('# Original instructions\n')
expect(readFileSync(join(dshInstallDir(id), 'local-cache'), 'utf8')).toBe('keep me')
write(repo, { [`${failure}.sh`]: '#!/bin/sh\npwd > setup-path\necho "ok fixed"\n' })
expect((await update(catalog(repo, commit(repo)))).ok).toBe(true)
})
}
it('uses the recorded source/ref for an unlisted install and does not redirect a fork with the same id', async () => {
const repo = create()
await installDsh({ source: repo, ref: 'main' })
write(repo, { 'AGENTS.md': '# New version\n' })
const ref = commit(repo)
const unrelated = { ...catalog(repo, ref), repo: join(root, 'different-source') }
expect(dshUpdateInfo(installedDsh(id)!, unrelated).updateAvailable).toBe(false)
expect((await update(unrelated)).ok).toBe(true)
expect(installedDsh(id)?.commit).toBe(ref)
})
it('offers the official repository rename as an update without treating other sources or folders as aliases', async () => {
const repo = create('store/agents/thing')
await installDsh({ source: repo, path: 'store/agents/thing' })
const record = { ...installedDsh(id)!, source: 'https://github.com/autonomous-ai/autonomous-harness.git/', revision: 'a'.repeat(40) }
const entry = { ...catalog(repo, record.commit!, record.path ?? undefined), repo: 'https://github.com/autonomous-ai/openharness', ref: 'b'.repeat(40), revision: 'c'.repeat(40) }
expect(dshUpdateInfo(record, entry)).toMatchObject({ updateAvailable: true, availableCommit: entry.ref })
expect(samePackageSource({ ...record, source: entry.repo }, { ...entry, repo: record.source })).toBe(true)
for (const source of [
'https://github.com/someone/autonomous-harness',
'https://github.com/someone/openharness',
'https://example.test/autonomous-ai/autonomous-harness',
'https://github.com/autonomous-ai/autonomous-harness-extra',
]) expect(dshUpdateInfo({ ...record, source }, entry).updateAvailable).toBe(false)
expect(dshUpdateInfo(record, { ...entry, path: 'store/agents/other' }).updateAvailable).toBe(false)
expect(dshUpdateInfo({ ...record, linked: true }, entry).updateAvailable).toBe(false)
})
it('updates a legacy official install from the canonical catalog source and preserves its workspace', async () => {
const path = 'store/agents/thing'
const repo = create(path)
await installDsh({ source: repo, path, ref: 'main' })
const before = installedDsh(id)!
const workspace = join(root, 'my-project')
mkdirSync(workspace)
await materializeWorkspace(before, workspace)
write(workspace, { 'project.txt': 'my authored work\n' })
const legacy = 'https://github.com/autonomous-ai/autonomous-harness'
const canonical = 'https://github.com/autonomous-ai/openharness'
upsertInstalledRecord({ ...before, source: legacy, ref: 'viewer-packages' })
write(join(repo, path), { 'AGENTS.md': '# Current featured experience\n' })
const entry = { ...catalog(repo, commit(repo), path), repo: canonical }
// Exercise real clone/setup/doctor without the network. Using the legacy URL would fail.
vi.stubEnv('GIT_CONFIG_COUNT', '2')
vi.stubEnv('GIT_CONFIG_KEY_0', `url.${repo}.insteadOf`)
vi.stubEnv('GIT_CONFIG_VALUE_0', canonical)
vi.stubEnv('GIT_CONFIG_KEY_1', `url.${join(root, 'retired-repo')}.insteadOf`)
vi.stubEnv('GIT_CONFIG_VALUE_1', legacy)
const result = await update(entry)
expect(result.ok, JSON.stringify(result)).toBe(true)
expect(installedDsh(id)).toMatchObject({ source: canonical, ref: entry.ref, commit: entry.ref, revision: entry.revision })
expect(readFileSync(join(before.dir, 'AGENTS.md'), 'utf8')).toBe('# Current featured experience\n')
expect(readFileSync(join(workspace, 'project.txt'), 'utf8')).toBe('my authored work\n')
expect(dshUpdateInfo(installedDsh(id)!, entry).updateAvailable).toBe(false)
})
it('records local clone sources absolutely so updates do not depend on the original working directory', async () => {
const repo = create()
await installDsh({ source: relative(process.cwd(), repo) })
expect(readInstalledIndex()[0]!.source).toBe(repo)
write(repo, { 'AGENTS.md': '# Latest' })
const ref = commit(repo)
expect((await update()).ok).toBe(true)
expect(installedDsh(id)?.commit).toBe(ref)
})
it('restores the harness if its update introduces a viewer that cannot be installed', async () => {
const repo = create()
await installDsh({ source: repo })
const before = readInstalledIndex()
write(repo, { 'harness.json': JSON.stringify({ ...manifest, viewer: { use: 'acme/unpublished' } }) })
expect(await update(catalog(repo, commit(repo)))).toMatchObject({ ok: false, error: 'VIEWER_UNAVAILABLE' })
expect(readInstalledIndex()).toEqual(before)
expect(installedDsh(id)?.manifest.viewer).toBeUndefined()
})
it('skips unchanged package content even when another folder changed in the monorepo', async () => {
const path = 'store/agents/thing'
const repo = create(path)
await installDsh({ source: repo, path, ref: 'main' })
const before = readInstalledIndex()
write(repo, { 'unrelated.txt': 'new app version' })
const entry = catalog(repo, commit(repo), path)
expect(dshUpdateInfo(installedDsh(id)!, entry).updateAvailable).toBe(false)
const phases: DshInstallProgress[] = []
expect((await update(entry, phases)).ok).toBe(true)
expect(phases.map(p => p.phase)).toEqual(['clone', 'done'])
expect(readInstalledIndex()).toEqual(before)
})
it('handles older records with no revision and refuses to change a linked checkout', async () => {
const repo = create()
await installDsh({ source: repo })
const [record] = readInstalledIndex()
delete record.revision
upsertInstalledRecord(record)
write(repo, { 'AGENTS.md': '# Updated' })
const entry = catalog(repo, commit(repo))
expect(dshUpdateInfo(record, entry).updateAvailable).toBe(true)
expect((await update(entry)).ok).toBe(true)
expect(installedDsh(id)?.revision).toBe(entry.revision)
await installDsh({ source: repo, link: true })
expect(dshUpdateInfo(installedDsh(id)!, entry).updateAvailable).toBe(false)
expect(await update(entry)).toMatchObject({ ok: false, error: 'LINKED_INSTALL' })
expect(lstatSync(dshInstallDir(id)).isSymbolicLink()).toBe(true)
expect(readFileSync(join(repo, 'AGENTS.md'), 'utf8')).toBe('# Updated')
})
it('leaves the installation intact after a clone failure or wrong package identity', async () => {
expect(await update()).toMatchObject({ ok: false, error: 'NOT_INSTALLED' })
const repo = create()
await installDsh({ source: repo })
const before = readInstalledIndex()
expect(await update({ ...catalog(repo, git(repo, 'rev-parse', 'HEAD')), ref: 'no-such-ref' })).toMatchObject({ ok: false, error: 'CLONE_FAILED' })
write(repo, { 'harness.json': JSON.stringify({ ...manifest, id: 'acme/other' }) })
expect(await update(catalog(repo, commit(repo)))).toMatchObject({ ok: false, error: 'PACKAGE_ID_MISMATCH' })
expect(readInstalledIndex()).toEqual(before)
expect(installedDsh(id)?.manifest.id).toBe(id)
})
it('blocks concurrent updates, installs and removal until the update finishes', async () => {
const repo = create()
await installDsh({ source: repo })
const gate = join(root, 'release')
write(repo, { 'setup.sh': `#!/bin/sh\necho waiting\nwhile [ ! -f '${gate}' ]; do sleep 0.05; done\npwd > setup-path\n` })
const entry = catalog(repo, commit(repo))
let waiting = false
const pending = updateDsh({ id, registry: () => entry, onLine: line => { if (line === 'waiting') waiting = true } })
try {
await vi.waitFor(() => expect(waiting).toBe(true), { timeout: 5000 })
expect(removeDsh(id)).toMatchObject({ ok: false, error: 'DSH_BUSY' })
expect(await update(entry)).toMatchObject({ ok: false, error: 'DSH_BUSY' })
expect(await installDsh({ source: repo })).toMatchObject({ ok: false, error: 'DSH_BUSY' })
} finally { writeFileSync(gate, '') }
expect((await pending).ok).toBe(true)
expect(existsSync(join(dshInstallDir(id), 'harness.json'))).toBe(true)
})
})