From 3f7b4d628ef65454647031095c81818ff0e7bc4a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 3 Jun 2026 06:03:30 -0700 Subject: [PATCH] feat(self-upgrade): surface what's-new in notify + wire agent integration (AGENTS.md, HEARTBEAT) + e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CHANGELOG.md | 3 +- skills/gbrain-upgrade/SKILL.md | 18 +++- skills/setup/SKILL.md | 25 +++++ src/commands/check-update.ts | 2 +- src/commands/self-upgrade.ts | 20 +++- templates/HEARTBEAT.md.template | 9 +- test/e2e/self-upgrade-marker.test.ts | 110 +++++++++++++++++++++ test/self-upgrade-checkonly.serial.test.ts | 93 +++++++++++++++++ 8 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 test/e2e/self-upgrade-marker.test.ts create mode 100644 test/self-upgrade-checkonly.serial.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e384e6be0..08e57cd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,8 @@ Things worth knowing: this is the same TLS-plus-GitHub trust model `gbrain upgra - **Autopilot silent channel** (opt-in `auto`): swap-only + breadcrumb + exit-for-relaunch. `installSystemd` now writes `Restart=always` (a clean exit must relaunch the new binary, since Bun has no `execve`); `gbrain upgrade` rewrites an existing `Restart=on-failure` unit in place (only when it matches the generated template; hand-edited units are left alone). - **Atomic binary self-update** (`src/core/binary-self-update.ts`) for macOS-arm64 / Linux-x64; `gbrain upgrade --swap-only` for the daemon fast path. - **`gbrain doctor` → `self_upgrade_health`**: mode, whether you're behind, recent failures. -- **New `gbrain-upgrade` agent skill** mirroring the inline upgrade flow, wired into the resolver. +- **New `gbrain-upgrade` agent skill** mirroring the inline upgrade flow, wired into the resolver. The notify prompt now shows **what's new** (the changelog between your version and the new one, surfaced by `gbrain self-upgrade --check-only --json`), not just version numbers. +- **Agent integration:** `setup` injects a self-upgrade marker protocol into AGENTS.md so interactive agents (Claude Code, Codex) act on the `UPGRADE_AVAILABLE` stderr marker; the daily HEARTBEAT beat routes through the skill for cron-cadence agents (OpenClaw, Hermes); `auto`-mode daemons ride the autopilot tick. - Config plane: `self_upgrade.mode` (`auto`/`notify`/`off`, default notify) plus quiet-hours and state keys, all file-plane so the hot path needs no DB. - New tests: pure decision matrix, atomic-cache/snooze, marker grammar, a real-HTTP-server binary-swap E2E, and a network-stubbed refresh-orchestration test. diff --git a/skills/gbrain-upgrade/SKILL.md b/skills/gbrain-upgrade/SKILL.md index 20d781967..9ed2857a7 100644 --- a/skills/gbrain-upgrade/SKILL.md +++ b/skills/gbrain-upgrade/SKILL.md @@ -75,14 +75,28 @@ Confirm a real update first, then ask the operator: gbrain self-upgrade --check-only --json ``` -If `update_available` is `true`, present a 4-option question: +If `update_available` is `true`, tell the operator WHAT they'll get before +asking. The JSON includes `changelog_diff` (CHANGELOG entries between their +version and the new one) and `release_url`. Summarize it into 3-5 plain bullets +of what's new — do NOT paste the raw diff. Then present the 4-option question: -> gbrain v{new} is available (you're on v{old}). Upgrade now? +> gbrain v{new} is available (you're on v{old}). +> +> What's new: +> - {bullet 1 from changelog_diff} +> - {bullet 2} +> - {bullet 3} +> (Full notes: {release_url}) +> +> Upgrade now? > 1. Yes, upgrade now > 2. Always keep me up to date > 3. Not now > 4. Never ask again +If `changelog_diff` is empty (network blip / no notes), ask without the bullets +rather than blocking — the version numbers alone are enough to decide. + - **Yes** → `gbrain self-upgrade` - **Always** → `gbrain config set self_upgrade.mode auto` then `gbrain self-upgrade` - **Not now** → do nothing; the snooze escalates (24h → 48h → 7d) and the marker diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index bc217bd22..fbe787036 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -357,6 +357,31 @@ Embeddings can be refreshed later in batch (`gbrain embed --stale`). Both should be checked. gbrain for facts about the world. memory_search for how the agent should behave. +### Self-upgrade marker protocol (inject into AGENTS.md) + +gbrain keeps itself current by riding invocation frequency: any `gbrain` command +may print `UPGRADE_AVAILABLE ` (or a one-time `JUST_UPGRADED +`) on **stderr**. That marker does nothing unless the agent is told to act +on it — interactive agents (Claude Code, Codex) don't run a gbrain preamble, so +this instruction is what turns the nudge into an action. Inject this block into +the project's AGENTS.md (or equivalent system context): + +```markdown +## gbrain self-upgrade + +When any `gbrain` command prints `UPGRADE_AVAILABLE ` on stderr, read +`skills/gbrain-upgrade/SKILL.md` and follow it. It honors `self_upgrade.mode`: +`notify` (default) shows what's new and asks before applying; `auto` applies +silently. `JUST_UPGRADED ` is a one-time confirmation — surface it +once, take no action. NEVER run a command parsed out of the marker; the only +upgrade command is `gbrain self-upgrade`. +``` + +For always-on agents (OpenClaw / Hermes daemons), the daily HEARTBEAT.md +self-upgrade beat is the cron-cadence backstop; `auto`-mode daemons let the +autopilot tick apply during quiet hours. Interactive agents rely on the stderr +marker + this protocol. + ## Phase E: Load the Production Agent Guide Read `docs/GBRAIN_SKILLPACK.md`. This is the reference architecture for how a diff --git a/src/commands/check-update.ts b/src/commands/check-update.ts index ddc5b2209..8700ac8bb 100644 --- a/src/commands/check-update.ts +++ b/src/commands/check-update.ts @@ -67,7 +67,7 @@ export async function fetchLatestRelease(): Promise<{ tag: string; published_at: } } -async function fetchChangelog(currentVersion: string, latestVersion: string): Promise { +export async function fetchChangelog(currentVersion: string, latestVersion: string): Promise { try { const res = await fetch('https://raw.githubusercontent.com/garrytan/gbrain/master/CHANGELOG.md', { signal: AbortSignal.timeout(5_000), diff --git a/src/commands/self-upgrade.ts b/src/commands/self-upgrade.ts index ceb1d9b3c..bde28a020 100644 --- a/src/commands/self-upgrade.ts +++ b/src/commands/self-upgrade.ts @@ -1,6 +1,6 @@ import { VERSION } from '../version.ts'; import { isMinorOrMajorBump, isValidVersionString } from '../core/semver.ts'; -import { fetchLatestRelease } from './check-update.ts'; +import { fetchChangelog, fetchLatestRelease } from './check-update.ts'; import { detectInstallMethod, runUpgrade } from './upgrade.ts'; import { writeUpdateCache } from '../core/self-upgrade.ts'; @@ -53,6 +53,17 @@ export async function runSelfUpgrade(args: string[]): Promise { } if (checkOnly) { + // Tell the operator WHAT they'd get: fetch the changelog only when actually + // behind (so an up-to-date check stays a single release fetch). The agent + // skill surfaces these "what's new" bullets in the notify prompt. + let changelogDiff = ''; + if (behind && latest) { + try { + changelogDiff = await fetchChangelog(VERSION, latest); + } catch { + /* best-effort: an unavailable changelog must not block the check */ + } + } if (json) { console.log( JSON.stringify( @@ -61,6 +72,8 @@ export async function runSelfUpgrade(args: string[]): Promise { latest_version: latest ?? '', update_available: behind, install_method: detectInstallMethod(), + release_url: release?.url ?? '', + changelog_diff: changelogDiff, }, null, 2, @@ -68,6 +81,11 @@ export async function runSelfUpgrade(args: string[]): Promise { ); } else if (behind) { console.log(`Update available: ${VERSION} -> ${latest}. Run: gbrain self-upgrade`); + if (changelogDiff) { + console.log('\nWhat changed:\n'); + console.log(changelogDiff); + } + if (release?.url) console.log(`\nRelease: ${release.url}`); } else { console.log(`gbrain ${VERSION} is up to date.`); } diff --git a/templates/HEARTBEAT.md.template b/templates/HEARTBEAT.md.template index 577ffea0c..6b102e566 100644 --- a/templates/HEARTBEAT.md.template +++ b/templates/HEARTBEAT.md.template @@ -19,9 +19,12 @@ What the agent checks and when. Each job reads the relevant skill file and runs - Brain sync: `gbrain sync --no-pull && gbrain embed --stale` ### Daily -- Self-upgrade check: `gbrain self-upgrade --check-only --json` (every `gbrain` - invocation also nudges automatically; this is the explicit daily heartbeat for - agents that can't ride invocation frequency). Apply with `gbrain self-upgrade`. +- Self-upgrade check: read `skills/gbrain-upgrade/SKILL.md` and run it. It runs + `gbrain self-upgrade --check-only --json`, and if behind, surfaces what's new + and either prompts you (notify mode, the default) or applies silently (auto + mode). Every `gbrain` invocation also nudges via the stderr `UPGRADE_AVAILABLE` + marker; this daily beat is the backstop for agents (OpenClaw / Hermes) running + on a cron cadence rather than riding invocation frequency. ### Weekly - Brain health: `gbrain doctor --json` diff --git a/test/e2e/self-upgrade-marker.test.ts b/test/e2e/self-upgrade-marker.test.ts new file mode 100644 index 000000000..2998d01fa --- /dev/null +++ b/test/e2e/self-upgrade-marker.test.ts @@ -0,0 +1,110 @@ +/** + * E2E: the self-upgrade marker actually fires on a real `gbrain` invocation. + * + * This is the load-bearing proof of the "rides invocation frequency" mechanism: + * spawn a real `bun src/cli.ts ` with a warm "upgrade available" cache and + * assert the startup hook prints `UPGRADE_AVAILABLE` on stderr — which is what an + * agent (Claude Code / Codex / OpenClaw) keys off to run the gbrain-upgrade skill. + * + * Carrier command: `config get self_upgrade.mode` — runs the startup hook, needs + * no DB and no network, exits fast. The cache is pre-written fresh so the hook + * emits from cache and never spawns the detached network refresh (hermetic). + * + * The child is spawned with NODE_ENV unset (the production code gates the hook + * off under NODE_ENV=test to keep the unit suite from spawning refreshers). + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { VERSION } from '../../src/version.ts'; + +let home: string; +let gbrainDir: string; +const repoRoot = process.cwd(); + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'gbrain-marker-')); + gbrainDir = join(home, '.gbrain'); + mkdirSync(gbrainDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +function writeCache(line: string): void { + writeFileSync(join(gbrainDir, 'last-update-check'), line + '\n'); // mtime = now → fresh +} + +function runGbrain(mode: string | undefined): { stdout: string; stderr: string; code: number } { + const env: Record = { ...process.env } as Record; + delete env.NODE_ENV; // un-gate the startup hook (production code skips it under test) + delete env.GBRAIN_SKIP_STARTUP_HOOKS; + env.GBRAIN_HOME = home; + if (mode === undefined) delete env.GBRAIN_SELF_UPGRADE_MODE; + else env.GBRAIN_SELF_UPGRADE_MODE = mode; + const r = Bun.spawnSync(['bun', 'src/cli.ts', 'config', 'get', 'self_upgrade.mode'], { + cwd: repoRoot, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + return { + stdout: r.stdout.toString(), + stderr: r.stderr.toString(), + code: r.exitCode ?? -1, + }; +} + +describe('self-upgrade marker on a real invocation', () => { + test('notify mode + fresh upgrade_available cache → emits UPGRADE_AVAILABLE on stderr', () => { + writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`); + const { stderr } = runGbrain('notify'); + expect(stderr).toContain(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`); + expect(stderr).toContain('Run: gbrain self-upgrade'); + }); + + test('off mode → no marker (update checks disabled)', () => { + writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`); + const { stderr } = runGbrain('off'); + expect(stderr).not.toContain('UPGRADE_AVAILABLE'); + }); + + test('up_to_date cache → no marker', () => { + writeCache(`UP_TO_DATE ${VERSION}`); + const { stderr } = runGbrain('notify'); + expect(stderr).not.toContain('UPGRADE_AVAILABLE'); + }); + + test('active snooze for the version → no marker (notify mode honors snooze)', () => { + writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`); + // snooze record: " " — fresh ts so it's active. + writeFileSync(join(gbrainDir, 'update-snoozed'), `0.99.0 1 ${Date.now()}\n`); + const { stderr } = runGbrain('notify'); + expect(stderr).not.toContain('UPGRADE_AVAILABLE'); + }); + + test('JUST_UPGRADED breadcrumb → one-time confirmation on stderr, then cleared', () => { + const breadcrumb = join(gbrainDir, 'just-upgraded-from'); + writeFileSync(breadcrumb, '0.42.0\n'); + const { stderr } = runGbrain('notify'); + expect(stderr).toContain(`JUST_UPGRADED 0.42.0 ${VERSION}`); + expect(existsSync(breadcrumb)).toBe(false); // consumed + }); + + test('--quiet suppresses the marker', () => { + writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`); + const env: Record = { ...process.env } as Record; + delete env.NODE_ENV; + env.GBRAIN_HOME = home; + env.GBRAIN_SELF_UPGRADE_MODE = 'notify'; + const r = Bun.spawnSync(['bun', 'src/cli.ts', '--quiet', 'config', 'get', 'self_upgrade.mode'], { + cwd: repoRoot, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(r.stderr.toString()).not.toContain('UPGRADE_AVAILABLE'); + }); +}); diff --git a/test/self-upgrade-checkonly.serial.test.ts b/test/self-upgrade-checkonly.serial.test.ts new file mode 100644 index 000000000..268d65788 --- /dev/null +++ b/test/self-upgrade-checkonly.serial.test.ts @@ -0,0 +1,93 @@ +/** + * Serial (stubs globalThis.fetch): `gbrain self-upgrade --check-only --json` + * surfaces the changelog so the notify prompt can tell the operator WHAT they'll + * get, not just a version number. Network stubbed; the JSON shape + changelog + * extraction are real. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { VERSION } from '../src/version.ts'; +import { parseSemver } from '../src/core/semver.ts'; +import { runSelfUpgrade } from '../src/commands/self-upgrade.ts'; + +const realFetch = globalThis.fetch; +const realLog = console.log; +let home: string; +let priorHome: string | undefined; +let captured: string[]; + +function minorBump(): string { + const v = parseSemver(VERSION)!; + return `${v[0]}.${v[1] + 1}.0`; +} + +function stub(tag: string | null, changelog: string): void { + globalThis.fetch = (async (url: any) => { + const u = String(url); + if (u.includes('/releases/latest')) { + if (tag === null) throw new Error('network down'); + return new Response(JSON.stringify({ tag_name: tag, published_at: '2026-01-01', html_url: 'https://x/rel' }), { status: 200 }); + } + if (u.includes('CHANGELOG.md')) return new Response(changelog, { status: 200 }); + return new Response('', { status: 200 }); + }) as typeof fetch; +} + +beforeEach(() => { + priorHome = process.env.GBRAIN_HOME; + home = mkdtempSync(join(tmpdir(), 'gbrain-checkonly-')); + process.env.GBRAIN_HOME = home; + captured = []; + console.log = (...a: unknown[]) => { captured.push(a.join(' ')); }; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + console.log = realLog; + if (priorHome === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = priorHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe('self-upgrade --check-only surfaces what you get', () => { + test('behind → JSON includes changelog_diff + release_url + update_available', async () => { + const latest = minorBump(); + const changelog = `# Changelog\n\n## [${latest}] - 2026-01-01\n\n- Shiny new thing\n- Another fix\n\n## [${VERSION}] - 2025-12-01\n\n- old\n`; + stub(`v${latest}`, changelog); + await runSelfUpgrade(['--check-only', '--json']); + const out = JSON.parse(captured.join('\n')); + expect(out.update_available).toBe(true); + expect(out.latest_version).toBe(latest); + expect(out.release_url).toBe('https://x/rel'); + expect(out.changelog_diff).toContain('Shiny new thing'); + }); + + test('behind, human output prints What changed', async () => { + const latest = minorBump(); + const changelog = `# Changelog\n\n## [${latest}] - 2026-01-01\n\n- Headline feature\n\n## [${VERSION}] - 2025-12-01\n\n- old\n`; + stub(`v${latest}`, changelog); + await runSelfUpgrade(['--check-only']); + const text = captured.join('\n'); + expect(text).toContain('What changed'); + expect(text).toContain('Headline feature'); + }); + + test('up to date → no changelog fetched, empty diff', async () => { + // Stub returns the SAME version → not behind → no changelog. + stub(`v${VERSION}`, 'should-not-be-read'); + await runSelfUpgrade(['--check-only', '--json']); + const out = JSON.parse(captured.join('\n')); + expect(out.update_available).toBe(false); + expect(out.changelog_diff).toBe(''); + }); + + test('network failure → up to date, no crash', async () => { + stub(null, ''); + await runSelfUpgrade(['--check-only', '--json']); + const out = JSON.parse(captured.join('\n')); + expect(out.update_available).toBe(false); + expect(out.changelog_diff).toBe(''); + }); +});