Files
gbrain/test/binary-self-update.test.ts
T
a57d98b813 v0.42.12.0 feat: self-upgrading gbrain — invocation-riding update check + opt-in auto-upgrade (#1798)
* feat(self-upgrade): decision/cache/snooze foundation + atomic binary self-update

Pure decideSelfUpgrade (invocation + autopilot channels), atomic untrusted
cache + escalating snooze + shared marker grammar (forged-marker rejection),
semver helpers, and real darwin-arm64/linux-x64 binary self-update
(download -> fsync -> smoke -> atomic rename; failure leaves old binary intact).
Tests incl. real-HTTP-server swap E2E.

* feat(self-upgrade): check-update cache/markers, self-upgrade command, CLI heartbeat hook

check-update gains gstack-style cache/snooze/markers + refreshUpdateCache +
exported fetchLatestRelease. New 'gbrain self-upgrade' command. cli.ts emits the
update marker on every invocation (cache-read-only hot path, detached
single-flight refresh, skip-set + recursion guard + NODE_ENV=test gate).

* feat(self-upgrade): autopilot silent channel, doctor check, runPostUpgrade setup, config + identity marker

autopilot opt-in silent channel (auto+quiet+idle, swap-only+breadcrumb+exit-relaunch)
+ installSystemd Restart=always + migrateSystemdUnitToRestartAlways. doctor
self_upgrade_health. runPostUpgrade applySelfUpgradeSetup (one-time consent +
systemd rewrite). init defaults mode=notify. config self_upgrade plane +
KNOWN_CONFIG_KEYS. get_brain_identity carries update marker.

* docs(self-upgrade): gbrain-upgrade agent skill, RESOLVER/manifest, auto-update doc reversal, HEARTBEAT

New skills/gbrain-upgrade agent flow (mirror gstack-upgrade) wired into RESOLVER +
manifest. upgrades-auto-update.md reversed to document opt-in auto + conservative
gates. HEARTBEAT self-upgrade --check-only line. llms-full regenerated.

* chore: bump version and changelog (v0.42.12.0)

Self-upgrading gbrain: invocation-riding update marker + opt-in autopilot
silent channel + real atomic binary self-update. Mirrors gstack's mechanism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(self-upgrade): write just-upgraded-from breadcrumb + clear stale cache after upgrade

Codex ship-review P3: the CLI startup hook reads just-upgraded-from to print the
one-time JUST_UPGRADED confirmation, but nothing wrote it — dead path. runUpgrade
now writes the breadcrumb (covers full + --swap-only) and clears the update-check
cache + snooze so a now-applied 'upgrade available' marker stops nudging.

* feat(self-upgrade): surface what's-new in notify + wire agent integration (AGENTS.md, HEARTBEAT) + e2e

- self-upgrade --check-only --json now includes changelog_diff + release_url
  (export fetchChangelog); the gbrain-upgrade skill shows 3-5 what's-new bullets
  before the 4-option prompt instead of just version numbers.
- setup injects a self-upgrade marker protocol into AGENTS.md so interactive
  agents (Claude Code, Codex) act on the UPGRADE_AVAILABLE stderr marker — the
  piece that makes notify actually fire for them.
- HEARTBEAT daily beat routes through the gbrain-upgrade skill (OpenClaw/Hermes
  cron cadence); auto-mode daemons ride the autopilot tick.
- e2e: real subprocess invocation proves the marker fires (notify emits;
  off/snooze/up-to-date silent; JUST_UPGRADED fires+clears; --quiet suppresses).
  Serial test: --check-only surfaces the changelog.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:20:03 -07:00

139 lines
5.0 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
expectedAssetName,
resolvePlatformAsset,
runBinarySelfUpdate,
type ReleaseAsset,
} from '../src/core/binary-self-update.ts';
const ASSETS: ReleaseAsset[] = [
{ name: 'gbrain-darwin-arm64', url: 'https://example.com/darwin-arm64' },
{ name: 'gbrain-linux-x64', url: 'https://example.com/linux-x64' },
];
describe('expectedAssetName / resolvePlatformAsset', () => {
test('maps the two published targets', () => {
expect(expectedAssetName('darwin', 'arm64')).toBe('gbrain-darwin-arm64');
expect(expectedAssetName('linux', 'x64')).toBe('gbrain-linux-x64');
});
test('null for unpublished platform/arch', () => {
expect(expectedAssetName('darwin', 'x64')).toBeNull();
expect(expectedAssetName('linux', 'arm64')).toBeNull();
expect(expectedAssetName('win32', 'x64')).toBeNull();
});
test('resolvePlatformAsset returns the matching URL or null', () => {
expect(resolvePlatformAsset(ASSETS, 'darwin', 'arm64')).toBe('https://example.com/darwin-arm64');
expect(resolvePlatformAsset(ASSETS, 'linux', 'x64')).toBe('https://example.com/linux-x64');
expect(resolvePlatformAsset(ASSETS, 'win32', 'x64')).toBeNull();
expect(resolvePlatformAsset([], 'darwin', 'arm64')).toBeNull(); // asset missing from release
});
});
async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-binup-'));
try {
return await fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe('runBinarySelfUpdate', () => {
test('happy path: stages, smokes, atomically replaces the target', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD BINARY');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'NEW BINARY'),
smoke: () => true,
});
expect(res.ok).toBe(true);
expect(res.asset).toBe('gbrain-darwin-arm64');
expect(readFileSync(target, 'utf8')).toBe('NEW BINARY');
});
});
test('unsupported platform → unsupported_platform, target untouched', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain.exe');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'win32',
arch: 'x64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
});
expect(res.ok).toBe(false);
expect(res.reason).toBe('unsupported_platform');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
test('fetch failure → fetch_failed, target untouched', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => null,
});
expect(res.reason).toBe('fetch_failed');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
test('asset missing from release → no_asset, target untouched', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'linux',
arch: 'x64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: [] }),
});
expect(res.reason).toBe('no_asset');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
test('download throws → download_failed, no staged leftover, target untouched', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async () => {
throw new Error('disk full');
},
});
expect(res.reason).toBe('download_failed');
expect(res.error).toContain('disk full');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
test('smoke fail → smoke_failed, staged discarded, target untouched', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'CORRUPT'),
smoke: () => false,
});
expect(res.reason).toBe('smoke_failed');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
});