mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
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:
+2
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user