mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.31.11 feat: thin-client auto-upgrade prompt (notice + act on remote bumps) (#816)
* feat: thin-client upgrade prompt core (orchestrator + helpers) Adds the maybePromptForUpgrade orchestrator with lockfile gating, atomic state-file IO, per-entry shape validation, decision matrix, D5 binary-advance verifier, prompt-scoped SIGINT handler, and DI seams for tests. Sibling helper promptLineStderr in cli-util.ts resolves to null on stdin EOF or after a 5min timeout instead of hanging. 50 unit tests, all green. Not wired into the CLI yet — that's the next commit. * feat: wire thin-client upgrade prompt into the identity banner printIdentityBannerBestEffort calls maybePromptForUpgrade after the banner prints (both cache hit and cache miss paths). bannerSuppressed + BrainIdentity are now exported for the orchestrator's consumption. bannerSuppressed early return guarantees bannerIsSuppressed=false at the call site. * feat: gbrain remote doctor — thin_client_upgrade_drift check Surfaces remote-version drift in non-TTY/quiet/CI contexts where the interactive prompt is suppressed. Returns ok+inconclusive on network error (informational; mcp_smoke covers the genuinely-down case with fail). Returns ok on local>=remote or patch drift; warn on minor/major drift with a fix hint pointing at gbrain upgrade, or the manual install URL if state shows a prior failed attempt. Test fixture now dispatches JSON-RPC tools/call by tool name so runUpgradeDriftCheck can exercise the full happy + prior_failed + stale-version paths against a real-shape MCP response. * chore: bump version and changelog (v0.31.11) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
cb5bf1d332
commit
0410dc4b42
@@ -2,6 +2,75 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.31.11] - 2026-05-10
|
||||
|
||||
**Thin-client gbrain notices when the remote brain has upgraded and offers to bring you along.**
|
||||
|
||||
If you're running `gbrain` against a remote `gbrain serve --http` host (the thin-client install from v0.31.1), each command now compares your local CLI version against the remote brain. When the remote has a newer minor or major release, you get a one-line prompt: `Upgrade local CLI now? [Y/n]`. Press Enter, the upgrade runs, the binary advances, your command re-prompts you to retype. Patch drift stays silent. Sticky decline per remote+version so you don't get re-asked every command after saying no. `gbrain remote doctor` surfaces the same drift as a warn-level check for quiet operators and CI.
|
||||
|
||||
### What you can now do
|
||||
|
||||
**Stop running stale binaries against a fresh brain.** Before this release, a thin-client install at v0.31.4 talking to a v0.32.0 brain would silently use the older protocol surface until the user noticed something was broken. The drift was invisible. Now the banner that already prints `[thin-client → host · brain: ... · v0.32.0]` follows up with an interactive upgrade prompt when local lags remote.
|
||||
|
||||
**Decline once, stay declined.** If you press `n`, the prompt remembers your answer for that exact remote+version combination in `~/.gbrain/upgrade-prompt-state.json`. Subsequent commands are silent. When the remote bumps again, you get fresh prompts because the version key changed. Independent answers per remote brain (`work` vs `home`) via mcp_url-scoped state.
|
||||
|
||||
**See drift in `gbrain remote doctor` even in non-TTY contexts.** The new `thin_client_upgrade_drift` check fires `warn` when minor/major drift is detected, with a fix hint pointing at `gbrain upgrade`. If a prior in-process upgrade attempt failed (state file shows `last_response: failed` for the same remote version), the hint pivots to the manual install URL so you don't loop on a broken binary.
|
||||
|
||||
**Concurrent OpenClaw subagents don't fight over the TTY.** One advisory lockfile (`~/.gbrain/upgrade-prompt.lock`) gates the prompt. If two subagents both detect drift at the same instant, one prompts and the rest silently no-op. Stale-lock reclaim after 60s handles the case where a prompting process died mid-question.
|
||||
|
||||
**Ctrl-C escapes the prompt cleanly.** A prompt-scoped SIGINT handler installs around the read and removes after, so pressing Ctrl-C exits 130 (the standard `network/aborted` code) instead of being swallowed by the outer dispatcher's AbortController-only handler.
|
||||
|
||||
### Numbers that matter
|
||||
|
||||
| Failure mode | Before v0.31.11 | After v0.31.11 |
|
||||
|---|---|---|
|
||||
| Local v0.31.4, remote v0.32.0 (minor drift) | No signal; user runs stale CLI until something breaks | Banner + interactive prompt; one Enter to upgrade |
|
||||
| User declines, runs `gbrain query` again | Re-prompted on every command | Sticky decline per (mcp_url, remote_version) |
|
||||
| `gbrain upgrade` runs but doesn't advance the binary (catch-and-print path) | Silent loop on broken binary | State=failed, exit 1, doctor hint pivots to manual install URL |
|
||||
| Two OpenClaw subagents both detect drift | Interleaved prompts on same TTY, both read each other's keystrokes | One prompts, others no-op via advisory lockfile |
|
||||
| Quiet/non-TTY/CI run with drift | No signal | `gbrain remote doctor` warns with fix hint |
|
||||
| Ctrl-C during the prompt | Swallowed (outer SIGINT handler installed already) | Exits 130 cleanly |
|
||||
| Stdin EOF mid-prompt (terminal closes, /dev/null piped past TTY check) | Hangs forever on `stdin.once('data')` | Resolves to null after 5min timeout; orchestrator treats as silent decline, no state write |
|
||||
|
||||
### What this means for your workflow
|
||||
|
||||
If you've been running `gbrain init --mcp-only` thin-client installs across a few machines, this closes the last "why is my brain confused?" failure mode: the answer was always "your local CLI is six releases behind the brain." Now the CLI tells you. The prompt fires at most once per command per (remote, version) and stays out of the way for quiet/non-TTY runs. CI sees the drift in `gbrain remote doctor`.
|
||||
|
||||
## To take advantage of v0.31.11
|
||||
|
||||
`gbrain upgrade` should land this automatically. If you're already on v0.31.11 the first time a remote brain you talk to bumps past you, you'll see the prompt. Nothing to configure.
|
||||
|
||||
1. **Verify the doctor check is wired:**
|
||||
```bash
|
||||
gbrain remote doctor --json | jq '.checks[] | select(.name == "thin_client_upgrade_drift")'
|
||||
```
|
||||
You should see either `status: "ok"` (local equals remote) or `status: "warn"` (drift detected, fix hint included).
|
||||
|
||||
2. **State file lives at `~/.gbrain/upgrade-prompt-state.json`.** It's keyed by mcp_url so multiple remote brains have isolated decline history. Delete the file to reset all answers.
|
||||
|
||||
3. **Lockfile lives at `~/.gbrain/upgrade-prompt.lock`.** If a prompt died without cleaning up, the next invocation reclaims after 60s of stale mtime.
|
||||
|
||||
4. **If any step fails or the prompt fires when it shouldn't,** please file an issue:
|
||||
https://github.com/garrytan/gbrain/issues with the output of `gbrain remote doctor --json` and the contents of `~/.gbrain/upgrade-prompt-state.json` if it exists.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
- `src/core/thin-client-upgrade-prompt.ts` — `maybePromptForUpgrade` orchestrator with full DI seams for tests. Lockfile gating (`acquirePromptLock` with stale-reclaim >60s mtime), atomic state-file IO via tmp+rename, per-entry shape validator that drops malformed entries while preserving valid siblings. Pure `decideAction` returns `prompt` or `noop` based on TTY gates, banner-suppressed, sticky decline, sticky yes, prior-failed re-prompt, and the D8 patch-drift-silent rule. `verifyUpgradeAdvanced` (D5) re-reads `gbrain --version` from a fresh subprocess to detect catch-and-print upgrade paths that return 0 without advancing the binary. Prompt-scoped SIGINT handler exits 130 cleanly.
|
||||
- `src/core/cli-util.ts` — `promptLineStderr(prompt, {timeoutMs})` sibling to `promptLine`. Resolves to `string` on stdin data, `null` on stdin EOF or after the 5-minute default timeout. Never rejects. Listener cleanup is idempotent via a `settled` guard.
|
||||
- `src/core/doctor-remote.ts` — `runUpgradeDriftCheck(config)` new check `thin_client_upgrade_drift`. Returns ok+inconclusive on network error (informational; the earlier `mcp_smoke` check covers the genuinely-unreachable case). Returns ok on local≥remote OR patch drift. Returns warn on minor/major drift with a fix hint that pivots to the manual install URL if a prior `gbrain upgrade` is recorded as `failed` for the same remote version.
|
||||
- `src/cli.ts` — exported `bannerSuppressed` + `BrainIdentity`; wired `maybePromptForUpgrade` into both banner code paths (cache hit and cache miss). Banner-suppressed early return guarantees `bannerIsSuppressed=false` at the call site.
|
||||
|
||||
#### Changed
|
||||
- `PromptReader` type now returns `Promise<string | null>` to accommodate EOF/timeout. Orchestrator handles null as silent decline with no state write (a transient stdin closure must not poison the per-version sticky-decline gate).
|
||||
|
||||
#### Tests
|
||||
- 63 new tests across `test/thin-client-upgrade-prompt.test.ts` (50 cases: safeCompare, driftLevel, state-file IO including atomic-write and corrupt-JSON fallthrough, every decision-matrix row, lockfile acquire/EEXIST/stale-reclaim, orchestrator yes/no/advanced/not-advanced/threw/null-from-EOF, SIGINT install/remove lifecycle including cleanup-on-throw, malformed-entry filtering preserves valid siblings, empty-key dropped) and `test/doctor-remote.test.ts` (5 new cases for `runUpgradeDriftCheck`: unreachable host returns ok+inconclusive, major drift no prior state shows auto-upgrade hint, major drift with prior_failed shows manual install hint, prior_failed for older version does NOT pivot stale, local equals remote returns ok). 100% pass.
|
||||
- Test fixture in `test/doctor-remote.test.ts` extended to dispatch JSON-RPC `tools/call` by tool name via per-test `mcpToolResults` map.
|
||||
|
||||
#### For contributors
|
||||
- The `_setVerifierForTest`, `_setPromptReaderForTest`, `_setUpgradeRunnerForTest` injection seams in `thin-client-upgrade-prompt.ts` follow the same pattern as `_clearIdentityCacheForTest` in `src/cli.ts` so tests can drive the orchestrator end-to-end without spawning subprocesses or touching the user's PATH.
|
||||
|
||||
## [0.31.10] - 2026-05-10
|
||||
|
||||
**Setup hands you a real bootstrap, not an empty brain. New `cold-start` skill sequences day-1 imports across markdown, contacts, calendar, email, conversations, X, archives, and meeting transcripts. New `ask-user` choice-gate pattern. Phase J in `setup` auto-launches cold-start when verification passes.**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.31.10",
|
||||
"version": "0.31.11",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
|
||||
+8
-2
@@ -14,6 +14,7 @@ import { serializeMarkdown } from './core/markdown.ts';
|
||||
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
|
||||
import type { CliOptions } from './core/cli-options.ts';
|
||||
import { callRemoteTool, RemoteMcpError, unpackToolResult } from './core/mcp-client.ts';
|
||||
import { maybePromptForUpgrade } from './core/thin-client-upgrade-prompt.ts';
|
||||
import { VERSION } from './version.ts';
|
||||
|
||||
// Build CLI name -> operation lookup
|
||||
@@ -321,7 +322,7 @@ async function runThinClientRouted(
|
||||
// command runs normally. Banner is observability, not load-bearing.
|
||||
// ============================================================================
|
||||
|
||||
interface BrainIdentity {
|
||||
export interface BrainIdentity {
|
||||
version: string;
|
||||
engine: 'postgres' | 'pglite';
|
||||
page_count: number;
|
||||
@@ -342,7 +343,7 @@ export function _clearIdentityCacheForTest(): void {
|
||||
identityCache.clear();
|
||||
}
|
||||
|
||||
function bannerSuppressed(cliOpts: CliOptions): boolean {
|
||||
export function bannerSuppressed(cliOpts: CliOptions): boolean {
|
||||
if (cliOpts.quiet) return true;
|
||||
if (process.env.GBRAIN_NO_BANNER === '1') return true;
|
||||
// Non-TTY default is suppressed (clean pipes); explicit env-flag overrides.
|
||||
@@ -391,6 +392,9 @@ async function printIdentityBannerBestEffort(
|
||||
const cached = identityCache.get(mcpUrl);
|
||||
if (cached && Date.now() - cached.cached_at_ms < IDENTITY_TTL_MS) {
|
||||
process.stderr.write(formatBanner(mcpUrl, cached.identity) + '\n');
|
||||
// v0.31.11: detect remote-version drift, prompt user to upgrade.
|
||||
// bannerIsSuppressed=false here — the early return above guaranteed it.
|
||||
await maybePromptForUpgrade(cfg, cached.identity, cliOpts, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -400,6 +404,8 @@ async function printIdentityBannerBestEffort(
|
||||
const id = await fetchIdentity(cfg, signal);
|
||||
identityCache.set(mcpUrl, { identity: id, cached_at_ms: Date.now() });
|
||||
process.stderr.write(formatBanner(mcpUrl, id) + '\n');
|
||||
// v0.31.11: detect remote-version drift, prompt user to upgrade.
|
||||
await maybePromptForUpgrade(cfg, id, cliOpts, false);
|
||||
} catch {
|
||||
// Swallow. Banner suppressed; main command continues. The CDX-4
|
||||
// hardened callRemoteTool will surface the same error class on the
|
||||
|
||||
@@ -14,3 +14,59 @@ export function promptLine(prompt: string): Promise<string> {
|
||||
process.stdin.resume();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as promptLine, but writes the prompt to stderr instead of stdout AND
|
||||
* resolves to `null` on stdin EOF or after `timeoutMs` (default 5 minutes)
|
||||
* instead of hanging forever. Used when the surrounding command must keep
|
||||
* stdout clean for machine-readable output (JSON, piped data); the thin-client
|
||||
* upgrade prompt fires before any routed command runs and would otherwise
|
||||
* pollute `gbrain query > out.json`.
|
||||
*
|
||||
* Return contract:
|
||||
* - resolves to a `string` (trimmed) on the first stdin data event
|
||||
* - resolves to `null` on stdin 'end' (parent shell closed, /dev/null piped
|
||||
* past the TTY check, etc.) or after `timeoutMs` elapses
|
||||
* - never rejects
|
||||
*
|
||||
* Callers MUST handle null explicitly — it is NOT the same as an empty string
|
||||
* (which means "user pressed Enter").
|
||||
*/
|
||||
export function promptLineStderr(prompt: string, opts: { timeoutMs?: number } = {}): Promise<string | null> {
|
||||
const timeoutMs = opts.timeoutMs ?? 300_000;
|
||||
return new Promise((resolve) => {
|
||||
process.stderr.write(prompt);
|
||||
process.stdin.setEncoding('utf-8');
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const cleanup = () => {
|
||||
process.stdin.removeListener('data', onData);
|
||||
process.stdin.removeListener('end', onEnd);
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
process.stdin.pause();
|
||||
};
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(chunk.toString().trim());
|
||||
};
|
||||
const onEnd = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
}
|
||||
process.stdin.once('data', onData);
|
||||
process.stdin.once('end', onEnd);
|
||||
process.stdin.resume();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
import type { GBrainConfig } from './config.ts';
|
||||
import { discoverOAuth, mintClientCredentialsToken, smokeTestMcp } from './remote-mcp-probe.ts';
|
||||
import { callRemoteTool, RemoteMcpError } from './mcp-client.ts';
|
||||
import { callRemoteTool, RemoteMcpError, unpackToolResult } from './mcp-client.ts';
|
||||
import { safeCompare, driftLevel, loadPromptState } from './thin-client-upgrade-prompt.ts';
|
||||
import { VERSION } from '../version.ts';
|
||||
|
||||
export interface RemoteCheck {
|
||||
name: string;
|
||||
@@ -218,9 +220,99 @@ export async function collectRemoteDoctorReport(
|
||||
checks.push(buildScopeCheck(grantedScope, scopeResult));
|
||||
}
|
||||
|
||||
// 6. v0.31.11: thin-client version-drift check. Calls get_brain_identity
|
||||
// to compare local CLI version against remote brain version. Reports:
|
||||
// - 'ok' when local >= remote OR drift is 'patch' (D8 policy: only
|
||||
// minor/major drift is meaningful enough to flag in doctor)
|
||||
// - 'warn' when minor/major drift detected; fix hint points at
|
||||
// `gbrain upgrade` (or, if state shows a prior 'failed' attempt,
|
||||
// points at the manual install path)
|
||||
// - 'ok' (informational) when network unreachable / fetch throws —
|
||||
// doctor MUST NOT fail loud on transient network issues; this check
|
||||
// is informational, the earlier mcp_smoke would have already failed
|
||||
// hard if the remote is genuinely down.
|
||||
if (!skipProbe) {
|
||||
checks.push(await runUpgradeDriftCheck(config));
|
||||
}
|
||||
|
||||
return finalize(remote, checks, tokenRes.token.scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.31.11: thin-client version-drift check. Surfaces remote-brain drift in
|
||||
* `gbrain doctor` so quiet/non-TTY users (who don't see the interactive
|
||||
* prompt) still learn about minor/major bumps. Pure data fetch + compare.
|
||||
*
|
||||
* Errors are non-fatal: any failure returns an 'ok' status with a
|
||||
* `network_error` detail. The earlier `mcp_smoke` check covers the
|
||||
* "remote is genuinely unreachable" case with a 'fail' status.
|
||||
*/
|
||||
export async function runUpgradeDriftCheck(config: GBrainConfig): Promise<RemoteCheck> {
|
||||
let remoteVersion: string;
|
||||
try {
|
||||
const raw = await callRemoteTool(config, 'get_brain_identity', {}, { timeoutMs: 2000 });
|
||||
const identity = unpackToolResult<{ version: string }>(raw);
|
||||
remoteVersion = identity.version;
|
||||
} catch (e) {
|
||||
return {
|
||||
name: 'thin_client_upgrade_drift',
|
||||
status: 'ok',
|
||||
message: 'could not fetch remote version (network or scope error); see other checks',
|
||||
detail: { error: e instanceof Error ? e.message : String(e), inconclusive: true },
|
||||
};
|
||||
}
|
||||
|
||||
const cmp = safeCompare(VERSION, remoteVersion);
|
||||
if (cmp === null) {
|
||||
return {
|
||||
name: 'thin_client_upgrade_drift',
|
||||
status: 'ok',
|
||||
message: `version comparison inconclusive (local=${VERSION}, remote=${remoteVersion})`,
|
||||
detail: { local: VERSION, remote: remoteVersion, inconclusive: true },
|
||||
};
|
||||
}
|
||||
if (cmp >= 0) {
|
||||
return {
|
||||
name: 'thin_client_upgrade_drift',
|
||||
status: 'ok',
|
||||
message: `local v${VERSION} ≥ remote v${remoteVersion}`,
|
||||
detail: { local: VERSION, remote: remoteVersion },
|
||||
};
|
||||
}
|
||||
const level = driftLevel(VERSION, remoteVersion);
|
||||
if (level === 'patch') {
|
||||
return {
|
||||
name: 'thin_client_upgrade_drift',
|
||||
status: 'ok',
|
||||
message: `local v${VERSION}, remote v${remoteVersion} (patch drift; not flagged)`,
|
||||
detail: { local: VERSION, remote: remoteVersion, level },
|
||||
};
|
||||
}
|
||||
|
||||
// Minor or major drift. Check the prompt-state file: if a prior 'failed'
|
||||
// attempt is recorded for this remote+version, point users at the manual
|
||||
// install path instead of the auto-upgrade command.
|
||||
let priorFailed = false;
|
||||
try {
|
||||
const state = loadPromptState();
|
||||
const entry = state.entries[config.remote_mcp?.mcp_url ?? ''];
|
||||
if (entry && entry.last_response === 'failed' && entry.last_prompted_remote_version === remoteVersion) {
|
||||
priorFailed = true;
|
||||
}
|
||||
} catch { /* state read is best-effort */ }
|
||||
|
||||
const fixHint = priorFailed
|
||||
? `Prior \`gbrain upgrade\` did not advance the binary. See https://github.com/garrytan/gbrain/releases for manual install.`
|
||||
: `Run \`gbrain upgrade\` to install v${remoteVersion}.`;
|
||||
|
||||
return {
|
||||
name: 'thin_client_upgrade_drift',
|
||||
status: 'warn',
|
||||
message: `${level} upgrade available: local v${VERSION} → remote v${remoteVersion}. ${fixHint}`,
|
||||
detail: { local: VERSION, remote: remoteVersion, level, prior_failed: priorFailed },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.31.1: minimal probe of the read + admin scope tiers via two harmless
|
||||
* read-only MCP calls. Write tier is NOT probed (no benign write op exists
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* v0.31.11 (Issue: thin-client auto-upgrade): when a thin-client install detects
|
||||
* that the remote `gbrain serve --http` host is running a newer version (minor
|
||||
* or major drift), prompt the user interactively to run `gbrain upgrade`.
|
||||
*
|
||||
* Hook seam: called from `printIdentityBannerBestEffort` in `src/cli.ts` after
|
||||
* the banner prints. The function short-circuits in every non-applicable case
|
||||
* so the banner code stays a thin caller.
|
||||
*
|
||||
* Eng-review-locked invariants (D1-D8 in plan):
|
||||
* - D1 — On successful upgrade, exit 0 with a re-run message. Do NOT continue
|
||||
* the original command on the stale in-memory binary.
|
||||
* - D2 — Exclusive non-blocking advisory lock around the prompt; loser no-ops.
|
||||
* - D5 — Re-read `gbrain --version` post-upgrade to verify the binary actually
|
||||
* advanced. `gbrain upgrade` returns 0 even on bun/clawhub catch-and-print
|
||||
* paths and on the `binary` install ("not yet implemented").
|
||||
* - D6 — Gate on both stdin AND stdout TTY; prompt writes to stderr.
|
||||
* - D7 — When `bannerSuppressed()` is true, emit nothing about upgrades.
|
||||
* - D8 — Only prompt on minor/major drift; patch drift silent.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, statSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import { compareVersions } from '../commands/migrations/index.ts';
|
||||
import { gbrainPath } from './config.ts';
|
||||
import type { GBrainConfig } from './config.ts';
|
||||
import { promptLineStderr } from './cli-util.ts';
|
||||
import type { CliOptions } from './cli-options.ts';
|
||||
|
||||
// Structural shape of the banner identity payload. Defined here (not imported
|
||||
// from src/cli.ts) to avoid a circular import; cli.ts's BrainIdentity is
|
||||
// structurally compatible.
|
||||
export interface BrainIdentityShape {
|
||||
version: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pure helpers (compile-time tested)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate a version string before handing it to `compareVersions`. Accepts
|
||||
* 3-segment or 4-segment dotted-numeric forms (digits only, no suffix). Anything
|
||||
* else fails closed.
|
||||
*/
|
||||
function isValidSemverLike(v: string): boolean {
|
||||
if (typeof v !== 'string' || v.length === 0) return false;
|
||||
const parts = v.split('.');
|
||||
if (parts.length < 3 || parts.length > 4) return false;
|
||||
for (const p of parts) {
|
||||
if (p.length === 0) return false;
|
||||
if (!/^\d+$/.test(p)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Null-safe wrapper over the existing `compareVersions` from
|
||||
* `src/commands/migrations/index.ts`. Returns null if either input is malformed
|
||||
* so callers can fail closed (no prompt) rather than firing on garbage.
|
||||
*/
|
||||
export function safeCompare(a: string, b: string): -1 | 0 | 1 | null {
|
||||
if (!isValidSemverLike(a) || !isValidSemverLike(b)) return null;
|
||||
return compareVersions(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the highest-segment difference between two versions. `'patch'` is
|
||||
* the lowest-significance bump; `'major'` is the highest. `'none'` means the
|
||||
* versions are equal (or local is ahead). Used by D8 to gate the prompt on
|
||||
* minor/major drift only.
|
||||
*/
|
||||
export function driftLevel(local: string, remote: string): 'major' | 'minor' | 'patch' | 'none' {
|
||||
if (!isValidSemverLike(local) || !isValidSemverLike(remote)) return 'none';
|
||||
const cmp = compareVersions(local, remote);
|
||||
if (cmp >= 0) return 'none';
|
||||
const la = local.split('.').map(n => parseInt(n, 10) || 0);
|
||||
const ra = remote.split('.').map(n => parseInt(n, 10) || 0);
|
||||
if ((la[0] ?? 0) !== (ra[0] ?? 0)) return 'major';
|
||||
if ((la[1] ?? 0) !== (ra[1] ?? 0)) return 'minor';
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State file
|
||||
// ============================================================================
|
||||
|
||||
export type LastResponse = 'yes' | 'no' | 'failed';
|
||||
|
||||
export interface PromptStateEntry {
|
||||
last_prompted_remote_version: string;
|
||||
last_response: LastResponse;
|
||||
last_prompted_at_iso: string;
|
||||
}
|
||||
|
||||
export interface PromptState {
|
||||
schema_version: 1;
|
||||
entries: Record<string, PromptStateEntry>;
|
||||
}
|
||||
|
||||
function statePath(): string {
|
||||
return gbrainPath('upgrade-prompt-state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-entry shape validator. Drops entries that don't have the expected fields
|
||||
* with the expected types instead of trusting `parsed as PromptState`. Defense
|
||||
* in depth against hand-edited files, schema migrations from a future version,
|
||||
* or partial truncation that left valid-JSON but malformed entries. Today every
|
||||
* comparison in `decideAction` and `runUpgradeDriftCheck` is strict-equality so
|
||||
* a malformed entry would fall through correctly, but this guards against a
|
||||
* future caller that destructures fields (which would crash on undefined).
|
||||
*/
|
||||
function isValidPromptStateEntry(value: unknown): value is PromptStateEntry {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const e = value as Record<string, unknown>;
|
||||
if (typeof e.last_prompted_remote_version !== 'string') return false;
|
||||
if (typeof e.last_prompted_at_iso !== 'string') return false;
|
||||
if (e.last_response !== 'yes' && e.last_response !== 'no' && e.last_response !== 'failed') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function loadPromptState(): PromptState {
|
||||
const path = statePath();
|
||||
if (!existsSync(path)) return { schema_version: 1, entries: {} };
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
parsed.schema_version !== 1 ||
|
||||
typeof parsed.entries !== 'object' ||
|
||||
parsed.entries === null
|
||||
) {
|
||||
return { schema_version: 1, entries: {} };
|
||||
}
|
||||
// Filter malformed entries — keep valid ones, drop bad ones silently. A
|
||||
// bad neighbor in the same file MUST NOT poison the good entries.
|
||||
const validatedEntries: Record<string, PromptStateEntry> = {};
|
||||
for (const [key, entry] of Object.entries(parsed.entries as Record<string, unknown>)) {
|
||||
if (typeof key !== 'string' || key.length === 0) continue;
|
||||
if (isValidPromptStateEntry(entry)) validatedEntries[key] = entry;
|
||||
}
|
||||
return { schema_version: 1, entries: validatedEntries };
|
||||
} catch {
|
||||
// Corrupt JSON or truncated mid-write file → fall through to empty state.
|
||||
// Production write goes through atomic rename; this branch is defense in depth.
|
||||
return { schema_version: 1, entries: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write: tmp file + rename. SIGKILL mid-write must not leave truncated
|
||||
* JSON the next invocation can't parse.
|
||||
*/
|
||||
export function savePromptState(state: PromptState): void {
|
||||
const path = statePath();
|
||||
ensureDir(dirname(path));
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
function ensureDir(path: string): void {
|
||||
try { mkdirSync(path, { recursive: true }); } catch { /* race: another process created it; fine */ }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lockfile (D2)
|
||||
// ============================================================================
|
||||
|
||||
const LOCK_FILENAME = 'upgrade-prompt.lock';
|
||||
const STALE_LOCK_MS = 60_000;
|
||||
|
||||
export interface PromptLock {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
function lockPath(): string {
|
||||
return gbrainPath(LOCK_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive non-blocking advisory lock. Returns null on EEXIST
|
||||
* (sibling process owns the prompt; caller no-ops). Stale-lock reclaim: if
|
||||
* the existing lockfile's mtime is >60s old, unlink and retry once.
|
||||
*/
|
||||
export function acquirePromptLock(): PromptLock | null {
|
||||
const path = lockPath();
|
||||
ensureDir(dirname(path));
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const fd = openSync(path, 'wx+');
|
||||
let released = false;
|
||||
return {
|
||||
release(): void {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try { closeSync(fd); } catch { /* ignore */ }
|
||||
try { unlinkSync(path); } catch { /* ignore */ }
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
if (code !== 'EEXIST') return null;
|
||||
// EEXIST — check staleness
|
||||
try {
|
||||
const st = statSync(path);
|
||||
if (Date.now() - st.mtimeMs > STALE_LOCK_MS) {
|
||||
try { unlinkSync(path); } catch { /* ignore */ }
|
||||
continue; // retry openSync
|
||||
}
|
||||
} catch { /* stat failed, give up */ }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decision matrix (pure)
|
||||
// ============================================================================
|
||||
|
||||
export interface DecisionInput {
|
||||
localVersion: string;
|
||||
remoteVersion: string;
|
||||
mcpUrl: string;
|
||||
state: PromptState;
|
||||
cliOpts: CliOptions;
|
||||
stdinIsTty: boolean;
|
||||
stdoutIsTty: boolean;
|
||||
bannerIsSuppressed: boolean;
|
||||
}
|
||||
|
||||
export type Decision = { kind: 'prompt'; level: 'major' | 'minor' } | { kind: 'noop' };
|
||||
|
||||
export function decideAction(input: DecisionInput): Decision {
|
||||
const cmp = safeCompare(input.localVersion, input.remoteVersion);
|
||||
if (cmp === null || cmp >= 0) return { kind: 'noop' };
|
||||
|
||||
const level = driftLevel(input.localVersion, input.remoteVersion);
|
||||
// D8 — patch drift silent.
|
||||
if (level === 'patch' || level === 'none') return { kind: 'noop' };
|
||||
|
||||
// D7 — banner suppressed = upgrade affordance silent.
|
||||
if (input.bannerIsSuppressed) return { kind: 'noop' };
|
||||
|
||||
// D6 — both TTY gates.
|
||||
if (!input.stdinIsTty || !input.stdoutIsTty) return { kind: 'noop' };
|
||||
|
||||
const entry = input.state.entries[input.mcpUrl];
|
||||
if (entry && entry.last_prompted_remote_version === input.remoteVersion) {
|
||||
if (entry.last_response === 'no') return { kind: 'noop' };
|
||||
if (entry.last_response === 'yes') return { kind: 'noop' };
|
||||
// 'failed' → fall through and re-prompt.
|
||||
}
|
||||
|
||||
return { kind: 'prompt', level };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Upgrade-success verification (D5)
|
||||
// ============================================================================
|
||||
|
||||
export type Verifier = (remoteVersion: string) => { advanced: boolean; newVersion: string | null };
|
||||
|
||||
let _verifier: Verifier = defaultVerifyUpgradeAdvanced;
|
||||
|
||||
export function _setVerifierForTest(fn: Verifier | null): void {
|
||||
_verifier = fn ?? defaultVerifyUpgradeAdvanced;
|
||||
}
|
||||
|
||||
function defaultVerifyUpgradeAdvanced(remoteVersion: string): { advanced: boolean; newVersion: string | null } {
|
||||
try {
|
||||
// Spawn `gbrain --version` as a fresh subprocess so we read the NEW binary
|
||||
// the upgrade just installed (not the old VERSION constant baked into the
|
||||
// currently-running process). Output shape: "gbrain X.Y.Z" or just "X.Y.Z".
|
||||
const out = execFileSync('gbrain', ['--version'], { encoding: 'utf-8', timeout: 10_000 });
|
||||
const match = out.trim().match(/(\d+\.\d+\.\d+(?:\.\d+)?)/);
|
||||
if (!match) return { advanced: false, newVersion: null };
|
||||
const newVersion = match[1];
|
||||
const cmp = safeCompare(newVersion, remoteVersion);
|
||||
return { advanced: cmp !== null && cmp >= 0, newVersion };
|
||||
} catch {
|
||||
return { advanced: false, newVersion: null };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt reader injection (for unit tests)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Prompt reader contract: returns the trimmed user input, OR `null` if stdin
|
||||
* EOFed or the read timed out. The orchestrator interprets null as "user is
|
||||
* not available to answer" — silent decline, no state write — so a transient
|
||||
* stdin closure doesn't poison the per-version sticky-decline gate.
|
||||
*/
|
||||
export type PromptReader = (prompt: string) => Promise<string | null>;
|
||||
|
||||
let _promptReader: PromptReader = promptLineStderr;
|
||||
|
||||
export function _setPromptReaderForTest(fn: PromptReader | null): void {
|
||||
_promptReader = fn ?? promptLineStderr;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Upgrade runner injection (for unit tests)
|
||||
// ============================================================================
|
||||
|
||||
export type UpgradeRunner = () => void;
|
||||
|
||||
function defaultRunUpgrade(): void {
|
||||
execSync('gbrain upgrade', { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
let _upgradeRunner: UpgradeRunner = defaultRunUpgrade;
|
||||
|
||||
export function _setUpgradeRunnerForTest(fn: UpgradeRunner | null): void {
|
||||
_upgradeRunner = fn ?? defaultRunUpgrade;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test escape: clear in-process state file path memo (none today, but matches
|
||||
// the _clearIdentityCacheForTest pattern for future-proofing).
|
||||
// ============================================================================
|
||||
|
||||
export function _clearPromptStateForTest(): void {
|
||||
// No in-process state to clear today; the file lives on disk and tests use
|
||||
// GBRAIN_HOME tempdirs for isolation. This stub exists for symmetry with
|
||||
// _clearIdentityCacheForTest in src/cli.ts so future caching can hook here.
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Orchestrator
|
||||
// ============================================================================
|
||||
|
||||
export interface PromptDeps {
|
||||
localVersion?: string;
|
||||
exit?: (code: number) => never;
|
||||
log?: (msg: string) => void;
|
||||
/** Test-only override; production reads `process.stdin.isTTY`. */
|
||||
stdinIsTty?: boolean;
|
||||
/** Test-only override; production reads `process.stdout.isTTY`. */
|
||||
stdoutIsTty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entrypoint called from `printIdentityBannerBestEffort`. Short-circuits
|
||||
* (returns) in every no-op case. On the prompt path:
|
||||
*
|
||||
* yes + upgrade advanced → persist 'yes', print re-run msg, exit 0
|
||||
* yes + upgrade NOT advanced → persist 'failed', print didn't-advance msg, exit 1
|
||||
* yes + upgrade threw → persist 'failed', print error msg, exit 1
|
||||
* no → persist 'no', return (caller continues)
|
||||
*
|
||||
* NEVER throws. Best-effort: any unexpected error in state IO swallows and
|
||||
* falls through to no-op, matching the banner's "observability not load-bearing"
|
||||
* posture.
|
||||
*/
|
||||
export async function maybePromptForUpgrade(
|
||||
cfg: GBrainConfig,
|
||||
identity: BrainIdentityShape,
|
||||
cliOpts: CliOptions,
|
||||
bannerIsSuppressed: boolean,
|
||||
deps: PromptDeps = {},
|
||||
): Promise<void> {
|
||||
const localVersion = deps.localVersion ?? (await import('../version.ts')).VERSION;
|
||||
const exitFn = deps.exit ?? ((code: number) => process.exit(code));
|
||||
const log = deps.log ?? ((msg: string) => process.stderr.write(msg + '\n'));
|
||||
|
||||
const mcpUrl = cfg.remote_mcp?.mcp_url;
|
||||
if (!mcpUrl) return;
|
||||
|
||||
let state: PromptState;
|
||||
try {
|
||||
state = loadPromptState();
|
||||
} catch {
|
||||
state = { schema_version: 1, entries: {} };
|
||||
}
|
||||
|
||||
const decision = decideAction({
|
||||
localVersion,
|
||||
remoteVersion: identity.version,
|
||||
mcpUrl,
|
||||
state,
|
||||
cliOpts,
|
||||
stdinIsTty: deps.stdinIsTty ?? Boolean(process.stdin.isTTY),
|
||||
stdoutIsTty: deps.stdoutIsTty ?? Boolean(process.stdout.isTTY),
|
||||
bannerIsSuppressed,
|
||||
});
|
||||
|
||||
if (decision.kind === 'noop') return;
|
||||
|
||||
// Acquire lock. EEXIST → sibling owns the prompt, no-op silently.
|
||||
const lock = acquirePromptLock();
|
||||
if (!lock) return;
|
||||
|
||||
try {
|
||||
const levelWord = decision.level === 'major' ? 'major' : 'minor';
|
||||
const promptText =
|
||||
`Remote brain is on v${identity.version} (you're on v${localVersion}). This is a ${levelWord} upgrade.\n` +
|
||||
`Upgrade local CLI now? [Y/n] `;
|
||||
// Prompt-scoped SIGINT handler. Without this, the SIGINT handler installed
|
||||
// by `runThinClientRouted` (which only aborts an AbortController the prompt
|
||||
// doesn't observe) leaves the prompt unable to be cancelled with Ctrl-C —
|
||||
// default-terminate is suppressed once any listener exists. Add ours, run
|
||||
// the prompt, remove it. Exit 130 matches the existing `network/aborted`
|
||||
// exit code in the dispatcher's catch block.
|
||||
const onPromptSigint = () => exitFn(130);
|
||||
process.on('SIGINT', onPromptSigint);
|
||||
let rawAnswer: string | null;
|
||||
try {
|
||||
rawAnswer = await _promptReader(promptText);
|
||||
} finally {
|
||||
process.off('SIGINT', onPromptSigint);
|
||||
}
|
||||
// Null = stdin EOF or timeout. Silent decline, no state write — a transient
|
||||
// closure (terminal hangup, /dev/null pipe past TTY check) must not lock
|
||||
// out future prompts for this version. The caller's command continues.
|
||||
if (rawAnswer === null) return;
|
||||
const answer = rawAnswer.toLowerCase();
|
||||
const accepted = answer === '' || answer === 'y' || answer === 'yes';
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
if (!accepted) {
|
||||
writeStateBestEffort(state, mcpUrl, identity.version, 'no', nowIso);
|
||||
return; // caller continues with the original command
|
||||
}
|
||||
|
||||
// Run upgrade subprocess. May throw on real failure; D5 verification
|
||||
// catches the catch-and-print-advice paths.
|
||||
let upgradeThrew = false;
|
||||
let upgradeError: string | null = null;
|
||||
try {
|
||||
_upgradeRunner();
|
||||
} catch (e) {
|
||||
upgradeThrew = true;
|
||||
upgradeError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
if (upgradeThrew) {
|
||||
writeStateBestEffort(state, mcpUrl, identity.version, 'failed', nowIso);
|
||||
log(`[upgrade failed: ${upgradeError}]`);
|
||||
exitFn(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// D5 — verify the binary actually advanced.
|
||||
const result = _verifier(identity.version);
|
||||
if (!result.advanced) {
|
||||
writeStateBestEffort(state, mcpUrl, identity.version, 'failed', nowIso);
|
||||
log(
|
||||
`gbrain upgrade did not actually advance the binary` +
|
||||
(result.newVersion ? ` (still on v${result.newVersion})` : '') +
|
||||
`. See the output above for manual steps.`,
|
||||
);
|
||||
exitFn(1);
|
||||
return;
|
||||
}
|
||||
|
||||
writeStateBestEffort(state, mcpUrl, identity.version, 'yes', nowIso);
|
||||
log(`Upgrade complete. Re-run your command to use v${result.newVersion ?? identity.version}.`);
|
||||
exitFn(0);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
function writeStateBestEffort(
|
||||
state: PromptState,
|
||||
mcpUrl: string,
|
||||
remoteVersion: string,
|
||||
response: LastResponse,
|
||||
iso: string,
|
||||
): void {
|
||||
try {
|
||||
const next: PromptState = {
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
...state.entries,
|
||||
[mcpUrl]: {
|
||||
last_prompted_remote_version: remoteVersion,
|
||||
last_response: response,
|
||||
last_prompted_at_iso: iso,
|
||||
},
|
||||
},
|
||||
};
|
||||
savePromptState(next);
|
||||
} catch {
|
||||
// State write is best-effort; failing to persist shouldn't crash the user's
|
||||
// command (or block their upgrade). Worst case: prompt fires again next time.
|
||||
}
|
||||
}
|
||||
+187
-10
@@ -13,9 +13,13 @@
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { createServer, Server } from 'http';
|
||||
import { collectRemoteDoctorReport } from '../src/core/doctor-remote.ts';
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { collectRemoteDoctorReport, runUpgradeDriftCheck } from '../src/core/doctor-remote.ts';
|
||||
import type { GBrainConfig } from '../src/core/config.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import { VERSION } from '../src/version.ts';
|
||||
|
||||
// v0.31.1: the new oauth_client_scopes_probe check uses the MCP SDK Client
|
||||
// against /mcp, which the test fixture only mocks at JSON-RPC initialize
|
||||
@@ -35,6 +39,11 @@ let discoveryBody: unknown = null;
|
||||
let tokenStatus = 200;
|
||||
let tokenBody: unknown = null;
|
||||
let mcpStatus = 200;
|
||||
// v0.31.11: per-tool result map for `tools/call` JSON-RPC requests on /mcp.
|
||||
// Tests that exercise runUpgradeDriftCheck seed `mcpToolResults['get_brain_identity']`
|
||||
// with the version they want the fixture to advertise. Unset → fall through to
|
||||
// the legacy initialize-shaped response.
|
||||
let mcpToolResults: Record<string, { content: Array<{ type: string; text: string }> }> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
@@ -56,15 +65,40 @@ beforeAll(async () => {
|
||||
return;
|
||||
}
|
||||
if (req.url === '/mcp') {
|
||||
res.statusCode = mcpStatus;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
// MCP smoke doesn't strictly parse the body — any 2xx with the bearer
|
||||
// accepted is enough signal. We send a minimal initialize response.
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'fixture', version: '1' } },
|
||||
}));
|
||||
// v0.31.11: read body so we can dispatch by JSON-RPC method. Pre-v0.31.11
|
||||
// the fixture only handled `initialize` (mcp_smoke's only call); the new
|
||||
// upgrade-drift check needs `tools/call` for `get_brain_identity`.
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
res.statusCode = mcpStatus;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (mcpStatus !== 200) { res.end(); return; }
|
||||
let parsed: { id?: number | string; method?: string; params?: { name?: string } } = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* ignore */ }
|
||||
const id = parsed.id ?? 1;
|
||||
const method = parsed.method;
|
||||
if (method === 'tools/call') {
|
||||
const toolName = parsed.params?.name;
|
||||
const seeded = toolName ? mcpToolResults[toolName] : undefined;
|
||||
if (seeded) {
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id, result: seeded }));
|
||||
return;
|
||||
}
|
||||
// No seeded result — return tool error so the caller can detect.
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0', id,
|
||||
result: { isError: true, content: [{ type: 'text', text: `unknown tool ${toolName}` }] },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// initialize (or anything else) — minimal handshake response.
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'fixture', version: '1' } },
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
@@ -87,6 +121,7 @@ function reset() {
|
||||
tokenStatus = 200;
|
||||
tokenBody = null;
|
||||
mcpStatus = 200;
|
||||
mcpToolResults = {};
|
||||
}
|
||||
|
||||
function makeConfig(overrides: Partial<NonNullable<GBrainConfig['remote_mcp']>> = {}): GBrainConfig {
|
||||
@@ -233,3 +268,145 @@ describe('collectRemoteDoctorReport', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// v0.31.11: thin_client_upgrade_drift check.
|
||||
//
|
||||
// The check's pure logic (safeCompare, driftLevel, loadPromptState) is covered
|
||||
// by test/thin-client-upgrade-prompt.test.ts. Here we verify the network-error
|
||||
// path returns an informational 'ok' (not 'fail') so transient connectivity
|
||||
// blips don't escalate doctor's overall status — the earlier mcp_smoke check
|
||||
// already covers the genuinely-unreachable case with a 'fail'.
|
||||
describe('runUpgradeDriftCheck', () => {
|
||||
test('unreachable host returns informational ok with inconclusive=true', async () => {
|
||||
// Point at a port that is not bound. callRemoteTool will throw; the check
|
||||
// must catch and return ok+inconclusive, not warn or fail.
|
||||
const config: GBrainConfig = {
|
||||
engine: 'postgres',
|
||||
remote_mcp: {
|
||||
issuer_url: 'http://127.0.0.1:1', // unreachable
|
||||
mcp_url: 'http://127.0.0.1:1/mcp',
|
||||
oauth_client_id: 'x',
|
||||
oauth_client_secret: 'y',
|
||||
},
|
||||
};
|
||||
const result = await runUpgradeDriftCheck(config);
|
||||
expect(result.name).toBe('thin_client_upgrade_drift');
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.detail?.inconclusive).toBe(true);
|
||||
});
|
||||
|
||||
test('major drift, no prior state → warn with auto-upgrade fix hint', async () => {
|
||||
reset();
|
||||
// Use 99.99.99 so this is always a major drift regardless of current VERSION.
|
||||
mcpToolResults['get_brain_identity'] = {
|
||||
content: [{ type: 'text', text: JSON.stringify({ version: '99.99.99' }) }],
|
||||
};
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-doctor-drift-'));
|
||||
try {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const result = await runUpgradeDriftCheck(makeConfig());
|
||||
expect(result.name).toBe('thin_client_upgrade_drift');
|
||||
expect(result.status).toBe('warn');
|
||||
expect(result.message).toContain('major upgrade available');
|
||||
expect(result.message).toContain(`v${VERSION}`);
|
||||
expect(result.message).toContain('v99.99.99');
|
||||
// Auto-upgrade hint (no prior failure on file)
|
||||
expect(result.message).toContain('Run `gbrain upgrade`');
|
||||
expect(result.detail?.prior_failed).toBe(false);
|
||||
expect(result.detail?.level).toBe('major');
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('major drift with prior_failed state → warn with manual-install fix hint', async () => {
|
||||
reset();
|
||||
mcpToolResults['get_brain_identity'] = {
|
||||
content: [{ type: 'text', text: JSON.stringify({ version: '99.99.99' }) }],
|
||||
};
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-doctor-drift-'));
|
||||
try {
|
||||
const config = makeConfig();
|
||||
// Seed the prompt-state file with a 'failed' entry for THIS mcp_url +
|
||||
// the same remote version the fixture is about to advertise. The check
|
||||
// should pivot the fix hint to the manual install URL.
|
||||
const stateDir = join(tmpHome, '.gbrain');
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(join(stateDir, 'upgrade-prompt-state.json'), JSON.stringify({
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
[config.remote_mcp!.mcp_url]: {
|
||||
last_prompted_remote_version: '99.99.99',
|
||||
last_response: 'failed',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
}));
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const result = await runUpgradeDriftCheck(config);
|
||||
expect(result.status).toBe('warn');
|
||||
expect(result.message).toContain('major upgrade available');
|
||||
// Manual-install hint, NOT the auto-upgrade hint
|
||||
expect(result.message).toContain('Prior `gbrain upgrade` did not advance');
|
||||
expect(result.message).toContain('https://github.com/garrytan/gbrain/releases');
|
||||
expect(result.message).not.toContain('Run `gbrain upgrade`');
|
||||
expect(result.detail?.prior_failed).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('prior_failed entry for a DIFFERENT remote version → auto-upgrade hint (not stale match)', async () => {
|
||||
reset();
|
||||
// Remote bumped past the version the user previously failed to upgrade to.
|
||||
// The check must NOT pivot to the manual-install hint — that prior failure
|
||||
// doesn't apply to this new bump.
|
||||
mcpToolResults['get_brain_identity'] = {
|
||||
content: [{ type: 'text', text: JSON.stringify({ version: '99.99.99' }) }],
|
||||
};
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-doctor-drift-'));
|
||||
try {
|
||||
const config = makeConfig();
|
||||
const stateDir = join(tmpHome, '.gbrain');
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(join(stateDir, 'upgrade-prompt-state.json'), JSON.stringify({
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
[config.remote_mcp!.mcp_url]: {
|
||||
last_prompted_remote_version: '99.0.0', // OLDER than fixture's 99.99.99
|
||||
last_response: 'failed',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
}));
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const result = await runUpgradeDriftCheck(config);
|
||||
expect(result.status).toBe('warn');
|
||||
expect(result.message).toContain('Run `gbrain upgrade`');
|
||||
expect(result.detail?.prior_failed).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('local equals remote → ok, no fix hint', async () => {
|
||||
reset();
|
||||
mcpToolResults['get_brain_identity'] = {
|
||||
content: [{ type: 'text', text: JSON.stringify({ version: VERSION }) }],
|
||||
};
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-doctor-drift-'));
|
||||
try {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const result = await runUpgradeDriftCheck(makeConfig());
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.message).toContain(`local v${VERSION}`);
|
||||
expect(result.message).not.toContain('upgrade available');
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* v0.31.11: thin-client auto-upgrade prompt — unit tests.
|
||||
*
|
||||
* Covers safeCompare, driftLevel, state-file IO (round-trip + corrupt + atomic),
|
||||
* decideAction (every row of the decision matrix from the plan), the lockfile
|
||||
* contract (acquire / EEXIST / stale-reclaim), and the orchestrator (yes/no/
|
||||
* upgrade-advanced/not-advanced/threw paths).
|
||||
*
|
||||
* Pure-Bun, no DB, no real network. Uses the test injection seams
|
||||
* (`_setVerifierForTest`, `_setPromptReaderForTest`, `_setUpgradeRunnerForTest`)
|
||||
* so the orchestrator runs end-to-end without spawning subprocesses or
|
||||
* touching the user's PATH.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, openSync, closeSync, utimesSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import {
|
||||
safeCompare,
|
||||
driftLevel,
|
||||
loadPromptState,
|
||||
savePromptState,
|
||||
acquirePromptLock,
|
||||
decideAction,
|
||||
maybePromptForUpgrade,
|
||||
_setVerifierForTest,
|
||||
_setPromptReaderForTest,
|
||||
_setUpgradeRunnerForTest,
|
||||
type PromptState,
|
||||
} from '../src/core/thin-client-upgrade-prompt.ts';
|
||||
import type { CliOptions } from '../src/core/cli-options.ts';
|
||||
import type { GBrainConfig } from '../src/core/config.ts';
|
||||
|
||||
const DEFAULT_CLI_OPTS: CliOptions = {
|
||||
quiet: false,
|
||||
progressJson: false,
|
||||
progressInterval: 1000,
|
||||
timeoutMs: null,
|
||||
};
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-upgrade-prompt-'));
|
||||
// Reset injection seams between tests.
|
||||
_setVerifierForTest(null);
|
||||
_setPromptReaderForTest(null);
|
||||
_setUpgradeRunnerForTest(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
_setVerifierForTest(null);
|
||||
_setPromptReaderForTest(null);
|
||||
_setUpgradeRunnerForTest(null);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// safeCompare
|
||||
// ============================================================================
|
||||
|
||||
describe('safeCompare', () => {
|
||||
test('equal versions return 0', () => {
|
||||
expect(safeCompare('0.31.11', '0.31.11')).toBe(0);
|
||||
});
|
||||
test('local less than remote returns -1', () => {
|
||||
expect(safeCompare('0.31.4', '0.31.11')).toBe(-1);
|
||||
expect(safeCompare('0.31.4', '0.32.0')).toBe(-1);
|
||||
expect(safeCompare('0.31.4', '1.0.0')).toBe(-1);
|
||||
});
|
||||
test('local greater than remote returns 1', () => {
|
||||
expect(safeCompare('0.32.0', '0.31.11')).toBe(1);
|
||||
expect(safeCompare('1.0.0', '0.99.99')).toBe(1);
|
||||
});
|
||||
test('4-segment versions parse (4th segment ignored by underlying comparator)', () => {
|
||||
// Underlying compareVersions from src/commands/migrations/index.ts only
|
||||
// compares segments 0-2 — the 4th segment is intentionally ignored. This
|
||||
// matches gbrain's actual 3-segment release practice (VERSION file).
|
||||
expect(safeCompare('0.31.4.0', '0.31.4.0')).toBe(0);
|
||||
expect(safeCompare('0.31.4.1', '0.31.4.2')).toBe(0); // 4th segment ignored
|
||||
expect(safeCompare('0.31.4.0', '0.31.5.0')).toBe(-1); // segment 2 differs
|
||||
});
|
||||
test('empty / missing / non-numeric returns null', () => {
|
||||
expect(safeCompare('', '0.31.4')).toBe(null);
|
||||
expect(safeCompare('0.31.4', '')).toBe(null);
|
||||
expect(safeCompare('0.31', '0.31.4')).toBe(null); // 2-segment
|
||||
expect(safeCompare('0.31.4', '0.31')).toBe(null);
|
||||
expect(safeCompare('0.31.4-rc1', '0.31.4')).toBe(null); // suffix
|
||||
expect(safeCompare('0.31.x', '0.31.4')).toBe(null);
|
||||
expect(safeCompare('a.b.c', '0.31.4')).toBe(null);
|
||||
expect(safeCompare('0.31.4.5.6', '0.31.4')).toBe(null); // 5-segment
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// driftLevel
|
||||
// ============================================================================
|
||||
|
||||
describe('driftLevel', () => {
|
||||
test('major bump', () => {
|
||||
expect(driftLevel('0.31.11', '1.0.0')).toBe('major');
|
||||
});
|
||||
test('minor bump (same major)', () => {
|
||||
expect(driftLevel('0.31.11', '0.32.0')).toBe('minor');
|
||||
});
|
||||
test('patch bump (same major+minor)', () => {
|
||||
expect(driftLevel('0.31.11', '0.31.12')).toBe('patch');
|
||||
});
|
||||
test('local equal to remote → none', () => {
|
||||
expect(driftLevel('0.31.11', '0.31.11')).toBe('none');
|
||||
});
|
||||
test('local ahead of remote → none', () => {
|
||||
expect(driftLevel('0.32.0', '0.31.11')).toBe('none');
|
||||
});
|
||||
test('malformed → none', () => {
|
||||
expect(driftLevel('garbage', '0.31.11')).toBe('none');
|
||||
expect(driftLevel('0.31.11', 'garbage')).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// State file IO
|
||||
// ============================================================================
|
||||
|
||||
describe('promptState IO', () => {
|
||||
test('missing file returns empty state', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const state = loadPromptState();
|
||||
expect(state).toEqual({ schema_version: 1, entries: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test('round-trip: save then load', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const state: PromptState = {
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'https://brain.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'no',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
savePromptState(state);
|
||||
const loaded = loadPromptState();
|
||||
expect(loaded).toEqual(state);
|
||||
});
|
||||
});
|
||||
|
||||
test('atomic write: tmp file written then renamed', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
savePromptState({ schema_version: 1, entries: {} });
|
||||
const finalPath = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
|
||||
const tmpPath = `${finalPath}.tmp`;
|
||||
expect(existsSync(finalPath)).toBe(true);
|
||||
expect(existsSync(tmpPath)).toBe(false); // tmp gets renamed away
|
||||
});
|
||||
});
|
||||
|
||||
test('corrupt JSON falls through to empty state', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
// Pre-create the gbrain dir + corrupt file
|
||||
const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
|
||||
const dir = join(tmpHome, '.gbrain');
|
||||
try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
writeFileSync(path, '{not valid json');
|
||||
const state = loadPromptState();
|
||||
expect(state).toEqual({ schema_version: 1, entries: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test('truncated mid-write file falls through to empty state', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
|
||||
const dir = join(tmpHome, '.gbrain');
|
||||
try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
writeFileSync(path, '{"schema_version":1,"entries":{"foo":{"last_'); // truncated
|
||||
const state = loadPromptState();
|
||||
expect(state).toEqual({ schema_version: 1, entries: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed entry (missing fields) is dropped, valid sibling preserved', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
|
||||
const dir = join(tmpHome, '.gbrain');
|
||||
try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
writeFileSync(path, JSON.stringify({
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'https://good.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'no',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
'https://malformed-1.example.com': { last_response: 'no' }, // missing version + iso
|
||||
'https://malformed-2.example.com': {
|
||||
last_prompted_remote_version: 42, // wrong type
|
||||
last_response: 'no',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
'https://malformed-3.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'banana', // invalid enum
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
}));
|
||||
const state = loadPromptState();
|
||||
expect(Object.keys(state.entries)).toEqual(['https://good.example.com']);
|
||||
expect(state.entries['https://good.example.com'].last_response).toBe('no');
|
||||
});
|
||||
});
|
||||
|
||||
test('empty-string entry key is dropped', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
|
||||
const dir = join(tmpHome, '.gbrain');
|
||||
try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
writeFileSync(path, JSON.stringify({
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'no',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
}));
|
||||
const state = loadPromptState();
|
||||
expect(Object.keys(state.entries)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('missing schema_version or wrong shape → empty state', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
|
||||
const dir = join(tmpHome, '.gbrain');
|
||||
try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
writeFileSync(path, JSON.stringify({ entries: {} })); // missing schema_version
|
||||
expect(loadPromptState()).toEqual({ schema_version: 1, entries: {} });
|
||||
|
||||
writeFileSync(path, JSON.stringify({ schema_version: 99, entries: {} })); // wrong version
|
||||
expect(loadPromptState()).toEqual({ schema_version: 1, entries: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-mcp-url entries are isolated', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const state: PromptState = {
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'https://work.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'no',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
'https://home.example.com': {
|
||||
last_prompted_remote_version: '1.0.0',
|
||||
last_response: 'yes',
|
||||
last_prompted_at_iso: '2026-05-10T13:00:00Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
savePromptState(state);
|
||||
const loaded = loadPromptState();
|
||||
expect(loaded.entries['https://work.example.com'].last_response).toBe('no');
|
||||
expect(loaded.entries['https://home.example.com'].last_response).toBe('yes');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// decideAction (pure decision matrix)
|
||||
// ============================================================================
|
||||
|
||||
const EMPTY_STATE: PromptState = { schema_version: 1, entries: {} };
|
||||
|
||||
describe('decideAction', () => {
|
||||
const baseInput = {
|
||||
localVersion: '0.31.4',
|
||||
remoteVersion: '0.32.0',
|
||||
mcpUrl: 'https://brain.example.com',
|
||||
state: EMPTY_STATE,
|
||||
cliOpts: DEFAULT_CLI_OPTS,
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
bannerIsSuppressed: false,
|
||||
};
|
||||
|
||||
test('drift detected, all gates pass → prompt', () => {
|
||||
expect(decideAction(baseInput)).toEqual({ kind: 'prompt', level: 'minor' });
|
||||
});
|
||||
|
||||
test('local equals remote → noop', () => {
|
||||
expect(decideAction({ ...baseInput, localVersion: '0.32.0' })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('local ahead of remote → noop', () => {
|
||||
expect(decideAction({ ...baseInput, localVersion: '1.0.0' })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('safeCompare null (malformed local) → noop', () => {
|
||||
expect(decideAction({ ...baseInput, localVersion: 'garbage' })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('D8: patch drift → noop', () => {
|
||||
expect(decideAction({ ...baseInput, localVersion: '0.32.0', remoteVersion: '0.32.1' })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('D8: minor drift → prompt', () => {
|
||||
expect(decideAction({ ...baseInput, localVersion: '0.31.0', remoteVersion: '0.32.0' })).toEqual({ kind: 'prompt', level: 'minor' });
|
||||
});
|
||||
|
||||
test('D8: major drift → prompt', () => {
|
||||
expect(decideAction({ ...baseInput, localVersion: '0.31.0', remoteVersion: '1.0.0' })).toEqual({ kind: 'prompt', level: 'major' });
|
||||
});
|
||||
|
||||
test('D7: bannerIsSuppressed → noop', () => {
|
||||
expect(decideAction({ ...baseInput, bannerIsSuppressed: true })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('D6: stdin not TTY → noop', () => {
|
||||
expect(decideAction({ ...baseInput, stdinIsTty: false })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('D6: stdout not TTY → noop', () => {
|
||||
expect(decideAction({ ...baseInput, stdoutIsTty: false })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('sticky decline (last_response=no, same remote version) → noop', () => {
|
||||
const state: PromptState = {
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'https://brain.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'no',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(decideAction({ ...baseInput, state })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('sticky yes (last_response=yes, same remote version) → noop', () => {
|
||||
const state: PromptState = {
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'https://brain.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'yes',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(decideAction({ ...baseInput, state })).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
test('failed prior attempt → re-prompt fresh', () => {
|
||||
const state: PromptState = {
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'https://brain.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'failed',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(decideAction({ ...baseInput, state })).toEqual({ kind: 'prompt', level: 'minor' });
|
||||
});
|
||||
|
||||
test('new bump after decline → re-prompt', () => {
|
||||
const state: PromptState = {
|
||||
schema_version: 1,
|
||||
entries: {
|
||||
'https://brain.example.com': {
|
||||
last_prompted_remote_version: '0.32.0',
|
||||
last_response: 'no',
|
||||
last_prompted_at_iso: '2026-05-10T12:00:00Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
// Remote bumped from 0.32.0 to 0.33.0
|
||||
expect(decideAction({ ...baseInput, state, remoteVersion: '0.33.0' })).toEqual({ kind: 'prompt', level: 'minor' });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Lockfile (D2)
|
||||
// ============================================================================
|
||||
|
||||
describe('acquirePromptLock', () => {
|
||||
test('acquire then release', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const lock = acquirePromptLock();
|
||||
expect(lock).not.toBeNull();
|
||||
const lockPath = join(tmpHome, '.gbrain', 'upgrade-prompt.lock');
|
||||
expect(existsSync(lockPath)).toBe(true);
|
||||
lock!.release();
|
||||
expect(existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('concurrent acquire returns null (EEXIST)', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const lock1 = acquirePromptLock();
|
||||
expect(lock1).not.toBeNull();
|
||||
const lock2 = acquirePromptLock();
|
||||
expect(lock2).toBeNull();
|
||||
lock1!.release();
|
||||
// Now a fresh acquire should succeed
|
||||
const lock3 = acquirePromptLock();
|
||||
expect(lock3).not.toBeNull();
|
||||
lock3!.release();
|
||||
});
|
||||
});
|
||||
|
||||
test('stale lock (>60s old mtime) reclaimed', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
// Manually create a stale lockfile
|
||||
const dir = join(tmpHome, '.gbrain');
|
||||
try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
const lockPath = join(dir, 'upgrade-prompt.lock');
|
||||
const fd = openSync(lockPath, 'wx+');
|
||||
closeSync(fd);
|
||||
// Backdate mtime to 2 minutes ago
|
||||
const past = new Date(Date.now() - 120_000);
|
||||
utimesSync(lockPath, past, past);
|
||||
// Acquire should reclaim
|
||||
const lock = acquirePromptLock();
|
||||
expect(lock).not.toBeNull();
|
||||
lock!.release();
|
||||
});
|
||||
});
|
||||
|
||||
test('release is idempotent', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const lock = acquirePromptLock();
|
||||
expect(lock).not.toBeNull();
|
||||
lock!.release();
|
||||
// Second release must not throw
|
||||
expect(() => lock!.release()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Orchestrator
|
||||
// ============================================================================
|
||||
|
||||
describe('maybePromptForUpgrade orchestrator', () => {
|
||||
const cfg: GBrainConfig = {
|
||||
remote_mcp: {
|
||||
mcp_url: 'https://brain.example.com',
|
||||
issuer_url: 'https://brain.example.com',
|
||||
oauth_client_id: 'test-client',
|
||||
},
|
||||
} as GBrainConfig;
|
||||
|
||||
const identity = { version: '0.32.0' };
|
||||
|
||||
test('no remote_mcp → returns immediately', async () => {
|
||||
let called = false;
|
||||
_setPromptReaderForTest(async () => { called = true; return 'n'; });
|
||||
await maybePromptForUpgrade({} as GBrainConfig, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test('decideAction=noop (patch drift) → returns without prompting', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
let prompted = false;
|
||||
_setPromptReaderForTest(async () => { prompted = true; return 'n'; });
|
||||
await maybePromptForUpgrade(cfg, { version: '0.31.5' }, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
});
|
||||
expect(prompted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('prompt → "n" → state persisted, returns without exit', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
_setPromptReaderForTest(async () => 'n');
|
||||
let exited = false;
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { exited = true; throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
expect(exited).toBe(false);
|
||||
const state = loadPromptState();
|
||||
expect(state.entries[cfg.remote_mcp!.mcp_url]).toBeDefined();
|
||||
expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('no');
|
||||
expect(state.entries[cfg.remote_mcp!.mcp_url].last_prompted_remote_version).toBe('0.32.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('prompt → "y" → upgrade runs → advanced → state=yes, exit 0', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
_setPromptReaderForTest(async () => 'y');
|
||||
let upgradeRan = false;
|
||||
_setUpgradeRunnerForTest(() => { upgradeRan = true; });
|
||||
_setVerifierForTest(() => ({ advanced: true, newVersion: '0.32.0' }));
|
||||
|
||||
let exitCode = -1;
|
||||
class ExitError extends Error { constructor(public code: number) { super(`exit ${code}`); } }
|
||||
try {
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { exitCode = code; throw new ExitError(code); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof ExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(upgradeRan).toBe(true);
|
||||
expect(exitCode).toBe(0);
|
||||
const state = loadPromptState();
|
||||
expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
test('prompt → "y" → upgrade runs → NOT advanced → state=failed, exit 1', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
_setPromptReaderForTest(async () => 'y');
|
||||
_setUpgradeRunnerForTest(() => { /* swallow, simulate exit 0 with no advance */ });
|
||||
_setVerifierForTest(() => ({ advanced: false, newVersion: '0.31.4' }));
|
||||
|
||||
let exitCode = -1;
|
||||
class ExitError extends Error { constructor(public code: number) { super(`exit ${code}`); } }
|
||||
try {
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { exitCode = code; throw new ExitError(code); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof ExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const state = loadPromptState();
|
||||
expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
test('prompt → "y" → upgrade throws → state=failed, exit 1', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
_setPromptReaderForTest(async () => 'y');
|
||||
_setUpgradeRunnerForTest(() => { throw new Error('subprocess died'); });
|
||||
_setVerifierForTest(() => { throw new Error('verifier should not be called'); });
|
||||
|
||||
let exitCode = -1;
|
||||
let logged = '';
|
||||
class ExitError extends Error { constructor(public code: number) { super(`exit ${code}`); } }
|
||||
try {
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { exitCode = code; throw new ExitError(code); }) as (code: number) => never,
|
||||
log: (msg: string) => { logged += msg; },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof ExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logged).toContain('subprocess died');
|
||||
const state = loadPromptState();
|
||||
expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
test('empty answer (just enter) treated as yes', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
_setPromptReaderForTest(async () => '');
|
||||
let upgradeRan = false;
|
||||
_setUpgradeRunnerForTest(() => { upgradeRan = true; });
|
||||
_setVerifierForTest(() => ({ advanced: true, newVersion: '0.32.0' }));
|
||||
|
||||
class ExitError extends Error { constructor(public code: number) { super(); } }
|
||||
try {
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { throw new ExitError(code); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof ExitError)) throw e;
|
||||
}
|
||||
expect(upgradeRan).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('installs and removes prompt-scoped SIGINT handler around the prompt', async () => {
|
||||
// Regression guard: without the prompt-scoped handler, Ctrl-C during the
|
||||
// prompt is swallowed by runThinClientRouted's outer AbortController-only
|
||||
// handler. We verify by counting SIGINT listeners before, during (inside
|
||||
// the prompt-reader callback), and after.
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const before = process.listeners('SIGINT').length;
|
||||
let listenersDuringPrompt = -1;
|
||||
_setPromptReaderForTest(async () => {
|
||||
listenersDuringPrompt = process.listeners('SIGINT').length;
|
||||
return 'n';
|
||||
});
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
const after = process.listeners('SIGINT').length;
|
||||
expect(listenersDuringPrompt).toBe(before + 1);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
test('SIGINT handler is removed even if the prompt reader throws', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
const before = process.listeners('SIGINT').length;
|
||||
_setPromptReaderForTest(async () => { throw new Error('stdin closed'); });
|
||||
await expect(
|
||||
maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
})
|
||||
).rejects.toThrow('stdin closed');
|
||||
expect(process.listeners('SIGINT').length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
test('prompt reader returns null (EOF) → no state write, no exit, caller continues', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
_setPromptReaderForTest(async () => null);
|
||||
let exited = false;
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { exited = true; throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
expect(exited).toBe(false);
|
||||
// CRITICAL: must NOT persist 'no' to state — a transient EOF should not
|
||||
// poison the per-version sticky-decline gate.
|
||||
const state = loadPromptState();
|
||||
expect(state.entries[cfg.remote_mcp!.mcp_url]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('lock contention (sibling holds lock) → no prompt fires', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
|
||||
// Pre-acquire the lock
|
||||
const sibling = acquirePromptLock();
|
||||
expect(sibling).not.toBeNull();
|
||||
try {
|
||||
let prompted = false;
|
||||
_setPromptReaderForTest(async () => { prompted = true; return 'y'; });
|
||||
await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
|
||||
localVersion: '0.31.4',
|
||||
exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
|
||||
log: () => { /* swallow */ },
|
||||
stdinIsTty: true,
|
||||
stdoutIsTty: true,
|
||||
});
|
||||
expect(prompted).toBe(false);
|
||||
} finally {
|
||||
sibling!.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user