mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +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>
177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
/**
|
|
* E2E: real atomic binary self-update against a live local release server.
|
|
*
|
|
* Unlike test/binary-self-update.test.ts (which stubs `download` + `smoke` to
|
|
* exercise the orchestration), this drives the REAL dangerous path end-to-end:
|
|
* real HTTP download (defaultDownload) → real chmod → real `--version` smoke
|
|
* (defaultSmoke / execFileSync) → real renameSync over a running "binary" →
|
|
* re-exec the swapped binary and assert it reports the new version.
|
|
*
|
|
* Only `fetchRelease` is injected (to point at the local server instead of the
|
|
* GitHub API). The "binary" is a `#!/bin/sh` script so the swap mechanics are
|
|
* exercised identically on darwin + linux; platform/arch are pinned to
|
|
* linux/x64 so `expectedAssetName` resolves deterministically regardless of host.
|
|
*
|
|
* No DB — runs in every environment.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { runBinarySelfUpdate, type ReleaseAsset } from '../../src/core/binary-self-update.ts';
|
|
|
|
const NEW_BINARY = '#!/bin/sh\necho "gbrain 0.43.0"\n';
|
|
const OLD_BINARY = '#!/bin/sh\necho "gbrain 0.42.0"\n';
|
|
const NON_GBRAIN = '#!/bin/sh\necho "not the tool"\n';
|
|
|
|
let server: ReturnType<typeof Bun.serve>;
|
|
let base: string;
|
|
|
|
beforeAll(() => {
|
|
server = Bun.serve({
|
|
port: 0,
|
|
fetch(req) {
|
|
const path = new URL(req.url).pathname;
|
|
if (path === '/good-asset') return new Response(NEW_BINARY, { status: 200 });
|
|
if (path === '/bad-smoke-asset') return new Response(NON_GBRAIN, { status: 200 });
|
|
if (path === '/404-asset') return new Response('nope', { status: 404 });
|
|
if (path === '/empty-asset') return new Response('', { status: 200 });
|
|
return new Response('not found', { status: 404 });
|
|
},
|
|
});
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
server.stop(true);
|
|
});
|
|
|
|
function makeTargetBinary(): { dir: string; target: string } {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-swap-'));
|
|
const target = join(dir, 'gbrain');
|
|
writeFileSync(target, OLD_BINARY);
|
|
chmodSync(target, 0o755);
|
|
return { dir, target };
|
|
}
|
|
|
|
function assets(url: string): ReleaseAsset[] {
|
|
return [{ name: 'gbrain-linux-x64', url }];
|
|
}
|
|
|
|
function versionOf(path: string): string {
|
|
return execFileSync(path, ['--version'], { encoding: 'utf-8' }).trim();
|
|
}
|
|
|
|
function tmpLeftovers(dir: string): string[] {
|
|
return readdirSync(dir).filter((f) => f.includes('.tmp.'));
|
|
}
|
|
|
|
describe('binary self-update — real swap E2E', () => {
|
|
test('happy path: downloads, smokes, atomically replaces the running binary', async () => {
|
|
const { dir, target } = makeTargetBinary();
|
|
try {
|
|
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
|
const result = await runBinarySelfUpdate(target, {
|
|
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/good-asset`) }),
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.asset).toBe('gbrain-linux-x64');
|
|
// The running "binary" was atomically replaced; a fresh exec sees the new version.
|
|
expect(versionOf(target)).toBe('gbrain 0.43.0');
|
|
expect(tmpLeftovers(dir)).toEqual([]); // staged temp renamed away, none left
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('smoke failure leaves the old binary untouched (no brick)', async () => {
|
|
const { dir, target } = makeTargetBinary();
|
|
try {
|
|
const result = await runBinarySelfUpdate(target, {
|
|
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/bad-smoke-asset`) }),
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toBe('smoke_failed');
|
|
expect(versionOf(target)).toBe('gbrain 0.42.0'); // old binary intact
|
|
expect(tmpLeftovers(dir)).toEqual([]); // staged temp cleaned up on failure
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('download HTTP error leaves the old binary untouched', async () => {
|
|
const { dir, target } = makeTargetBinary();
|
|
try {
|
|
const result = await runBinarySelfUpdate(target, {
|
|
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/404-asset`) }),
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toBe('download_failed');
|
|
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
|
expect(existsSync(target)).toBe(true);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('empty downloaded asset is rejected, old binary intact', async () => {
|
|
const { dir, target } = makeTargetBinary();
|
|
try {
|
|
const result = await runBinarySelfUpdate(target, {
|
|
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/empty-asset`) }),
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toBe('download_failed');
|
|
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('no matching asset for platform → no_asset, no download attempted', async () => {
|
|
const { dir, target } = makeTargetBinary();
|
|
try {
|
|
const result = await runBinarySelfUpdate(target, {
|
|
fetchRelease: async () => ({ tag: 'v0.43.0', assets: [{ name: 'gbrain-darwin-arm64', url: `${base}/good-asset` }] }),
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toBe('no_asset');
|
|
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('unsupported platform short-circuits before any network call', async () => {
|
|
const { dir, target } = makeTargetBinary();
|
|
let fetched = false;
|
|
try {
|
|
const result = await runBinarySelfUpdate(target, {
|
|
fetchRelease: async () => {
|
|
fetched = true;
|
|
return { tag: 'v0.43.0', assets: assets(`${base}/good-asset`) };
|
|
},
|
|
platform: 'win32',
|
|
arch: 'x64',
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toBe('unsupported_platform');
|
|
expect(fetched).toBe(false); // never hit the network
|
|
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|