mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(bootstrap): extend probes for files/oauth_clients/sources.archived* + add MIGRATIONS introspection guard Adds 7 new forward-reference probes to applyForwardReferenceBootstrap on both engines, closes the column-only forward-ref class via a new MIGRATIONS-source introspection contract test. New probes: - files.source_id + files.page_id (v18 forward refs) - oauth_clients.source_id + oauth_clients.federated_read (v60+v61+v65) - sources.archived + archived_at + archive_expires_at (v34 promoted from JSONB) The sources.archived* columns are the codex-flagged class: they're added inline in v34's CREATE TABLE definition but `CREATE TABLE IF NOT EXISTS sources` is a no-op on pre-v34 brains, so downstream visibility filters (search/list_pages) trip on old brains. needsPagesBootstrap now folds archive columns into its CREATE TABLE so pre-v0.18 brains get a v34-shape sources in one go; needsSourcesArchive then only fires on the pre-v34 case (sources exists, archive cols don't). Closes the structural bug class via test/helpers/extract-added-columns.ts: reads src/core/migrate.ts as text and extracts every ALTER TABLE ADD COLUMN. The new contract test asserts every (table, column) pair is covered by EITHER the bootstrap's ALTER TABLE statements, the bootstrap's CREATE TABLE definitions, OR the schema blob's CREATE TABLE bodies. The column-only class (no index, no FK; just an inline CREATE TABLE column the schema blob can't add to existing tables) is now caught at PR time. Source-text introspection catches all three migration shapes uniformly: - top-level `sql:` field - `sqlFor.postgres` / `sqlFor.pglite` overrides - handler-body `engine.runMigration(N, \`ALTER TABLE ...\`)` (v34 shape) Pre-existing parseBaseTableColumns parser bug fixed: now strips `--` line comments and `/* ... */` blocks before identifying column names. Without this, a column preceded by a comment was silently dropped. Catches pages.page_kind and others that were silently uncovered. 13 columns added by migrations but not in PGLITE_SCHEMA_SQL are exempted with a unified rationale: they have no schema-blob forward reference; migration handles all upgrade paths cleanly. Refreshing the schema blob is a separate concern. Issues closed: #1018 (v60 oauth_clients), #974 (files.source_id/page_id), #820 (v0.13.0 migration files.page_id cascade); pre-empts the sources.archived class before any pre-v34 brain trips on it. Tests: - 9 cases in test/schema-bootstrap-coverage.test.ts (5 existing + 4 new) - helper-level unit tests cover SQL shape variants (IF NOT EXISTS, quoted identifiers, ALTER TABLE IF EXISTS ONLY, multi-statement) - planted-bug regression verifies the gate actually catches new uncovered columns Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(orphans): filter soft-deleted pages on both candidate and link-source sides Closes #1021. The v0.26.5 soft-delete invariant requires that findOrphanPages exclude both: 1. Candidate pages that are themselves soft-deleted 2. Inbound links from soft-deleted source pages Pre-fix, findOrphanPages had no deleted_at filter at all. Soft-deleted pages with no inbound links were counted as orphans (inflating counts). Pre-codex-tension-D11, only the candidate-side filter was planned. Codex C11 caught the second case: a live page that has ONE inbound link from a soft-deleted source page was hidden from orphan results — the link still existed in the links table, the EXISTS subquery saw it, the page looked "linked." Now the inner JOIN on pages enforces src.deleted_at IS NULL. Three regression tests pin the contract: - soft-deleted page with no inbound → NOT orphan - live page with ONLY inbound link from soft-deleted source → IS orphan - live page with live inbound → NOT orphan (smoke check that the new filters don't break unchanged behavior) Engine parity: same SQL shape on both Postgres and PGLite engines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(think): route runThink through gateway.chat adapter (closes #952) Pre-fix, runThink instantiated `new Anthropic()` directly and read ANTHROPIC_API_KEY from process.env. Claude Desktop's stdio MCP launch doesn't inherit shell env, so `gbrain config set anthropic_api_key sk-...` (writes to ~/.gbrain/config.json) never reached the SDK and every MCP think call degraded to "no LLM available." The adapter routes through gateway.chat() — the canonical seam per CLAUDE.md. Gateway reads the API key from gbrain config OR env, picks up prompt caching, rate-leases, retry, and the test seam (__setChatTransportForTests) that v0.31.12 established. Per plan-eng-review D10 (cross-model tension with codex C7+C8+C9+C10), four spec points landed: 1. Drop `new Anthropic()` direct path entirely. Every non-stub LLM call from runThink routes through gateway. 2. Real availability check (NOT a false-positive `getChatModel()` truthy). `tryBuildGatewayClient` probes both the recipe (resolveRecipe throws AIConfigError on unknown providers) AND the API key (reads process.env + loadConfig at the gbrain config layer for parity with gateway's own auth resolution). Returns null on miss; runThink takes the graceful "no LLM available" early-return preserving the legacy NO_ANTHROPIC_API_KEY warning signal. 3. Model-id normalization. resolveModel returns bare anthropic ids (claude-opus-4-7); gateway.chat needs provider:model. Adapter auto-prefixes anthropic: when the id is bare. Provider:model strings pass through unchanged. 4. Response-shape conversion. ChatResult → Anthropic.Message via chatResultToMessage. mapStopReason translates gateway's provider-neutral stop reasons (end / length / tool_calls / refusal / content_filter / other) to Anthropic's stop_reason ('end_turn' / 'max_tokens' / 'tool_use'); refusal/content_filter/other fall through to end_turn (no Anthropic equivalent). Usage tokens pass through. `opts.client` injection preserved (test seam — see ThinkLLMClient). `opts.stubResponse` preserved (pure-test escape). Tests: - test/think-gateway-adapter.test.ts (9 cases): response shape, stop reason mapping, model-id normalization (bare + prefixed), provider unknown returns null, ANTHROPIC_API_KEY absent returns null (regression for legacy graceful degradation), hasAnthropicKey reads process.env correctly. Uses withEnv per the test-isolation contract. - test/think-pipeline.serial.test.ts (17 existing cases): unchanged; the graceful-degradation case at line 213 still produces the NO_ANTHROPIC_API_KEY warning because tryBuildGatewayClient returns null when no key is configured, taking the legacy early-return path. Closes #952. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): distinguish git worktree from submodule via path-segment match (closes #889) Pre-fix, `manageGitignore` treated every `.git`-as-file as a submodule and skipped gitignore management. Both submodules AND worktrees use `.git` as a file (not a directory), so the legacy `statSync.isFile()` check couldn't discriminate. Worktrees got misclassified as submodules and their .gitignore wasn't managed. Per plan-eng-review D4 (chose path-segment match over absolute-vs- relative path heuristic): the gitdir path contains: - `/modules/<name>` for submodules (skip — managed by parent repo) - `/worktrees/<name>` for worktrees (MANAGE — first-class repo) Both are documented Git internal layouts, stable across all 4 {relative, absolute} × {modules, worktrees} combinations including the absorbed-submodule edge case from `git submodule absorbgitdirs` (where the submodule's gitdir flips to an absolute path). Malformed `.git` file (no `gitdir:` prefix, IO error) → MANAGE, preserving the pre-#889 catch{} fail-closed-toward-managing semantics. Tests (5 new + 1 regression renamed): - REGRESSION: submodule relative gitdir/modules/ → skip (D49 contract) - absorbed submodule absolute gitdir/modules/ → skip (edge case) - CRITICAL: worktree absolute gitdir/worktrees/ → MANAGE (closes #889) - worktree relative gitdir/worktrees/ → MANAGE - malformed .git file → MANAGE (preserves catch behavior) - regular .git directory → MANAGE (existing smoke) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(walkers): pruneDir helper + descent-time exclusion + transcript predicate (closes #923, #202) Per plan-eng-review D12 (cross-model tension with codex C12+C13), three structural changes: 1. Extract `pruneDir(name)` helper in src/core/sync.ts. Returns false for directory names walkers must NEVER descend into: `node_modules` (latent bug — no leading dot), dot-prefix dirs (`.git`, `.obsidian`, `.raw`, `.cache`, etc.), `ops`, and `*.raw` sidecar dirs (gbrain convention — `people/pedro.raw/` holds raw source for pedro.md). Walkers consult it at descent time BEFORE recursion, saving the IO cost of walking entire vendor / hidden / sidecar subtrees only to filter them at file-emit time. 2. `isSyncable` itself gains the same exclusion set (via pruneDir on each path segment). Closes the latent bug where node_modules markdown files slipped through: `node_modules/some-pkg/README.md` returned true pre-fix because the legacy dot-prefix check only blocked `.node_modules` (with a leading dot), not the actual `node_modules`. CRITICAL regression test in test/sync.test.ts pins the contract per IRON RULE. 3. Two walkers rewritten to use pruneDir at descent + per-walker file predicate at emit: - `walkMarkdownFiles` (src/commands/extract.ts): pruneDir + isSyncable ({strategy:'markdown'}). Pre-fix this walker had ONLY an ad-hoc dot-prefix exclusion and didn't call isSyncable at all — descended into node_modules, emitted markdown files from there, ignored README/ ops/.raw filters. - `listTextFiles` (src/core/cycle/transcript-discovery.ts): pruneDir + own .txt/.md predicate. DOES NOT use isSyncable({strategy:'markdown'}) because transcripts accept .txt and don't share markdown sync's README/ops exclusions (codex C12). Also made RECURSIVE — pre-fix it walked only the top dir, so transcripts in `corpus/2026/` were invisible (codex C14 — descent-time pruning is the right shape but the test would have passed vacuously on a non-recursive walker). Verified blast radius before adding node_modules: every existing isSyncable caller (sync.ts:558-561 sync filter, frontmatter.ts:264 validate, brain-writer.ts:305 reverse-write, import.ts:454 import filter) wants node_modules excluded — this is a latent-bug fix, not a behavior change for any legitimate caller. Tests: - 7 new isSyncable cases including the node_modules CRITICAL regression - 6 new pruneDir cases (node_modules, dot-prefix, ops, *.raw, content dirs that should pass, empty-string default) - Existing extract.test.ts + extract-fs.test.ts unchanged and passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(todos): file v0.36.x follow-ups for runThink rewrite + Supabase bootstrap parity Two follow-up TODOs filed during the v0.36 dreamy-thompson wave: 1. runThink full rewrite (D5+D7 from plan-eng-review): drop the ThinkLLMClient indirection now that v0.36 routes through gateway.chat. 12+ tests need migration to __setChatTransportForTests. Blocked by this wave landing. 2. Supabase parity test for applyForwardReferenceBootstrap (codex C6 residual): real Docker Postgres E2E catches schema correctness but not Supabase pooler/direct-pool routing. The probe uses this.sql but PostgresEngine.initSchema chooses a DDL connection; the divergence has caused multiple historical wedges (#699, #820 lineage). Both entries include full context per the CLAUDE.md TODOS-format spec (what, why, pros, cons, blocked-by, plan reference). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bootstrap): thread DDL connection through applyForwardReferenceBootstrap Codex adversarial review during /ship caught a P1: initSchema selected a DDL connection, took pg_advisory_lock(42) on it, but applyForwardReferenceBootstrap used `this.sql` (the instance pool) inside. Bootstrap probes ran outside the lock scope on a different connection. Failure mode: two concurrent gbrain instances could BOTH enter the bootstrap block on Supabase transaction-pooler setups because the advisory lock was held on a different connection than the one running ALTER TABLE. The pooler's statement_timeout could also kill the probes mid-flight without affecting the lock-holder, leaving an inconsistent schema state. Fix: applyForwardReferenceBootstrap now accepts an optional connection parameter. initSchema passes the DDL conn (the one holding the lock). this.sql remains the fallback for any unit-test path that calls bootstrap directly. PGLite engine doesn't need this change — single connection, no pooler. This was pre-existing (every prior probe used this.sql), but the v0.36 wave is explicitly about fixing the Supabase upgrade-wedge class. Codex's position was correct: don't ship the wave with the underlying connection mismatch still there. The Supabase parity TEST FIXTURE follow-up remains on TODOS.md (test infra needed to PROVE the fix works under real pooler topology), but the bug itself is closed. 15/15 bootstrap tests pass. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.35.5.0) Six-correctness-fix wave: bootstrap forward-ref class (4 issues + 1 pre-empt), orphans soft-delete leak (both sides), runThink → gateway.chat adapter, git worktree vs submodule discriminator, walker pruneDir + descent-time exclusion, plus a Codex-P1 catch during /ship that threaded the DDL connection through applyForwardReferenceBootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update CLAUDE.md for v0.35.5.0 backend correctness wave Fold v0.35.5.0 file-level annotations into CLAUDE.md: - postgres-engine.ts + pglite-engine.ts: 7 new applyForwardReferenceBootstrap probes (files.source_id/page_id, oauth_clients.source_id/federated_read, sources.archived/archived_at/archive_expires_at) + DDL connection threading - test/schema-bootstrap-coverage.test.ts: new MIGRATIONS-source introspection guard + parseBaseTableColumns comment-stripping fix - src/core/sync.ts: new pruneDir helper + manageGitignore worktree discriminator - src/core/think/index.ts (new entry): runThink gateway adapter for MCP stdio key resolution - src/core/operations.ts (new entry): findOrphanPages soft-delete filter Regenerate llms-full.txt via bun run build:llms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1514 lines
64 KiB
TypeScript
1514 lines
64 KiB
TypeScript
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,
|
||
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,
|
||
} from '../core/sync-concurrency.ts';
|
||
import { tryAcquireDbLock, SYNC_LOCK_ID } from '../core/db-lock.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;
|
||
}
|
||
|
||
/**
|
||
* 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];
|
||
}
|
||
|
||
/**
|
||
* Shell out to git with a generous maxBuffer.
|
||
*
|
||
* Node's default maxBuffer is 1 MiB. `git diff --name-status -M` on a
|
||
* 60–100K 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 10–20 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 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],
|
||
);
|
||
}
|
||
|
||
export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<SyncResult> {
|
||
// CODEX-2 (v0.22.13): cross-process writer lock for performSync. Two
|
||
// concurrent syncs can otherwise read the same last_commit anchor, both
|
||
// write last_commit unconditionally, and the last writer wins — including
|
||
// regressing the bookmark backwards. cycle.ts already takes gbrain-cycle
|
||
// for its broader scope; performSync (called from cycle, jobs handler,
|
||
// and CLI) takes gbrain-sync just for the writer window. The two ids
|
||
// nest cleanly: cycle holds gbrain-cycle, calls performSync, performSync
|
||
// takes gbrain-sync. Other callers serialize on gbrain-sync against
|
||
// each other AND against the cycle's sync phase.
|
||
//
|
||
// skipLock is reserved for callers that already serialize via another
|
||
// mechanism (none in v0.22.13; reserved for future).
|
||
let lockHandle: { release: () => Promise<void> } | null = null;
|
||
if (!opts.skipLock) {
|
||
lockHandle = await tryAcquireDbLock(engine, SYNC_LOCK_ID);
|
||
if (!lockHandle) {
|
||
throw new Error(
|
||
`Another sync is in progress (lock ${SYNC_LOCK_ID} held). ` +
|
||
`Wait for it to finish, or run 'gbrain doctor' if it has been more than 30 minutes.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
try {
|
||
return await performSyncInner(engine, opts);
|
||
} finally {
|
||
if (lockHandle) {
|
||
try { await lockHandle.release(); } catch { /* best-effort release */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<SyncResult> {
|
||
// 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);
|
||
}
|
||
|
||
// 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) {
|
||
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':
|
||
console.error(
|
||
`[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.`);
|
||
}
|
||
|
||
// 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) {
|
||
console.error(`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.
|
||
if (!opts.noPull && !detachedHead) {
|
||
const _t0 = Date.now();
|
||
console.error(`[gbrain phase] sync.git_pull start`);
|
||
try {
|
||
const { pullRepo } = await import('../core/git-remote.ts');
|
||
pullRepo(repoPath);
|
||
console.error(`[gbrain phase] sync.git_pull done ${Date.now() - _t0}ms`);
|
||
} catch (e: unknown) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
console.error(`[gbrain phase] sync.git_pull error ${Date.now() - _t0}ms (${msg.slice(0, 80)})`);
|
||
if (msg.includes('non-fast-forward') || msg.includes('diverged')) {
|
||
console.error(`Warning: git pull failed (remote diverged). Syncing from local state.`);
|
||
} else {
|
||
console.error(`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 {
|
||
console.error(`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 {
|
||
console.error(`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) {
|
||
console.log(
|
||
`[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.
|
||
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) {
|
||
const slug = await resolveSlugByPathOrSourcePath(engine, path, opts.sourceId);
|
||
try {
|
||
const existing = await engine.getPage(slug, pageOpts);
|
||
if (existing) {
|
||
await engine.deletePage(slug, pageOpts);
|
||
console.log(` 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) {
|
||
console.log(`Sync dry run: ${lastCommit.slice(0, 8)}..${headCommit.slice(0, 8)}`);
|
||
if (filtered.added.length) console.log(` Added: ${filtered.added.join(', ')}`);
|
||
if (filtered.modified.length) console.log(` Modified: ${filtered.modified.join(', ')}`);
|
||
if (filtered.deleted.length) console.log(` Deleted: ${filtered.deleted.join(', ')}`);
|
||
if (filtered.renamed.length) console.log(` Renamed: ${filtered.renamed.map(r => `${r.from} -> ${r.to}`).join(', ')}`);
|
||
if (totalChanges === 0) console.log(` 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) {
|
||
console.log(`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 });
|
||
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 });
|
||
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);
|
||
console.error(` 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.
|
||
console.error(` 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) =>
|
||
console.error(` 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) {
|
||
console.error(
|
||
`\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) {
|
||
console.error(
|
||
` 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) {
|
||
console.log(` 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 {
|
||
const page = await engine.getPage(slug);
|
||
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).
|
||
// TODO(multi-source): runEmbed → src/commands/embed.ts:175 + :418 call
|
||
// upsertChunks defaulting to source='default'. For non-default-source syncs
|
||
// the page row lives at (sourceId, slug) so this fails with "Page not found"
|
||
// OR (when a same-slug 'default' row coexists) updates the wrong source's
|
||
// chunks. Data R1 MED 2 — deferred to a follow-up PR; threading sourceId
|
||
// through embed.ts is a larger refactor than this fix's scope. The current
|
||
// try/catch swallows the failure as best-effort, so the sync result still
|
||
// reports `embedded: 0` for the right reason.
|
||
let embedded = 0;
|
||
if (!noEmbed && pagesAffected.length > 0 && pagesAffected.length <= 100) {
|
||
try {
|
||
const { runEmbed } = await import('./embed.ts');
|
||
await runEmbed(engine, ['--slugs', ...pagesAffected]);
|
||
// Before commit 2 lands: runEmbed is void. Best estimate is pagesAffected,
|
||
// since runEmbed re-embeds every requested slug. Commit 2 sharpens this
|
||
// with EmbedResult.embedded.
|
||
embedded = pagesAffected.length;
|
||
} catch { /* embedding is best-effort */ }
|
||
} else if (noEmbed || totalChanges > 100) {
|
||
console.log(`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' });
|
||
console.log(
|
||
`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);
|
||
console.log(`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();
|
||
console.error(`[gbrain phase] sync.fullsync.import start strategy=${opts.strategy ?? 'markdown'}`);
|
||
const result = await runImport(engine, importArgs, {
|
||
commit: headCommit,
|
||
strategy: opts.strategy,
|
||
sourceId: opts.sourceId,
|
||
});
|
||
console.error(
|
||
`[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) {
|
||
console.error(
|
||
`\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) {
|
||
console.error(
|
||
` 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.
|
||
// Before commit 2: runEmbed is void; use result.imported as best estimate of
|
||
// pages touched. Commit 2 sharpens this with real EmbedResult counts.
|
||
let embedded = 0;
|
||
if (!opts.noEmbed) {
|
||
try {
|
||
const { runEmbed } = await import('./embed.ts');
|
||
await runEmbed(engine, ['--stale']);
|
||
embedded = result.imported;
|
||
} catch { /* embedding is 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[]) {
|
||
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');
|
||
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');
|
||
// 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.
|
||
let concurrency: number | undefined;
|
||
try {
|
||
concurrency = parseWorkers(concurrencyStr);
|
||
} 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.
|
||
const explicitSource = args.find((a, i) => args[i - 1] === '--source') || null;
|
||
let sourceId: string | undefined = undefined;
|
||
if (explicitSource || process.env.GBRAIN_SOURCE) {
|
||
const { resolveSourceId } = await import('../core/source-resolver.ts');
|
||
sourceId = await resolveSourceId(engine, explicitSource);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const src of sources) {
|
||
const cfg = (src.config || {}) as { syncEnabled?: boolean; strategy?: 'markdown' | 'code' | 'auto' };
|
||
if (cfg.syncEnabled === false) {
|
||
console.log(`Skipping disabled source: ${src.name}`);
|
||
continue;
|
||
}
|
||
console.log(`\n--- Syncing source: ${src.name} ---`);
|
||
const repoOpts: SyncOpts = {
|
||
repoPath: src.local_path!,
|
||
dryRun, full, noPull, noEmbed, skipFailed, retryFailed,
|
||
sourceId: src.id,
|
||
strategy: cfg.strategy,
|
||
concurrency,
|
||
};
|
||
try {
|
||
const result = await performSync(engine, repoOpts);
|
||
printSyncResult(result);
|
||
// Codex P2: --all loop must also manage .gitignore per-source. Without
|
||
// this, multi-source users who rely on `gbrain sync --all` never get
|
||
// the advertised db_only ignore rules unless they sync each repo
|
||
// individually.
|
||
if (result.status !== 'dry_run' && result.status !== 'blocked_by_failures') {
|
||
manageGitignore(src.local_path!, engine.kind);
|
||
}
|
||
} catch (e: unknown) {
|
||
console.error(`Error syncing ${src.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function printSyncResult(result: SyncResult) {
|
||
switch (result.status) {
|
||
case 'up_to_date':
|
||
console.log('Already up to date.');
|
||
break;
|
||
case 'synced':
|
||
console.log(`Synced ${result.fromCommit?.slice(0, 8)}..${result.toCommit.slice(0, 8)}:`);
|
||
console.log(` +${result.added} added, ~${result.modified} modified, -${result.deleted} deleted, R${result.renamed} renamed`);
|
||
console.log(` ${result.chunksCreated} chunks created${result.embedded > 0 ? `, ${result.embedded} pages embedded` : ''}`);
|
||
break;
|
||
case 'first_sync':
|
||
console.log(`First sync complete. Checkpoint: ${result.toCommit.slice(0, 8)}`);
|
||
console.log(` ${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':
|
||
console.log(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
|
||
console.log(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
|
||
console.log(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
|
||
break;
|
||
}
|
||
}
|