Files
gbrain/test/sync-pull-failed-anchor.serial.test.ts
00bcd66c3e fix(sync): failed git pull with zero imports reports partial (pull_failed) instead of up_to_date (#3068) (#3253)
* fix(sync): report partial (pull_failed) instead of up_to_date when git pull fails with zero imports (#3068)

A warn-and-continue internal git pull failure (e.g. a local-path origin
rejected by protocol.file.allow=never) combined with a zero-import run
previously reported `up_to_date`, exited 0, and bumped the last_sync_at
freshness heartbeat. A permanently-failing pull was therefore invisible
forever: doctor's sync_freshness never fired and every scheduled sync
looked clean while the source silently went stale.

Now, when the pull failed and the run imported nothing, sync returns
`partial` with the new reason `pull_failed`, leaves last_commit AND
last_sync_at untouched (so staleness monitoring fires), and prints a
dedicated non-success message. The fall-through-to-working-tree design
is unchanged: local commits still import when the remote is unreachable,
and the anchor still advances over commits that were actually imported.

Addresses the report in #3068.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): surface pull_failed to CLI exit codes, sync --all JSON, and the cycle phase (#3068 review round)

Codex review round 1 follow-ups:

- Single-source `gbrain sync` sets exit code 1 on partial/pull_failed
  (timeout-class partials keep exit 0 — they converge on retry; a failing
  pull does not).
- `sync --all` exits 1 when any source reports pull_failed, and the
  --json envelope carries the per-source partial `reason`.
- The autopilot cycle's sync phase maps partial/pull_failed to `warn`
  with a dedicated summary and a `syncReason` detail, so a scheduled
  cycle no longer reports a clean run over a wedged source.
- The regression test now isolates GBRAIN_HOME to a temp dir so the
  first full sync cannot touch the real sync-failure ledger.
- Current-state docs: KEY_FILES.md sync.ts entry + TESTING.md inventory
  describe the pull_failed contract and the new test.

Addresses the report in #3068.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): route pull_failed exit through the owned verdict channel; make the regression test serial (#3068 review round 2)

Codex review round 2 follow-ups:

- Single-source exit now uses setCliExitVerdict(1) instead of a raw
  process.exitCode assignment, which the CLI teardown deliberately
  ignores (PGLite's Emscripten runtime clobbers process.exitCode
  mid-run; the owned channel in src/core/cli-force-exit.ts is the only
  trusted verdict). Pinned by test/cli-exit-verdict-pin.test.ts.
- The regression test is renamed to *.serial.test.ts because it pins
  GBRAIN_HOME for the whole file (scripts/check-test-isolation.sh R1);
  docs updated to the new name.

Addresses the report in #3068.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:09:30 -07:00

191 lines
9.1 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
/**
* #3068 regression: a failed internal `git pull` (warn-and-continue class,
* e.g. a local-filesystem-path origin rejected by the SSRF flag
* `protocol.file.allow=never`) combined with a zero-import run must NOT be
* reported as a clean `up_to_date` sync:
*
* - status must be `partial` with reason `pull_failed` (not `up_to_date`),
* - `last_commit` (the sync anchor) must not move,
* - `last_sync_at` (the freshness heartbeat doctor/sources-status read)
* must not be bumped — otherwise a permanently-failing pull keeps the
* source looking fresh forever while it silently goes stale.
*
* The fall-through-to-working-tree design is unchanged: local commits still
* import when the remote is unreachable, and once the operator repairs the
* checkout (e.g. a manual `git pull`, which does not carry the SSRF flags)
* the next sync imports the missed content from the untouched anchor.
*
* The local-path-origin topology below reproduces the pull failure
* deterministically: `pullRepo` always passes `-c protocol.file.allow=never`,
* so its internal pull fails on every cycle while plain `git pull` succeeds.
*/
describe('#3068: failed git pull + zero imports must not report up_to_date', () => {
let engine: PGLiteEngine;
const dirs: string[] = [];
// Hermetic GBRAIN_HOME: performFullSync (first sync) reads/writes the
// sync-failure ledger under the gbrain home — never touch the real one.
let isolatedHome: string;
let origGbrainHome: string | undefined;
beforeAll(async () => {
origGbrainHome = process.env.GBRAIN_HOME;
isolatedHome = mkdtempSync(join(tmpdir(), 'gbrain-3068-home-'));
process.env.GBRAIN_HOME = isolatedHome;
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
if (origGbrainHome !== undefined) process.env.GBRAIN_HOME = origGbrainHome;
else delete process.env.GBRAIN_HOME;
rmSync(isolatedHome, { recursive: true, force: true });
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
while (dirs.length) {
const d = dirs.pop();
if (d) rmSync(d, { recursive: true, force: true });
}
});
function personMd(title: string, body: string): string {
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
}
function mkUpstream(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-3068-upstream-'));
dirs.push(dir);
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), content);
}
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
return dir;
}
/** Clone `upstream` to a sibling temp dir — origin is a local filesystem path. */
function mkMirror(upstream: string): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-3068-mirror-'));
dirs.push(dir);
rmSync(dir, { recursive: true, force: true });
execSync(`git clone ${JSON.stringify(upstream)} ${JSON.stringify(dir)}`, { stdio: 'pipe' });
return dir;
}
async function sourceRow(): Promise<{ last_commit: string | null; last_sync_at: string | null }> {
const rows = await engine.executeRaw<{ last_commit: string | null; last_sync_at: string | null }>(
`SELECT last_commit, last_sync_at FROM sources WHERE id = 'default'`,
);
return rows[0] ?? { last_commit: null, last_sync_at: null };
}
// Pull ENABLED (no noPull) — the internal pull must actually run and fail.
const SYNC_OPTS = { noEmbed: true, noExtract: true, sourceId: 'default' } as const;
test('zero-import sync after failed pull reports partial/pull_failed; anchor and heartbeat frozen; manual pull recovers', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const upstream = mkUpstream({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
const mirror = mkMirror(upstream);
// First sync: the internal pull already fails (local-path origin), but the
// fall-through imports the working tree — unchanged behavior.
const first = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
const afterFirst = await sourceRow();
expect(afterFirst.last_commit).not.toBeNull();
expect(afterFirst.last_sync_at).not.toBeNull();
// New content lands upstream; the mirror's internal pull can never fetch it.
writeFileSync(join(upstream, 'people/bob.md'), personMd('Bob', 'zzuniquetoken99 new content.'));
execSync('git add -A && git commit -m "new page"', { cwd: upstream, stdio: 'pipe' });
// Wait so a (buggy) heartbeat bump would be observable as a changed timestamp.
await new Promise((r) => setTimeout(r, 1100));
// Pre-fix this reported `up_to_date`, bumped last_sync_at, and exited clean
// forever — the #3068 silent wedge.
const wedged = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(wedged.status).toBe('partial');
expect(wedged.reason).toBe('pull_failed');
expect(wedged.added + wedged.modified + wedged.deleted + wedged.renamed).toBe(0);
expect(wedged.fromCommit).toBe(afterFirst.last_commit);
expect(wedged.toCommit).toBe(afterFirst.last_commit ?? '');
const afterWedged = await sourceRow();
expect(afterWedged.last_commit).toBe(afterFirst.last_commit); // anchor unchanged
expect(afterWedged.last_sync_at).toEqual(afterFirst.last_sync_at); // heartbeat NOT bumped
// Recovery: a manual pull (no SSRF flags) fast-forwards the mirror; the
// next sync imports the missed content from the untouched anchor.
execSync('git pull', { cwd: mirror, stdio: 'pipe' });
const recovered = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(recovered.status).toBe('synced');
expect(recovered.added).toBe(1);
expect(await engine.getPage('people/bob')).not.toBeNull();
const afterRecovered = await sourceRow();
expect(afterRecovered.last_commit).not.toBe(afterFirst.last_commit); // anchor advanced with the import
});
test('failed pull with local syncable commits still imports them (fall-through preserved)', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const upstream = mkUpstream({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
const mirror = mkMirror(upstream);
const first = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
// A local commit in the mirror itself — importable without any pull.
execSync('git config user.email "test@test.com"', { cwd: mirror, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: mirror, stdio: 'pipe' });
writeFileSync(join(mirror, 'people/carol.md'), personMd('Carol', 'Carol is local.'));
execSync('git add -A && git commit -m "local carol"', { cwd: mirror, stdio: 'pipe' });
// The internal pull still fails, but there is real local work — the
// warn-and-continue design imports it and advances the anchor over the
// commits that were actually imported.
const synced = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(synced.status).toBe('synced');
expect(synced.added).toBe(1);
expect(await engine.getPage('people/carol')).not.toBeNull();
});
test('local no-syncable-content commit after failed pull also reports partial without advancing the anchor', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const upstream = mkUpstream({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
const mirror = mkMirror(upstream);
const first = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
const anchor = (await sourceRow()).last_commit;
// A local commit with no syncable content (non-markdown file).
execSync('git config user.email "test@test.com"', { cwd: mirror, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: mirror, stdio: 'pipe' });
writeFileSync(join(mirror, 'notes.txt'), 'not syncable');
execSync('git add -A && git commit -m "local txt"', { cwd: mirror, stdio: 'pipe' });
const result = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(result.status).toBe('partial');
expect(result.reason).toBe('pull_failed');
expect((await sourceRow()).last_commit).toBe(anchor); // anchor unchanged
});
});