mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +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>
202 lines
6.9 KiB
TypeScript
202 lines
6.9 KiB
TypeScript
/**
|
|
* Real atomic self-update for the compiled-`binary` install method
|
|
* (v0.42 self-upgrading-gbrain wave, eng-review Finding 2 — "make the atomic
|
|
* swap claim true for the one method we own").
|
|
*
|
|
* `bun` / `bun-link` / `clawhub` delegate their swap to those package managers.
|
|
* The compiled standalone binary is the only method gbrain itself writes, so
|
|
* it's the only place we can (and now do) guarantee atomicity:
|
|
*
|
|
* resolve published asset → download to a temp sibling of the live binary →
|
|
* fsync + chmod +x → `--version` smoke test → renameSync over the live path.
|
|
*
|
|
* rename(2) over a running binary is safe on darwin/linux (the running process
|
|
* keeps the old inode; the next exec picks up the new file). Every failure
|
|
* (no asset / fetch / download / smoke / rename) leaves the OLD binary
|
|
* untouched — there is no half-written-binary brick path. Windows can't rename
|
|
* over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
|
|
* published, so those degrade to notify-only via `resolvePlatformAsset`
|
|
* returning null. Trust model: TLS + GitHub, same as `gbrain upgrade` (no
|
|
* signature verification this wave — D7a TODO).
|
|
*
|
|
* Published asset matrix mirrors `.github/workflows/release.yml`:
|
|
* darwin-arm64 → gbrain-darwin-arm64
|
|
* linux-x64 → gbrain-linux-x64
|
|
*/
|
|
|
|
import { chmodSync, closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
export interface ReleaseAsset {
|
|
name: string;
|
|
url: string;
|
|
}
|
|
|
|
export type BinarySelfUpdateReason =
|
|
| 'unsupported_platform'
|
|
| 'fetch_failed'
|
|
| 'no_asset'
|
|
| 'download_failed'
|
|
| 'smoke_failed'
|
|
| 'replace_failed';
|
|
|
|
export interface BinarySelfUpdateResult {
|
|
ok: boolean;
|
|
reason?: BinarySelfUpdateReason;
|
|
error?: string;
|
|
/** Asset name resolved (when applicable). */
|
|
asset?: string;
|
|
}
|
|
|
|
/** The release asset basename gbrain publishes for this platform/arch, or null
|
|
* when no asset is published (degrade to notify-only). */
|
|
export function expectedAssetName(platform: NodeJS.Platform, arch: NodeJS.Architecture): string | null {
|
|
if (platform === 'darwin' && arch === 'arm64') return 'gbrain-darwin-arm64';
|
|
if (platform === 'linux' && arch === 'x64') return 'gbrain-linux-x64';
|
|
return null;
|
|
}
|
|
|
|
/** Pick the download URL for this platform/arch from a release's asset list. */
|
|
export function resolvePlatformAsset(
|
|
assets: ReleaseAsset[],
|
|
platform: NodeJS.Platform = process.platform,
|
|
arch: NodeJS.Architecture = process.arch,
|
|
): string | null {
|
|
const name = expectedAssetName(platform, arch);
|
|
if (!name) return null;
|
|
const match = assets.find((a) => a.name === name);
|
|
return match?.url ?? null;
|
|
}
|
|
|
|
export interface BinarySelfUpdateDeps {
|
|
/** Fetch the latest release's tag + asset list. Default hits the GitHub API. */
|
|
fetchRelease?: () => Promise<{ tag: string; assets: ReleaseAsset[] } | null>;
|
|
/** Download `url` to `destPath`. Default streams the HTTP body to disk. */
|
|
download?: (url: string, destPath: string) => Promise<void>;
|
|
/** Smoke-test the staged binary; returns true if `<path> --version` looks like gbrain. */
|
|
smoke?: (stagedPath: string) => boolean;
|
|
platform?: NodeJS.Platform;
|
|
arch?: NodeJS.Architecture;
|
|
}
|
|
|
|
async function defaultFetchRelease(): Promise<{ tag: string; assets: ReleaseAsset[] } | null> {
|
|
try {
|
|
const res = await fetch('https://api.github.com/repos/garrytan/gbrain/releases/latest', {
|
|
headers: { 'User-Agent': 'gbrain-self-upgrade' },
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = (await res.json()) as any;
|
|
const assets: ReleaseAsset[] = Array.isArray(data.assets)
|
|
? data.assets.map((a: any) => ({ name: String(a.name ?? ''), url: String(a.browser_download_url ?? '') }))
|
|
: [];
|
|
return { tag: String(data.tag_name ?? ''), assets };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function defaultDownload(url: string, destPath: string): Promise<void> {
|
|
const res = await fetch(url, {
|
|
headers: { 'User-Agent': 'gbrain-self-upgrade' },
|
|
redirect: 'follow',
|
|
signal: AbortSignal.timeout(120_000),
|
|
});
|
|
if (!res.ok) throw new Error(`download HTTP ${res.status}`);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
if (buf.length === 0) throw new Error('downloaded asset is empty');
|
|
writeFileSync(destPath, buf);
|
|
// fsync so a crash between write and rename can't leave a torn file.
|
|
const fd = openSync(destPath, 'r');
|
|
try {
|
|
fsyncSync(fd);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function defaultSmoke(stagedPath: string): boolean {
|
|
try {
|
|
const out = execFileSync(stagedPath, ['--version'], { encoding: 'utf-8', timeout: 10_000 });
|
|
return /gbrain\s/i.test(out);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
let _tmpCounter = 0;
|
|
|
|
/**
|
|
* Perform a real atomic self-update of the binary at `targetPath` (defaults to
|
|
* the running binary, `process.execPath`). Returns a tagged result; never
|
|
* throws. On any failure the original binary is left untouched.
|
|
*/
|
|
export async function runBinarySelfUpdate(
|
|
targetPath: string = process.execPath,
|
|
deps: BinarySelfUpdateDeps = {},
|
|
): Promise<BinarySelfUpdateResult> {
|
|
const platform = deps.platform ?? process.platform;
|
|
const arch = deps.arch ?? process.arch;
|
|
const fetchRelease = deps.fetchRelease ?? defaultFetchRelease;
|
|
const download = deps.download ?? defaultDownload;
|
|
const smoke = deps.smoke ?? defaultSmoke;
|
|
|
|
const assetName = expectedAssetName(platform, arch);
|
|
if (!assetName) {
|
|
return { ok: false, reason: 'unsupported_platform' };
|
|
}
|
|
|
|
const release = await fetchRelease();
|
|
if (!release) {
|
|
return { ok: false, reason: 'fetch_failed', asset: assetName };
|
|
}
|
|
|
|
const url = resolvePlatformAsset(release.assets, platform, arch);
|
|
if (!url) {
|
|
return { ok: false, reason: 'no_asset', asset: assetName };
|
|
}
|
|
|
|
// Stage in a temp sibling so the rename is same-filesystem (atomic).
|
|
const staged = join(dirname(targetPath), `.${assetName}.tmp.${process.pid}.${_tmpCounter++}`);
|
|
try {
|
|
await download(url, staged);
|
|
} catch (e) {
|
|
safeUnlink(staged);
|
|
return { ok: false, reason: 'download_failed', error: errMsg(e), asset: assetName };
|
|
}
|
|
|
|
try {
|
|
chmodSync(staged, 0o755);
|
|
} catch (e) {
|
|
safeUnlink(staged);
|
|
return { ok: false, reason: 'download_failed', error: errMsg(e), asset: assetName };
|
|
}
|
|
|
|
if (!smoke(staged)) {
|
|
safeUnlink(staged);
|
|
return { ok: false, reason: 'smoke_failed', asset: assetName };
|
|
}
|
|
|
|
try {
|
|
renameSync(staged, targetPath); // atomic on same fs; old binary intact if this throws
|
|
} catch (e) {
|
|
safeUnlink(staged);
|
|
return { ok: false, reason: 'replace_failed', error: errMsg(e), asset: assetName };
|
|
}
|
|
|
|
return { ok: true, asset: assetName };
|
|
}
|
|
|
|
function safeUnlink(path: string): void {
|
|
try {
|
|
unlinkSync(path);
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
|
|
function errMsg(e: unknown): string {
|
|
return e instanceof Error ? e.message : String(e);
|
|
}
|