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.
This commit is contained in:
Garry Tan
2026-06-03 06:03:30 -07:00
parent 6622e1b762
commit 3f7b4d628e
8 changed files with 272 additions and 8 deletions
+2 -1
View File
@@ -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.
+16 -2
View File
@@ -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
+25
View File
@@ -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 <old> <new>` (or a one-time `JUST_UPGRADED <old>
<new>`) 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 <old> <new>` 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 <old> <new>` 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
+1 -1
View File
@@ -67,7 +67,7 @@ export async function fetchLatestRelease(): Promise<{ tag: string; published_at:
}
}
async function fetchChangelog(currentVersion: string, latestVersion: string): Promise<string> {
export async function fetchChangelog(currentVersion: string, latestVersion: string): Promise<string> {
try {
const res = await fetch('https://raw.githubusercontent.com/garrytan/gbrain/master/CHANGELOG.md', {
signal: AbortSignal.timeout(5_000),
+19 -1
View File
@@ -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<void> {
}
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<void> {
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<void> {
);
} 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.`);
}
+6 -3
View File
@@ -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`
+110
View File
@@ -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 <cmd>` 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<string, string> = { ...process.env } as Record<string, string>;
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: "<version> <level> <epoch-ms>" — 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<string, string> = { ...process.env } as Record<string, string>;
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');
});
});
@@ -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('');
});
});