mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
import {
|
|
canSelfUpdate,
|
|
clearSnooze,
|
|
clearUpdateCache,
|
|
decideSelfUpgrade,
|
|
formatMarker,
|
|
isCacheFresh,
|
|
isSnoozeActive,
|
|
parseMarker,
|
|
readSnooze,
|
|
readUpdateCache,
|
|
reconcileBreadcrumb,
|
|
resolveSelfUpgradeMode,
|
|
snoozeDurationMs,
|
|
writeSnooze,
|
|
writeUpdateCache,
|
|
type DecideSelfUpgradeInputs,
|
|
} from '../src/core/self-upgrade.ts';
|
|
|
|
function baseInputs(over: Partial<DecideSelfUpgradeInputs> = {}): DecideSelfUpgradeInputs {
|
|
return {
|
|
mode: 'notify',
|
|
currentVersion: '0.42.0',
|
|
latestVersion: '0.43.0',
|
|
failedVersions: [],
|
|
channel: 'invocation',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
async function withTmpHome<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-selfupgrade-'));
|
|
try {
|
|
return await withEnv({ GBRAIN_HOME: dir, GBRAIN_SELF_UPGRADE_MODE: undefined }, fn);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
describe('decideSelfUpgrade — pure branches', () => {
|
|
test('mode=off short-circuits', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ mode: 'off' })).action).toBe('off');
|
|
});
|
|
|
|
test('null/invalid latest → not_behind (fail-open)', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ latestVersion: null })).action).toBe('not_behind');
|
|
expect(decideSelfUpgrade(baseInputs({ latestVersion: 'garbage' })).action).toBe('not_behind');
|
|
expect(decideSelfUpgrade(baseInputs({ latestVersion: 'rm -rf /' })).action).toBe('not_behind');
|
|
});
|
|
|
|
test('equal version → not_behind', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ latestVersion: '0.42.0' })).action).toBe('not_behind');
|
|
});
|
|
|
|
test('latest < current → downgrade_or_yanked (never acts)', () => {
|
|
const d = decideSelfUpgrade(baseInputs({ currentVersion: '0.43.0', latestVersion: '0.42.0' }));
|
|
expect(d.action).toBe('downgrade_or_yanked');
|
|
});
|
|
|
|
test('patch/micro bump only → not_behind (ignored)', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.42.1' })).action).toBe('not_behind');
|
|
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.3.0', latestVersion: '0.42.3.1' })).action).toBe('not_behind');
|
|
});
|
|
|
|
test('minor bump → behind', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.43.0' })).action).toBe('notify');
|
|
});
|
|
|
|
test('major bump → behind', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '1.0.0' })).action).toBe('notify');
|
|
});
|
|
|
|
test('known-bad latest → known_bad (no retry)', () => {
|
|
const d = decideSelfUpgrade(baseInputs({ failedVersions: ['0.43.0'] }));
|
|
expect(d.action).toBe('known_bad');
|
|
});
|
|
|
|
test('invocation channel: snoozed → throttled', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ snoozed: true })).action).toBe('throttled');
|
|
});
|
|
|
|
test('invocation channel: not snoozed → notify', () => {
|
|
expect(decideSelfUpgrade(baseInputs({ snoozed: false })).action).toBe('notify');
|
|
});
|
|
|
|
describe('autopilot channel gates', () => {
|
|
const auto = (over: Partial<DecideSelfUpgradeInputs> = {}) =>
|
|
decideSelfUpgrade(
|
|
baseInputs({
|
|
mode: 'auto',
|
|
channel: 'autopilot',
|
|
idle: true,
|
|
inQuietHours: true,
|
|
canSelfUpdate: true,
|
|
throttledByInterval: false,
|
|
...over,
|
|
}),
|
|
);
|
|
|
|
test('all gates pass → apply', () => {
|
|
expect(auto().action).toBe('apply');
|
|
});
|
|
test('throttledByInterval → throttled', () => {
|
|
expect(auto({ throttledByInterval: true }).action).toBe('throttled');
|
|
});
|
|
test('not idle → busy', () => {
|
|
expect(auto({ idle: false }).action).toBe('busy');
|
|
});
|
|
test('outside quiet hours → outside_quiet_hours', () => {
|
|
expect(auto({ inQuietHours: false }).action).toBe('outside_quiet_hours');
|
|
});
|
|
test('cannot self-update → unsupported_install', () => {
|
|
expect(auto({ canSelfUpdate: false }).action).toBe('unsupported_install');
|
|
});
|
|
test('gate order: known_bad beats idle/quiet gates', () => {
|
|
expect(auto({ failedVersions: ['0.43.0'], idle: false }).action).toBe('known_bad');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('canSelfUpdate', () => {
|
|
test('bun / bun-link / clawhub always self-update', () => {
|
|
for (const m of ['bun', 'bun-link', 'clawhub']) {
|
|
expect(canSelfUpdate(m, 'darwin', 'arm64')).toBe(true);
|
|
expect(canSelfUpdate(m, 'win32', 'x64')).toBe(true);
|
|
}
|
|
});
|
|
test('binary self-updates only where a release asset exists (darwin-arm64, linux-x64)', () => {
|
|
expect(canSelfUpdate('binary', 'darwin', 'arm64')).toBe(true);
|
|
expect(canSelfUpdate('binary', 'linux', 'x64')).toBe(true);
|
|
expect(canSelfUpdate('binary', 'darwin', 'x64')).toBe(false); // no asset published
|
|
expect(canSelfUpdate('binary', 'linux', 'arm64')).toBe(false); // no asset published
|
|
expect(canSelfUpdate('binary', 'win32', 'x64')).toBe(false);
|
|
});
|
|
test('unknown method cannot self-update', () => {
|
|
expect(canSelfUpdate('unknown', 'darwin', 'arm64')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('marker grammar', () => {
|
|
test('round-trips up_to_date and upgrade_available', () => {
|
|
const a = { kind: 'up_to_date' as const, current: '0.42.0' };
|
|
expect(parseMarker(formatMarker(a))).toEqual(a);
|
|
const b = { kind: 'upgrade_available' as const, current: '0.42.0', latest: '0.43.0' };
|
|
expect(parseMarker(formatMarker(b))).toEqual(b);
|
|
});
|
|
test('rejects forged / malformed markers', () => {
|
|
expect(parseMarker('UPGRADE_AVAILABLE 0.42.0 $(rm -rf /)')).toBeNull();
|
|
expect(parseMarker('UPGRADE_AVAILABLE 0.42.0')).toBeNull();
|
|
expect(parseMarker('EVIL 0.42.0 0.43.0')).toBeNull();
|
|
expect(parseMarker('UP_TO_DATE not-a-version')).toBeNull();
|
|
expect(parseMarker('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('snooze', () => {
|
|
test('escalating durations cap at 7d', () => {
|
|
expect(snoozeDurationMs(1)).toBe(24 * 3600 * 1000);
|
|
expect(snoozeDurationMs(2)).toBe(48 * 3600 * 1000);
|
|
expect(snoozeDurationMs(3)).toBe(7 * 24 * 3600 * 1000);
|
|
expect(snoozeDurationMs(99)).toBe(7 * 24 * 3600 * 1000);
|
|
});
|
|
test('isSnoozeActive: only for matching version, within window', () => {
|
|
const now = 1_000_000_000_000;
|
|
const rec = { version: '0.43.0', level: 1, ts: now };
|
|
expect(isSnoozeActive(rec, '0.43.0', now + 1000)).toBe(true);
|
|
expect(isSnoozeActive(rec, '0.43.0', now + 25 * 3600 * 1000)).toBe(false); // expired
|
|
expect(isSnoozeActive(rec, '0.44.0', now + 1000)).toBe(false); // different version
|
|
expect(isSnoozeActive(null, '0.43.0', now)).toBe(false);
|
|
});
|
|
test('writeSnooze escalates level for same version, resets for new version', async () => {
|
|
await withTmpHome(() => {
|
|
const now = 1_700_000_000_000;
|
|
expect(writeSnooze('0.43.0', now)).toBe(1);
|
|
expect(writeSnooze('0.43.0', now)).toBe(2);
|
|
expect(writeSnooze('0.43.0', now)).toBe(3);
|
|
expect(writeSnooze('0.43.0', now)).toBe(3); // capped
|
|
expect(writeSnooze('0.44.0', now)).toBe(1); // new version resets
|
|
const rec = readSnooze();
|
|
expect(rec?.version).toBe('0.44.0');
|
|
expect(rec?.level).toBe(1);
|
|
clearSnooze();
|
|
expect(readSnooze()).toBeNull();
|
|
});
|
|
});
|
|
test('corrupt snooze file → null', async () => {
|
|
await withTmpHome(async () => {
|
|
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
const { gbrainPath } = await import('../src/core/config.ts');
|
|
mkdirSync(gbrainPath(), { recursive: true });
|
|
writeFileSync(gbrainPath('update-snoozed'), 'garbage not three fields here');
|
|
expect(readSnooze()).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('cache', () => {
|
|
test('write → read round-trip, atomic, fresh check', async () => {
|
|
await withTmpHome(() => {
|
|
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.43.0' });
|
|
const entry = readUpdateCache();
|
|
expect(entry?.marker).toEqual({ kind: 'upgrade_available', current: '0.42.0', latest: '0.43.0' });
|
|
expect(isCacheFresh(entry!, entry!.mtimeMs + 1000)).toBe(true);
|
|
expect(isCacheFresh(entry!, entry!.mtimeMs + 13 * 3600 * 1000)).toBe(false); // > 12h
|
|
clearUpdateCache();
|
|
expect(readUpdateCache()).toBeNull();
|
|
});
|
|
});
|
|
test('up_to_date uses the 60min TTL', async () => {
|
|
await withTmpHome(() => {
|
|
writeUpdateCache({ kind: 'up_to_date', current: '0.42.0' });
|
|
const entry = readUpdateCache()!;
|
|
expect(isCacheFresh(entry, entry.mtimeMs + 59 * 60 * 1000)).toBe(true);
|
|
expect(isCacheFresh(entry, entry.mtimeMs + 61 * 60 * 1000)).toBe(false);
|
|
});
|
|
});
|
|
test('corrupt cache file → null (fail-open)', async () => {
|
|
await withTmpHome(async () => {
|
|
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
const { gbrainPath } = await import('../src/core/config.ts');
|
|
mkdirSync(gbrainPath(), { recursive: true });
|
|
writeFileSync(gbrainPath('last-update-check'), 'EVIL not a marker');
|
|
expect(readUpdateCache()).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('writeJustUpgraded', () => {
|
|
test('writes the from-version breadcrumb the CLI startup hook reads', async () => {
|
|
await withTmpHome(async () => {
|
|
const { writeJustUpgraded, justUpgradedPath } = await import('../src/core/self-upgrade.ts');
|
|
const { readFileSync } = await import('node:fs');
|
|
writeJustUpgraded('0.42.0');
|
|
expect(readFileSync(justUpgradedPath(), 'utf8').trim()).toBe('0.42.0');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('reconcileBreadcrumb', () => {
|
|
test('no breadcrumb → no transition', () => {
|
|
expect(reconcileBreadcrumb(undefined, '0.42.0').transition).toBeNull();
|
|
expect(reconcileBreadcrumb({}, '0.42.0').transition).toBeNull();
|
|
});
|
|
test('breadcrumb matches running version → applied, breadcrumb cleared, last_applied set', () => {
|
|
const r = reconcileBreadcrumb({ attempting_version: '0.43.0' }, '0.43.0');
|
|
expect(r.transition).toBe('applied');
|
|
expect(r.state.attempting_version).toBeUndefined();
|
|
expect(r.state.last_applied_version).toBe('0.43.0');
|
|
});
|
|
test('breadcrumb != running version → failed, recorded known-bad, breadcrumb cleared', () => {
|
|
const r = reconcileBreadcrumb({ attempting_version: '0.43.0' }, '0.42.0');
|
|
expect(r.transition).toBe('failed');
|
|
expect(r.state.attempting_version).toBeUndefined();
|
|
expect(r.state.failed_versions).toContain('0.43.0');
|
|
});
|
|
test('failed dedups into existing failed_versions', () => {
|
|
const r = reconcileBreadcrumb({ attempting_version: '0.43.0', failed_versions: ['0.43.0', '0.41.0'] }, '0.42.0');
|
|
expect(r.state.failed_versions?.filter((v) => v === '0.43.0').length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('resolveSelfUpgradeMode', () => {
|
|
test('defaults to notify', async () => {
|
|
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: undefined }, () => {
|
|
expect(resolveSelfUpgradeMode(null)).toBe('notify');
|
|
expect(resolveSelfUpgradeMode({})).toBe('notify');
|
|
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'bogus' } })).toBe('notify');
|
|
});
|
|
});
|
|
test('config plane honored', async () => {
|
|
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: undefined }, () => {
|
|
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'auto' } })).toBe('auto');
|
|
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'off' } })).toBe('off');
|
|
});
|
|
});
|
|
test('env overrides config', async () => {
|
|
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: 'off' }, () => {
|
|
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'auto' } })).toBe('off');
|
|
});
|
|
});
|
|
});
|