Files
gbrain/src/core/write-through.ts
42375bded5 fix(sync): stop the sync data-loss family — ops/ prune, DB-only write-through, full-sync gate drift (#2404, #2426, #2607) (#2938)
Three verified-open defects, one family: sync silently destroying or
diverging on content it should preserve.

#2404 (P0) — 'ops' was hardcoded in PRUNE_DIR_NAMES (a v0.2.0-era
carve-out), so any path with an ops segment was 'pruned-dir': committed
ops/*.md never imported, and modified ops/* files hit the
unsyncableModified delete loop (whose #1433 guard only spared
'metafile'), silently deleting put-created pages like the bundled
daily-task-manager's canonical ops/tasks on every sync. Fix: remove
'ops' from the prune list (ordinary user content; the vendor/generated
entries stay), and harden the delete loop to also skip 'pruned-dir' —
a page under a pruned dir can only exist via a deliberate put_page.

#2426 (P0) — write-through content stayed DB-only and was deleted by
sync --full. All three compounding bugs fixed:
 1. writePageThrough now best-effort commits the artifact (path-limited
    git commit) on durability-hardened repos, so the post-commit hook
    can push it; result carries committed?: boolean.
 2. scripts/brain-commit-push.sh stages+commits BEFORE any pull — the
    old fetch+pull-rebase-first order aborted on any dirty tree, so the
    helper could never commit a MODIFIED page; brain_push's
    rebase-on-reject already handles an advanced remote.
 3. The full-sync delete-reconcile partitions stale pages by git
    history (listEverCommittedPaths): never-committed source_paths are
    DB-only write-through — pages are KEPT and re-exported to the
    working tree instead of soft-deleted. Builds on the #2828
    mass-delete valve (covers the below-valve cases).

#2607 — the sync --full git ls-files fast path bypassed pruneDir, so a
full pass imported (and resurrected soft-deleted) pages under dot-dirs
and vendored trees that incremental sync excludes. Fix:
isCollectibleForWalker applies the same segment-level pruneDir gate as
classifySync, so full and incremental enumeration agree.

One regression test per defect (all verified failing against master
src): test/sync-ops-pages.serial.test.ts,
test/write-through-commit.serial.test.ts, the #2426 helper-order test
in test/brain-durability-hook.serial.test.ts,
test/sync-reconcile-db-only.serial.test.ts,
test/import-git-fastpath-prune.test.ts.

Fixes #2404
Fixes #2426
Fixes #2607

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:41:25 -07:00

188 lines
8.5 KiB
TypeScript

/**
* Shared disk write-through for the canonical ingestion path.
*
* After a page row lands in the DB (via importFromContent / putPage), this
* renders the row to markdown via `serializePageToMarkdown` and writes it to
* `sync.repo_path` so the brain repo has a committable `.md` artifact that
* round-trips cleanly through `gbrain sync`. The file is rendered FROM the DB
* row, so the two sinks cannot diverge.
*
* Extracted from the v0.38 `put_page` write-through (operations.ts) so the
* `put_page` op AND `gbrain brainstorm/lsd --save` share one implementation
* instead of hand-rolling parallel (and divergent) copies. The extraction also
* upgraded the write to be ATOMIC — the original used a bare `writeFileSync`
* into a live git tree that `gbrain sync` / autopilot actively walk, so a crash
* mid-write left a partial `.md` that sync would fail to parse. We now write to
* a unique temp sibling and `rename` into place (rename is atomic on the same
* filesystem), matching the `.tmp + rename` convention used by
* import-checkpoint.ts / op-checkpoint.ts.
*
* Trust gating (subagent sandbox, dry-run) stays at the CALLER — this helper
* only does "row exists + repo is a real dir → render + atomic write".
*/
import { existsSync, statSync, mkdirSync, writeFileSync, renameSync, unlinkSync } from 'fs';
import { dirname, join } from 'path';
import { randomBytes } from 'crypto';
import type { BrainEngine } from './engine.ts';
import { serializePageToMarkdown, resolvePageFilePath } from './markdown.ts';
import { isWriteTargetContained } from './path-confine.ts';
import { isDurabilityHardened, commitWriteThroughFile } from './brain-repo-durability.ts';
/** Minimal logger surface — structurally compatible with operations.ts `Logger`. */
export interface WriteThroughLogger {
warn(msg: string): void;
}
export interface WriteThroughResult {
written: boolean;
path?: string;
/**
* True when the write was also committed to git (#2426). Only attempted on
* repos hardened via `gbrain sources harden` (durability hook installed);
* the hook then background-pushes the commit. Best-effort — a false/absent
* value never blocks the write.
*/
committed?: boolean;
/**
* Non-error reasons the file was not written:
* - no_repo_configured: the resolved target (source `local_path` or, for a
* sole-source brain, `sync.repo_path`) is unset (DB-only by design).
* - repo_not_found: target set but missing / not a directory.
* - source_repo_belongs_to_other_source: the assigned source has no
* `local_path`, and `sync.repo_path` is another source's own working tree
* — #2018: writing here would pollute that sibling's repo, so we skip.
* - page_not_found_after_write: the DB row isn't readable back (the caller's
* DB write failed or targeted a different source).
* - path_escapes_source_root: the computed file path resolves outside the
* source's working tree (hostile slug row / symlinked subtree) — refused.
*/
skipped?: 'no_repo_configured' | 'repo_not_found' | 'source_repo_belongs_to_other_source' | 'page_not_found_after_write' | 'path_escapes_source_root';
/** Set when the render/write/rename itself threw (EACCES, ENOTDIR, disk full). */
error?: string;
}
export interface WritePageThroughOpts {
sourceId?: string;
/** Merged over the page's own frontmatter at render time (e.g. provenance). */
frontmatterOverrides?: Record<string, unknown>;
logger?: WriteThroughLogger;
}
/**
* Render the DB row for `slug` to markdown and atomically write it under
* `sync.repo_path`. Never throws — failures are reported via the result's
* `skipped` / `error` fields (the DB write is the durable sink; the file is
* best-effort and reconciled by the next `gbrain sync`).
*/
export async function writePageThrough(
engine: BrainEngine,
slug: string,
opts: WritePageThroughOpts = {},
): Promise<WriteThroughResult> {
const sourceId = opts.sourceId ?? 'default';
try {
// #2018: pick the disk target so a page is NEVER written into a different
// source's working tree. Two legitimate topologies, plus the leak guard:
// 1. The assigned source has its OWN `local_path` (a separate working
// tree) → write at that tree's root (matches how `scanOneSource` reads
// it back; never nested under `.sources/`).
// 2. No per-source `local_path` → nest under the host repo
// (`sync.repo_path`): default at the root, non-default under
// `.sources/<id>/` (the established multi-source layout).
// 3. LEAK GUARD: if `sync.repo_path` is literally ANOTHER source's own
// `local_path`, nesting this page there would pollute that sibling's
// git repo (the reported bug). Skip instead.
let filePath: string;
let writeRoot: string;
const srcRows = await engine.executeRaw<{ local_path: string | null }>(
`SELECT local_path FROM sources WHERE id = $1`,
[sourceId],
);
const sourceLocalPath = srcRows[0]?.local_path ?? null;
if (sourceLocalPath) {
if (!existsSync(sourceLocalPath) || !statSync(sourceLocalPath).isDirectory()) {
return { written: false, skipped: 'repo_not_found' };
}
filePath = join(sourceLocalPath, `${slug}.md`);
writeRoot = sourceLocalPath;
} else {
const repoPath = await engine.getConfig('sync.repo_path');
if (!repoPath) {
return { written: false, skipped: 'no_repo_configured' };
}
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
return { written: false, skipped: 'repo_not_found' };
}
// Leak guard: refuse to write into a path that is some OTHER source's
// own working tree (#2018).
const collide = await engine.executeRaw<{ one: number }>(
`SELECT 1 AS one FROM sources WHERE id <> $1 AND local_path = $2 LIMIT 1`,
[sourceId, repoPath],
);
if (collide.length > 0) {
return { written: false, skipped: 'source_repo_belongs_to_other_source' };
}
filePath = resolvePageFilePath(repoPath, slug, sourceId);
writeRoot = repoPath;
}
// Defense-in-depth (#1647-slug / codex #6): confirm the computed file path
// stays within the source's working tree before any mkdir/write. validateSlug
// already rejects `..`/backslash/control/%2e in the slug at write time, so
// this guards a pre-existing hostile row or a symlinked intermediate dir
// under the source tree from escaping to an arbitrary filesystem location.
if (!isWriteTargetContained(filePath, writeRoot)) {
return { written: false, skipped: 'path_escapes_source_root' };
}
const writtenPage = await engine.getPage(slug, { sourceId });
if (!writtenPage) {
return { written: false, skipped: 'page_not_found_after_write' };
}
const tags = await engine.getTags(slug, { sourceId });
const md = serializePageToMarkdown(writtenPage, tags, {
frontmatterOverrides: opts.frontmatterOverrides,
});
mkdirSync(dirname(filePath), { recursive: true });
// Atomic write: unique temp sibling + rename. Unique name (pid + random)
// so two concurrent saves to the same target can't clobber each other's
// temp file. Clean up the temp on any failure so we never leak a stray
// `.tmp` next to the real file.
const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
try {
writeFileSync(tmpPath, md, 'utf8');
renameSync(tmpPath, filePath);
} catch (writeErr) {
try {
if (existsSync(tmpPath)) unlinkSync(tmpPath);
} catch {
// best-effort cleanup; surface the original write error below
}
throw writeErr;
}
// #2426: on a durability-hardened repo (user ran `gbrain sources harden`),
// commit the artifact so it reaches git — pre-fix, write-through content
// stayed uncommitted forever: never pushed, `last_sync_at` frozen, and
// silently deleted by a later `sync --full` delete-reconcile. The local
// post-commit hook background-pushes the commit. Best-effort: a commit
// failure never fails the write (the DB row + file are the durable sinks).
let committed = false;
try {
if (isDurabilityHardened(writeRoot)) {
committed = commitWriteThroughFile(writeRoot, filePath, slug);
}
} catch { /* best-effort */ }
return { written: true, path: filePath, ...(committed ? { committed } : {}) };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
opts.logger?.warn(`[write-through] failed for ${slug}: ${msg}`);
return { written: false, error: msg };
}
}