Files
gbrain/src/commands/sync.ts
T
Garry TanandClaude Opus 4.7 f56cc619ba v0.41.13.0 fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) (#1456)
* fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436)

Five real production bugs from infiniteGameExp (PostgreSQL onboarding) and
foxhoundinc (dream-cycle reproduction), each silent-failure shape where gbrain
told the user the operation succeeded when it didn't.

* #1422 — `gbrain dream` swallowed connectEngine errors. Bind the caught
  error and surface `[dream] WARNING: could not connect to DB (...)` on
  stderr before falling through to filesystem-only phases. runDream(null)
  no-DB fallback preserved.

* #1433 — `gbrain sync` deleted previously-indexed log.md / schema.md /
  index.md / README.md pages on every re-sync. Refactor isSyncable
  through private classifySync helper; expose unsyncableReason (companion
  returning the same tagged reason) and SYNC_SKIP_FILES named export.
  Cleanup loop guards on reason === 'metafile' before deleting.

* #1434 — `gbrain sync` without --source on single-vault brains routed
  to source_id='default' (zero pages) and silently failed. Add resolver
  tier 5.5 'sole_non_default' AFTER brain_default (explicit user intent
  wins). Wire runSync + runImport to call resolveSourceWithTier
  unconditionally so the tier actually fires. Stderr nudge on tier hit;
  suppress with GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1.

* #1309 — overlapping ingest roots created duplicate pages. New
  BrainEngine.findDuplicatePage?(sourceId, {hash, frontmatterId}) with
  identity-based posture: SKIP when frontmatter.id matches (true
  external duplicate), WARN-ALWAYS on content_hash collision with
  different/missing fm.id, FAIL CLOSED on lookup error. Migration v95
  adds partial index pages_dedup_idx (Postgres CONCURRENTLY, PGLite
  plain CREATE).

* #1436 — MCP fuzzy get_page returned slug candidates from sources
  outside caller's scope. resolveSlugs signature extended with
  {sourceId?, sourceIds?} matching the sourceScopeOpts helper output;
  operations.ts threads it through. Both engines preserve unscoped
  back-compat for internal CLI callers.

Plus a stable tiebreaker on searchVector ORDER BY (score DESC, page_id
ASC, chunk_id ASC) in both engines. Caught while wiring the index above
— basis-vector eval fixtures with tied scores depend on planner row
order, which any new index on pages could flip. Pins eval-replay-gate
ranking determinism against future index changes.

Per codex review of the original plan: caught 6 load-bearing gaps that
the engineering review missed (runSync bypass, #1436 misclassified as
fixed, dedup fail-open, content-hash-alone too aggressive, soft-delete
filter missing, tier-ordering contradiction). All folded in pre-merge.

Tests: 65 new wave cases across 7 new files + 1 extended; all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.41.13.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:53:59 -07:00

2617 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, relative } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { importFile } from '../core/import-file.ts';
import { collectSyncableFiles } from './import.ts';
import { createInterface } from 'readline';
import {
buildSyncManifest,
isSyncable,
unsyncableReason,
resolveSlugForPath,
recordSyncFailures,
unacknowledgedSyncFailures,
acknowledgeSyncFailures,
formatCodeBreakdown,
} from '../core/sync.ts';
import { estimateTokens, CHUNKER_VERSION } from '../core/chunkers/code.ts';
import { EMBEDDING_MODEL, estimateEmbeddingCostUsd } from '../core/embedding.ts';
import { errorFor, serializeError } from '../core/errors.ts';
import type { SyncManifest } from '../core/sync.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { loadConfig } from '../core/config.ts';
import {
autoConcurrency,
shouldRunParallel,
parseWorkers,
DEFAULT_PARALLEL_SOURCES,
} from '../core/sync-concurrency.ts';
import {
tryAcquireDbLock,
withRefreshingLock,
LockUnavailableError,
syncLockId,
SYNC_LOCK_ID,
} from '../core/db-lock.ts';
import {
withSourcePrefix,
slog,
serr,
} from '../core/console-prefix.ts';
import { loadStorageConfig } from '../core/storage-config.ts';
import { getDefaultSourcePath } from '../core/source-resolver.ts';
import { sortNewestFirst } from '../core/sort-newest-first.ts';
export interface SyncResult {
status: 'up_to_date' | 'synced' | 'first_sync' | 'dry_run' | 'blocked_by_failures';
fromCommit: string | null;
toCommit: string;
added: number;
modified: number;
deleted: number;
renamed: number;
chunksCreated: number;
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
embedded: number;
pagesAffected: string[];
failedFiles?: number; // count of parse failures (Bug 9)
}
/**
* v0.20.0 Cathedral II Layer 8 (D1) — walk each source's working tree and
* sum tokens for every syncable file. This is a conservative overestimate
* (full file content, not just the incremental diff) because `sync --all`
* on a source that hasn't been synced yet WILL embed every file in the
* working tree. For already-synced sources with only incremental changes,
* the overestimate is the ceiling, not the floor — users never get
* surprised by MORE cost than the preview claims. The false-high bias is
* intentional: a lower estimate that undersells the real bill would be
* worse than one that oversells.
*/
function estimateSyncAllCost(sources: Array<{ local_path: string | null; config: Record<string, unknown> }>): {
totalTokens: number;
totalFiles: number;
activeSources: number;
perSource: Array<{ path: string; tokens: number; files: number }>;
} {
let totalTokens = 0;
let totalFiles = 0;
let activeSources = 0;
const perSource: Array<{ path: string; tokens: number; files: number }> = [];
for (const src of sources) {
if (!src.local_path) continue;
const cfg = (src.config || {}) as { syncEnabled?: boolean; strategy?: 'markdown' | 'code' | 'auto' };
if (cfg.syncEnabled === false) continue;
activeSources++;
let sourceTokens = 0;
let sourceFiles = 0;
try {
// v0.31.2: cost preview routed through collectSyncableFiles
// (single hardened walker; see import.ts). Previously
// walkSyncableFiles used statSync (followed symlinks). New walker
// uses lstat + inode-cycle + max-depth so the preview matches
// what the real sync will actually walk.
const files = collectSyncableFiles(src.local_path, { strategy: cfg.strategy ?? 'markdown' });
for (const fullPath of files) {
try {
const stat = statSync(fullPath);
if (stat.size > 5_000_000) continue; // skip large binaries
const content = readFileSync(fullPath, 'utf-8');
sourceTokens += estimateTokens(content);
sourceFiles++;
} catch {
// Best-effort per file. Skip unreadable files silently;
// sync itself tolerates the same.
}
}
} catch {
// Best-effort: a source whose local_path is gone or unreadable just
// contributes 0. The sync itself would have failed anyway; no point
// blocking the preview on a pre-existing fault.
}
totalTokens += sourceTokens;
totalFiles += sourceFiles;
perSource.push({ path: src.local_path, tokens: sourceTokens, files: sourceFiles });
}
return { totalTokens, totalFiles, activeSources, perSource };
}
/** Interactive [y/N] prompt. Resolves false on non-y answers or EOF. */
async function promptYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
});
rl.on('close', () => resolve(false));
});
}
export interface SyncOpts {
repoPath?: string;
dryRun?: boolean;
full?: boolean;
noPull?: boolean;
noEmbed?: boolean;
noExtract?: boolean;
/** Bug 9 — acknowledge + skip past current failure set (CLI --skip-failed). */
skipFailed?: boolean;
/** Bug 9 — re-attempt unacknowledged failures explicitly (CLI --retry-failed). */
retryFailed?: boolean;
/**
* v0.18.0 Step 5 — sync a specific named source. When set, sync reads
* local_path + last_commit from the sources table (not the global
* config.sync.* keys) and writes last_commit + last_sync_at back to
* the same row. Backward compat: when undefined, sync uses the
* pre-v0.17 global-config path unchanged.
*/
sourceId?: string;
/** Multi-repo: sync strategy override (markdown, code, auto). */
strategy?: 'markdown' | 'code' | 'auto';
/**
* Number of parallel workers for the import phase. When > 1, each worker
* gets its own small Postgres connection pool and files are dispatched via
* an atomic queue index (same pattern as `import --workers N`).
*
* Deletes and renames remain serial (order-dependent).
* Default: undefined → auto-concurrency picks (`src/core/sync-concurrency.ts`).
*
* v0.22.13 (PR #490 Q1): when this is explicitly set, the >50-file floor
* is bypassed — explicit user intent beats the auto-path safety net.
*/
concurrency?: number;
/**
* Internal: skip acquiring the gbrain-sync DB lock. Set by the cycle
* handler (cycle.ts) which already holds gbrain-cycle and therefore
* already serializes against other cycle runs. CLI sync, jobs handler,
* and any external caller leave this undefined so they take the lock.
*
* v0.22.13 (PR #490 CODEX-2). Not part of the public CLI surface.
*/
skipLock?: boolean;
/**
* Internal: override the DB lock id taken around the writer window.
* Not part of the public CLI surface — explicit escape hatch for
* callers that already know which lock id they want.
*
* Defaults to:
* - `gbrain-sync:<sourceId>` when `opts.sourceId` is set
* (multi-source / federated brains; the per-source invariant)
* - `gbrain-sync` (the legacy global lock) when `sourceId` is unset
* (single-default-source brains; preserves bit-for-bit behavior
* for installs that never set up multiple sources)
*
* Why source-id keyed by default (v0.40.3.0):
* PR #1314 originally only changed the lock id inside the parallel
* `sync --all` fan-out. That introduced a worse race than the global
* lock fixes — `gbrain sync --all` (per-source lock) running
* concurrently with `gbrain sync --source foo` (global lock) would
* both write source foo simultaneously. The fix: every source-scoped
* sync (CLI, --all fan-out, cycle, jobs handler) defaults to the
* per-source lock. Same source = same lock id, always.
*
* For the per-source path, `performSync` ALSO switches from bare
* `tryAcquireDbLock` to `withRefreshingLock` so long-running sources
* (the PR's whole motivation — media-corpus, 250K+ chunks) don't lose
* their lock at the 30-minute TTL mid-run. The legacy global-lock
* path keeps bare `tryAcquireDbLock` for back-compat (no caller
* depends on the global lock surviving past 30 minutes today).
*
* Total live Postgres connections per parallel `sync --all` wave:
* parallel × workers × 2 (per-file pool inside each worker)
* + parent pool. See DEFAULT_PARALLEL_SOURCES in sync-concurrency.ts.
*/
lockId?: string;
}
/**
* v0.32.7 CJK wave (codex post-merge F4): resolve a slug by `pages.source_path`
* first, falling back to `resolveSlugForPath(path)`.
*
* Frontmatter-fallback pages (emoji-only / Thai / Arabic / exotic-script
* filenames where `slugifyPath` returns empty and the slug came from the
* frontmatter) have a slug that ISN'T derivable from the path. Delete and
* rename operations that only know the path would otherwise orphan these
* pages by trying to delete the path-derived (wrong) slug.
*
* Returns the actual stored slug when source_path matches a row, or the
* path-derived slug when there's no match (normal-case path-derived pages).
*/
export async function resolveSlugByPathOrSourcePath(
engine: BrainEngine,
path: string,
sourceId?: string,
): Promise<string> {
try {
const rows = await engine.executeRaw<{ slug: string }>(
sourceId
? `SELECT slug FROM pages WHERE source_path = $1 AND source_id = $2 LIMIT 1`
: `SELECT slug FROM pages WHERE source_path = $1 LIMIT 1`,
sourceId ? [path, sourceId] : [path],
);
if (rows.length > 0 && rows[0].slug) return rows[0].slug;
} catch {
// Fall through — best-effort. Pre-migration brains or query errors
// shouldn't break delete/rename for path-derived pages.
}
return resolveSlugForPath(path);
}
/**
* git CLI helper.
*
* `configs` flags are emitted as `-c key=val` pairs BEFORE `-C repoPath` and
* BEFORE the subcommand. `core.quotepath=false` is always emitted first so CJK
* (and other non-ASCII) paths arrive as UTF-8 in `diff --name-status` and
* sibling commands. Callers that need additional git config should pass via
* the `configs` parameter; never inline `-c` into `args`.
*
* Exported for `test/sync.test.ts` invariant assertion only.
*/
export function buildGitInvocation(repoPath: string, args: string[], configs: string[] = []): string[] {
const cfg = ['core.quotepath=false', ...configs].flatMap(c => ['-c', c]);
return [...cfg, '-C', repoPath, ...args];
}
export function buildAutoEmbedArgs(slugs: string[], sourceId?: string): string[] {
return sourceId ? ['--source', sourceId, '--slugs', ...slugs] : ['--slugs', ...slugs];
}
/**
* Shell out to git with a generous maxBuffer.
*
* Node's default maxBuffer is 1 MiB. `git diff --name-status -M` on a
* 60100K file repo easily exceeds that, causing an ENOBUFS crash that
* kills the sync process with no error message in the log.
*
* 100 MiB is generous but still bounded — a 100K-file diff with long
* paths tops out around 1020 MiB in practice.
*/
function git(repoPath: string, args: string[], configs: string[] = []): string {
return execFileSync('git', buildGitInvocation(repoPath, args, configs), {
encoding: 'utf-8',
timeout: 30000,
maxBuffer: 100 * 1024 * 1024,
}).trim();
}
function hasOriginRemote(repoPath: string): boolean {
try {
execFileSync('git', buildGitInvocation(repoPath, ['remote', 'get-url', 'origin']), {
encoding: 'utf-8',
timeout: 30000,
stdio: ['ignore', 'ignore', 'ignore'],
});
return true;
} catch {
return false;
}
}
function isDetachedHead(repoPath: string): boolean {
try {
git(repoPath, ['symbolic-ref', '--quiet', 'HEAD']);
return false;
} catch {
return true;
}
}
function unique<T>(items: T[]): T[] {
return [...new Set(items)];
}
function buildDetachedWorkingTreeManifest(repoPath: string): SyncManifest {
const manifest = buildSyncManifest(git(repoPath, ['diff', '--name-status', '-M', 'HEAD']));
const untracked = git(repoPath, ['ls-files', '--others', '--exclude-standard'])
.split('\n')
.filter(line => line.length > 0);
return {
added: unique([...manifest.added, ...untracked]),
modified: unique(manifest.modified),
deleted: unique(manifest.deleted),
renamed: manifest.renamed,
};
}
// v0.18.0 Step 5: source-scoped sync state helpers. When opts.sourceId
// is set, read/write the per-source row instead of the global config
// keys. These wrappers centralize the branch so every read/write site
// picks the right storage — future Step 5 work (failure-tracking per
// source) hooks here too.
async function readSyncAnchor(
engine: BrainEngine,
sourceId: string | undefined,
which: 'repo_path' | 'last_commit',
): Promise<string | null> {
if (sourceId) {
const col = which === 'repo_path' ? 'local_path' : 'last_commit';
const rows = await engine.executeRaw<Record<string, string | null>>(
`SELECT ${col} AS value FROM sources WHERE id = $1`,
[sourceId],
);
return rows[0]?.value ?? null;
}
return await engine.getConfig(`sync.${which}`);
}
async function writeSyncAnchor(
engine: BrainEngine,
sourceId: string | undefined,
which: 'repo_path' | 'last_commit',
value: string,
): Promise<void> {
if (sourceId) {
const col = which === 'repo_path' ? 'local_path' : 'last_commit';
// last_sync_at bookmarked on every last_commit advance.
if (which === 'last_commit') {
await engine.executeRaw(
`UPDATE sources SET last_commit = $1, last_sync_at = now() WHERE id = $2`,
[value, sourceId],
);
} else {
await engine.executeRaw(
`UPDATE sources SET ${col} = $1 WHERE id = $2`,
[value, sourceId],
);
}
return;
}
await engine.setConfig(`sync.${which}`, value);
}
/**
* v0.20.0 Cathedral II Layer 12 (SP-1 fix) — read/write the chunker version
* last used to sync a given source. When it mismatches CURRENT_CHUNKER_VERSION,
* `performSync` forces a full walk regardless of git HEAD equality. Without
* this gate, bumping CHUNKER_VERSION does NOTHING on an unchanged repo
* because sync short-circuits at `up_to_date` before reaching
* `importCodeFile`'s content_hash check.
*
* Per-source storage matches writeSyncAnchor's shape — sources.chunker_version
* TEXT column from the v27 migration. No global fallback: non-source syncs
* (pre-v0.17 brains with no sources table) never had CHUNKER_VERSION
* version-gating, so they keep the v0.19.0 behavior.
*/
async function readChunkerVersion(
engine: BrainEngine,
sourceId: string | undefined,
): Promise<string | null> {
if (!sourceId) return null;
const rows = await engine.executeRaw<{ chunker_version: string | null }>(
`SELECT chunker_version FROM sources WHERE id = $1`,
[sourceId],
);
return rows[0]?.chunker_version ?? null;
}
async function writeChunkerVersion(
engine: BrainEngine,
sourceId: string | undefined,
version: string,
): Promise<void> {
if (!sourceId) return;
await engine.executeRaw(
`UPDATE sources SET chunker_version = $1 WHERE id = $2`,
[version, sourceId],
);
}
/**
* v0.40 Federated Sync v2: `gbrain sync trigger --source <id> [--priority high|normal|low]`
*
* Push-trigger entry point. Wraps `queue.add('sync', ...)` with priority -10
* (above autopilot's 0) so push-triggered syncs preempt scheduled ones.
* Use cases: GitHub webhook handler (POST /webhooks/github), CLI nudge after
* a manual git pull, scripted dispatch from `gbrain sources federate`.
*
* Sets `auto_embed_backfill: true` so the extended sync handler (T6/T7)
* auto-enqueues an embed-backfill job after the sync settles.
*
* Output: prints `job_id=N` to stdout for shell composition. Errors exit 1.
*/
export async function runSyncTrigger(engine: BrainEngine, args: string[]): Promise<void> {
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: gbrain sync trigger --source <id> [--priority high|normal|low]
Queue a push-triggered sync job for one source. Prints the resulting job id
on stdout. The autopilot worker picks it up and runs performSync against the
named source; if the sync added/modified pages, an embed-backfill job is
auto-enqueued (subject to D6 budget cap + D19 source-level cooldown).
Use cases:
- GitHub webhook → 'gbrain sync trigger --source <repo>'
- Manual nudge after 'git pull' inside a federated source
- Programmatic triggers from CI / shell automation
See also:
gbrain sources webhook set <id> Set up GitHub-signed push webhook
gbrain sources status Per-source sync + embed coverage
`);
return;
}
const sourceIdArg = args.find((a, i) => args[i - 1] === '--source') ?? null;
if (!sourceIdArg) {
console.error('Error: --source <id> is required');
console.error("Usage: gbrain sync trigger --source <id> [--priority high|normal|low]");
process.exit(2);
}
const priorityArg = args.find((a, i) => args[i - 1] === '--priority') ?? 'high';
const priorityMap: Record<string, number> = { high: -10, normal: 0, low: 5 };
const priority = priorityMap[priorityArg];
if (priority === undefined) {
console.error(`Invalid --priority value: "${priorityArg}". Must be high|normal|low.`);
process.exit(2);
}
// Verify source exists before submitting
const { fetchSource } = await import('../core/sources-load.ts');
const source = await fetchSource(engine, sourceIdArg);
if (!source) {
console.error(`Source "${sourceIdArg}" not found. List with: gbrain sources list`);
process.exit(1);
}
const { MinionQueue } = await import('../core/minions/queue.ts');
const queue = new MinionQueue(engine);
const job = await queue.add(
'sync',
{
sourceId: sourceIdArg,
repoPath: source.local_path,
auto_embed_backfill: true,
embed_reason: 'sync_trigger',
},
{
priority,
idempotency_key: `sync-trigger:${sourceIdArg}:${Math.floor(Date.now() / 30_000)}`,
maxWaiting: 1,
},
);
console.log(`job_id=${job.id}`);
}
export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<SyncResult> {
// v0.22.13 CODEX-2: cross-process writer lock prevents two concurrent
// syncs from racing on the same last_commit anchor (last writer wins,
// bookmark regresses, silent corruption).
//
// v0.40.5.0: per-source DB lock via `syncLockId(sourceId)`. Two sources
// (default + zion-brain) take distinct lock rows and don't serialize.
// SYNC_LOCK_ID is now a back-compat alias for syncLockId('default').
//
// v0.40.6.0 (D11 from PR #1314 review): pair the per-source lock with
// `withRefreshingLock` so long-running sources (media-corpus, 250K+
// chunks) don't lose their lock at the 30-minute TTL mid-run. Closes
// the bug class where a >30min sync could let a parallel acquire steal
// the lock and race on the final commit + bookmark write.
//
// skipLock is reserved for callers that already serialize via another
// mechanism (e.g. cycle.ts holds gbrain-cycle for the broader scope).
if (opts.skipLock) {
return await performSyncInner(engine, opts);
}
const lockKey = opts.lockId ?? syncLockId(opts.sourceId ?? 'default');
// When `opts.sourceId` is set OR `opts.lockId` is explicitly overridden,
// use the TTL-refreshing lock so long sources stay safe. The default
// path (no sourceId, no lockId) keeps the bare tryAcquireDbLock for
// bit-for-bit back-compat with single-default-source brains.
const usePerSourcePath = opts.lockId !== undefined || opts.sourceId !== undefined;
if (usePerSourcePath) {
try {
return await withRefreshingLock(engine, lockKey, () => performSyncInner(engine, opts));
} catch (err) {
if (err instanceof LockUnavailableError) {
throw new Error(await formatLockBusyMessage(engine, lockKey));
}
throw err;
}
}
// Legacy global-lock path (single-default-source brains).
const lockHandle = await tryAcquireDbLock(engine, lockKey);
if (!lockHandle) {
throw new Error(await formatLockBusyMessage(engine, lockKey));
}
try {
return await performSyncInner(engine, opts);
} finally {
try { await lockHandle.release(); } catch { /* best-effort release */ }
}
}
/**
* v0.41.6.0 D3: rich "Another sync is in progress" message that names the
* holder PID, hostname, age, and the right --break-lock invocation to
* recover. Falls back to the legacy message when inspectLock can't read
* the row (best-effort — the lock itself was still busy).
*/
async function formatLockBusyMessage(engine: BrainEngine, lockKey: string): Promise<string> {
const { inspectLock } = await import('../core/db-lock.ts');
let snap;
try { snap = await inspectLock(engine, lockKey); }
catch { snap = null; }
if (!snap) {
return (
`Another sync is in progress (lock ${lockKey} held). ` +
`Wait for it to finish, or run 'gbrain doctor' if it has been more than 30 minutes.`
);
}
const ageHuman = formatAgeHuman(snap.age_ms);
const breakHint = lockKey.startsWith('gbrain-sync:')
? `gbrain sync --break-lock --source ${lockKey.slice('gbrain-sync:'.length)}`
: `gbrain sync --break-lock`;
const ttlNote = snap.ttl_expired ? ' [TTL expired]' : '';
return (
`Another sync is in progress (lock ${lockKey} held by pid ${snap.holder_pid} on ${snap.holder_host}, ` +
`started ${ageHuman} ago${ttlNote}).\n` +
`If pid ${snap.holder_pid} is dead, re-run with --break-lock to clear it:\n` +
` ${breakHint}\n` +
`Or wait for the holder to finish.`
);
}
/**
* v0.41.6.0 D3: `gbrain sync --break-lock` / `--force-break-lock` worker.
* Returns the process exit code (0 = lock cleared or absent; 1 = refused).
*
* Safe path (`force=false`): refuses unless the holder is on this host
* AND either (a) TTL has expired (the lock is structurally available
* already) OR (b) the holder PID is dead AND the lock is older than 60s
* (the age guard defeats PID-reuse coincidence — Linux PID space wraps
* at 32768 so a 10-day-old lock with pid=12345 may be falsely
* refused-to-clear because an unrelated process now owns pid 12345; 60s
* is the codex F7-amended minimum age that makes coincidence unlikely).
*
* Force path (`force=true`): skips liveness check, deletes the row,
* warns loudly that the holder may still be writing.
*
* Both paths use the same atomic `DELETE ... RETURNING id` so a race
* with another break-lock or with TTL-eviction can't produce confusing
* post-conditions.
*/
async function runBreakLock(
engine: BrainEngine,
lockKey: string,
sourceId: string,
opts: { force: boolean; json: boolean },
): Promise<number> {
const { inspectLock, deleteLockRow } = await import('../core/db-lock.ts');
const { hostname } = await import('os');
const localHost = hostname();
let snap;
try { snap = await inspectLock(engine, lockKey); }
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (opts.json) console.log(JSON.stringify({ status: 'error', error: msg, lock: lockKey }));
else console.error(`Failed to inspect lock ${lockKey}: ${msg}`);
return 1;
}
if (!snap) {
if (opts.json) console.log(JSON.stringify({ status: 'absent', lock: lockKey, source_id: sourceId }));
else console.log(`Lock ${lockKey} is not held (nothing to break).`);
return 0;
}
// Force path: skip all guards, atomic DELETE, warn.
if (opts.force) {
const { deleted } = await deleteLockRow(engine, lockKey, snap.holder_pid);
if (opts.json) {
console.log(JSON.stringify({
status: deleted ? 'force_broken' : 'race_already_cleared',
lock: lockKey, source_id: sourceId, snapshot: snap,
}));
} else if (deleted) {
console.log(`Force-broke lock ${lockKey} (was held by pid ${snap.holder_pid} on ${snap.holder_host}, age ${formatAgeHuman(snap.age_ms)}).`);
console.log('WARNING: the holder may still be writing. Verify with `gbrain doctor` before re-running.');
} else {
console.log(`Lock ${lockKey} was already cleared by another process between our check and DELETE (race-safe).`);
}
return 0;
}
// Safe path: must be local host AND (TTL-expired OR (PID-dead AND age >= 60s)).
if (snap.holder_host !== localHost) {
if (opts.json) {
console.log(JSON.stringify({
status: 'refused',
reason: 'cross_host',
lock: lockKey, source_id: sourceId, snapshot: snap, local_host: localHost,
}));
} else {
console.error(`Lock ${lockKey} is held on a different host (${snap.holder_host}, this host is ${localHost}).`);
console.error('Cross-host PID liveness is unsound. To break anyway, use --force-break-lock');
console.error('(only safe when you KNOW the holder is dead — verify before forcing).');
}
return 1;
}
let safe = false;
let reason: string;
if (snap.ttl_expired) {
safe = true;
reason = 'ttl_expired';
} else {
// PID liveness check on local host. process.kill(pid, 0) throws ESRCH
// when the PID is dead. Combined with 60s age guard (per outside-voice F7).
let alive = true;
try { process.kill(snap.holder_pid, 0); }
catch { alive = false; }
const oldEnough = snap.age_ms >= 60_000;
if (!alive && oldEnough) {
safe = true;
reason = 'pid_dead_age_60s';
} else if (!alive && !oldEnough) {
reason = 'pid_dead_but_lock_too_young';
} else {
reason = 'pid_alive';
}
}
if (!safe) {
if (opts.json) {
console.log(JSON.stringify({
status: 'refused', reason, lock: lockKey, source_id: sourceId, snapshot: snap,
}));
} else {
console.error(`Refusing to break lock ${lockKey}: holder pid ${snap.holder_pid} appears alive on ${snap.holder_host} (age ${formatAgeHuman(snap.age_ms)}).`);
if (reason === 'pid_dead_but_lock_too_young') {
console.error('(PID is dead but the lock is younger than 60s — the PID may have been reused. Wait or use --force-break-lock if you are certain.)');
} else {
console.error('If the holder is wedged, kill it first then re-run --break-lock,');
console.error('OR use --force-break-lock to clear regardless (the holder may still write afterwards).');
}
}
return 1;
}
const { deleted } = await deleteLockRow(engine, lockKey, snap.holder_pid);
if (opts.json) {
console.log(JSON.stringify({
status: deleted ? 'broken' : 'race_already_cleared',
reason, lock: lockKey, source_id: sourceId, snapshot: snap,
}));
} else if (deleted) {
console.log(`Broke lock ${lockKey} (was held by pid ${snap.holder_pid} on ${snap.holder_host}, age ${formatAgeHuman(snap.age_ms)}; reason: ${reason}).`);
} else {
console.log(`Lock ${lockKey} was already cleared by another process between our check and DELETE (race-safe).`);
}
return 0;
}
function formatAgeHuman(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${s % 60}s`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h${m % 60}m`;
const d = Math.floor(h / 24);
return `${d}d${h % 24}h`;
}
async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<SyncResult> {
// v0.41.8.0 (D9 / #1342): phase breadcrumbs. The #1342 reporter saw
// ZERO stderr output before their sync hang, which made the bug
// impossible to triage. Mirror the existing `[gbrain phase] sync.git_pull`
// pattern at the major phase boundaries so the next #1342-shaped
// report names WHICH phase spun. Doesn't fix #1342 but converts
// "hung with no output" into actionable diagnostic data.
serr(`[gbrain phase] sync.resolve_repo`);
// Resolve repo path
const repoPath = opts.repoPath || await readSyncAnchor(engine, opts.sourceId, 'repo_path');
if (!repoPath) {
const hint = opts.sourceId
? `Source "${opts.sourceId}" has no local_path. Run: gbrain sources add ${opts.sourceId} --path <path>`
: `No repo path specified. Use --repo or run gbrain init with --repo first.`;
throw new Error(hint);
}
serr(`[gbrain phase] sync.load_active_pack`);
// v0.39 T1.5: load active pack ONCE at sync entry; pass to every per-file
// importFile call below. Codex perf finding #7: per-file loadActivePack adds
// disk/YAML/hash overhead × thousands of files. Best-effort: pack load
// failure falls through to legacy inferType (parity preserved).
let syncActivePack: { page_types: ReadonlyArray<{ name: string; path_prefixes: ReadonlyArray<string> }> } | undefined;
try {
const { loadActivePack } = await import('../core/schema-pack/load-active.ts');
const { loadConfig } = await import('../core/config.ts');
const resolved = await loadActivePack({
cfg: loadConfig(),
remote: false, // sync is always a trusted CLI / autopilot caller
sourceId: opts.sourceId,
});
syncActivePack = { page_types: resolved.manifest.page_types };
} catch {
syncActivePack = undefined;
}
// v0.28: source-aware re-clone branch. When the source has a remote_url
// recorded (i.e. it was registered via `sources add --url`), the on-disk
// clone is auto-managed. validateRepoState classifies the on-disk state;
// we recover from missing/no-git/not-a-dir by re-cloning, refuse on
// url-drift or corruption with structured hints.
if (opts.sourceId) {
serr(`[gbrain phase] sync.validate_repo_state`);
const { validateRepoState } = await import('../core/git-remote.ts');
const { recloneIfMissing } = await import('../core/sources-ops.ts');
const cfgRows = await engine.executeRaw<{ config: unknown }>(
`SELECT config FROM sources WHERE id = $1`,
[opts.sourceId],
);
const cfg =
typeof cfgRows[0]?.config === 'string'
? (JSON.parse(cfgRows[0].config as string) as Record<string, unknown>)
: ((cfgRows[0]?.config ?? {}) as Record<string, unknown>);
const remoteUrl = typeof cfg.remote_url === 'string' ? cfg.remote_url : null;
if (remoteUrl) {
const state = validateRepoState(repoPath, remoteUrl);
switch (state) {
case 'healthy':
break;
case 'missing':
case 'no-git':
case 'not-a-dir':
serr(
`[gbrain] auto-recovery: re-cloning "${opts.sourceId}" (clone state: ${state}).`,
);
await recloneIfMissing(engine, opts.sourceId);
break;
case 'corrupted':
throw new Error(
`Source "${opts.sourceId}" clone at ${repoPath} is corrupted ` +
`(\`git remote get-url origin\` failed). Run: ` +
`gbrain sources remove ${opts.sourceId} --confirm-destructive && ` +
`gbrain sources add ${opts.sourceId} --url ${remoteUrl}`,
);
case 'url-drift':
throw new Error(
`Source "${opts.sourceId}" clone at ${repoPath} has a remote ` +
`that differs from config.remote_url=${remoteUrl}. ` +
`Re-clone with: gbrain sources rebase-clone ${opts.sourceId} ` +
`(if available, else: sources remove + sources add).`,
);
}
}
}
// Validate git repo
if (!existsSync(join(repoPath, '.git'))) {
throw new Error(`Not a git repository: ${repoPath}. GBrain sync requires a git-initialized repo.`);
}
serr(`[gbrain phase] sync.detect_head`);
// Detect detached HEAD up front so the working-tree fallback fires for both
// the default sync and `--no-pull` callers. Only the actual git pull is
// gated on opts.noPull.
const detachedHead = isDetachedHead(repoPath);
if (detachedHead && !opts.noPull) {
serr(`Detached HEAD on ${repoPath}; skipping git pull. Syncing from local working tree.`);
}
// Git pull (unless --no-pull). v0.28.1 codex finding (HIGH): the legacy
// git() helper at sync.ts:192 spawns git without GIT_SSRF_FLAGS, so
// every steady-state pull was bypassing the redirect/submodule/protocol
// hardening that cloneRepo applies. Route through pullRepo from
// git-remote.ts so the flag set is consistent across initial clone and
// ongoing pulls — single source of truth for the defensive flags.
const originRemotePresent = !opts.noPull && !detachedHead ? hasOriginRemote(repoPath) : false;
if (!opts.noPull && !detachedHead && !originRemotePresent) {
serr(`No origin remote on ${repoPath}; skipping git pull. Syncing from local working tree.`);
}
if (!opts.noPull && !detachedHead && originRemotePresent) {
const _t0 = Date.now();
serr(`[gbrain phase] sync.git_pull start`);
try {
const { pullRepo } = await import('../core/git-remote.ts');
pullRepo(repoPath);
serr(`[gbrain phase] sync.git_pull done ${Date.now() - _t0}ms`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
serr(`[gbrain phase] sync.git_pull error ${Date.now() - _t0}ms (${msg.slice(0, 80)})`);
if (msg.includes('non-fast-forward') || msg.includes('diverged')) {
serr(`Warning: git pull failed (remote diverged). Syncing from local state.`);
} else {
serr(`Warning: git pull failed: ${msg.slice(0, 100)}`);
}
}
}
// Get current HEAD
let headCommit: string;
try {
headCommit = git(repoPath, ['rev-parse', 'HEAD']);
} catch {
throw new Error(`No commits in repo ${repoPath}. Make at least one commit before syncing.`);
}
// Read sync state (source-scoped when sourceId is set, global otherwise)
const lastCommit = opts.full ? null : await readSyncAnchor(engine, opts.sourceId, 'last_commit');
// Ancestry validation: if lastCommit exists, verify it's still in history
if (lastCommit) {
try {
git(repoPath, ['cat-file', '-t', lastCommit]);
} catch {
serr(`Sync anchor commit ${lastCommit.slice(0, 8)} missing (force push?). Running full reimport.`);
return performFullSync(engine, repoPath, headCommit, opts);
}
// Verify ancestry
try {
git(repoPath, ['merge-base', '--is-ancestor', lastCommit, headCommit]);
} catch {
serr(`Sync anchor ${lastCommit.slice(0, 8)} is not an ancestor of HEAD. Running full reimport.`);
return performFullSync(engine, repoPath, headCommit, opts);
}
}
// First sync
if (!lastCommit) {
return performFullSync(engine, repoPath, headCommit, opts);
}
// v0.20.0 Cathedral II Layer 12 (codex SP-1 fix): before returning
// 'up_to_date' on git-HEAD equality, check the chunker version gate.
// If sources.chunker_version mismatches CURRENT_CHUNKER_VERSION, force
// a full re-walk so existing chunks get re-chunked under the new
// pipeline (qualified symbol names, parent scope, doc-comment column
// population, etc.). Without this, upgraded brains silently stay on
// the old chunks — the whole reason we bumped the version.
const storedVersion = await readChunkerVersion(engine, opts.sourceId);
const currentVersion = String(CHUNKER_VERSION);
const versionMismatch = storedVersion !== null && storedVersion !== currentVersion;
const versionNeverSet = storedVersion === null && opts.sourceId !== undefined;
const detachedWorkingTreeManifest = detachedHead ? buildDetachedWorkingTreeManifest(repoPath) : null;
const hasDetachedWorkingTreeChanges = detachedWorkingTreeManifest !== null &&
(detachedWorkingTreeManifest.added.length > 0 ||
detachedWorkingTreeManifest.modified.length > 0 ||
detachedWorkingTreeManifest.deleted.length > 0 ||
detachedWorkingTreeManifest.renamed.length > 0);
if (lastCommit === headCommit && !versionMismatch && !versionNeverSet && !hasDetachedWorkingTreeChanges) {
return {
status: 'up_to_date',
fromCommit: lastCommit,
toCommit: headCommit,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
if ((versionMismatch || versionNeverSet) && lastCommit === headCommit) {
slog(
`[sync] chunker_version gate: stored=${storedVersion ?? 'unset'}, current=${currentVersion}. ` +
`Forcing full re-chunk pass (git HEAD unchanged but pipeline version advanced).`,
);
const result = await performFullSync(engine, repoPath, headCommit, opts);
await writeChunkerVersion(engine, opts.sourceId, currentVersion);
return result;
}
// Diff using git diff (net result, not per-commit)
const diffOutput = git(repoPath, ['diff', '--name-status', '-M', `${lastCommit}..${headCommit}`]);
const manifest = buildSyncManifest(diffOutput);
if (detachedWorkingTreeManifest) {
manifest.added = unique([...manifest.added, ...detachedWorkingTreeManifest.added]);
manifest.modified = unique([...manifest.modified, ...detachedWorkingTreeManifest.modified]);
manifest.deleted = unique([...manifest.deleted, ...detachedWorkingTreeManifest.deleted]);
manifest.renamed = [...manifest.renamed, ...detachedWorkingTreeManifest.renamed];
}
// Filter to syncable files (strategy-aware)
const syncOpts = opts.strategy ? { strategy: opts.strategy } : undefined;
const filtered: SyncManifest = {
added: manifest.added.filter(p => isSyncable(p, syncOpts)),
modified: manifest.modified.filter(p => isSyncable(p, syncOpts)),
deleted: manifest.deleted.filter(p => isSyncable(p, syncOpts)),
renamed: manifest.renamed.filter(r => isSyncable(r.to, syncOpts)),
};
// Delete pages that became un-syncable (modified but filtered out).
// v0.20.0 Cathedral II SP-5: resolveSlugForPath picks the right slug shape
// (markdown vs code) based on the chunker's classifier, so a Rust file that
// became un-syncable (e.g., moved under `.gitignore` or filtered by
// strategy=markdown) deletes the actual code-slug page, not a ghost
// markdown-slug that never existed.
//
// v0.41.13 (#1433): the original cleanup loop deleted EVERY pre-existing
// page for unsyncable-modified paths, including `log.md`, `schema.md`,
// `index.md`, `README.md` — files that fail `isSyncable` precisely
// because they're metafiles by convention, not because the user
// "removed" them from the strategy. infiniteGameExp's domain `log.md`
// pages had been indexed by an older gbrain version (or via direct
// put_page) and were silently dropped on every subsequent sync. The
// fix uses `unsyncableReason` (factored from `isSyncable` so they
// cannot drift) to skip the delete when the reason is `'metafile'`.
//
// Honest scope: this guard only fixes the `manifest.modified` case.
// `manifest.deleted` is filtered upstream at sync.ts:757 via the same
// `isSyncable` call, so `rm log.md` followed by sync also doesn't
// delete the page. That's the same pre-fix behavior — removing the
// page requires `gbrain pages purge-deleted` or a direct MCP delete.
// Filed as v0.42+ follow-up for a `gbrain pages remove <slug>` surface.
const unsyncableModified = manifest.modified.filter(p => !isSyncable(p, syncOpts));
// v0.18.0+ multi-source: scope getPage + deletePage to opts.sourceId so
// unsyncable cleanup in source A doesn't accidentally sweep same-slug
// pages in sources B/C/D.
const pageOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
for (const path of unsyncableModified) {
// v0.41.13 #1433: never delete on metafile classification.
if (unsyncableReason(path, syncOpts) === 'metafile') continue;
const slug = await resolveSlugByPathOrSourcePath(engine, path, opts.sourceId);
try {
const existing = await engine.getPage(slug, pageOpts);
if (existing) {
await engine.deletePage(slug, pageOpts);
slog(` Deleted un-syncable page: ${slug}`);
}
} catch { /* ignore */ }
}
const totalChanges = filtered.added.length + filtered.modified.length +
filtered.deleted.length + filtered.renamed.length;
// Dry run
if (opts.dryRun) {
slog(`Sync dry run: ${lastCommit.slice(0, 8)}..${headCommit.slice(0, 8)}`);
if (filtered.added.length) slog(` Added: ${filtered.added.join(', ')}`);
if (filtered.modified.length) slog(` Modified: ${filtered.modified.join(', ')}`);
if (filtered.deleted.length) slog(` Deleted: ${filtered.deleted.join(', ')}`);
if (filtered.renamed.length) slog(` Renamed: ${filtered.renamed.map(r => `${r.from} -> ${r.to}`).join(', ')}`);
if (totalChanges === 0) slog(` No syncable changes.`);
return {
status: 'dry_run',
fromCommit: lastCommit,
toCommit: headCommit,
added: filtered.added.length,
modified: filtered.modified.length,
deleted: filtered.deleted.length,
renamed: filtered.renamed.length,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
if (totalChanges === 0) {
// Update sync state even with no syncable changes (git advanced)
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
return {
status: 'up_to_date',
fromCommit: lastCommit,
toCommit: headCommit,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
const noEmbed = opts.noEmbed || totalChanges > 100;
if (totalChanges > 100) {
slog(`Large sync (${totalChanges} files). Importing text, deferring embeddings.`);
}
const pagesAffected: string[] = [];
let chunksCreated = 0;
const start = Date.now();
// Per-file progress on stderr so agents see each step of a big sync.
// Phases: sync.deletes, sync.renames, sync.imports.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// Process deletes first (prevents slug conflicts). SP-5: resolveSlugForPath
// dispatches to the right slug shape so code file deletes hit the real page.
// v0.18.0+ multi-source: scope deletePage so we only delete the source-A
// row, not every same-slug row across all sources.
const deleteOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
if (filtered.deleted.length > 0) {
progress.start('sync.deletes', filtered.deleted.length);
for (const path of filtered.deleted) {
const slug = await resolveSlugByPathOrSourcePath(engine, path, opts.sourceId);
await engine.deletePage(slug, deleteOpts);
pagesAffected.push(slug);
progress.tick(1, slug);
}
progress.finish();
}
// Process renames (updateSlug preserves page_id, chunks, embeddings).
// SP-5: both old and new slugs use resolveSlugForPath so a .ts → .ts
// rename (code→code), .md → .md (markdown→markdown), or cross-kind rename
// all resolve to the right slug shape for each side.
if (filtered.renamed.length > 0) {
progress.start('sync.renames', filtered.renamed.length);
// v0.18.0+ multi-source: scope updateSlug so the rename only touches the
// source-A row, not every same-slug row across sources (which would
// either sweep them all OR violate (source_id, slug) UNIQUE).
const renameOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
for (const { from, to } of filtered.renamed) {
const oldSlug = await resolveSlugByPathOrSourcePath(engine, from, opts.sourceId);
// The new path doesn't yet have a row, so resolve from path only.
const newSlug = resolveSlugForPath(to);
try {
await engine.updateSlug(oldSlug, newSlug, renameOpts);
} catch {
// Slug doesn't exist or collision, treat as add
}
// Reimport at new path (picks up content changes)
const filePath = join(repoPath, to);
if (existsSync(filePath)) {
const result = await importFile(engine, filePath, to, { noEmbed, sourceId: opts.sourceId, activePack: syncActivePack });
if (result.status === 'imported') chunksCreated += result.chunks;
}
pagesAffected.push(newSlug);
progress.tick(1, newSlug);
}
progress.finish();
}
// Process adds and modifies.
//
// NOTE: do NOT wrap this loop in engine.transaction(). importFromContent
// already opens its own inner transaction per file, and PGLite transactions
// are not reentrant — they acquire the same _runExclusiveTransaction mutex,
// so a nested call from inside a user callback queues forever on the mutex
// the outer transaction is still holding. Result: incremental sync hangs in
// ep_poll whenever the diff crosses the old > 10 threshold that used to
// trigger the outer wrap. Per-file atomicity is also the right granularity:
// one file's failure should not roll back the others' successful imports.
//
// v0.15.2: per-file progress on stderr via the shared reporter.
// Bug 9: per-file failures captured in `failedFiles` so the caller can
// gate `sync.last_commit` advancement and record recoverable errors.
const failedFiles: Array<{ path: string; error: string; line?: number }> = [];
const addsAndMods = [...filtered.added, ...filtered.modified];
// Sort newest-first so date-prefixed brain paths get embedded before older
// ones. See src/core/sort-newest-first.ts for the policy.
sortNewestFirst(addsAndMods);
// v0.22.13 (PR #490 Q5): one source of truth for the concurrency decision.
// engine.kind === 'pglite' → forced 1; explicit opts.concurrency wins;
// auto path returns DEFAULT_PARALLEL_WORKERS only when fileCount > 100.
const explicitConcurrency = opts.concurrency !== undefined;
const effectiveConcurrency = autoConcurrency(engine, addsAndMods.length, opts.concurrency);
const runParallel = shouldRunParallel(effectiveConcurrency, addsAndMods.length, explicitConcurrency);
if (addsAndMods.length > 0) {
progress.start('sync.imports', addsAndMods.length);
// Core import logic shared by serial and parallel paths.
// repoPath is validated non-null at the top of performSyncInner; narrow for TS.
const syncRepoPath = repoPath!;
async function importOnePath(eng: BrainEngine, path: string): Promise<void> {
const filePath = join(syncRepoPath, path);
if (!existsSync(filePath)) {
// CODEX-3 (v0.22.13): a file the diff said exists at headCommit but
// is gone from disk means the working tree has drifted (someone ran
// `git checkout` / `git reset` mid-sync, or the file was deleted
// post-diff). Record as a failure so last_commit does NOT advance —
// the silent-skip-then-advance pathology was the bug.
failedFiles.push({
path,
error: 'file vanished mid-sync (working tree drifted from headCommit)',
});
progress.tick(1, `skip:${path}`);
return;
}
try {
// v0.18.0+ multi-source: thread `opts.sourceId` so per-page tx writes
// (putPage / getTags / addTag / removeTag / deleteChunks / upsertChunks
// / addLink) target (sourceId, slug). Pre-fix the schema DEFAULT
// 'default' was applied even for non-default sources, fabricating
// duplicate rows that crashed bare-slug subqueries with Postgres 21000.
const result = await importFile(eng, filePath, path, { noEmbed, sourceId: opts.sourceId, activePack: syncActivePack });
if (result.status === 'imported') {
chunksCreated += result.chunks;
pagesAffected.push(result.slug);
} else if (result.status === 'skipped' && (result as any).error) {
failedFiles.push({ path, error: String((result as any).error) });
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
serr(` Warning: skipped ${path}: ${msg}`);
failedFiles.push({ path, error: msg });
}
progress.tick(1, path);
}
if (runParallel) {
// A1 (v0.22.13): use engine.kind discriminator instead of config?.engine
// string compare or constructor.name sniff. Q3: belt-and-suspenders fall
// back to serial when database_url is unset, so we never crash on a null
// assertion if config is missing.
const config = loadConfig();
if (engine.kind === 'pglite' || !config?.database_url) {
for (const path of addsAndMods) {
await importOnePath(engine, path);
}
} else {
const { PostgresEngine } = await import('../core/postgres-engine.ts');
const { resolvePoolSize } = await import('../core/db.ts');
const workerPoolSize = Math.min(2, resolvePoolSize(2));
const workerCount = Math.min(effectiveConcurrency, addsAndMods.length);
const databaseUrl = config.database_url;
// Q4 (v0.22.13): banner on stderr so stdout stays clean for --json.
serr(` Parallel sync: ${workerCount} workers for ${addsAndMods.length} files`);
const workerEngines: InstanceType<typeof PostgresEngine>[] = [];
try {
// Connect workers one-by-one rather than Promise.all so a partial
// failure leaves us with the connected ones in workerEngines for
// the finally-block cleanup. The original code lost track of
// already-connected engines on any one failure.
for (let i = 0; i < workerCount; i++) {
const eng = new PostgresEngine();
await eng.connect({ database_url: databaseUrl, poolSize: workerPoolSize });
workerEngines.push(eng);
}
// Atomic queue index — JS is single-threaded; the read-then-increment
// happens between awaits, so no lock is needed.
let queueIndex = 0;
await Promise.all(
workerEngines.map(async (eng) => {
while (true) {
const idx = queueIndex++;
if (idx >= addsAndMods.length) break;
await importOnePath(eng, addsAndMods[idx]);
}
}),
);
} finally {
// A2 (v0.22.13): try/finally guarantees connection cleanup even when
// the worker loop throws (partial connect failure, OOM, mid-import
// signal). Each disconnect is best-effort — one worker failing to
// disconnect must not strand the others.
await Promise.all(
workerEngines.map((e) =>
e.disconnect().catch((err: unknown) =>
serr(` worker disconnect failed: ${err instanceof Error ? err.message : String(err)}`),
),
),
);
}
}
} else {
// Serial path (small auto diffs or explicit --workers 1).
for (const path of addsAndMods) {
await importOnePath(engine, path);
}
}
progress.finish();
}
// CODEX-3 (v0.22.13): head-drift gate. If git HEAD moved during the import
// window (someone ran `git checkout` or `git pull` in another terminal /
// sibling Conductor workspace), the chunks we just imported reflect a
// different tree than `headCommit` claims. Refuse to advance last_commit
// so the next sync re-walks against the new HEAD. The lock from CODEX-2
// prevents *this* gbrain process from stepping on itself; this gate
// catches drift caused by external `git` commands the lock cannot see.
try {
const currentHead = git(repoPath, ['rev-parse', 'HEAD']);
if (currentHead !== headCommit) {
failedFiles.push({
path: '<head>',
error: `git HEAD drifted during sync: captured ${headCommit.slice(0, 8)}, now ${currentHead.slice(0, 8)}`,
});
}
} catch (e) {
// rev-parse failure is itself a drift signal (worktree disappeared).
failedFiles.push({
path: '<head>',
error: `git HEAD verification failed: ${e instanceof Error ? e.message : String(e)}`,
});
}
const elapsed = Date.now() - start;
// Bug 9 — gate the sync bookmark on success. If any per-file parse
// failed, record it to ~/.gbrain/sync-failures.jsonl and DO NOT advance
// sync.last_commit. The next sync re-walks the same diff and re-attempts
// the failed files. Escape hatches: --skip-failed acknowledges the
// current set, --retry-failed re-parses before running the normal sync.
if (failedFiles.length > 0) {
recordSyncFailures(failedFiles, headCommit);
// Emit structured summary grouped by error code so the operator
// can see *why* files failed, not just how many.
const codeBreakdown = formatCodeBreakdown(failedFiles);
if (!opts.skipFailed) {
serr(
`\nSync blocked: ${failedFiles.length} file(s) failed to parse:\n` +
`${codeBreakdown}\n\n` +
`Fix the YAML frontmatter in the files above and re-run, or use ` +
`'gbrain sync --skip-failed' to acknowledge and move on.`,
);
// Update last_run + repo_path (progress on infra) but NOT last_commit.
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
return {
status: 'blocked_by_failures',
fromCommit: lastCommit,
toCommit: headCommit,
added: filtered.added.length,
modified: filtered.modified.length,
deleted: filtered.deleted.length,
renamed: filtered.renamed.length,
chunksCreated,
embedded: 0,
pagesAffected,
failedFiles: failedFiles.length,
};
}
// --skip-failed: acknowledge the now-recorded set and proceed.
const acked = acknowledgeSyncFailures();
if (acked.count > 0) {
serr(
` Acknowledged ${acked.count} failure(s) and advancing past them:\n` +
`${formatCodeBreakdown(acked.summary)}`,
);
}
}
// Update sync state AFTER all changes succeed (source-scoped when
// opts.sourceId is set, global config otherwise).
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
// v0.20.0 Cathedral II Layer 12: persist the chunker version we just
// finished with so the next sync's up_to_date gate respects it. Only
// source-scoped syncs track this (see readChunkerVersion for rationale).
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
// Log ingest
await engine.logIngest({
source_type: 'git_sync',
source_ref: `${repoPath} @ ${headCommit.slice(0, 8)}`,
pages_updated: pagesAffected,
summary: `Sync: +${filtered.added.length} ~${filtered.modified.length} -${filtered.deleted.length} R${filtered.renamed.length}, ${chunksCreated} chunks, ${elapsed}ms`,
});
// Auto-extract links + timeline (always, extraction is cheap CPU).
// Thread opts.sourceId so the extract phase reconciles edges + timeline
// entries against the right source — pre-fix (Data R1 HIGH 1) this phase
// bypassed sourceId entirely and the bare-slug subquery in addTimelineEntry
// (Data R1 HIGH 2) crashed with 21000 in multi-source brains.
const extractOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
if (!opts.noExtract && pagesAffected.length > 0) {
try {
const { extractLinksForSlugs, extractTimelineForSlugs } = await import('./extract.ts');
const linksCreated = await extractLinksForSlugs(engine, repoPath, pagesAffected, extractOpts);
const timelineCreated = await extractTimelineForSlugs(engine, repoPath, pagesAffected, extractOpts);
if (linksCreated > 0 || timelineCreated > 0) {
slog(` Extracted: ${linksCreated} links, ${timelineCreated} timeline entries`);
}
} catch { /* extraction is best-effort */ }
}
// v0.31.2: facts extraction now routes through the shared
// src/core/facts/backstop.ts helper (PR1 commit 6). Sync uses
// queue mode (fire-and-forget) + 'high-only' filter so a 50-page
// sync doesn't block on N sequential Sonnet calls. The pre-fix
// inline loop is gone — it carried (a) a dead-code type filter
// ('conversation'/'transcript'/'therapy'/'call' aren't real
// PageTypes), (b) a divergent eligibility shape from put_page,
// and (c) raw extract→insert without dedup/supersede.
if (!opts.noExtract && pagesAffected.length > 0 && pagesAffected.length <= 50) {
const { runFactsBackstop } = await import('../core/facts/backstop.ts');
const factsSourceId = opts.sourceId ?? 'default';
for (const slug of pagesAffected) {
try {
// v0.40 D21: source-scoped getPage. Pre-v0.40 this called
// engine.getPage(slug) WITHOUT sourceId, then wrote facts under
// factsSourceId. On a federated brain with the same slug in two
// sources (e.g. people/garry-tan in default + zion-brain), this
// would attribute facts to the wrong source. Codex outside-voice
// catch on the v0.40 plan review.
const page = await engine.getPage(slug, { sourceId: factsSourceId });
if (!page) continue;
await runFactsBackstop(
{
slug,
type: page.type,
compiled_truth: page.compiled_truth ?? '',
frontmatter: page.frontmatter ?? {},
},
{
engine,
sourceId: factsSourceId,
sessionId: `sync:${slug}`,
source: 'sync:import',
mode: 'queue',
notabilityFilter: 'high-only',
},
);
} catch { /* per-page enqueue is best-effort */ }
}
}
// Auto-embed (skip for large syncs — embedding calls OpenAI).
// Thread sourceId so incremental source syncs embed the page row they just
// imported instead of falling back to the default source.
//
// v0.37 fix wave (Lane D.3 + CDX2-8): switched from `runEmbed` (which
// does its own process.exit) to `runEmbedCore` so sync can detect the
// dim-mismatch class and surface a stderr hint without killing the
// sync. Non-mismatch errors stay best-effort (rate limits, transient
// network) — those shouldn't break sync.
let embedded = 0;
if (!noEmbed && pagesAffected.length > 0 && pagesAffected.length <= 100) {
try {
const { runEmbedCore } = await import('./embed.ts');
const embedOpts = opts.sourceId
? { slugs: pagesAffected, sourceId: opts.sourceId }
: { slugs: pagesAffected };
await runEmbedCore(engine, embedOpts);
embedded = pagesAffected.length;
} catch (e: unknown) {
const { EmbeddingDimMismatchError } = await import('./embed.ts');
if (e instanceof EmbeddingDimMismatchError) {
serr('\n' + e.recipeMessage + '\n');
serr(`Tip: pass --no-embed to sync without embedding, then`);
serr(`run 'gbrain embed --stale' after fixing the schema.\n`);
}
// Other errors stay best-effort — rate limits, transient network.
}
} else if (noEmbed || totalChanges > 100) {
slog(`Text imported. Run 'gbrain embed --stale' to generate embeddings.`);
}
return {
status: 'synced',
fromCommit: lastCommit,
toCommit: headCommit,
added: filtered.added.length,
modified: filtered.modified.length,
deleted: filtered.deleted.length,
renamed: filtered.renamed.length,
chunksCreated,
embedded,
pagesAffected,
};
}
async function performFullSync(
engine: BrainEngine,
repoPath: string,
headCommit: string,
opts: SyncOpts,
): Promise<SyncResult> {
// Dry-run: walk the repo, count syncable files, return without writing.
// Fixes the silent-write-on-dry-run bug where performFullSync called
// runImport unconditionally regardless of opts.dryRun.
//
// v0.31.2 (codex C6): use the strategy-aware walker. Pre-fix this
// hardcoded `collectMarkdownFiles(repoPath)` and filtered with
// default-markdown `isSyncable(rel)`, so `gbrain sync --strategy
// code --dry-run` always reported zero files even when ~1500 code
// files were waiting.
if (opts.dryRun) {
const allFiles = collectSyncableFiles(repoPath, { strategy: opts.strategy ?? 'markdown' });
slog(
`Full-sync dry run (strategy=${opts.strategy ?? 'markdown'}): ` +
`${allFiles.length} file(s) would be imported ` +
`from ${repoPath} @ ${headCommit.slice(0, 8)}.`,
);
return {
status: 'dry_run',
fromCommit: null,
toCommit: headCommit,
added: allFiles.length,
modified: 0,
deleted: 0,
renamed: 0,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
// v0.22.13 (PR #490 A1 + Q5): full sync is always "large" by definition
// (entire working tree). Auto-concurrency fires unconditionally for Postgres;
// PGLite stays serial because its engine is single-connection. Routes the
// policy through autoConcurrency() so it stays consistent with incremental
// sync and the jobs handler.
const FULL_SYNC_LARGE_MARKER = Number.MAX_SAFE_INTEGER;
const fullConcurrency = autoConcurrency(engine, FULL_SYNC_LARGE_MARKER, opts.concurrency);
slog(`Running full import of ${repoPath}${fullConcurrency > 1 ? ` (${fullConcurrency} workers)` : ''}...`);
const { runImport } = await import('./import.ts');
const importArgs = [repoPath];
if (opts.noEmbed) importArgs.push('--no-embed');
if (fullConcurrency > 1) importArgs.push('--workers', String(fullConcurrency));
// v0.31.2: thread strategy through so code-strategy first sync
// actually enumerates code files (closes bug 1).
// v0.30.x: thread sourceId so performFullSync routes pages to the named
// source (incremental path already does this).
const _fullImportT0 = Date.now();
serr(`[gbrain phase] sync.fullsync.import start strategy=${opts.strategy ?? 'markdown'}`);
const result = await runImport(engine, importArgs, {
commit: headCommit,
strategy: opts.strategy,
sourceId: opts.sourceId,
});
serr(
`[gbrain phase] sync.fullsync.import done ${Date.now() - _fullImportT0}ms ` +
`imported=${result.imported} skipped=${result.skipped} errors=${result.errors}`,
);
// Bug 9 — gate the full-sync bookmark on success. runImport already
// writes its own sync.last_commit conditionally (import.ts), but
// performFullSync is called on first-sync + force-full paths where
// the sync module owns the last_commit write. Respect the same gate.
if (result.failures.length > 0) {
recordSyncFailures(result.failures, headCommit);
const codeBreakdown = formatCodeBreakdown(result.failures);
if (!opts.skipFailed) {
serr(
`\nFull sync blocked: ${result.failures.length} file(s) failed:\n` +
`${codeBreakdown}\n\n` +
`Fix the YAML in those files and re-run, or use '--skip-failed'.`,
);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
return {
status: 'blocked_by_failures',
fromCommit: null,
toCommit: headCommit,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: result.chunksCreated,
embedded: 0,
pagesAffected: [],
failedFiles: result.failures.length,
};
}
const acked = acknowledgeSyncFailures();
if (acked.count > 0) {
serr(
` Acknowledged ${acked.count} failure(s) and advancing past them:\n` +
`${formatCodeBreakdown(acked.summary)}`,
);
}
}
// Persist sync state so next sync is incremental (C1 fix: was missing).
// v0.18.0 Step 5: routed through writeSyncAnchor so --source pins it
// to the right sources row rather than the global config.
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
// v0.20.0 Cathedral II Layer 12: persist chunker version for the gate.
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
// Full sync doesn't track pagesAffected, so fall back to embed --stale.
// v0.37 fix wave (Lane D.3 + CDX2-8): switched to runEmbedCore for the
// same reason as the incremental path — surface dim-mismatch via hint
// instead of silently swallowing or killing the process.
let embedded = 0;
if (!opts.noEmbed) {
try {
const { runEmbedCore } = await import('./embed.ts');
await runEmbedCore(engine, { stale: true });
embedded = result.imported;
} catch (e: unknown) {
const { EmbeddingDimMismatchError } = await import('./embed.ts');
if (e instanceof EmbeddingDimMismatchError) {
serr('\n' + e.recipeMessage + '\n');
serr(`Tip: pass --no-embed to sync without embedding, then`);
serr(`run 'gbrain embed --stale' after fixing the schema.\n`);
}
// Other errors stay best-effort.
}
}
return {
status: 'first_sync',
fromCommit: null,
toCommit: headCommit,
added: result.imported,
modified: 0,
deleted: 0,
renamed: 0,
chunksCreated: result.chunksCreated,
embedded,
pagesAffected: [],
};
}
export async function runSync(engine: BrainEngine, args: string[]) {
// v0.40 Federated Sync v2: `gbrain sync trigger` subcommand
// Routes to runSyncTrigger which queues a 'sync' minion job with
// auto_embed_backfill=true. Falls through to the normal sync path
// if 'trigger' isn't the first arg.
if (args[0] === 'trigger') {
return runSyncTrigger(engine, args.slice(1));
}
// v0.37 fix wave (Lane D.4 + CDX2-12): print usage when `--help`/`-h` is
// passed. Pre-fix this was unreachable because the dispatcher's generic
// CLI-only short-circuit fired first; sync is now in CLI_ONLY_SELF_HELP.
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: gbrain sync [options]
Sync the brain repo's text content into the engine, then embed.
Options:
--no-embed Skip the embed step. Use this when the embed
provider is misconfigured or you want to defer
embedding (run 'gbrain embed --stale' later).
--workers N Run the import phase with N parallel workers
(alias: --concurrency). Default: 4 when the
diff is >100 files, else serial.
--source <id> Scope sync to a single source. Defaults to the
brain's default source.
--repo <path> Path to the brain repo. Defaults to the path
saved by 'gbrain init'.
--full Force a full re-sync (rare; usually incremental).
--dry-run Show what would be synced without writing.
--skip-failed Acknowledge previously-recorded sync failures so
the bookmark can advance past unparseable files.
--retry-failed Re-attempt previously-failed files; clear on success.
--watch Re-sync continuously on an interval.
--interval N Watch-mode interval in seconds (default 60).
--no-pull Skip 'git pull' before the sync (useful for tests).
--all Sync every registered source instead of just the
default (multi-source brains).
--parallel N (with --all) Run up to N sources concurrently.
Default: min(sourceCount, --workers, 4). Each
source takes its own per-source DB lock
(gbrain-sync:<source_id>) so independent sources
sync without contending. Total live Postgres
connections per wave ≈ parallel × workers × 2
(per-file pool) + parent pool. Pass --parallel 1
to force serial.
--json Emit a structured JSON envelope on stdout
({schema_version: 1, sources, parallel,
ok_count, error_count}). Human banners route to
stderr so '--json | jq' parses cleanly.
Exit codes: 0 = all sources ok, 1 = any error,
2 = cost-prompt-not-confirmed.
--yes Accept any interactive prompts (CI / non-TTY).
See also:
gbrain embed --stale Re-embed all stale chunks (post --no-embed).
gbrain doctor Diagnose dim mismatches and other sync issues.
`);
return;
}
const repoPath = args.find((a, i) => args[i - 1] === '--repo') || undefined;
const watch = args.includes('--watch');
const intervalStr = args.find((a, i) => args[i - 1] === '--interval');
const interval = intervalStr ? parseInt(intervalStr, 10) : 60;
const dryRun = args.includes('--dry-run');
const full = args.includes('--full');
const noPull = args.includes('--no-pull');
const noEmbed = args.includes('--no-embed');
const skipFailed = args.includes('--skip-failed');
const retryFailed = args.includes('--retry-failed');
const syncAll = args.includes('--all');
const jsonOut = args.includes('--json');
const yesFlag = args.includes('--yes');
// v0.41.6.0 D3: lock-recovery flags. --break-lock (safe) verifies the
// holder is local-host + (TTL-expired OR PID-dead+60s-old) before
// deleting the row. --force-break-lock skips the liveness check. Both
// are refused when combined with --all (per-source invocation required;
// v0.40 lock keys are gbrain-sync:<sourceId>).
const breakLock = args.includes('--break-lock');
const forceBreakLock = args.includes('--force-break-lock');
// v0.41.6.0 D3: handle --break-lock / --force-break-lock BEFORE the
// sync would otherwise contend on the lock. Resolves the active source,
// inspects the lock, decides break/refuse, exits without running sync.
if (breakLock || forceBreakLock) {
if (syncAll) {
console.error('--break-lock / --force-break-lock cannot be combined with --all.');
console.error('Per-source lock keys (gbrain-sync:<sourceId>) require per-source invocation.');
console.error('');
console.error('To recover stale locks across all sources, shell-loop:');
console.error(` for src in $(gbrain sources list --json | jq -r '.[].id'); do gbrain sync --break-lock --source "$src"; done`);
process.exit(1);
}
const sourceArg = args.find((a, i) => args[i - 1] === '--source');
const sourceId = sourceArg ?? 'default';
const lockKey = `gbrain-sync:${sourceId}`;
const exit = await runBreakLock(engine, lockKey, sourceId, { force: forceBreakLock, json: jsonOut });
process.exit(exit);
}
// v0.41.6.0 D1: preflight embedding credentials BEFORE the import phase
// so a missing OPENAI_API_KEY exits with one clean line instead of
// writing N identical entries to sync-failures.jsonl. Skipped when
// --no-embed (the canonical opt-out) or --dry-run (no provider calls
// happen in dry-run anyway).
if (!noEmbed && !dryRun) {
const { validateEmbeddingCreds, EmbeddingCredentialError } = await import('../core/embed-preflight.ts');
try {
validateEmbeddingCreds();
} catch (e) {
if (e instanceof EmbeddingCredentialError) {
if (jsonOut) {
console.log(JSON.stringify({ status: 'embedding_credentials_missing', diagnosis: e.diagnosis }));
} else {
console.error('');
console.error(e.userMessage);
console.error('');
}
process.exit(1);
}
throw e;
}
}
// v0.40 D4+D18: parallel `sync --all` by default; --serial opts back to v1.
// --no-auto-embed skips the per-source embed-backfill auto-enqueue.
// --max-sources N caps fan-out (default min(sources.length, 8)).
const serialFlag = args.includes('--serial');
const noAutoEmbed = args.includes('--no-auto-embed');
const maxSourcesStr = args.find((a, i) => args[i - 1] === '--max-sources');
const maxSources = maxSourcesStr ? parseInt(maxSourcesStr, 10) : undefined;
if (maxSourcesStr && (!Number.isFinite(maxSources!) || maxSources! < 1)) {
console.error(`Invalid --max-sources value: "${maxSourcesStr}". Must be a positive integer.`);
process.exit(1);
}
const strategyArg = args.find((a, i) => args[i - 1] === '--strategy') as SyncOpts['strategy'] | undefined;
const concurrencyStr = args.find((a, i) => args[i - 1] === '--concurrency' || args[i - 1] === '--workers');
const parallelStr = args.find((a, i) => args[i - 1] === '--parallel');
// v0.22.13 (PR #490 Q2): parseWorkers throws on '0', '-3', 'foo', '1.5' instead
// of silently falling through to auto-concurrency or NaN. Loud failure beats
// a 4-worker spawn from a typo. v0.40.3.0: same validation applies to --parallel.
let concurrency: number | undefined;
let parallelOverride: number | undefined;
try {
concurrency = parseWorkers(concurrencyStr);
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
try {
parallelOverride = parseWorkers(parallelStr);
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
// --skip-failed: acknowledge pre-existing unacked failures BEFORE the sync
// runs, not only ones the current run produces. Without this, the common
// recovery flow — fix the YAML, re-run sync, then run --skip-failed to
// clear the log — fails to clear anything: when there are no NEW failures
// (because the files are now fixed), the inner ack path in performSync is
// never reached, and "Already up to date." leaves the log untouched. Both
// doctor and printSyncResult instruct users to run --skip-failed in
// exactly this case, so the flag has to handle stale entries up-front.
if (skipFailed) {
const stale = unacknowledgedSyncFailures();
if (stale.length > 0) {
const acked = acknowledgeSyncFailures();
console.log(`Acknowledged ${acked.count} pre-existing failure(s).`);
}
}
// v0.18.0 Step 5: --source resolves to a sources(id) row. Falls back
// to pre-v0.17 global config (sync.repo_path + sync.last_commit) when
// no flag, no env, no dotfile is present.
//
// v0.41.13 (#1434): always call the resolver, not just when explicit/env
// is set. Pre-fix, `gbrain sync` without --source skipped resolution and
// left sourceId undefined — which the engine treated as the seeded
// 'default' source. Users with a single non-default registered source
// (studiovault, etc.) silently routed every write to a source holding
// 0 pages, then createVersion threw on the slug lookup.
//
// The resolver's new `sole_non_default` tier (5.5) routes those
// single-source brains to the right place automatically; the nudge
// surfaces the auto-route to stderr so the user knows what happened
// and can pass --source to override if needed.
const explicitSource = args.find((a, i) => args[i - 1] === '--source') || null;
const { resolveSourceWithTier, formatSoleNonDefaultNudge } = await import('../core/source-resolver.ts');
const resolved = await resolveSourceWithTier(engine, explicitSource);
const sourceId: string = resolved.source_id;
if (resolved.tier === 'sole_non_default') {
const nudge = formatSoleNonDefaultNudge(sourceId);
if (nudge) process.stderr.write(nudge + '\n');
}
// v0.19.0 — `sync --all` iterates all registered sources with a
// local_path. Sources are the canonical v0.18.0 abstraction: per-source
// last_commit, last_sync_at, config.federated flags. Per-source
// bookmarks live in the sources table (not ~/.gbrain/config.json),
// which is why this path replaced Garry's OpenClaw `multi-repo.ts` shim.
//
// Only sources with a non-null local_path participate. A GitHub-only
// source (no checkout) has nothing for `sync` to pull. Sources with
// syncEnabled=false in config.jsonb are skipped too.
if (syncAll) {
const sources = await engine.executeRaw<{ id: string; name: string; local_path: string | null; config: Record<string, unknown> }>(
`SELECT id, name, local_path, config FROM sources WHERE local_path IS NOT NULL`,
);
if (!sources || sources.length === 0) {
console.log('No sources with local_path configured. Use `gbrain sources add <id> --path <path>` first.');
return;
}
// v0.20.0 Cathedral II Layer 8 D1 — cost preview + ConfirmationRequired
// gate. Before kicking off a multi-source sync that may embed tens of
// thousands of chunks (real money), walk the sync-diff set(s), sum
// tokens, compute USD estimate, and gate:
// - TTY + !json + !yes → interactive [y/N] prompt
// - non-TTY OR --json OR piped → emit ConfirmationRequired envelope,
// exit 2 (reserve 1 for runtime errors)
// - --yes → skip prompt entirely
// - --dry-run → preview + exit 0
// Skipped entirely when --no-embed is set (user already opted out of
// the cost and will run `embed --stale` later).
if (!noEmbed) {
const preview = estimateSyncAllCost(sources);
const costUsd = estimateEmbeddingCostUsd(preview.totalTokens);
const previewMsg =
`sync --all preview: ${preview.totalFiles} files across ${preview.activeSources} source(s), ` +
`~${preview.totalTokens.toLocaleString()} tokens, est. $${costUsd.toFixed(2)} on ${EMBEDDING_MODEL}.`;
if (dryRun) {
if (jsonOut) {
console.log(JSON.stringify({ status: 'dry_run', preview, costUsd, model: EMBEDDING_MODEL }));
} else {
console.log(previewMsg);
console.log('--dry-run: exit without syncing.');
}
return;
}
if (!yesFlag) {
const isTTY = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
if (!isTTY || jsonOut) {
// Agent-facing path: emit structured envelope, exit 2.
const envelope = serializeError(errorFor({
class: 'ConfirmationRequired',
code: 'cost_preview_requires_yes',
message: previewMsg,
hint: 'Pass --yes to proceed, or --dry-run to see the preview and exit 0.',
}));
console.log(JSON.stringify({ error: envelope, preview, costUsd, model: EMBEDDING_MODEL }));
process.exit(2);
}
// Interactive TTY path: prompt [y/N].
console.log(previewMsg);
const answer = await promptYesNo('Proceed? [y/N] ');
if (!answer) {
console.log('Cancelled.');
return;
}
}
}
// v0.40.5.0 Federated Sync v2 (master) + v0.40.6.0 layering (this branch):
// master added parallel fan-out via pMapAllSettled, embed-backfill auto-
// submit, --serial / --max-sources / --no-auto-embed flags, and feature-
// flagged the whole thing behind sync.federated_v2. This branch layers
// additive improvements on top:
// - humanSink swap so `--json` keeps stdout clean (D4)
// - --skip-failed / --retry-failed reject under parallel>1 (D15 — the
// sync-failures.jsonl is brain-global, parallel acks race)
// - connection-budget stderr warning at parallel × workers × 2 > 16 (D10)
// - withSourcePrefix wrap inside runOne so slog/serr lines from
// performSync get the [<source-id>] prefix under parallel mode (D6)
// - stable JSON envelope {schema_version:1, sources, ...} when --json
const { isFederatedV2Enabled } = await import('../core/feature-flags.ts');
const v2Enabled = await isFederatedV2Enabled(engine);
const activeSources = sources.filter((s) => {
const cfg = (s.config || {}) as { syncEnabled?: boolean };
return cfg.syncEnabled !== false;
});
const disabledCount = sources.length - activeSources.length;
const humanSink: NodeJS.WriteStream = jsonOut ? process.stderr : process.stdout;
const writeHuman = (line: string) => humanSink.write(line + '\n');
if (disabledCount > 0) {
writeHuman(`Skipping ${disabledCount} disabled source(s).`);
}
if (activeSources.length === 0) {
if (jsonOut) {
console.log(JSON.stringify({
schema_version: 1,
sources: [],
parallel: 0,
ok_count: 0,
error_count: 0,
}));
}
return;
}
// Per-source result accumulator for the optional --json envelope.
type PerSourceResult = {
sourceId: string;
sourceName: string;
status: 'ok' | 'error';
result?: SyncResult;
error?: string;
};
const perSourceResults: PerSourceResult[] = [];
const runOne = async (src: typeof sources[number]): Promise<SyncResult> => {
const cfg = (src.config || {}) as { strategy?: 'markdown' | 'code' | 'auto' };
// D18: parallel path defers embed; auto-enqueue embed-backfill after.
const effectiveNoEmbed = v2Enabled && !serialFlag && !noEmbed ? true : noEmbed;
const repoOpts: SyncOpts = {
repoPath: src.local_path!,
dryRun, full, noPull,
noEmbed: effectiveNoEmbed,
skipFailed, retryFailed,
sourceId: src.id,
strategy: cfg.strategy,
concurrency,
};
// v0.40.6.0 (D6): wrap performSync in withSourcePrefix so every slog /
// serr line emitted from inside the sync code path gets prefixed with
// `[<source-id>] `. Under master's pMapAllSettled fan-out, this is
// what makes `grep '\[media-corpus\]'` against parallel output work.
const result = await withSourcePrefix(src.id, () => performSync(engine, repoOpts));
if (result.status !== 'dry_run' && result.status !== 'blocked_by_failures') {
manageGitignore(src.local_path!, engine.kind);
}
// D18: auto-enqueue embed-backfill per source (unless opted out)
if (
v2Enabled &&
!noAutoEmbed &&
!dryRun &&
result.status !== 'dry_run' &&
result.status !== 'up_to_date'
) {
try {
const { submitEmbedBackfill } = await import('../core/embed-backfill-submit.ts');
const sub = await submitEmbedBackfill(engine, src.id, { reason: 'sync_all' });
if (sub.status === 'submitted') {
writeHuman(` → embed-backfill job ${sub.jobId} queued for ${src.name}`);
} else if (sub.status === 'cooldown') {
writeHuman(` → embed-backfill skipped (cooldown) for ${src.name}`);
} else if (sub.status === 'spend_capped') {
writeHuman(` → embed-backfill skipped (24h spend cap $${sub.spendCapUsd}) for ${src.name}`);
}
} catch (e) {
process.stderr.write(` → embed-backfill submission failed for ${src.name}: ${e instanceof Error ? e.message : String(e)}\n`);
}
}
return result;
};
const parallelEligible =
v2Enabled && !serialFlag && engine.kind !== 'pglite' && activeSources.length > 1;
// v0.40.6.0 (D15): refuse --skip-failed / --retry-failed when running
// parallel. sync-failures.jsonl is brain-global; parallel acks race.
if (parallelEligible && (skipFailed || retryFailed)) {
const flag = skipFailed ? '--skip-failed' : '--retry-failed';
console.error(
`Error: ${flag} is not supported under parallel sync.\n` +
` (the sync-failures log is brain-global and parallel acks race).\n` +
` Re-run with --serial for the recovery flow:\n` +
` gbrain sync --all --serial ${flag}`,
);
process.exit(1);
}
// Effective parallelism — surfaced in the --json envelope so consumers
// know how the run was actually dispatched. 1 in the serial fallback,
// capped at min(sourceCount, --max-sources, 8) in the parallel path.
const effectiveParallel = parallelEligible
? Math.min(activeSources.length, maxSources ?? 8)
: 1;
if (parallelEligible) {
const { pMapAllSettled } = await import('../core/parallel.ts');
const cap = effectiveParallel;
// v0.40.6.0 (D10): connection-budget stderr warning. Each per-file
// worker opens its own PostgresEngine with poolSize=2, so the real
// live-connection ceiling is `cap × workers × 2` per wave plus the
// parent pool. The original PR understated by 2× — fix the math.
const effectiveWorkers = concurrency ?? 4;
const budget = cap * effectiveWorkers * 2;
if (budget > 16) {
process.stderr.write(
`[sync --all] Connection budget: parallel=${cap} × workers=${effectiveWorkers} × 2 ` +
`(per-file pool) = ${budget} concurrent connections per fan-out wave (+ parent pool). ` +
`Check pgbouncer/Postgres max_connections (SELECT count(*) FROM pg_stat_activity); ` +
`raise the cap or lower --max-sources/--workers if you see "too many clients" errors.\n`,
);
}
writeHuman(`\nParallel sync: ${activeSources.length} sources, ${cap} concurrent workers.\n`);
const results = await pMapAllSettled(activeSources, cap, async (src) => {
const r = await runOne(src);
return { name: src.name, result: r };
});
// Print per-source aggregate at the end. humanSink so --json stays clean.
writeHuman('\n--- sync --all aggregate ---');
for (let i = 0; i < results.length; i++) {
const r = results[i];
const src = activeSources[i];
if (r.status === 'fulfilled') {
writeHuman(` ✓ ${src.name}: ${r.value.result.status} (added=${r.value.result.added}, modified=${r.value.result.modified}, deleted=${r.value.result.deleted})`);
perSourceResults.push({
sourceId: src.id,
sourceName: src.name,
status: 'ok',
result: r.value.result,
});
} else {
const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
process.stderr.write(` ✗ ${src.name}: ${msg}\n`);
perSourceResults.push({
sourceId: src.id,
sourceName: src.name,
status: 'error',
error: msg,
});
}
}
} else {
for (const src of activeSources) {
writeHuman(`\n--- Syncing source: ${src.name} ---`);
try {
const result = await runOne(src);
printSyncResult(result, humanSink);
perSourceResults.push({
sourceId: src.id,
sourceName: src.name,
status: 'ok',
result,
});
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`Error syncing ${src.name}: ${msg}\n`);
perSourceResults.push({
sourceId: src.id,
sourceName: src.name,
status: 'error',
error: msg,
});
}
}
}
const okCount = perSourceResults.filter((r) => r.status === 'ok').length;
const errCount = perSourceResults.filter((r) => r.status === 'error').length;
if (jsonOut) {
// Sort by source_id at emit time so the envelope is deterministic
// even though completion order is not (pMapAllSettled semantics).
const sortedSources = perSourceResults
.slice()
.sort((a, b) => a.sourceId.localeCompare(b.sourceId))
.map((r) => ({
source_id: r.sourceId,
name: r.sourceName,
status: r.status,
...(r.result ? {
sync_status: r.result.status,
added: r.result.added,
modified: r.result.modified,
deleted: r.result.deleted,
chunks_created: r.result.chunksCreated,
embedded: r.result.embedded,
} : {}),
...(r.error ? { error: r.error } : {}),
}));
console.log(JSON.stringify({
schema_version: 1,
sources: sortedSources,
parallel: effectiveParallel,
ok_count: okCount,
error_count: errCount,
}));
}
if (errCount > 0) process.exit(1);
return;
}
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed, skipFailed, retryFailed, sourceId, strategy: strategyArg, concurrency };
// Bug 9 — --retry-failed: before running normal sync, clear acknowledgment
// flags so the sync picks them up as fresh work. The actual re-attempt
// happens inside the regular incremental/full loop because once the commit
// pointer is behind the failures, the diff naturally revisits them.
if (retryFailed) {
const failures = unacknowledgedSyncFailures();
if (failures.length === 0) {
console.log('No unacknowledged sync failures to retry.');
} else {
console.log(`Retrying ${failures.length} previously-failed file(s)...`);
// Don't acknowledge them yet — they must succeed to clear.
}
}
if (!watch) {
const result = await performSync(engine, opts);
printSyncResult(result);
// Issue #2 + eng-review pass-2 finding #1 + Codex P1: manage .gitignore ONLY
// on successful sync. Skip on dry-run (don't mutate disk in preview mode)
// and blocked_by_failures (sync state is inconsistent — defer .gitignore
// until next clean run). Resolve the effective repo path so the wire-up
// fires in the common case where the user runs `gbrain sync` without
// passing --repo every time.
if (result.status !== 'dry_run' && result.status !== 'blocked_by_failures') {
const effectiveRepoPath = opts.repoPath ?? (await getDefaultSourcePath(engine));
if (effectiveRepoPath) {
manageGitignore(effectiveRepoPath, engine.kind);
}
}
return;
}
// Watch mode
let consecutiveErrors = 0;
console.log(`Watching for changes every ${interval}s... (Ctrl+C to stop)`);
while (true) {
try {
const result = await performSync(engine, { ...opts, full: false });
consecutiveErrors = 0;
if (result.status === 'synced') {
const ts = new Date().toISOString().slice(11, 19);
console.log(`[${ts}] Synced: +${result.added} ~${result.modified} -${result.deleted} R${result.renamed}`);
}
// Same gate as non-watch: only manage .gitignore on successful sync.
// Same repo-resolution path so watch mode catches the implicit-resolved case.
if (result.status !== 'dry_run' && result.status !== 'blocked_by_failures') {
const effectiveRepoPath = opts.repoPath ?? (await getDefaultSourcePath(engine));
if (effectiveRepoPath) {
manageGitignore(effectiveRepoPath, engine.kind);
}
}
} catch (e: unknown) {
consecutiveErrors++;
const msg = e instanceof Error ? e.message : String(e);
console.error(`[${new Date().toISOString().slice(11, 19)}] Sync error (${consecutiveErrors}/5): ${msg}`);
if (consecutiveErrors >= 5) {
console.error(`5 consecutive sync failures. Stopping watch.`);
process.exit(1);
}
}
await new Promise(r => setTimeout(r, interval * 1000));
}
}
/**
* v0.40.3.0 — resolve effective per-source concurrency for `sync --all`.
*
* Inputs:
* - sourceCount: number of `syncEnabled !== false` sources to walk
* - explicitParallel: user's `--parallel N` value (post `parseWorkers`),
* `undefined` when the flag was not provided
* - workers: user's `--workers N` value, used as a soft cap when no
* explicit `--parallel` is given. The per-source budget can't exceed
* the per-file worker count because each per-file worker opens its
* own PostgresEngine pool — see DEFAULT_PARALLEL_SOURCES in
* `src/core/sync-concurrency.ts` for the connection-math story.
* - engineKind: PGLite is single-connection → always returns 1.
*
* Rules:
* - PGLite → always 1
* - sourceCount <= 0 → 1 (divide-by-zero guard)
* - explicit `--parallel` → wins, clamped to `[1, sourceCount]`
* - auto path → `min(sourceCount, workers ?? DEFAULT_PARALLEL_SOURCES)`
*
* Returns >= 1. Single-source brains return 1 (no point fanning out).
*/
export function resolveParallelism(input: {
sourceCount: number;
explicitParallel?: number;
workers?: number;
engineKind: 'pglite' | 'postgres';
}): number {
if (input.engineKind === 'pglite') return 1;
if (input.sourceCount <= 0) return 1;
if (input.explicitParallel !== undefined) {
return Math.max(1, Math.min(input.explicitParallel, input.sourceCount));
}
const ceiling = input.workers && input.workers > 0
? Math.min(input.workers, DEFAULT_PARALLEL_SOURCES)
: DEFAULT_PARALLEL_SOURCES;
return Math.max(1, Math.min(input.sourceCount, ceiling));
}
/**
* v0.40.3.0 — per-source sync wrapper for the `--all` worker pool.
*
* Three responsibilities:
* 1. Build the per-source `SyncOpts` from the shared CLI flags.
* 2. Wrap the call in `withSourcePrefix(src.id, ...)` so every
* `slog`/`serr` line emitted from inside `performSync` (and its
* callees) gets prefixed with `[<source-id>] ` for greppable
* parallel output (D6 + D12 + D13).
* 3. Pre-render the start banner into the returned `log` string so
* the worker pool flushes it (and any subsequent `printSyncResult`)
* via the human sink — which `--json` routes to stderr to keep
* stdout JSON-only (D4).
*
* Note: source.name is shown in the start banner (one-shot, easy to
* escape) but the prefix on every line uses source.id (slug-validated;
* no newline-injection risk per D13).
*
* The per-source DB lock invariant (D8) fires inside `performSync` —
* since `repoOpts.sourceId` is set, the per-source lock is the default.
* `withRefreshingLock` (D11) handles TTL renewal automatically for
* sources that exceed 30 minutes.
*/
export async function syncOneSource(
engine: BrainEngine,
src: { id: string; name: string; local_path: string | null; config: Record<string, unknown> },
shared: {
dryRun: boolean;
full: boolean;
noPull: boolean;
noEmbed: boolean;
skipFailed: boolean;
retryFailed: boolean;
concurrency: number | undefined;
},
): Promise<{ result: SyncResult; log: string }> {
const cfg = (src.config || {}) as { strategy?: 'markdown' | 'code' | 'auto' };
const log = `\n--- Syncing source: ${src.name} ---\n`;
const repoOpts: SyncOpts = {
repoPath: src.local_path!,
dryRun: shared.dryRun,
full: shared.full,
noPull: shared.noPull,
noEmbed: shared.noEmbed,
skipFailed: shared.skipFailed,
retryFailed: shared.retryFailed,
sourceId: src.id,
strategy: cfg.strategy,
concurrency: shared.concurrency,
// lockId defaults to `gbrain-sync:${src.id}` via the invariant in
// performSync (no explicit override needed — sourceId triggers it).
};
const result = await withSourcePrefix(src.id, () => performSync(engine, repoOpts));
return { result, log };
}
/**
* v0.40.3.0 — read-only per-source dashboard for `gbrain sources status`.
*
* Aggregates from existing tables (no schema changes):
* - sources: last_commit, last_sync_at, archived, config.syncEnabled
* (filtered: archived=false, local_path IS NOT NULL)
* - pages: per-source page count (excluding soft-deleted)
* - content_chunks: per-source total + count of unembedded chunks for
* the ACTIVE embedding column (resolved via the
* registry — see `src/core/search/embedding-column.ts`).
* Voyage / multimodal / non-default-column brains
* see counts against the column they actually use.
* - sync-failures.jsonl: unacknowledged failures (brain-global; the
* JSONL log isn't per-source. v0.40.4 TODO source-scopes it.)
*
* Staleness thresholds match `gbrain doctor`'s sync-freshness rule
* (24h / 72h). Sources that have NEVER synced (last_sync_at IS NULL)
* report `staleness_hours: null` so callers can disambiguate "first run
* pending" from "32h since last successful sync".
*
* Errors propagate. Pre-v0.40.3.0 the dashboard swallowed all DB errors
* and reported zero counts, which lied at exactly the moment it mattered
* (Q2 sub-fix from Codex review). The dashboard is read-only — a thrown
* error surfaces the real problem (DB down, permission denied, statement
* timeout) instead of misleading the operator with a "0 chunks" report.
*/
export interface SyncStatusReportSource {
source_id: string;
name: string;
local_path: string | null;
sync_enabled: boolean;
last_sync_at: string | null;
staleness_hours: number | null;
staleness_class: 'fresh' | 'stale' | 'severe' | 'unknown';
last_commit: string | null;
pages: number;
chunks_total: number;
chunks_unembedded: number;
embedding_coverage_pct: number;
}
export interface SyncStatusReport {
schema_version: 1;
generated_at: string;
sources: SyncStatusReportSource[];
unacknowledged_failures: number;
/** The embedding column counts were computed against. Useful for
* operators verifying their Voyage / multimodal setup is reported
* correctly. */
embedding_column: string;
}
export async function buildSyncStatusReport(
engine: BrainEngine,
sources: Array<{ id: string; name: string; local_path: string | null; config: Record<string, unknown> }>,
): Promise<SyncStatusReport> {
// Resolve the active embedding column via the registry. Brains pointed
// at Voyage / multimodal / any non-default column get accurate counts
// for the column they actually use (D16 → A, Codex P2 #10).
const { resolveEmbeddingColumn, quoteIdentifier } = await import('../core/search/embedding-column.ts');
// loadConfig() returns null when ~/.gbrain/config.json is missing.
// resolveEmbeddingColumn handles missing fields via its own
// gateway-fallback chain, so a minimal stub satisfies the call shape.
const cfg = loadConfig() ?? ({ engine: engine.kind } as Parameters<typeof resolveEmbeddingColumn>[1]);
const resolved = resolveEmbeddingColumn(undefined, cfg);
const embeddingColIdent = quoteIdentifier(resolved.name);
const sourceIds = sources.map((s) => s.id);
type SourceRow = {
id: string;
last_commit: string | null;
last_sync_at: string | Date | null;
};
type CountRow = {
source_id: string;
pages: string | number;
chunks_total: string | number;
chunks_unembedded: string | number;
};
// Pull last_commit + last_sync_at fresh (caller may have called us
// with stale rows). Empty source list → skip the round-trip.
const sourceRows = sourceIds.length === 0
? []
: await engine.executeRaw<SourceRow>(
`SELECT id, last_commit, last_sync_at FROM sources WHERE id = ANY($1::text[])`,
[sourceIds],
);
const sourceMap = new Map<string, SourceRow>();
for (const r of sourceRows) sourceMap.set(r.id, r);
// Per-source page + chunk + unembedded-chunk counts in a single
// round-trip. Canonical SQL (verified against
// src/commands/doctor.ts:2740): content_chunks joined on page_id
// (NOT page_slug — Codex P0 #1), filtered for non-soft-deleted pages
// (NOT NULL — soft-delete shipped v0.26.5), unembedded counted
// against the resolved active embedding column (D16 → A).
//
// No try/catch swallow — a thrown error means DB down / permission
// denied / statement timeout (NOT a schema variant). Surfacing the
// real error is better than a misleading "0 chunks" report (Q2).
let countRows: CountRow[] = [];
if (sourceIds.length > 0) {
countRows = await engine.executeRaw<CountRow>(
`WITH s AS (
SELECT unnest($1::text[]) AS source_id
)
SELECT
s.source_id,
COALESCE(p.pages, 0) AS pages,
COALESCE(c.chunks_total, 0) AS chunks_total,
COALESCE(c.chunks_unembedded, 0) AS chunks_unembedded
FROM s
LEFT JOIN (
SELECT source_id, COUNT(*) AS pages
FROM pages
WHERE deleted_at IS NULL
GROUP BY source_id
) p ON p.source_id = s.source_id
LEFT JOIN (
SELECT pg.source_id,
COUNT(*) AS chunks_total,
COUNT(*) FILTER (WHERE cc.${embeddingColIdent} IS NULL) AS chunks_unembedded
FROM content_chunks cc
JOIN pages pg ON pg.id = cc.page_id
WHERE pg.deleted_at IS NULL
GROUP BY pg.source_id
) c ON c.source_id = s.source_id`,
[sourceIds],
);
}
const countMap = new Map<string, { pages: number; chunks_total: number; chunks_unembedded: number }>();
for (const r of countRows) {
countMap.set(r.source_id, {
pages: Number(r.pages) || 0,
chunks_total: Number(r.chunks_total) || 0,
chunks_unembedded: Number(r.chunks_unembedded) || 0,
});
}
const now = Date.now();
const out: SyncStatusReportSource[] = sources.map((src) => {
const cfgEntry = (src.config || {}) as { syncEnabled?: boolean };
const row = sourceMap.get(src.id) || { id: src.id, last_commit: null, last_sync_at: null };
const counts = countMap.get(src.id) || { pages: 0, chunks_total: 0, chunks_unembedded: 0 };
const lastSyncMs = row.last_sync_at
? (row.last_sync_at instanceof Date ? row.last_sync_at.getTime() : Date.parse(row.last_sync_at))
: null;
const stalenessHours = lastSyncMs !== null && Number.isFinite(lastSyncMs)
? (now - lastSyncMs) / 3_600_000
: null;
let stalenessClass: 'fresh' | 'stale' | 'severe' | 'unknown' = 'unknown';
if (stalenessHours !== null) {
if (stalenessHours < 24) stalenessClass = 'fresh';
else if (stalenessHours < 72) stalenessClass = 'stale';
else stalenessClass = 'severe';
}
const embeddingCoveragePct = counts.chunks_total === 0
? 100
: Math.round(((counts.chunks_total - counts.chunks_unembedded) / counts.chunks_total) * 1000) / 10;
const lastSyncIso = row.last_sync_at
? (row.last_sync_at instanceof Date ? row.last_sync_at.toISOString() : row.last_sync_at)
: null;
return {
source_id: src.id,
name: src.name,
local_path: src.local_path,
sync_enabled: cfgEntry.syncEnabled !== false,
last_sync_at: lastSyncIso,
staleness_hours: stalenessHours === null ? null : Math.round(stalenessHours * 10) / 10,
staleness_class: stalenessClass,
last_commit: row.last_commit,
pages: counts.pages,
chunks_total: counts.chunks_total,
chunks_unembedded: counts.chunks_unembedded,
embedding_coverage_pct: embeddingCoveragePct,
};
});
// Unacknowledged sync failures — brain-global (the JSONL log isn't
// per-source). v0.40.4 TODO will source-scope this. Best-effort:
// missing file / parse error returns 0, doesn't throw the dashboard.
let unackedCount = 0;
try {
unackedCount = unacknowledgedSyncFailures().length;
} catch {
unackedCount = 0;
}
return {
schema_version: 1,
generated_at: new Date().toISOString(),
sources: out,
unacknowledged_failures: unackedCount,
embedding_column: resolved.name,
};
}
/**
* v0.40.3.0 — render a `SyncStatusReport` as a human-readable table.
*
* `sink` defaults to `process.stdout` so the bare `gbrain sources status`
* invocation writes its table to stdout. `--json` callers don't use
* this — they emit `JSON.stringify(report)` to stdout directly.
*/
export function printSyncStatusReport(
report: SyncStatusReport,
sink: NodeJS.WriteStream = process.stdout,
): void {
const write = (line: string) => sink.write(line + '\n');
write(`\nSync status — generated ${report.generated_at}`);
write(`Embedding column: ${report.embedding_column}\n`);
if (report.sources.length === 0) {
write(' (no sources registered)');
return;
}
const headers = ['SOURCE', 'STATE', 'STALENESS', 'PAGES', 'EMBEDDED', 'LAST SYNC'];
const rows = report.sources.map((s) => {
const stale = s.staleness_hours === null
? 'never'
: `${s.staleness_hours.toFixed(1)}h`;
const stateBits: string[] = [];
if (!s.sync_enabled) stateBits.push('disabled');
stateBits.push(s.staleness_class);
return [
s.name,
stateBits.join(','),
stale,
String(s.pages),
`${s.embedding_coverage_pct}%`,
s.last_sync_at ?? '(never)',
];
});
const widths = headers.map((h, i) =>
Math.max(h.length, ...rows.map((r) => r[i].length)),
);
// Numeric columns (PAGES at index 3, EMBEDDED at index 4, STALENESS
// at index 2) right-pad-left so digits align cleanly. Text columns
// left-pad-right per the existing `sources list` convention.
const NUMERIC_COLS = new Set([2, 3, 4]);
const fmt = (cells: string[]) =>
cells.map((c, i) => (NUMERIC_COLS.has(i) ? c.padStart(widths[i]) : c.padEnd(widths[i]))).join(' ');
write(fmt(headers));
write(fmt(widths.map((w) => '-'.repeat(w))));
for (const r of rows) write(fmt(r));
write(`\nUnacknowledged sync failures (brain-wide): ${report.unacknowledged_failures}`);
const severe = report.sources.filter((s) => s.staleness_class === 'severe').length;
if (severe > 0) {
write(`WARNING: ${severe} source(s) are SEVERELY stale (>72h). Run \`gbrain sync --all\` to refresh.`);
}
}
/**
* Auto-manage .gitignore entries for db_only directories.
*
* Caller invokes ONLY on successful sync — this function trusts that the
* sync's data state is consistent. See `runSync` for the gating logic.
*
* Idempotent: re-running adds no duplicate entries. The managed block has
* a stable comment header so it's grep-able and editable.
*
* Skipped (with actionable warning) when:
* - GBRAIN_NO_GITIGNORE=1 — D23 escape hatch for shared-repo setups
* - The repo is a git submodule (`.git` is a file not a directory) —
* D49 lock; submodule .gitignore changes don't survive parent updates
*
* On PGLite (D4): emits a once-per-process soft-warn explaining that
* tiering has limited effect — but still manages the .gitignore so the
* config-present user gets the gitignore housekeeping.
*
* Failures (write permission denied, EROFS, etc.) are caught, warned, and
* swallowed (D9 lock). Sync's primary job is moving data; .gitignore
* management is a side effect — don't kill the main job for the side effect.
*/
let _pgliteTierWarned = false;
export function __resetPGLiteTierWarn(): void {
_pgliteTierWarned = false;
}
export function manageGitignore(
repoPath: string,
engineKind?: 'pglite' | 'postgres',
): void {
if (process.env.GBRAIN_NO_GITIGNORE === '1') {
return;
}
// Submodule + worktree detection (closes #889 misclassification).
// Both submodules and worktrees use `.git` as a FILE (not a directory), so
// statSync.isFile() doesn't discriminate. Discriminator is the gitdir path
// segment:
// - submodule: gitdir contains `/modules/<name>` (skip — managed by parent)
// - worktree: gitdir contains `/worktrees/<name>` (MANAGE — first-class repo)
// Both contracts are documented Git internal layouts and stable across all 4
// {relative, absolute} × {modules, worktrees} combinations, including the
// absorbed-submodule case from `git submodule absorbgitdirs`.
// Malformed `.git` file (no `gitdir:` prefix, unreadable) → MANAGE (fail-closed
// toward managing, preserving the pre-#889 catch{} behavior).
const dotGit = join(repoPath, '.git');
if (existsSync(dotGit)) {
try {
if (statSync(dotGit).isFile()) {
const content = readFileSync(dotGit, 'utf-8');
const match = content.match(/gitdir:\s*(.+)/);
const gitdir = match ? match[1].trim() : '';
if (gitdir.includes('/modules/')) {
console.warn(
`Note: skipping .gitignore management — ${repoPath} is a git submodule. ` +
`Add db_only directories to your parent repo's .gitignore manually.`,
);
return;
}
// Worktree (gitdir contains /worktrees/) OR malformed .git falls through
// to the existing manage path. Worktrees are first-class repos — they
// need .gitignore management too. Malformed → MANAGE preserves the
// pre-#889 fail-closed-toward-managing catch behavior.
}
} catch {
// proceed; can't tell, default to managing
}
}
let storageConfig;
try {
storageConfig = loadStorageConfig(repoPath);
} catch (error) {
// StorageConfigError (overlap) or read error — surface, don't manage.
console.warn(
`Skipped .gitignore update: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
if (!storageConfig || storageConfig.db_only.length === 0) {
return;
}
// D4 soft-warn: storage tiering has limited effect on PGLite, but the
// .gitignore housekeeping still helps. Warn once per process; proceed.
if (engineKind === 'pglite' && !_pgliteTierWarned) {
_pgliteTierWarned = true;
console.warn(
`Note: storage tiering has limited effect on PGLite — pages live in your ` +
`local database file regardless of tier. Managing .gitignore anyway.`,
);
}
const gitignorePath = join(repoPath, '.gitignore');
let gitignoreContent = '';
if (existsSync(gitignorePath)) {
try {
gitignoreContent = readFileSync(gitignorePath, 'utf-8');
} catch (error) {
console.warn(
`Could not read ${gitignorePath} (${error instanceof Error ? error.message : String(error)}) — ` +
`skipping .gitignore update. Add db_only directories manually.`,
);
return;
}
}
const existingLines = new Set(gitignoreContent.split('\n').map((line) => line.trim()));
const linesToAdd: string[] = [];
for (const dir of storageConfig.db_only) {
if (!existingLines.has(dir) && !existingLines.has(`/${dir}`)) {
linesToAdd.push(dir);
}
}
if (linesToAdd.length === 0) return;
if (gitignoreContent && !gitignoreContent.endsWith('\n')) {
gitignoreContent += '\n';
}
gitignoreContent += '\n# Auto-managed by gbrain (db_only directories)\n';
gitignoreContent += linesToAdd.join('\n') + '\n';
try {
writeFileSync(gitignorePath, gitignoreContent);
} catch (error) {
console.warn(
`Could not update ${gitignorePath} (${error instanceof Error ? error.message : String(error)}) — ` +
`please add db_only directories manually:\n ${linesToAdd.join('\n ')}`,
);
}
}
/**
* Render a SyncResult to a Writable sink.
*
* `sink` defaults to `process.stdout` so existing single-source callers
* see identical output. The `--all` parallel path passes `process.stderr`
* when `--json` is set, so banners stay off stdout and the JSON envelope
* pipes cleanly through `jq` (D4).
*/
function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.stdout) {
const write = (line: string) => sink.write(line + '\n');
switch (result.status) {
case 'up_to_date':
write('Already up to date.');
break;
case 'synced':
write(`Synced ${result.fromCommit?.slice(0, 8)}..${result.toCommit.slice(0, 8)}:`);
write(` +${result.added} added, ~${result.modified} modified, -${result.deleted} deleted, R${result.renamed} renamed`);
write(` ${result.chunksCreated} chunks created${result.embedded > 0 ? `, ${result.embedded} pages embedded` : ''}`);
break;
case 'first_sync':
write(`First sync complete. Checkpoint: ${result.toCommit.slice(0, 8)}`);
write(` ${result.added} file(s) imported, ${result.chunksCreated} chunks${result.embedded > 0 ? `, ${result.embedded} pages embedded` : ''}`);
break;
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
}
}