mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.32.0 fix(sync): coerce non-string frontmatter titles + bounded auto-skip failure ledger (#1939) (#1956)
* fix(import): coerce non-string frontmatter title/slug/type (#1939) YAML `title: 2024-06-01` parses to a Date and `title: 1458` to a number; the old `(frontmatter.X as string)` cast was a compile-time lie, so downstream `.toLowerCase()` threw and (via the importer failure gate) could wedge sync indefinitely. parseMarkdown now coerces via coerceFrontmatterString (Date -> UTC ISO date, deterministic), and the pure assessContentSanity self-protects against a non-string title. * feat(sync): bounded auto-skip failure ledger; poison file can't wedge indexing (#1939) New src/core/sync-failure-ledger.ts owns the failure store + a crash-safe, multi-source, concurrent bounded auto-skip valve. A file that fails N consecutive syncs (GBRAIN_SYNC_AUTOSKIP_AFTER, default 3) auto-skips so it can't freeze all indexing forever, while fresh failures still fail-closed and a `<head>` history-rewrite sentinel hard-blocks even with --skip-failed. - (source_id, path) keying — failures never merge across sources - success clears a path so attempts are truly consecutive - advance-before-ack ordering (a crash can't mark a file skipped while wedged) - shared applySyncFailureGate used by BOTH the incremental and full-sync gates - legacy-row normalization + duplicate collapse on load - cross-process lock + atomic temp-rename, age-based stale-lock break sync.ts re-exports the ledger for existing callers; import.ts records source-scoped and defers the bookmark to the gate under managedBookmark. * fix(doctor): sync_failures severity via one shared decision on both surfaces (#1939) Local buildChecks and remote doctorReportRemote now both route through decideSyncFailureSeverity, so a stuck bookmark escalates WARN -> FAIL consistently (oldest-open age > fail cadence, or large unresolved count), auto-skipped pages stay visible (WARN, not hidden), and the acknowledged/acknowledged_at field-split that caused drift is gone. The remote surface stays subprocess-free (file read + Date.parse only). * chore(test): add trailing newline to e5-lease-cap-ab baseline fixture * fix(sync): address adversarial review findings on the failure ledger (#1939) - #1: a parse-failed file that is later deleted/renamed-away no longer leaves a permanent open ledger row. Removed paths (filtered.deleted, renamed-from, and the "gone from disk" forward-delete skip branch) are treated as resolved so the ledger self-heals instead of aging doctor to a stuck FAIL. - #3: decideSyncFailureSeverity escalates to FAIL on OPEN (blocking) failures only — auto_skipped rows already advanced the bookmark, so they stay WARN-visible regardless of count, matching the state-machine contract. * chore: bump version and changelog (v0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document sync-failure ledger + auto-skip valve for v0.42.30.0 KEY_FILES.md: new src/core/sync-failure-ledger.ts entry (bounded auto-skip state machine, decideGateAction/decideSyncFailureSeverity/applySyncFailureGate, GBRAIN_SYNC_AUTOSKIP_AFTER); update sync.ts (failure store moved to ledger, re-exported), doctor.ts (sync_failures severity via shared rule on both surfaces), markdown.ts (coerceFrontmatterString), import.ts (managedBookmark). live-sync.md: poison-file auto-skip tricky-spot. Regenerated llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.31.0 (queue collision on 0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.32.0 (queue collision) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f401d7407e
commit
5a06af5a57
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.42.32.0] - 2026-06-07
|
||||
|
||||
**A single un-parseable note can no longer silently stop your brain from indexing anything new.** A page whose YAML frontmatter `title:` was a bare date (`title: 2024-06-01`) or number (`title: 1458`) parsed as a Date/number, not text — and the importer threw when it tried to lowercase it. That throw blocked the sync bookmark from advancing, so every later `gbrain sync` re-walked the whole repo, never reached HEAD, and quietly stopped indexing new commits. The page was committed and on GitHub, but `gbrain get` returned `page_not_found` with no surfaced error.
|
||||
|
||||
Two fixes. First, a non-string title/slug/type now coerces deterministically at parse time — a YAML date becomes its UTC ISO string (`2024-06-01`), so the same page reads the same on every machine and the import never throws. Second, the importer gained a **bounded auto-skip safety valve**: a file that fails to import N consecutive syncs (default 3, `GBRAIN_SYNC_AUTOSKIP_AFTER`) is recorded and skipped so it can't wedge all indexing forever — while a *fresh* failure still fails closed (the bookmark holds and you're told what broke), and a repository history rewrite still hard-blocks even with `--skip-failed`. Skipped pages stay visible: `gbrain doctor` keeps warning until you fix them, and escalates to a hard failure when a real failure has blocked the bookmark past the staleness window.
|
||||
|
||||
`gbrain doctor` now decides sync-failure severity through one shared rule on both the local and remote surfaces, so a stuck bookmark surfaces identically whether you run doctor on your own machine or against a remote brain.
|
||||
|
||||
### Added
|
||||
- **Bounded auto-skip sync ledger.** A file that fails N consecutive syncs (`GBRAIN_SYNC_AUTOSKIP_AFTER`, default 3; set `0` to disable) is auto-skipped so one poison file can't freeze indexing for the whole brain. Skips are per-source, survive crashes (the bookmark advances before anything is marked skipped), and self-heal — fix or delete the file and the next sync clears it. `gbrain doctor` lists what was skipped and why.
|
||||
|
||||
### Fixed
|
||||
- **Non-string frontmatter titles no longer wedge indexing (#1939).** `title: 2024-06-01` / `title: 1458` (and date/number `slug`/`type`) coerce to deterministic strings at parse time instead of throwing, so a handful of date-named notes can't silently stop your brain from indexing new commits.
|
||||
- **`gbrain doctor` sync-failure severity is now consistent across surfaces (#1939).** Local and remote doctor share one decision: a stuck bookmark escalates to FAIL once it has blocked past the staleness window (or many files are blocking), while already-skipped pages stay a visible warning.
|
||||
|
||||
### To take advantage of v0.42.32.0
|
||||
Upgrade and run `gbrain sync` once. Any pages that previously wedged the importer (bare date/number titles) now import on their own. If a file still genuinely can't parse, sync tells you which one; fix it, or let the auto-skip valve move past it after a few runs and watch for it in `gbrain doctor`. Tune the threshold with `GBRAIN_SYNC_AUTOSKIP_AFTER` (set `0` to keep the strict fail-closed behavior).
|
||||
|
||||
## [0.42.31.0] - 2026-06-07
|
||||
|
||||
**You can now write typed graph edges with your own provenance straight from the CLI — `gbrain link-add a b --link-type relies-on --link-source citation-graph` — and an external edge-writer (a citation-graph ingester, an importer, a classifier) no longer needs a gbrain schema migration to register a new provenance.** Two ergonomics gaps for tools that compute edges out-of-band, filed by a downstream agent building a citation-graph ingester (#1941).
|
||||
|
||||
@@ -1828,22 +1828,18 @@ at plan time and got carved out:
|
||||
|
||||
## v0.40.3.0 follow-ups (v0.41+)
|
||||
|
||||
- [ ] **v0.41+: source-scope the `sync-failures.jsonl` log so `--skip-failed` works under `--parallel > 1`.**
|
||||
v0.40.3.0 shipped `gbrain sync --all --parallel N` as a continuous worker pool
|
||||
with per-source DB locks. The remaining unsafe path: `recordSyncFailures()` /
|
||||
`acknowledgeSyncFailures()` in `src/core/sync.ts` write to a brain-global JSONL
|
||||
file at `~/.gbrain/sync-failures.jsonl` with no per-source scope. Under parallel
|
||||
sync, source A's `--skip-failed` ack can swallow source B's failures recorded
|
||||
while B was still running. v0.40.3.0's safe interim: refuse to combine
|
||||
`--skip-failed` / `--retry-failed` with `--parallel > 1` (loud error, paste-ready
|
||||
hint pointing at `--parallel 1`). The proper fix: (1) extend the JSONL row
|
||||
schema with a `source_id` field; (2) `recordSyncFailures(failures, sourceId)`
|
||||
stamps the field; (3) `acknowledgeSyncFailures({sourceId})` filters acks to
|
||||
one source's rows; (4) `unacknowledgedSyncFailures({sourceId})` reads the
|
||||
subset. Drop the v0.40.3.0 restriction once source-scoped acks are
|
||||
deterministic. Estimate: ~1-2 days. Filed during v0.40.3.0 plan review by
|
||||
Codex outside-voice (decision D15 → B in the eng-review plan at
|
||||
`~/.claude/plans/system-instruction-you-are-working-fluttering-grove.md`).
|
||||
- [ ] **v0.41+: drop the `--skip-failed` / `--retry-failed` + `--parallel > 1` restriction now that the failure log is source-scoped.**
|
||||
**Priority:** P3
|
||||
v0.42.32.0 (#1939) landed the source-scoping infrastructure this TODO asked
|
||||
for: `src/core/sync-failure-ledger.ts` keys every row by `(source_id, path)`,
|
||||
`recordFailures(sourceId, …)` stamps it, `acknowledgeFailures(sourceId)` /
|
||||
`autoSkipFailures(sourceId, …)` filter to one source, and a cross-process
|
||||
lock + atomic temp-rename (`withLedgerLock`) makes concurrent read-modify-write
|
||||
safe. The remaining work is just to LIFT the v0.40.3.0 interim guard at
|
||||
`src/commands/sync.ts:3078` (`parallelEligible && (skipFailed || retryFailed)`
|
||||
→ loud refuse) after adding a test that proves source-scoped acks stay
|
||||
deterministic under `--all --parallel N`. Estimate: ~0.5 day. Originally filed
|
||||
during the v0.40.3.0 plan review (Codex outside-voice, decision D15 → B).
|
||||
|
||||
- [ ] **v0.41+ (optional): extend `checkSyncFreshness` to include `embedding_coverage_pct`
|
||||
per source.** v0.40.3.0 plan originally proposed adding a NEW doctor check
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -120,6 +120,17 @@ hashes match. If both a cron and `--watch` fire simultaneously, no conflict.
|
||||
server is down when a push happens, that sync is missed. Pair webhooks
|
||||
with a cron fallback that catches anything the webhook missed.
|
||||
|
||||
4. **A single un-parseable file can't wedge all indexing.** When a file fails
|
||||
to import (malformed YAML frontmatter, an unquoted colon, etc.), sync holds
|
||||
the bookmark and tells you exactly which file broke — a *fresh* failure
|
||||
fails closed so nothing is silently dropped. But a file that fails the same
|
||||
way `GBRAIN_SYNC_AUTOSKIP_AFTER` consecutive syncs (default 3, set `0` to
|
||||
disable) is auto-skipped so the rest of the brain keeps indexing past it.
|
||||
Skipped files don't disappear: `gbrain doctor` keeps warning until you fix
|
||||
or delete them, and fixing the file clears it on the next sync. A repository
|
||||
history rewrite still hard-blocks even with `--skip-failed`. Run
|
||||
`gbrain sync --skip-failed` to acknowledge a known-bad set yourself.
|
||||
|
||||
## How to Verify
|
||||
|
||||
1. **Edit a file and search for the change.** Edit a brain markdown file,
|
||||
|
||||
@@ -2617,6 +2617,17 @@ hashes match. If both a cron and `--watch` fire simultaneously, no conflict.
|
||||
server is down when a push happens, that sync is missed. Pair webhooks
|
||||
with a cron fallback that catches anything the webhook missed.
|
||||
|
||||
4. **A single un-parseable file can't wedge all indexing.** When a file fails
|
||||
to import (malformed YAML frontmatter, an unquoted colon, etc.), sync holds
|
||||
the bookmark and tells you exactly which file broke — a *fresh* failure
|
||||
fails closed so nothing is silently dropped. But a file that fails the same
|
||||
way `GBRAIN_SYNC_AUTOSKIP_AFTER` consecutive syncs (default 3, set `0` to
|
||||
disable) is auto-skipped so the rest of the brain keeps indexing past it.
|
||||
Skipped files don't disappear: `gbrain doctor` keeps warning until you fix
|
||||
or delete them, and fixing the file clears it on the next sync. A repository
|
||||
history rewrite still hard-blocks even with `--skip-failed`. Run
|
||||
`gbrain sync --skip-failed` to acknowledge a known-bad set yourself.
|
||||
|
||||
## How to Verify
|
||||
|
||||
1. **Edit a file and search for the change.** Edit a brain markdown file,
|
||||
|
||||
+1
-1
@@ -143,5 +143,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.42.31.0"
|
||||
"version": "0.42.32.0"
|
||||
}
|
||||
|
||||
+37
-34
@@ -569,29 +569,24 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
|
||||
// remote doctor.
|
||||
}
|
||||
|
||||
// 4. Sync failures (file-plane state, not in-DB; see src/core/sync.ts).
|
||||
// Read the JSONL file directly at the canonical path; cheap and engine-agnostic.
|
||||
// 4. Sync failures (file-plane ledger; see src/core/sync-failure-ledger.ts).
|
||||
// issue #1939: read via the shared loader + severity decision so this remote
|
||||
// surface agrees with the local buildChecks emitter by construction. Stays
|
||||
// subprocess-free (file read + Date.parse only, no git), preserving the remote
|
||||
// trust boundary. Escalates to FAIL when a stuck bookmark has blocked past the
|
||||
// sync-freshness fail cadence or unresolved count is large.
|
||||
try {
|
||||
const { readFileSync, existsSync } = await import('fs');
|
||||
const { gbrainPath } = await import('../core/config.ts');
|
||||
const path = gbrainPath('sync-failures.jsonl');
|
||||
let unacked = 0;
|
||||
if (existsSync(path)) {
|
||||
const lines = readFileSync(path, 'utf-8').split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as { acknowledged_at?: string | null };
|
||||
if (!entry.acknowledged_at) unacked++;
|
||||
} catch { /* skip malformed line */ }
|
||||
}
|
||||
}
|
||||
checks.push({
|
||||
name: 'sync_failures',
|
||||
status: unacked === 0 ? 'ok' : 'warn',
|
||||
message: unacked === 0
|
||||
? 'No unacked failures'
|
||||
: `${unacked} unacked failure(s) — run \`gbrain sync --skip-failed\` on the host to acknowledge`,
|
||||
});
|
||||
const { loadSyncFailures, decideSyncFailureSeverity } = await import('../core/sync.ts');
|
||||
const entries = loadSyncFailures();
|
||||
const failHours = _resolveSyncFreshnessHours('GBRAIN_SYNC_FRESHNESS_FAIL_HOURS', 72);
|
||||
const sev = decideSyncFailureSeverity({ entries, nowMs: Date.now(), failHours });
|
||||
const msg =
|
||||
sev.unresolved === 0
|
||||
? 'No unresolved sync failures'
|
||||
: `${sev.unresolved} unresolved sync failure(s)` +
|
||||
(sev.auto_skipped > 0 ? ` (${sev.auto_skipped} auto-skipped — pages NOT indexed)` : '') +
|
||||
` — run \`gbrain sync --skip-failed\` on the host to acknowledge`;
|
||||
checks.push({ name: 'sync_failures', status: sev.status, message: msg });
|
||||
} catch {
|
||||
checks.push({ name: 'sync_failures', status: 'ok', message: 'No failures recorded' });
|
||||
}
|
||||
@@ -4396,19 +4391,25 @@ export async function buildChecks(
|
||||
// Without this doctor check, users see "sync blocked" and have no
|
||||
// surface showing which files to fix.
|
||||
try {
|
||||
const { unacknowledgedSyncFailures, loadSyncFailures, summarizeFailuresByCode } = await import('../core/sync.ts');
|
||||
const unacked = unacknowledgedSyncFailures();
|
||||
const { unacknowledgedSyncFailures, loadSyncFailures, summarizeFailuresByCode, decideSyncFailureSeverity } = await import('../core/sync.ts');
|
||||
const all = loadSyncFailures();
|
||||
if (unacked.length > 0) {
|
||||
const codeSummary = summarizeFailuresByCode(unacked);
|
||||
// issue #1939: "unresolved" = open + auto_skipped. Severity (ok/warn/fail)
|
||||
// comes from the SAME shared decision the remote surface uses, so a stuck
|
||||
// bookmark blocked past the fail cadence (or a large unresolved count)
|
||||
// escalates to FAIL instead of staying a quiet WARN forever.
|
||||
const unresolved = unacknowledgedSyncFailures();
|
||||
if (unresolved.length > 0) {
|
||||
const failHours = _resolveSyncFreshnessHours('GBRAIN_SYNC_FRESHNESS_FAIL_HOURS', 72);
|
||||
const sev = decideSyncFailureSeverity({ entries: all, nowMs: Date.now(), failHours });
|
||||
const codeSummary = summarizeFailuresByCode(unresolved);
|
||||
const codeBreakdown = codeSummary.map(s => `${s.code}=${s.count}`).join(', ');
|
||||
const preview = unacked.slice(0, 3).map(f => `${f.path} (${f.error.slice(0, 60)})`).join('; ');
|
||||
const preview = unresolved.slice(0, 3).map(f => `${f.path} (${f.error.slice(0, 60)})`).join('; ');
|
||||
// v0.40.3.0 T8b (D8 + D12 Bug 3): emit a single sync-retry-failed
|
||||
// step. sync-skip-failed is DELIBERATELY NOT emitted as a remediation
|
||||
// — auto-skipping failed syncs hides data loss. Operators can still
|
||||
// run `gbrain sync --skip-failed` manually.
|
||||
const { makeRemediationStep } = await import('../core/remediation-step.ts');
|
||||
const oldestTs = unacked.reduce(
|
||||
const oldestTs = unresolved.reduce(
|
||||
(acc, f) => (acc === '' || f.ts < acc ? f.ts : acc),
|
||||
'',
|
||||
);
|
||||
@@ -4417,18 +4418,20 @@ export async function buildChecks(
|
||||
job: 'sync-retry-failed',
|
||||
// Content-stable per codex D12 Bug 2: count + oldest_ts captures
|
||||
// the relevant state without using a real timestamp.
|
||||
params: { failure_count: unacked.length, oldest_failure: oldestTs },
|
||||
severity: unacked.length >= 10 ? 'high' : 'medium',
|
||||
params: { failure_count: unresolved.length, oldest_failure: oldestTs },
|
||||
severity: sev.status === 'fail' ? 'high' : 'medium',
|
||||
est_seconds: 30,
|
||||
est_usd_cost: 0,
|
||||
rationale: `Retry ${unacked.length} unacked sync failure(s) (codes: ${codeBreakdown})`,
|
||||
rationale: `Retry ${unresolved.length} unresolved sync failure(s) (codes: ${codeBreakdown})`,
|
||||
});
|
||||
checks.push({
|
||||
name: 'sync_failures',
|
||||
status: 'warn',
|
||||
status: sev.status,
|
||||
message:
|
||||
`${unacked.length} unacknowledged sync failure(s) [${codeBreakdown}]. ${preview}` +
|
||||
`${unacked.length > 3 ? `, and ${unacked.length - 3} more` : ''}. ` +
|
||||
`${unresolved.length} unresolved sync failure(s) [${codeBreakdown}]` +
|
||||
(sev.auto_skipped > 0 ? ` — ${sev.auto_skipped} auto-skipped (pages NOT indexed)` : '') +
|
||||
`. ${preview}` +
|
||||
`${unresolved.length > 3 ? `, and ${unresolved.length - 3} more` : ''}. ` +
|
||||
`Fix the file(s) and re-run 'gbrain sync', or use 'gbrain sync --skip-failed' to acknowledge.`,
|
||||
remediation: [retryStep],
|
||||
remediation_status: 'remediable',
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface RunImportResult {
|
||||
export async function runImport(
|
||||
engine: BrainEngine,
|
||||
args: string[],
|
||||
opts: { commit?: string; strategy?: SyncStrategy; sourceId?: string } = {},
|
||||
opts: { commit?: string; strategy?: SyncStrategy; sourceId?: string; managedBookmark?: boolean } = {},
|
||||
): Promise<RunImportResult> {
|
||||
const noEmbed = args.includes('--no-embed');
|
||||
const fresh = args.includes('--fresh');
|
||||
@@ -438,13 +438,17 @@ export async function runImport(
|
||||
// Not a git repo or git not available
|
||||
}
|
||||
|
||||
if (gitHead) {
|
||||
// issue #1939: when performFullSync drives runImport it owns the failure
|
||||
// ledger + bookmark via the shared gate (applySyncFailureGate). Skipping the
|
||||
// internal handling here prevents double-recording (which would double-count
|
||||
// the auto-skip `attempts` streak) and a competing bookmark write.
|
||||
if (gitHead && !opts.managedBookmark) {
|
||||
// Record failures into the central JSONL so doctor can surface them.
|
||||
// Use gitHead as the commit so a later sync can tell "same broken
|
||||
// state as last time" from "new broken state."
|
||||
// state as last time" from "new broken state." Source-scoped (#1939 #2).
|
||||
if (failures.length > 0) {
|
||||
const { recordSyncFailures } = await import('../core/sync.ts');
|
||||
recordSyncFailures(failures, gitHead);
|
||||
const { recordFailures } = await import('../core/sync.ts');
|
||||
recordFailures(opts.sourceId ?? 'default', failures, gitHead);
|
||||
}
|
||||
if (failures.length === 0) {
|
||||
await engine.setConfig('sync.last_commit', gitHead);
|
||||
|
||||
+173
-107
@@ -11,10 +11,14 @@ import {
|
||||
isSyncable,
|
||||
unsyncableReason,
|
||||
resolveSlugForPath,
|
||||
recordSyncFailures,
|
||||
unacknowledgedSyncFailures,
|
||||
acknowledgeSyncFailures,
|
||||
loadSyncFailures,
|
||||
formatCodeBreakdown,
|
||||
applySyncFailureGate,
|
||||
isSkippablePath,
|
||||
resolveAutoSkipThreshold,
|
||||
DEFAULT_SOURCE_ID,
|
||||
} from '../core/sync.ts';
|
||||
import { estimateTokens, CHUNKER_VERSION } from '../core/chunkers/code.ts';
|
||||
import {
|
||||
@@ -1472,6 +1476,10 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
};
|
||||
|
||||
const pagesAffected: string[] = [];
|
||||
// issue #1939: file paths that imported cleanly this run. The failure-ledger
|
||||
// gate clears these so a previously-failing file's `attempts` streak resets
|
||||
// on success (consecutive-failure semantics for the auto-skip valve).
|
||||
const succeededPaths: string[] = [];
|
||||
let chunksCreated = 0;
|
||||
// v0.41.13.0 (T2): tracks add+modify files actually persisted so far.
|
||||
// Only bumped from inside importOnePath's success path. partial() reports
|
||||
@@ -1811,6 +1819,11 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// correct only when the gate compared HEAD == captured; under pinning a
|
||||
// forward delete must not block.
|
||||
await markCompleted(path);
|
||||
// issue #1939 adversarial finding #1: a file that previously failed to
|
||||
// parse (open ledger row) and is now gone from disk is resolved — clear
|
||||
// its row so it can't age doctor to a permanent FAIL. (This covers the
|
||||
// net-zero add-then-delete range where the path isn't in filtered.deleted.)
|
||||
succeededPaths.push(path);
|
||||
progress.tick(1, `skip:${path}`);
|
||||
return;
|
||||
}
|
||||
@@ -1831,6 +1844,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
if (result.status === 'imported') {
|
||||
chunksCreated += result.chunks;
|
||||
pagesAffected.push(result.slug);
|
||||
// issue #1939: record the file path (not slug) so the gate clears any
|
||||
// prior failure-ledger row — success resets the auto-skip attempt streak.
|
||||
succeededPaths.push(path);
|
||||
// v0.41.13.0 (T2): bump filesImported on every successful
|
||||
// persist. partial() reports this so cron operators see how
|
||||
// much actually landed before --timeout fired.
|
||||
@@ -1993,79 +2009,104 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
|
||||
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, pin);
|
||||
// Emit structured summary grouped by error code so the operator
|
||||
// can see *why* files failed, not just how many.
|
||||
// issue #1939 — gate the bookmark through the shared failure ledger.
|
||||
// • Fresh failures still BLOCK (fail-closed): the next sync re-walks the
|
||||
// diff and re-attempts. Escape hatch: --skip-failed.
|
||||
// • A file that fails >= threshold consecutive syncs AUTO-SKIPS so a poison
|
||||
// file can't wedge all indexing forever (recorded, surfaced by doctor).
|
||||
// • A `<head>` SENTINEL (history rewrite) HARD-BLOCKS even with
|
||||
// --skip-failed — advancing would record a commit that no longer matches
|
||||
// the indexed tree.
|
||||
// `advance` is the bookmark write; the gate runs it ONLY when advancing, and
|
||||
// ALWAYS before marking anything auto-skipped/acknowledged (crash-atomic).
|
||||
const advance = async (): Promise<void> => {
|
||||
// v0.42.x (#1794): advance to the PINNED target (not live HEAD) — commits
|
||||
// past the pin are the next sync's pin..HEAD diff. `commitTimeMs(pin)` stamps
|
||||
// newest_content_at against the commit we drained to. `last_sync_at` is bumped
|
||||
// HERE and ONLY here so the autopilot scheduler never sees a stuck source as
|
||||
// "fresh". The checkpoint rows clear here — CONVERGENCE CONTRACT: sync
|
||||
// convergence == IMPORT convergence; downstream extract/facts/embed is
|
||||
// decoupled (its own resumable stale sweeps).
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(repoPath, pin));
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
await clearOpCheckpoint(engine, ckpt.paths);
|
||||
await clearOpCheckpoint(engine, ckpt.target);
|
||||
};
|
||||
|
||||
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
|
||||
// row) and is then deleted/renamed-away never re-enters failedFiles and never
|
||||
// imports, so its row would never clear and would age doctor to a permanent
|
||||
// FAIL. Treat removed paths as resolved so the ledger self-heals.
|
||||
const resolvedPaths = [
|
||||
...succeededPaths,
|
||||
...filtered.deleted,
|
||||
...filtered.renamed.map(r => r.from),
|
||||
];
|
||||
|
||||
const gate = await applySyncFailureGate({
|
||||
sourceId: opts.sourceId ?? DEFAULT_SOURCE_ID,
|
||||
failedFiles,
|
||||
succeededPaths: resolvedPaths,
|
||||
commit: pin,
|
||||
skipFailed: opts.skipFailed === true,
|
||||
advance,
|
||||
});
|
||||
|
||||
if (!gate.advanced) {
|
||||
const codeBreakdown = formatCodeBreakdown(failedFiles);
|
||||
if (!opts.skipFailed) {
|
||||
if (gate.sentinelBlocked) {
|
||||
serr(
|
||||
`\nSync blocked: ${failedFiles.length} file(s) failed to parse:\n` +
|
||||
`\nSync blocked: repository history changed during sync (force-push / reset).\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.`,
|
||||
`The pinned target is no longer an ancestor of HEAD; advancing would record ` +
|
||||
`a commit that doesn't match the indexed tree. Re-run sync to re-pin against ` +
|
||||
`current HEAD.`,
|
||||
);
|
||||
// Update last_run + repo_path (progress on infra) but NOT last_commit.
|
||||
// v0.42.x (#1794): the checkpoint is INTENTIONALLY left in place — the
|
||||
// banked `completed` set (flushed above) lets the next run skip the files
|
||||
// already drained and re-attempt only the failures.
|
||||
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: pin,
|
||||
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) {
|
||||
} else {
|
||||
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
|
||||
serr(
|
||||
` Acknowledged ${acked.count} failure(s) and advancing past them:\n` +
|
||||
`${formatCodeBreakdown(acked.summary)}`,
|
||||
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
|
||||
`${codeBreakdown}\n\n` +
|
||||
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
|
||||
`acknowledge and move on. A file that keeps failing auto-skips after ` +
|
||||
`${resolveAutoSkipThreshold()} consecutive syncs.`,
|
||||
);
|
||||
}
|
||||
// Update last_run + repo_path (progress on infra) but NOT last_commit. The
|
||||
// checkpoint is INTENTIONALLY left in place — the banked completed set lets
|
||||
// the next run skip the drained files and re-attempt only the failures.
|
||||
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: pin,
|
||||
added: filtered.added.length,
|
||||
modified: filtered.modified.length,
|
||||
deleted: filtered.deleted.length,
|
||||
renamed: filtered.renamed.length,
|
||||
chunksCreated,
|
||||
embedded: 0,
|
||||
pagesAffected,
|
||||
failedFiles: failedFiles.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Update sync state AFTER all changes succeed (source-scoped when
|
||||
// opts.sourceId is set, global config otherwise). v0.42.x (#1794): advance to
|
||||
// the PINNED target (not live HEAD) — commits past the pin are the next
|
||||
// sync's pin..HEAD diff. `commitTimeMs(pin)` stamps newest_content_at against
|
||||
// the commit we actually drained to. `last_sync_at` is bumped HERE and ONLY
|
||||
// here (inside writeSyncAnchor) — never on a killed partial — so the
|
||||
// autopilot scheduler never sees a stuck source as "fresh".
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(repoPath, pin));
|
||||
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));
|
||||
// v0.42.x (#1794): import drained the full lastCommit..pin range — the anchor
|
||||
// is advanced and both checkpoint rows clear here. CONVERGENCE CONTRACT: sync
|
||||
// convergence == IMPORT convergence. Downstream (extract/facts/embed below)
|
||||
// is DELIBERATELY decoupled from the anchor: it is size-gated (inline only for
|
||||
// small syncs) and otherwise handled by its own resumable sweeps
|
||||
// (extract --stale watermark, embed --stale / embed-backfill, the extract_facts
|
||||
// + conversation_facts_backfill cycle phases). Coupling a 44k-page facts/embed
|
||||
// pass into the anchor gate would re-introduce the exact non-convergence this
|
||||
// fix exists to kill. A kill mid-downstream just means the banked pages get
|
||||
// their links/embeddings/facts from the next stale sweep.
|
||||
await clearOpCheckpoint(engine, ckpt.paths);
|
||||
await clearOpCheckpoint(engine, ckpt.target);
|
||||
// Advanced. Surface what the gate did past the failures.
|
||||
if (gate.acknowledged > 0) {
|
||||
serr(` Acknowledged ${gate.acknowledged} failure(s) and advanced past them.`);
|
||||
}
|
||||
if (gate.autoSkipped.length > 0) {
|
||||
serr(
|
||||
`\n Auto-skipped ${gate.autoSkipped.length} file(s) that failed >= ` +
|
||||
`${resolveAutoSkipThreshold()} consecutive syncs:\n` +
|
||||
gate.autoSkipped.map(p => ` ${p}`).join('\n') + '\n' +
|
||||
` Bookmark advanced; these pages are NOT indexed and remain in ` +
|
||||
`sync-failures.jsonl. 'gbrain doctor' will warn until they're fixed.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Log ingest
|
||||
await engine.logIngest({
|
||||
@@ -2263,55 +2304,80 @@ async function performFullSync(
|
||||
commit: headCommit,
|
||||
strategy: opts.strategy,
|
||||
sourceId: opts.sourceId,
|
||||
// issue #1939: performFullSync owns the failure ledger + bookmark via the
|
||||
// shared gate below; don't let runImport double-record or write its own.
|
||||
managedBookmark: true,
|
||||
});
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// issue #1939 — gate the full-sync bookmark through the SAME shared ledger as
|
||||
// the incremental path (Codex #6: a wedge here on first/forced sync was
|
||||
// previously unreachable by the valve). A full re-import is authoritative for
|
||||
// the whole tree, so any previously-tracked failing path that ISN'T failing
|
||||
// now has been resolved → clear it (resets its auto-skip streak); current
|
||||
// failures still climb their attempts.
|
||||
const fullSourceId = opts.sourceId ?? DEFAULT_SOURCE_ID;
|
||||
const fullFailureSet = new Set(result.failures.map(f => f.path));
|
||||
const fullSucceeded = loadSyncFailures()
|
||||
.filter(e => e.source_id === fullSourceId && isSkippablePath(e.path) && !fullFailureSet.has(e.path))
|
||||
.map(e => e.path);
|
||||
const advanceFull = async (): Promise<void> => {
|
||||
// Persist sync state so the next sync is incremental. Routed through
|
||||
// writeSyncAnchor so --source pins the right sources row.
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit, newestCommitMs(repoPath));
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
};
|
||||
|
||||
// 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, newestCommitMs(repoPath));
|
||||
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));
|
||||
const fullGate = await applySyncFailureGate({
|
||||
sourceId: fullSourceId,
|
||||
failedFiles: result.failures,
|
||||
succeededPaths: fullSucceeded,
|
||||
commit: headCommit,
|
||||
skipFailed: opts.skipFailed === true,
|
||||
advance: advanceFull,
|
||||
});
|
||||
|
||||
if (!fullGate.advanced) {
|
||||
const codeBreakdown = formatCodeBreakdown(result.failures);
|
||||
if (fullGate.sentinelBlocked) {
|
||||
serr(`\nFull sync blocked: repository history changed during sync.\n${codeBreakdown}`);
|
||||
} else {
|
||||
const fileFailCount = result.failures.filter(f => isSkippablePath(f.path)).length;
|
||||
serr(
|
||||
`\nFull sync blocked: ${fileFailCount} file(s) failed:\n` +
|
||||
`${codeBreakdown}\n\n` +
|
||||
`Fix the YAML in those files and re-run, or use '--skip-failed'. A file ` +
|
||||
`that keeps failing auto-skips after ${resolveAutoSkipThreshold()} consecutive syncs.`,
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
if (fullGate.acknowledged > 0) {
|
||||
serr(` Acknowledged ${fullGate.acknowledged} failure(s) and advanced past them.`);
|
||||
}
|
||||
if (fullGate.autoSkipped.length > 0) {
|
||||
serr(
|
||||
`\n Auto-skipped ${fullGate.autoSkipped.length} file(s) that failed >= ` +
|
||||
`${resolveAutoSkipThreshold()} consecutive syncs. These pages are NOT indexed; ` +
|
||||
`'gbrain doctor' will warn until they're fixed.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -376,14 +376,18 @@ export function assessContentSanity(opts: {
|
||||
// doesn't repeat the lowercase per literal.
|
||||
const bodyHead = body.slice(0, SCAN_HEAD_BYTES);
|
||||
const bodyHeadLower = bodyHead.toLowerCase();
|
||||
const titleLower = opts.title.toLowerCase();
|
||||
// Defensive coercion (issue #1939): this is a pure exported fn; lint.ts and
|
||||
// import-file both pass `parsed.title`, which a malformed YAML date/number
|
||||
// title could make non-string. Never throw on a bad title.
|
||||
const title = String(opts.title ?? '');
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
const junk_pattern_matches: string[] = [];
|
||||
for (const p of BUILT_IN_JUNK_PATTERNS) {
|
||||
const scope = p.applies_to ?? 'both';
|
||||
let matched = false;
|
||||
if (scope === 'title' || scope === 'both') {
|
||||
if (p.pattern.test(opts.title)) matched = true;
|
||||
if (p.pattern.test(title)) matched = true;
|
||||
}
|
||||
if (!matched && (scope === 'body' || scope === 'both')) {
|
||||
if (p.pattern.test(bodyHead)) matched = true;
|
||||
|
||||
@@ -112,7 +112,8 @@ function nonRedundancy(page: Page): number {
|
||||
}
|
||||
|
||||
function hasTitle(page: Page): number {
|
||||
return page.title && page.title.trim().length > 0 ? 1 : 0;
|
||||
// Coerce (issue #1939): a malformed YAML date/number title could be non-string.
|
||||
return String(page.title ?? '').trim().length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function hasBody(page: Page): number {
|
||||
|
||||
+22
-3
@@ -49,6 +49,25 @@ export interface ParsedMarkdown {
|
||||
errors?: ParseValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a raw YAML frontmatter value into a string.
|
||||
*
|
||||
* js-yaml parses unquoted scalars by type: `title: 2024-06-01` becomes a JS
|
||||
* `Date`, `title: 1458` becomes a `number`. The old `(frontmatter.X as string)`
|
||||
* cast was a compile-time lie — at runtime the value stayed a Date/number, so
|
||||
* any downstream `.toLowerCase()` / `.trim()` threw and (via the importer's
|
||||
* failure gate) could wedge sync indefinitely (issue #1939).
|
||||
*
|
||||
* Dates coerce to their UTC ISO date (`2024-06-01`) — deterministic across
|
||||
* machines and matching the on-disk source token, unlike `String(date)` which
|
||||
* renders a timezone-dependent long form. Everything else uses `String()`.
|
||||
*/
|
||||
export function coerceFrontmatterString(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown file with YAML frontmatter into its components.
|
||||
*
|
||||
@@ -105,12 +124,12 @@ export function parseMarkdown(
|
||||
|
||||
const { compiled_truth, timeline } = splitBody(body);
|
||||
|
||||
const type = (frontmatter.type as string) || (
|
||||
const type = coerceFrontmatterString(frontmatter.type) || (
|
||||
opts?.activePack ? inferTypeFromPack(filePath, opts.activePack) : inferType(filePath)
|
||||
);
|
||||
const title = (frontmatter.title as string) || inferTitle(filePath);
|
||||
const title = coerceFrontmatterString(frontmatter.title).trim() || inferTitle(filePath);
|
||||
const tags = extractTags(frontmatter);
|
||||
const slug = (frontmatter.slug as string) || inferSlug(filePath);
|
||||
const slug = coerceFrontmatterString(frontmatter.slug) || inferSlug(filePath);
|
||||
|
||||
const cleanFrontmatter = { ...frontmatter };
|
||||
delete cleanFrontmatter.type;
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Sync failure ledger — issue #1939 (was "Bug 9" in src/core/sync.ts)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// When a sync run catches a per-file parse error (YAML with unquoted
|
||||
// colons, malformed frontmatter, a non-string title, etc.), we record it
|
||||
// here instead of just logging and moving on. Goals:
|
||||
// 1. Gate the sync.last_commit bookmark advance in BOTH sync gates
|
||||
// (incremental + full/runImport) through one shared policy.
|
||||
// 2. Give a visible, per-(source,path) record of what failed, with the
|
||||
// commit hash to re-attempt after fixing the source file.
|
||||
// 3. `gbrain sync --skip-failed` acknowledges a known-bad set.
|
||||
// 4. BOUNDED AUTO-SKIP: a file that fails N consecutive syncs is
|
||||
// auto-skipped so a single poison file can't wedge ALL indexing
|
||||
// forever — without ever silently dropping a FRESH failure, and
|
||||
// never auto-skipping an infra sentinel like `<head>`.
|
||||
//
|
||||
// This module is a LEAF (imports only fs/path/crypto/config) so it can be
|
||||
// re-exported from sync.ts without a circular dependency. The state lives
|
||||
// in `~/.gbrain/sync-failures.jsonl`, one JSON object per line.
|
||||
//
|
||||
// State machine (per (source_id, path)):
|
||||
//
|
||||
// recordFailures (file fails a sync run)
|
||||
// │ attempts++ each consecutive run
|
||||
// ▼
|
||||
// ┌──────┐ clearFailures (file imports OK) ┌─────────┐
|
||||
// │ open │ ───────────────────────────────────────│ removed │
|
||||
// └──────┘ └─────────┘
|
||||
// │ │
|
||||
// --skip-│ │ attempts >= threshold AND no fresh failures
|
||||
// failed │ │ (after bookmark advance)
|
||||
// ▼ ▼
|
||||
// ┌────────────┐ ┌──────────────┐
|
||||
// │acknowledged│ │ auto_skipped │ (still UNRESOLVED → doctor WARN
|
||||
// │ (resolved) │ │ visible │ until the file imports cleanly)
|
||||
// └────────────┘ └──────────────┘
|
||||
//
|
||||
// `acknowledged` = ok (human resolved). `open` + `auto_skipped` = unresolved
|
||||
// (doctor surfaces them). `<head>` and any `<…>` sentinel is recorded but
|
||||
// NEVER auto-skipped/acknowledged-to-advance — a history rewrite must hard-block.
|
||||
|
||||
import {
|
||||
existsSync as _existsSync,
|
||||
readFileSync as _readFileSync,
|
||||
writeFileSync as _writeFileSync,
|
||||
mkdirSync as _mkdirSync,
|
||||
renameSync as _renameSync,
|
||||
openSync as _openSync,
|
||||
closeSync as _closeSync,
|
||||
unlinkSync as _unlinkSync,
|
||||
statSync as _statSync,
|
||||
} from 'fs';
|
||||
import { join as _joinPath } from 'path';
|
||||
import { gbrainPath as _gbrainPath } from './config.ts';
|
||||
import { createHash as _createHash } from 'crypto';
|
||||
|
||||
export const DEFAULT_SOURCE_ID = 'default';
|
||||
/** Reserved sentinel paths (e.g. `<head>`) start with this; never file paths. */
|
||||
export const SENTINEL_PREFIX = '<';
|
||||
export const DEFAULT_AUTOSKIP_AFTER = 3;
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_SPIN_MS = 50;
|
||||
const LOCK_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type SyncFailureState = 'open' | 'acknowledged' | 'auto_skipped';
|
||||
|
||||
export interface SyncFailure {
|
||||
/** Owning source (#1939 Codex #2 — failures must not merge across sources). */
|
||||
source_id: string;
|
||||
path: string;
|
||||
error: string;
|
||||
/** Structured error code extracted from the error message. */
|
||||
code: string;
|
||||
/** Most recent commit this path failed on. */
|
||||
commit: string;
|
||||
line?: number;
|
||||
/** ISO — start of the current unresolved streak. */
|
||||
first_seen: string;
|
||||
/** ISO — last update. */
|
||||
ts: string;
|
||||
/** Consecutive failed sync runs for (source_id, path). */
|
||||
attempts: number;
|
||||
state: SyncFailureState;
|
||||
resolved_at?: string;
|
||||
// Legacy MIRROR fields, still WRITTEN for one release so any pre-#1939
|
||||
// reader of `acknowledged_at` keeps working. Derived from `state`.
|
||||
acknowledged?: boolean;
|
||||
acknowledged_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AcknowledgeResult {
|
||||
count: number;
|
||||
summary: Array<{ code: string; count: number }>;
|
||||
}
|
||||
|
||||
/** A real importable file (not an infra sentinel like `<head>`). */
|
||||
export function isSkippablePath(path: string): boolean {
|
||||
return !path.startsWith(SENTINEL_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the auto-skip threshold from `GBRAIN_SYNC_AUTOSKIP_AFTER`
|
||||
* (default 3). `0` disables the valve entirely (pure fail-closed).
|
||||
*/
|
||||
export function resolveAutoSkipThreshold(): number {
|
||||
const raw = process.env.GBRAIN_SYNC_AUTOSKIP_AFTER;
|
||||
if (raw === undefined || raw === '') return DEFAULT_AUTOSKIP_AFTER;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return DEFAULT_AUTOSKIP_AFTER;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extraction of a structured error code from a sync failure
|
||||
* message. Order matters: DB-layer errors are checked BEFORE YAML-layer
|
||||
* ones so Postgres `duplicate key` isn't mislabeled as a YAML duplicate-key.
|
||||
*/
|
||||
export function classifyErrorCode(errorMsg: string): string {
|
||||
// SLUG_MISMATCH: thrown by importFromFile() at src/core/import-file.ts.
|
||||
if (/slug.*does not match|SLUG_MISMATCH/i.test(errorMsg)) return 'SLUG_MISMATCH';
|
||||
|
||||
// DB-layer errors come BEFORE the YAML duplicate-key check.
|
||||
if (/duplicate key value violates unique constraint|DB_DUPLICATE_KEY/i.test(errorMsg)) {
|
||||
return 'DB_DUPLICATE_KEY';
|
||||
}
|
||||
if (/canceling statement due to statement timeout|STATEMENT_TIMEOUT/i.test(errorMsg)) {
|
||||
return 'STATEMENT_TIMEOUT';
|
||||
}
|
||||
|
||||
// YAML / frontmatter patterns.
|
||||
if (/YAML parse failed|YAML_PARSE/i.test(errorMsg)) return 'YAML_PARSE';
|
||||
if (/YAMLException|duplicated mapping key|YAML_DUPLICATE_KEY/i.test(errorMsg)) {
|
||||
return 'YAML_DUPLICATE_KEY';
|
||||
}
|
||||
if (/File is empty or whitespace-only|Frontmatter must start with ---|MISSING_OPEN/i.test(errorMsg)) {
|
||||
return 'MISSING_OPEN';
|
||||
}
|
||||
if (/No closing --- delimiter|Heading at line .* found inside frontmatter|MISSING_CLOSE/i.test(errorMsg)) {
|
||||
return 'MISSING_CLOSE';
|
||||
}
|
||||
if (/Frontmatter block is empty|EMPTY_FRONTMATTER/i.test(errorMsg)) return 'EMPTY_FRONTMATTER';
|
||||
if (/Content contains null bytes|NULL_BYTES|null byte/i.test(errorMsg)) return 'NULL_BYTES';
|
||||
if (/Nested double quotes|NESTED_QUOTES/i.test(errorMsg)) return 'NESTED_QUOTES';
|
||||
|
||||
// Generic fallbacks.
|
||||
if (/invalid UTF-?8|INVALID_UTF8/i.test(errorMsg)) return 'INVALID_UTF8';
|
||||
if (/file too large|content too large|FILE_TOO_LARGE/i.test(errorMsg)) return 'FILE_TOO_LARGE';
|
||||
if (/skipping symlink|symlink|SYMLINK_NOT_ALLOWED/i.test(errorMsg)) return 'SYMLINK_NOT_ALLOWED';
|
||||
|
||||
// takes-v2 fence + holder grammar failures.
|
||||
if (/TAKES_TABLE_MALFORMED|TAKES_ROW_NUM_COLLISION|TAKES_FENCE_UNBALANCED/i.test(errorMsg)) {
|
||||
return 'TAKES_TABLE_MALFORMED';
|
||||
}
|
||||
if (/TAKES_HOLDER_INVALID/i.test(errorMsg)) return 'TAKES_HOLDER_INVALID';
|
||||
|
||||
// Embedding error classification.
|
||||
if (/embedding requires [A-Z][A-Z0-9_]+_API_KEY|EMBEDDING_NO_CREDS/i.test(errorMsg)) {
|
||||
return 'EMBEDDING_NO_CREDS';
|
||||
}
|
||||
if (/Anthropic has no embedding model|EMBEDDING_NO_TOUCHPOINT/i.test(errorMsg)) {
|
||||
return 'EMBEDDING_NO_TOUCHPOINT';
|
||||
}
|
||||
if (/\brate.?limit|\b429\b|too many requests|rate_limited|RateLimit/i.test(errorMsg)) {
|
||||
return 'EMBEDDING_RATE_LIMIT';
|
||||
}
|
||||
if (/insufficient_quota|quota exceeded|exceeded.*quota|credit balance is too low|billing|EMBEDDING_QUOTA/i.test(errorMsg)) {
|
||||
return 'EMBEDDING_QUOTA';
|
||||
}
|
||||
if (/maximum context length|max_tokens|context length|input too long|input length exceeds|tokens? exceed|too many tokens|EMBEDDING_OVERSIZE/i.test(errorMsg)) {
|
||||
return 'EMBEDDING_OVERSIZE';
|
||||
}
|
||||
|
||||
// content-sanity reject disposition.
|
||||
if (/PAGE_JUNK_PATTERN/i.test(errorMsg)) return 'PAGE_JUNK_PATTERN';
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/** Group failures by error code and return a sorted summary. */
|
||||
export function summarizeFailuresByCode(
|
||||
failures: Array<{ error: string; code?: string }>,
|
||||
): Array<{ code: string; count: number }> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const f of failures) {
|
||||
const code = f.code ?? classifyErrorCode(f.error);
|
||||
counts[code] = (counts[code] ?? 0) + 1;
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([code, count]) => ({ code, count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a code-grouped summary as a human-readable multi-line string.
|
||||
* Accepts either raw failures (summarized internally) or an already-
|
||||
* summarized `{code, count}[]`. Empty input → empty string.
|
||||
*/
|
||||
export function formatCodeBreakdown(
|
||||
input: Array<{ error: string; code?: string }> | Array<{ code: string; count: number }>,
|
||||
): string {
|
||||
const summary =
|
||||
input.length > 0 && typeof (input[0] as { count?: unknown }).count === 'number'
|
||||
? (input as Array<{ code: string; count: number }>)
|
||||
: summarizeFailuresByCode(input as Array<{ error: string; code?: string }>);
|
||||
return summary.map(s => ` ${s.code}: ${s.count}`).join('\n');
|
||||
}
|
||||
|
||||
function _failuresDir(): string {
|
||||
return _gbrainPath();
|
||||
}
|
||||
|
||||
export function syncFailuresPath(): string {
|
||||
return _joinPath(_failuresDir(), 'sync-failures.jsonl');
|
||||
}
|
||||
|
||||
function _ledgerKey(f: { source_id: string; path: string }): string {
|
||||
// NUL separator can't appear in a source id or path.
|
||||
return `${f.source_id} | ||||