mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
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>
188 lines
8.5 KiB
TypeScript
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 };
|
|
}
|
|
}
|