diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 909c98d4..ff76f645 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -23,7 +23,7 @@ * SQL escaping and version-bump logic lives in the docs module. */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { loadConfig } from "../config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { getVersion } from "../cli/version.js"; @@ -55,13 +55,15 @@ import { setAuto, findEntry, listEntries } from "../docs/auto-registry.js"; import { defaultIo, runDocsOnboarding } from "../docs/onboarding.js"; import { isAutoEnabled } from "../docs/auto-registry.js"; import { readRefreshMeta } from "../docs/meta.js"; -import { tryGitTopLevel } from "../graph/git-hook-install.js"; +import { tryGitTopLevel, postCommitHookPath, containsOurMarkers } from "../graph/git-hook-install.js"; import { runWikiRefreshCycle, runLocalWikiRefresh } from "../docs/wiki-refresh.js"; import { loadSnapshotByCommit } from "../graph/diff.js"; import { repoDir } from "../graph/snapshot.js"; import { execFileSync } from "node:child_process"; import { hostname, userInfo } from "node:os"; import type { GitRunner } from "../docs/candidates.js"; +import { defaultGit } from "../docs/candidates.js"; +import { currentScope } from "../docs/branch-scope.js"; import { deriveProjectKey } from "../utils/repo-identity.js"; import { makeDocEmbedder } from "../docs/embed.js"; import { backfillDocEmbeddings } from "../docs/backfill.js"; @@ -119,6 +121,74 @@ function gitHeadOf(cwd: string): string | null { } } +/** + * True iff a hivemind-managed post-commit hook is installed for `cwd`. The hook + * is one of the two refresh triggers; its presence is what upgrades the wiki + * from "refreshes on session start" to "refreshes on every commit". + */ +function hookInstalledAt(cwd: string): boolean { + const p = postCommitHookPath(cwd); + if (!p) return false; + try { + return existsSync(p) && containsOurMarkers(readFileSync(p, "utf8")); + } catch { + return false; // unreadable hook → treat as absent, never throw on a hint path + } +} + +/** + * The freshness hint printed after a manual generation command when the user + * declines (or can't be asked about) auto refresh. A corpus is only kept up to + * date when the auto-registry flag is ON — it gates BOTH refresh triggers (the + * post-commit hook AND the SessionStart catch-up tick). The hook adds instant + * per-commit refresh on top. With the flag OFF nothing refreshes the corpus at + * all, so a freshly-generated corpus silently goes stale — this hint is the + * source-level close of that gap. `subject` names what stays fresh, phrased to + * open a sentence ("This wiki" / "These docs"), so the message matches the + * command that printed it. Returns null when both are wired (no nag). + */ +export function wikiFreshnessHint(state: { autoEnabled: boolean; hookInstalled: boolean; subject: string }): string | null { + if (state.autoEnabled && state.hookInstalled) return null; // fully wired + if (state.autoEnabled) { + // Refreshes on session start already; only the per-commit hook is missing. + return "Auto refresh is ON (updates on session start). For instant per-commit refresh: hivemind graph init"; + } + // Flag OFF → the corpus will NOT refresh until enabled. + const base = `${state.subject} will NOT stay fresh — nothing refreshes it yet. Enable per-session refresh: hivemind docs auto on`; + return state.hookInstalled ? base : `${base} (and per-commit refresh: hivemind graph init)`; +} + +/** + * After a manual generation command (`docs wiki` / `docs generate`) writes a + * corpus, wire freshness. A human is ASKED to turn on auto refresh; on yes we + * flip the same per-(org, project) registry flag `docs auto on` sets, so both + * the post-commit hook and the SessionStart tick will keep the corpus fresh. + * Automation (no TTY) is never blocked on stdin — it only sees the passive + * `wikiFreshnessHint`. `subject` names what stays fresh in the prompt + * ("this wiki" / "these docs"). No-op when auto is already on. + */ +async function offerAutoRefresh( + cfg: ReturnType, + project: string, + cwd: string, + subject: string, +): Promise { + if (isAutoEnabled(cfg.orgId, project)) return; + const io = defaultIo(); + if (io.interactive) { + const a = await io.ask(`\nKeep ${subject} fresh automatically on every commit? [y/N] `); + if (/^y(es)?$/i.test(a.trim())) { + setAuto({ orgId: cfg.orgId, orgName: cfg.orgName, project, path: cwd, auto: true }); + console.log("Auto refresh ON. For instant per-commit refresh also run: hivemind graph init"); + return; + } + } + // Capitalize the subject to open the hint sentence ("this wiki" → "This wiki"). + const sentenceSubject = subject.charAt(0).toUpperCase() + subject.slice(1); + const hint = wikiFreshnessHint({ autoEnabled: false, hookInstalled: hookInstalledAt(cwd), subject: sentenceSubject }); + if (hint) console.log(`\n${hint}`); +} + function requireConfig(): NonNullable> { const cfg = loadConfig(); if (!cfg) { @@ -403,7 +473,8 @@ export async function runDocsCommand(args: string[]): Promise { } let row: DocRow | null = null; try { - row = await getDocLatest(query, tableName, docId); + const showCwd = flagValue(args, "--cwd") ?? process.cwd(); + row = await getDocLatest(query, tableName, docId, { readerScope: currentScope(defaultGit(showCwd)) }); } catch (err) { if (!isMissingTableError((err as Error).message)) throw err; } @@ -480,7 +551,7 @@ export async function runDocsCommand(args: string[]): Promise { try { rows = allView ? await listDocs(query, tableName, { status, limit }) - : await listDocs(query, tableName, { status, projectOrLegacy: explicitProject !== undefined ? resolveProjectArg(explicitProject) : headerProject, limit }); + : await listDocs(query, tableName, { status, projectOrLegacy: explicitProject !== undefined ? resolveProjectArg(explicitProject) : headerProject, limit, readerScope: explicitProject === undefined ? currentScope(defaultGit(flagValue(args, "--cwd") ?? process.cwd())) : undefined }); } catch (err) { if (!isMissingTableError((err as Error).message)) throw err; } @@ -668,6 +739,10 @@ export async function runDocsCommand(args: string[]): Promise { if (!process.env.HIVEMIND_QUERY_TIMEOUT_MS) process.env.HIVEMIND_QUERY_TIMEOUT_MS = "30000"; const report = await runWikiRefreshCycle({ query, tableName, snap, repoRoot: cwd, project, + // Branch identity: on the trunk this is `main` (canonical corpus); on a + // feature branch it is `b:`, so the refresh reads/writes/leases + // its own overlay and never touches main. + scope: currentScope(git), run: makeHostRunPrompt(), runPage: makeHostPageRunPrompt(), git, owner: `${userInfo().username}@${hostname()}:${process.pid}`, force, @@ -858,6 +933,9 @@ export async function runDocsCommand(args: string[]): Promise { if (!process.env.HIVEMIND_QUERY_TIMEOUT_MS) process.env.HIVEMIND_QUERY_TIMEOUT_MS = "30000"; const report = await generateWikiPages({ query, tableName, snap, repoRoot: cwd, project, + // Branch identity for the written rows: `main` on the trunk, `b:` + // on a feature branch (a branch-scoped overlay, invisible on main). + scope: currentScope(defaultGit(cwd)), include, exclude, existing, force, limit, concurrency, run: makeHostRunPrompt(), runPage: makeHostPageRunPrompt(), @@ -869,6 +947,10 @@ export async function runDocsCommand(args: string[]): Promise { if (o.status === "created") console.log(` created ${o.doc_id} (${o.files} files, ${o.chunks} chunk${o.chunks === 1 ? "" : "s"})`); else console.log(` ${o.status} ${o.doc_id}: ${o.reason ?? ""}`); } + // Freshness wiring: a just-generated corpus is a still photo unless auto + // refresh is on. Offer to enable it (a human gets asked; automation only + // sees the passive hint and is never blocked on stdin). + await offerAutoRefresh(cfg, project, cwd, "this wiki"); return; } @@ -955,6 +1037,9 @@ export async function runDocsCommand(args: string[]): Promise { for (const o of report.outcomes) { if (o.status !== "created") console.log(` ${o.status} ${o.doc_id}: ${o.reason ?? ""}`); } + // Same freshness wiring as `docs wiki`: offer to keep these per-file docs + // fresh, or fall back to the passive hint under automation. + await offerAutoRefresh(cfg, project, cwd, "these docs"); return; } diff --git a/src/deeplake-schema.ts b/src/deeplake-schema.ts index 50f88bdb..81083d95 100644 --- a/src/deeplake-schema.ts +++ b/src/deeplake-schema.ts @@ -204,6 +204,11 @@ export const DOCS_COLUMNS: readonly ColumnDef[] = Object.freeze([ // (written only by the elected refresh turn); `u:|b:` = // a personal branch overlay (v2, opt-in). Reads default to `main`. { name: "scope", sql: "TEXT NOT NULL DEFAULT 'main'" }, + // Per-page source fingerprint: JSON `{file: git-blob-sha}` the page was + // generated from. Drives freshness (stale iff it differs from HEAD's), the + // overlay-divergence decision, the origin publish gate, and merge promotion. + // Read only where needed (scoped reads) so generic reads stay heal-safe. + { name: "source_fp", sql: "TEXT NOT NULL DEFAULT '{}'" }, { name: "version", sql: "BIGINT NOT NULL DEFAULT 1" }, { name: "created_at", sql: "TEXT NOT NULL DEFAULT ''" }, { name: "updated_at", sql: "TEXT NOT NULL DEFAULT ''" }, diff --git a/src/docs/branch-scope.ts b/src/docs/branch-scope.ts new file mode 100644 index 00000000..4c8ea1c5 --- /dev/null +++ b/src/docs/branch-scope.ts @@ -0,0 +1,116 @@ +/** + * Branch identity for doc rows — the value of the `scope` column. + * + * A doc row's `scope` encodes WHICH branch's view it belongs to: + * - `main` — the canonical, public corpus (everyone sees it). + * - `b:` — a shared overlay for a pushed feature branch (everyone + * who is on that branch sees it; others fall back to main). + * + * There is deliberately NO per-user dimension in the cloud scope. Private docs + * (generated from code that is only in a local, unpushed commit) never enter + * the shared table at all — they live in a local store. So the only scopes that + * reach Deeplake are `main` and `b:`, and a pushed branch doc is shared + * by the branch, not owned by a user (two teammates on the same branch push to + * the same origin/, so the doc converges — last write wins). + * + * NB: this `scope` (a branch view) is unrelated to the generation `--scope` + * (`file` | `symbol`), which is a granularity, not an identity. + * + * All git access goes through an injected runner so the logic is pure and unit + * testable — same seam as wiki-refresh (`args.git([...]) => string | null`). + */ + +/** Injected git runner: returns stdout (trimmed by callers) or null on failure. */ +export type GitRunner = (args: string[]) => string | null; + +/** The canonical, public corpus scope. */ +export const MAIN_SCOPE = "main"; + +/** Prefix marking a branch-overlay scope. */ +const BRANCH_PREFIX = "b:"; + +/** The `scope` value for a feature branch overlay. */ +export function branchScope(branch: string): string { + return `${BRANCH_PREFIX}${branch}`; +} + +export type ParsedScope = + | { kind: "main" } + | { kind: "branch"; branch: string }; + +/** Parse a stored `scope` value back into its identity. Unknown -> main. */ +export function parseScope(scope: string | undefined): ParsedScope { + if (scope && scope.startsWith(BRANCH_PREFIX)) { + return { kind: "branch", branch: scope.slice(BRANCH_PREFIX.length) }; + } + return { kind: "main" }; +} + +/** + * Current branch name, or null when git can't answer or HEAD is detached. + * A detached HEAD has no branch identity, so it resolves to the trunk (main). + */ +export function currentBranch(git: GitRunner): string | null { + const out = git(["rev-parse", "--abbrev-ref", "HEAD"])?.trim(); + if (!out || out === "HEAD") return null; // detached / no branch + return out; +} + +/** + * The default branch (trunk) of the repo, from `origin/HEAD`. Falls back to + * `main` when there is no origin or the symbolic ref is unset — the safe + * default (a repo with no remote treats its work as the canonical corpus). + */ +export function trunkBranch(git: GitRunner): string { + // `refs/remotes/origin/HEAD -> refs/remotes/origin/` + const ref = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])?.trim(); + if (ref) { + const slash = ref.lastIndexOf("/"); + const name = slash >= 0 ? ref.slice(slash + 1) : ref; + if (name) return name; + } + return "main"; +} + +/** + * The `scope` value for the current git checkout: `main` when on the trunk (or + * detached / non-git), else `b:`. This is the single place that maps + * "where git HEAD is" onto a doc identity. + */ +export function currentScope(git: GitRunner, trunk?: string): string { + const branch = currentBranch(git); + if (branch === null) return MAIN_SCOPE; + const trunkName = trunk ?? trunkBranch(git); + return branch === trunkName ? MAIN_SCOPE : branchScope(branch); +} + +/** + * Pick the row a reader on `readerScope` should see, from all candidate rows + * for ONE doc_id across scopes. Precedence: + * reader's own branch overlay > main > (any other branch: ignored) + * Within the winning scope, the highest `version` wins. + * + * This is the read-side isolation guarantee: a reader on `b:feature` sees the + * feature overlay if it exists, else falls back to main, and NEVER another + * branch's overlay; a reader on `main` sees only `main` rows. Rows missing a + * `scope` (legacy / un-stamped) count as `main` — so pre-branch corpora keep + * resolving unchanged. + */ +export function pickByScopePrecedence( + rows: readonly T[], + readerScope: string, +): T | null { + let best: T | null = null; + let bestRank = -1; + for (const r of rows) { + const s = r.scope || MAIN_SCOPE; // empty/undefined scope resolves as main + // 2 = the reader's own scope, 1 = main fallback, 0 = a foreign branch. + const rank = s === readerScope ? 2 : s === MAIN_SCOPE ? 1 : 0; + if (rank === 0) continue; // never surface another branch's overlay + if (best === null || rank > bestRank || (rank === bestRank && r.version > best.version)) { + best = r; + bestRank = rank; + } + } + return best; +} diff --git a/src/docs/candidates.ts b/src/docs/candidates.ts index cd58a107..b8349bd8 100644 --- a/src/docs/candidates.ts +++ b/src/docs/candidates.ts @@ -21,7 +21,7 @@ import type { GraphSnapshot } from "../graph/types.js"; /** Run `git ` in `cwd`; returns stdout, or null on any failure. */ export type GitRunner = (args: string[]) => string | null; -function defaultGit(cwd: string): GitRunner { +export function defaultGit(cwd: string): GitRunner { return (args) => { try { return execFileSync("git", ["-C", cwd, ...args], { diff --git a/src/docs/fingerprint.ts b/src/docs/fingerprint.ts new file mode 100644 index 00000000..e8d40b28 --- /dev/null +++ b/src/docs/fingerprint.ts @@ -0,0 +1,123 @@ +/** + * Per-page source fingerprint = the git blob-sha of each of a page's member + * files, as of the committed tree (HEAD). This is the objective, content-defined + * freshness signal: + * - git already content-addresses every file, so the blob-sha is a pure + * function of the file's bytes — no reading/hashing here, we ask git; + * - a page is stale iff its stored fingerprint differs from the current one + * (a member file changed, joined, or left); + * - path-independent: a rebase/merge/branch that lands the SAME bytes yields + * the SAME fingerprint, so freshness never chases the commit graph. + * + * It is stored per doc row (`source_fp`, a JSON `{file: blob-sha}` map) and drives + * freshness, the overlay-divergence decision, the origin publish gate, and the + * merge-promotion match. + * + * NB: fingerprints reflect COMMITTED content (`git ls-tree HEAD`), not the dirty + * working tree — a doc is never fresher than a pushable commit. + */ + +import type { GitRunner } from "./branch-scope.js"; + +/** Map of `file path -> git blob-sha` for a page's member files. */ +export type SourceFingerprint = Record; + +/** + * Blob-sha per file at a git `ref` (a commit / branch / `origin/`) via a + * single `git ls-tree`. Files git can't resolve (deleted, untracked, absent at + * that ref, or no git) are simply absent from the map — so a page whose file was + * deleted or isn't on the ref reads as "changed". Returns `{}` when git or the + * ref is unavailable, which downstream treats as "unknown → stale / not there". + */ +export function computeFingerprintAt(git: GitRunner, ref: string, files: readonly string[]): SourceFingerprint { + const fp: SourceFingerprint = {}; + if (files.length === 0) return fp; + const out = git(["ls-tree", ref, "--", ...files]); + if (out === null) return fp; + for (const line of out.split("\n")) { + const tab = line.indexOf("\t"); + if (tab < 0) continue; + // ` blob \t` + const meta = line.slice(0, tab).trim().split(/\s+/); + const path = line.slice(tab + 1); + if (meta.length >= 3 && meta[1] === "blob" && path) fp[path] = meta[2]; + } + return fp; +} + +/** Blob-sha per file at the committed tree (HEAD) — the freshness baseline. */ +export function computeFingerprint(git: GitRunner, files: readonly string[]): SourceFingerprint { + return computeFingerprintAt(git, "HEAD", files); +} + +/** + * True iff every member file is committed-clean — working tree == HEAD, with no + * modified or untracked bytes among them (`git status --porcelain -- ` + * is empty). Doc content is read from the WORKING TREE, while the fingerprint + * and publish gate reason about committed HEAD; publishing is only consistent + * when the two agree. A dirty or untracked member file means the page would + * document uncommitted bytes stamped as committed HEAD — so it must be held. + * No git (null) → true: a non-git repo has no notion of "committed". + */ +export function workingTreeClean(git: GitRunner, files: readonly string[]): boolean { + if (files.length === 0) return true; + const out = git(["status", "--porcelain", "--", ...files]); + if (out === null) return true; + return out.trim() === ""; +} + +/** + * Is a page's source PUBLISHED — i.e. does every member file have, on + * `origin/`, the exact same blob it has at HEAD? True → the doc can be + * shared to the cloud (teammates on the branch have this code). False → the code + * is only local (unpushed): the doc must stay private, never published early. + * A repo with no `origin/` yields false (nothing is shared yet). + */ +export function sourcePushed(git: GitRunner, files: readonly string[], branch: string): boolean { + if (files.length === 0) return true; + const head = computeFingerprint(git, files); + const origin = computeFingerprintAt(git, `origin/${branch}`, files); + return isFresh(head, origin); +} + +/** Serialize a fingerprint for the `source_fp` TEXT column (stable key order). */ +export function serializeFingerprint(fp: SourceFingerprint): string { + const sorted: SourceFingerprint = {}; + for (const k of Object.keys(fp).sort()) sorted[k] = fp[k]; + return JSON.stringify(sorted); +} + +/** Parse the `source_fp` cell; garbage degrades to `{}` (→ treated as stale). */ +export function parseFingerprint(raw: unknown): SourceFingerprint { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const out: SourceFingerprint = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "string") out[k] = v; + } + return out; + } + if (typeof raw === "string" && raw.trim() !== "") { + try { + return parseFingerprint(JSON.parse(raw)); + } catch { + return {}; + } + } + return {}; +} + +/** + * Files whose blob differs between two fingerprints — including files that + * appear in only one (added/removed from the page). Sorted for determinism. + */ +export function changedFiles(a: SourceFingerprint, b: SourceFingerprint): string[] { + const changed: string[] = []; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of keys) if (a[k] !== b[k]) changed.push(k); + return changed.sort(); +} + +/** A page is fresh iff its stored fingerprint matches the current one exactly. */ +export function isFresh(stored: SourceFingerprint, current: SourceFingerprint): boolean { + return changedFiles(stored, current).length === 0; +} diff --git a/src/docs/private-store.ts b/src/docs/private-store.ts new file mode 100644 index 00000000..ae8fe3f7 --- /dev/null +++ b/src/docs/private-store.ts @@ -0,0 +1,101 @@ +/** + * Local private doc store — the on-disk home for docs generated from a branch's + * COMMITTED-but-UNPUSHED code. Such a doc describes code teammates don't have + * yet, so it must never reach the shared cloud table (the publish gate holds + * it). Instead it lives here, readable only by this machine's owner, until the + * source is pushed and the next refresh promotes it to the cloud branch overlay. + * + * Layout: one JSON file per (project, scope) under + * ~/.hivemind/docs-private/__.json + * holding a map `{ [doc_id]: PrivateDoc }`. A whole-file read/write keeps it + * trivially consistent at the per-branch scale (dozens of pages). + * + * Each entry carries the `source_fp` it was generated from, so the reader can + * apply the same freshness verdict as cloud docs (stale banner) and drop an + * entry once it no longer matches HEAD. + */ + +import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; + +export interface PrivateDoc { + doc_id: string; + path: string; + content: string; + /** Serialized fingerprint (`{file: blob-sha}`) the doc was generated from. */ + source_fp: string; + tier: "fast" | "slow"; + updated_at: string; +} + +/** Root dir for the private store (overridable for tests). */ +export function privateStoreRoot(): string { + // `||` (not `??`): an empty override must fall through to the default, not + // resolve the store to a relative path under cwd. + return process.env.HIVEMIND_DOCS_PRIVATE_DIR || join(homedir(), ".hivemind", "docs-private"); +} + +/** + * The store file for (project, scope). The name is an INJECTIVE hash of the + * pair (with a null separator) so distinct branches/projects never collide onto + * one file — a plain char-substitution slug is not injective (`b:feat/x` and + * `b:feat_x` would both become `b_feat_x`), which would cross-contaminate one + * branch's private docs with another's. + */ +function storeFile(project: string, scope: string): string { + // Full 256-bit digest of (project, scope) with a NUL separator that can + // never appear in either — collision-free in practice, and not a lossy slug. + const key = createHash("sha256").update(`${project}\u0000${scope}`).digest("hex"); + return join(privateStoreRoot(), `${key}.json`); +} + +function readMap(file: string): Record { + try { + if (!existsSync(file)) return {}; + const raw = JSON.parse(readFileSync(file, "utf-8")) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return raw as Record; + } catch { + return {}; // corrupt file → empty, never throw on the read path + } +} + +function writeMap(file: string, map: Record): void { + mkdirSync(dirname(file), { recursive: true }); + // Per-process tmp name so concurrent writers don't clobber a shared `.tmp` + // before rename. (Same-(project,scope) refreshes are already serialized by + // the refresh lease; this guards cross-process / cross-scope overlap.) + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(map, null, 1) + "\n"); + renameSync(tmp, file); // atomic replace +} + +/** The private doc for (project, scope, doc_id), or null. */ +export function readPrivateDoc(project: string, scope: string, docId: string): PrivateDoc | null { + return readMap(storeFile(project, scope))[docId] ?? null; +} + +/** All private docs for (project, scope). */ +export function listPrivateDocs(project: string, scope: string): PrivateDoc[] { + return Object.values(readMap(storeFile(project, scope))); +} + +/** Upsert a private doc for (project, scope). */ +export function writePrivateDoc(project: string, scope: string, doc: PrivateDoc): void { + const file = storeFile(project, scope); + const map = readMap(file); + map[doc.doc_id] = doc; + writeMap(file, map); +} + +/** Remove a private doc (e.g. after it's promoted to the cloud on push). */ +export function deletePrivateDoc(project: string, scope: string, docId: string): void { + const file = storeFile(project, scope); + const map = readMap(file); + if (docId in map) { + delete map[docId]; + writeMap(file, map); + } +} diff --git a/src/docs/promote.ts b/src/docs/promote.ts new file mode 100644 index 00000000..a0cfc7bb --- /dev/null +++ b/src/docs/promote.ts @@ -0,0 +1,154 @@ +/** + * Merge promotion — when a branch's changes land in main, the overlay it built + * is REUSED as main's canonical page instead of regenerating it (saving LLM). + * + * The safety condition is content-defined: an overlay may be promoted only when + * its stored source fingerprint equals main's CURRENT source fingerprint for the + * same files — i.e. the exact bytes the overlay documents are now what main has. + * (After merging feature A into main with no other change to those files, A's + * overlay fingerprint == main's fingerprint → promote. If the merge combined A + * with other edits, the fingerprints differ → no promotion, main regenerates.) + * + * Promotion re-stamps the overlay content at scope `main` and archives the now + * redundant branch overlay. `planPromotions` is a pure decision (testable with a + * fake git); `promoteMergedOverlays` executes it against the table. + */ + +import { sqlIdent, sqlStr } from "../utils/sql.js"; +import { stableUnionRows } from "./stable-read.js"; +import { upsertDoc, editDoc } from "./write.js"; +import { parseFilesIndex, WIKI_DOC_PREFIX } from "./wiki-generate.js"; +import { computeFingerprint, parseFingerprint, serializeFingerprint, isFresh } from "./fingerprint.js"; +import { MAIN_SCOPE, parseScope, type GitRunner } from "./branch-scope.js"; +import type { QueryFn } from "./read.js"; + +/** Minimal row shape the planner needs. */ +export interface PromoteRow { + doc_id: string; + path: string; + content: string; + tier: string; + scope: string; + source_fp: string; +} + +export interface Promotion { + doc_id: string; + /** The overlay scope being promoted (e.g. `b:feat`). */ + fromScope: string; + path: string; + content: string; + tier: string; + /** Main's current fingerprint (what the promoted row is stamped with). */ + mainFp: string; +} + +/** + * Decide which branch overlays can be promoted to main. An overlay qualifies iff + * its `source_fp` equals main's current fingerprint for the page's CURRENT group + * membership (`groupFiles`, from the live snapshot — NOT the possibly-stale + * `## Files` index of a stored page). This is what makes promotion safe under a + * membership change: if the merge added/removed a member, the current + * fingerprint won't match the overlay's, so it is not promoted and the normal + * refresh regenerates it. A page whose group no longer exists is skipped. Pure — + * all git access is the injected runner. + */ +export function planPromotions( + rows: readonly PromoteRow[], + git: GitRunner, + groupFiles: ReadonlyMap, +): Promotion[] { + // Group rows per doc_id into { main, overlays }. + const byDoc = new Map(); + for (const r of rows) { + if (!r.doc_id.startsWith(WIKI_DOC_PREFIX)) continue; + const g = byDoc.get(r.doc_id) ?? { overlays: [] }; + if (parseScope(r.scope).kind === "branch") g.overlays.push(r); + else g.main = r; + byDoc.set(r.doc_id, g); + } + + const out: Promotion[] = []; + for (const [doc_id, g] of byDoc) { + if (g.overlays.length === 0) continue; + const files = groupFiles.get(doc_id); + if (!files || files.length === 0) continue; // group gone / empty → let refresh handle it + const mainFp = computeFingerprint(git, files); + if (Object.keys(mainFp).length === 0) continue; // no git signal → don't promote + const mainFpStr = serializeFingerprint(mainFp); + for (const ov of g.overlays) { + // The overlay must ALSO cover exactly the current membership (its ## Files + // == the group's files) — else it documents a stale file set. + if (!sameFileSet(parseFilesIndex(ov.content), files)) continue; + if (isFresh(parseFingerprint(ov.source_fp), mainFp)) { + out.push({ doc_id, fromScope: ov.scope, path: ov.path, content: ov.content, tier: ov.tier, mainFp: mainFpStr }); + } + } + } + return out; +} + +/** Order-independent equality of two file lists. */ +function sameFileSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const s = new Set(a); + return b.every((f) => s.has(f)); +} + +export interface PromoteOutcome { + doc_id: string; + fromScope: string; + action: "promoted"; +} + +/** + * Execute merge promotions for a project: re-stamp each qualifying overlay as + * main, then archive the overlay. Runs on the trunk after a merge. Best-effort + * per page — one failure doesn't abort the rest. + */ +export async function promoteMergedOverlays( + query: QueryFn, + tableName: string, + project: string, + git: GitRunner, + groupFiles: ReadonlyMap, + opts: { agent?: string; pluginVersion?: string } = {}, +): Promise { + const safe = sqlIdent(tableName); + const raw = await stableUnionRows( + query, + `SELECT id, doc_id, path, content, tier, scope, source_fp FROM "${safe}" ` + + `WHERE project = '${sqlStr(project)}' AND status = 'active'`, + ); + const rows: PromoteRow[] = raw.map((r) => ({ + doc_id: String(r.doc_id ?? ""), + path: String(r.path ?? ""), + content: String(r.content ?? ""), + tier: String(r.tier ?? "slow"), + scope: String(r.scope ?? MAIN_SCOPE), + source_fp: String(r.source_fp ?? "{}"), + })); + + const outcomes: PromoteOutcome[] = []; + for (const p of planPromotions(rows, git, groupFiles)) { + try { + await upsertDoc(query, tableName, { + doc_id: p.doc_id, + path: p.path, + content: p.content, + tier: p.tier === "fast" ? "fast" : "slow", + project, + scope: MAIN_SCOPE, + source_fp: p.mainFp, + agent: opts.agent ?? "docs-wiki-promote", + plugin_version: opts.pluginVersion, + }); + // Archive the now-redundant overlay (its content is main's truth). + await editDoc(query, tableName, { doc_id: p.doc_id, status: "archived" }, { project, scope: p.fromScope }); + outcomes.push({ doc_id: p.doc_id, fromScope: p.fromScope, action: "promoted" }); + } catch { + // best-effort — leave this overlay for the next cycle + } + } + return outcomes; +} diff --git a/src/docs/read.ts b/src/docs/read.ts index 39bf9de0..9a7ca06e 100644 --- a/src/docs/read.ts +++ b/src/docs/read.ts @@ -15,6 +15,7 @@ import { sqlIdent, sqlLike, sqlStr } from "../utils/sql.js"; import { stableUnionRows } from "./stable-read.js"; +import { pickByScopePrecedence } from "./branch-scope.js"; export type QueryFn = (sql: string) => Promise>>; @@ -43,6 +44,9 @@ export interface DocRow { * absent — generic reads do NOT select it so un-healed tables keep working; * only post-ensure paths (meta/pull/wiki) read or filter by it. */ scope?: string; + /** Serialized source fingerprint (`{file: blob-sha}`). Read only in scoped + * reads; defaults to `{}` when the column is absent/unselected. */ + source_fp?: string; version: number; created_at: string; updated_at: string; @@ -57,28 +61,79 @@ export interface ListDocsOpts { project?: string; /** Filter to one project but keep legacy unstamped rows (project='') visible. */ projectOrLegacy?: string; + /** + * The reader's branch view (`main` or `b:`). When set, each doc_id + * resolves with branch precedence (reader's overlay > main > foreign: hidden) + * instead of plain latest-version — so a refresh on a branch sees ITS overlay + * where one exists and main as the base everywhere else. Absent → legacy + * scope-agnostic latest-version behavior. + */ + readerScope?: string; /** Max rows returned. Default 200. */ limit?: number; } /** - * Project selector SQL. `project` = STRICT (write paths: never touch another + * Row selector SQL. `project` = STRICT (write paths: never touch another * project's row). `projectOrLegacy` = read paths on shared tables: scope to * one project but keep legacy rows (written before stamping, project='') - * visible everywhere. + * visible everywhere. `scope` = the identity dimension (`main` = canonical, + * `u:|b:` = overlay): when set, resolution is confined to that + * scope so a write/read never crosses into a sibling branch overlay or main. */ -function buildProjectFilter(opts: { project?: string; projectOrLegacy?: string }): string { - if (opts.project !== undefined) return ` AND project = '${sqlStr(opts.project)}'`; - if (opts.projectOrLegacy !== undefined) { - return ` AND (project = '${sqlStr(opts.projectOrLegacy)}' OR project = '')`; +function buildProjectFilter(opts: { project?: string; projectOrLegacy?: string; scope?: string }): string { + const clauses: string[] = []; + if (opts.project !== undefined) { + clauses.push(`project = '${sqlStr(opts.project)}'`); + } else if (opts.projectOrLegacy !== undefined) { + clauses.push(`(project = '${sqlStr(opts.projectOrLegacy)}' OR project = '')`); + } + if (opts.scope !== undefined) { + clauses.push(`scope = '${sqlStr(opts.scope)}'`); } - return ""; + return clauses.length ? ` AND ${clauses.join(" AND ")}` : ""; } const SELECT_COLS = "id, doc_id, path, content, anchors, tier, status, project, version, " + "created_at, updated_at, agent, plugin_version"; +/** + * True ONLY for a "column doesn't exist" error — the one case where dropping the + * scope/source_fp columns from a scoped read is safe. Any other failure (a + * timeout, a backend error) must propagate: silently degrading to an unscoped + * read would lose branch isolation and could surface a foreign overlay. + */ +function isMissingColumnError(err: unknown): boolean { + const m = err instanceof Error ? err.message : String(err); + return /(does not exist|no such column|unknown column|undefined column)/i.test(m) && + /(scope|source_fp|column)/i.test(m); +} + +/** + * Scoped read with graceful COLUMN degrade: prefer `scope, source_fp`; on a + * missing-column error fall back to `scope` alone (still isolation-safe, + * source_fp defaults to `{}`); then to unscoped (reachable only when the `scope` + * column truly doesn't exist → no overlays can exist → every row is main). A + * non-column error is rethrown, never degraded. + */ +async function scopedUnion( + query: QueryFn, + buildSql: (cols: string) => string, +): Promise>> { + const tiers = [`${SELECT_COLS}, scope, source_fp`, `${SELECT_COLS}, scope`, SELECT_COLS]; + let lastErr: unknown; + for (let i = 0; i < tiers.length; i++) { + try { + return await stableUnionRows(query, buildSql(tiers[i])); + } catch (e) { + if (i === tiers.length - 1 || !isMissingColumnError(e)) throw e; + lastErr = e; + } + } + throw lastErr; +} + /** * Return the latest version row for every distinct `doc_id`, filtered by * status (and optionally project), capped at `limit`. The "latest per id" @@ -97,10 +152,14 @@ export async function listDocs( // row set right after writes, which would make a refresh silently skip stale // docs. stableUnionRows re-reads until the union converges so we see EVERY // row. (ORDER BY is moot through the union — we re-sort after dedup below.) - const rows = await stableUnionRows( - query, - `SELECT ${SELECT_COLS} FROM "${safe}" ORDER BY version DESC, updated_at DESC, id DESC`, - ); + // `scope`/`source_fp` are selected only in reader-branch mode (generic reads + // stay schema-heal-safe); scopedUnion degrades on a missing column but never + // on a timeout (which would silently lose branch isolation). + const scoped = opts.readerScope !== undefined; + const orderBy = "ORDER BY version DESC, updated_at DESC, id DESC"; + const rows = scoped + ? await scopedUnion(query, (cols) => `SELECT ${cols} FROM "${safe}" ${orderBy}`) + : await stableUnionRows(query, `SELECT ${SELECT_COLS} FROM "${safe}" ${orderBy}`); // Dedup key includes the project: in a shared org table the same doc_id // legitimately exists once per project, and a doc_id-only dedup would let @@ -109,6 +168,10 @@ export async function listDocs( // Latest is picked by EXPLICIT comparison (version, updated_at, id) — the // SQL ORDER BY does not survive stableUnionRows (union order is first-seen // across re-reads), so "first row wins" could keep a stale version. + // In reader-branch mode, gather every scope's rows per key and resolve by + // precedence (overlay > main > foreign hidden); otherwise the legacy + // max-(version, updated_at, id) winner. + const candidates = new Map(); const latest = new Map(); for (const r of rows) { const row = normalize(r); @@ -117,6 +180,11 @@ export async function listDocs( if (opts.project !== undefined && row.project !== opts.project) continue; if (opts.projectOrLegacy !== undefined && row.project !== opts.projectOrLegacy && row.project !== "") continue; const key = `${row.project}\u0000${row.doc_id}`; + if (scoped) { + const list = candidates.get(key); + if (list) list.push(row); else candidates.set(key, [row]); + continue; + } const prev = latest.get(key); if ( !prev || @@ -128,6 +196,12 @@ export async function listDocs( latest.set(key, row); } } + if (scoped) { + for (const [, list] of candidates) { + const winner = pickByScopePrecedence(list, opts.readerScope!); + if (winner) latest.set(`${winner.project}${winner.doc_id}`, winner); + } + } const statusFilter = opts.status ?? "active"; const filtered = [...latest.values()].filter(r => { @@ -162,7 +236,7 @@ export interface DocMetaRow { export async function listDocMeta( query: QueryFn, tableName: string, - opts: { dirPrefix?: string; project?: string } = {}, + opts: { dirPrefix?: string; project?: string; readerScope?: string } = {}, ): Promise { const safe = sqlIdent(tableName); const clauses: string[] = []; @@ -175,12 +249,23 @@ export async function listDocMeta( clauses.push(`(project = '${sqlStr(opts.project)}' OR project = '')`); } const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""; + // In reader-branch mode, select `scope` too and resolve per doc_id by branch + // precedence — otherwise a foreign branch overlay could top the index. Falls + // back (missing-column) to the scope-less latest-version behavior. + const scoped = opts.readerScope !== undefined; + const baseCols = "id, doc_id, version, updated_at, status, tier"; // `id` is selected (not returned) so the read-stability gate can union rows // by their unique key — see stableUnionRows(idKey="id"). - const rows = await stableUnionRows( - query, - `SELECT id, doc_id, version, updated_at, status, tier FROM "${safe}"${where}`, - ); + let rows: Array>; + try { + rows = await stableUnionRows(query, `SELECT ${scoped ? `${baseCols}, scope` : baseCols} FROM "${safe}"${where}`); + } catch (e) { + if (!scoped || !isMissingColumnError(e)) throw e; + rows = await stableUnionRows(query, `SELECT ${baseCols} FROM "${safe}"${where}`); + } + + interface MetaCand extends DocMetaRow { scope: string } + const candidates = new Map(); const latest = new Map(); for (const r of rows) { const doc_id = String(r.doc_id ?? ""); @@ -190,16 +275,23 @@ export async function listDocMeta( const version = typeof vRaw === "number" ? vRaw : Number(vRaw); if (!Number.isFinite(version)) continue; const updated_at = String(r.updated_at ?? ""); + const tier = String(r.tier ?? "fast"); + const meta: DocMetaRow = { doc_id, version, updated_at, status: String(r.status ?? ""), tier: tier === "slow" ? "slow" : "fast" }; + if (scoped) { + const cand: MetaCand = { ...meta, scope: String(r.scope || "main") }; + const list = candidates.get(doc_id); + if (list) list.push(cand); else candidates.set(doc_id, [cand]); + continue; + } const prev = latest.get(doc_id); if (!prev || version > prev.version || (version === prev.version && updated_at > prev.updated_at)) { - const tier = String(r.tier ?? "fast"); - latest.set(doc_id, { - doc_id, - version, - updated_at, - status: String(r.status ?? ""), - tier: tier === "slow" ? "slow" : "fast", - }); + latest.set(doc_id, meta); + } + } + if (scoped) { + for (const [doc_id, list] of candidates) { + const winner = pickByScopePrecedence(list, opts.readerScope!); + if (winner) latest.set(doc_id, { doc_id: winner.doc_id, version: winner.version, updated_at: winner.updated_at, status: winner.status, tier: winner.tier }); } } return [...latest.values()]; @@ -216,7 +308,7 @@ export async function listDocsByIds( query: QueryFn, tableName: string, docIds: string[], - opts: { project?: string; projectOrLegacy?: string } = {}, + opts: { project?: string; projectOrLegacy?: string; scope?: string; readerScope?: string } = {}, ): Promise { const ids = [...new Set(docIds.filter((d) => d !== ""))]; if (ids.length === 0) return []; @@ -226,19 +318,33 @@ export async function listDocsByIds( // once per project, and an unscoped read can resolve to the wrong project's // row. Omitting it keeps the historical single-project behavior. const projFilter = buildProjectFilter(opts); - const rows = await stableUnionRows( - query, - `SELECT ${SELECT_COLS} FROM "${safe}" WHERE doc_id IN (${inList})${projFilter}`, - ); + const scoped = opts.readerScope !== undefined; + const rows = scoped + ? await scopedUnion(query, (cols) => `SELECT ${cols} FROM "${safe}" WHERE doc_id IN (${inList})${projFilter}`) + : await stableUnionRows(query, `SELECT ${SELECT_COLS} FROM "${safe}" WHERE doc_id IN (${inList})${projFilter}`); + + // Reader-branch mode resolves each doc_id by precedence; else latest-version. + const candidates = new Map(); const latest = new Map(); for (const r of rows) { const row = normalize(r); if (!row) continue; + if (scoped) { + const list = candidates.get(row.doc_id); + if (list) list.push(row); else candidates.set(row.doc_id, [row]); + continue; + } const prev = latest.get(row.doc_id); if (!prev || row.version > prev.version || (row.version === prev.version && row.updated_at > prev.updated_at)) { latest.set(row.doc_id, row); } } + if (scoped) { + for (const [doc_id, list] of candidates) { + const winner = pickByScopePrecedence(list, opts.readerScope!); + if (winner) latest.set(doc_id, winner); + } + } return [...latest.values()]; } @@ -251,7 +357,7 @@ export async function getDocLatest( query: QueryFn, tableName: string, docId: string, - opts: { project?: string; projectOrLegacy?: string } = {}, + opts: { project?: string; projectOrLegacy?: string; scope?: string; readerScope?: string } = {}, ): Promise { const safe = sqlIdent(tableName); // Read ALL version rows for this doc through the stability gate, then pick @@ -261,6 +367,22 @@ export async function getDocLatest( // the max guarantees we never resolve to a stale version or miss the doc. // Optional project selector — see listDocsByIds. const projFilter = buildProjectFilter(opts); + + // Read-side branch resolution: when a `readerScope` is given, DON'T filter by + // scope in SQL — fetch every scope's rows for this doc_id and let + // pickByScopePrecedence choose (reader's overlay > main > foreign: hidden). + // The `scope` column is selected only in this mode (generic reads stay + // schema-heal-safe); a table missing the column degrades gracefully — the + // catch retries without it, so every row reads as `main`. + if (opts.readerScope !== undefined) { + const raw = await scopedUnion( + query, + (cols) => `SELECT ${cols} FROM "${safe}" WHERE doc_id = '${sqlStr(docId)}'${projFilter}`, + ); + const rows = raw.map(normalize).filter((r): r is DocRow => r !== null); + return pickByScopePrecedence(rows, opts.readerScope); + } + const raw = await stableUnionRows( query, `SELECT ${SELECT_COLS} FROM "${safe}" WHERE doc_id = '${sqlStr(docId)}'${projFilter}`, @@ -329,7 +451,10 @@ function normalize(row: Record): DocRow | null { tier: tier === "slow" ? "slow" : "fast", status: String(row.status ?? ""), project: String(row.project ?? ""), - scope: String(row.scope ?? "main"), + // `||` (not `??`): a stored empty scope is a legacy/unstamped row and must + // resolve as main, not as a distinct "" identity that hides from precedence. + scope: String(row.scope || "main"), + source_fp: String(row.source_fp ?? "{}"), version, created_at: String(row.created_at ?? ""), updated_at: String(row.updated_at ?? ""), diff --git a/src/docs/vfs-handler.ts b/src/docs/vfs-handler.ts index 19c074c1..e9bace03 100644 --- a/src/docs/vfs-handler.ts +++ b/src/docs/vfs-handler.ts @@ -23,6 +23,10 @@ import { listDocMeta, listDocsByIds, getDocLatest, type QueryFn } from "./read.j import { buildDocsIndex, dirOf, firstDocLine, type DocMeta } from "./index-render.js"; import { searchDocs, type SearchOptions } from "../shell/grep-core.js"; import { sqlLike } from "../utils/sql.js"; +import { computeFingerprint, parseFingerprint, changedFiles } from "./fingerprint.js"; +import { parseFilesIndex, WIKI_DOC_PREFIX } from "./wiki-generate.js"; +import { readPrivateDoc } from "./private-store.js"; +import { MAIN_SCOPE, type GitRunner } from "./branch-scope.js"; import type { DocEmbedder } from "./embed.js"; export type DocsVfsResult = @@ -34,6 +38,19 @@ export interface DocsVfsOptions { embedQuery?: DocEmbedder; /** Project scope for shared org tables (legacy '' rows always included). */ project?: string; + /** + * The reader's branch view (`main` or `b:`), from their git checkout. + * When set, a leaf doc resolves with branch precedence: the reader's own + * overlay if present, else main, never another branch's overlay. Absent → + * legacy behavior (latest version, scope-agnostic). + */ + readerScope?: string; + /** + * Git runner for the read-time freshness verdict: when present, a leaf doc + * whose stored fingerprint differs from HEAD's is served WITH a banner naming + * the changed files, so the reader falls back to source and stays correct. + */ + git?: GitRunner; } /** Resolve a `/docs/` subpath to rendered text from the docs table. */ @@ -80,7 +97,7 @@ export async function handleDocsVfs( else if (!path.endsWith(".md")) dir = path; // a bare directory (e.g. `cat .../docs/src/graph`) if (dir !== null) { - const meta: DocMeta[] = (await listDocMeta(query, tableName, { dirPrefix: dir, project: opts.project })).map((r) => ({ + const meta: DocMeta[] = (await listDocMeta(query, tableName, { dirPrefix: dir, project: opts.project, readerScope: opts.readerScope })).map((r) => ({ doc_id: r.doc_id, version: r.version, updated_at: r.updated_at, @@ -92,7 +109,7 @@ export async function handleDocsVfs( .map((m) => m.doc_id); const summaries = new Map(); if (directFiles.length > 0) { - for (const d of await listDocsByIds(query, tableName, directFiles, { projectOrLegacy: opts.project })) { + for (const d of await listDocsByIds(query, tableName, directFiles, { projectOrLegacy: opts.project, readerScope: opts.readerScope })) { summaries.set(d.doc_id, firstDocLine(d.content)); } } @@ -101,11 +118,44 @@ export async function handleDocsVfs( // Leaf: ".md" → doc for that file. const docId = path.slice(0, -".md".length); - const row = await getDocLatest(query, tableName, docId, { projectOrLegacy: opts.project }); + + // Highest precedence: THIS machine's private doc for the current branch (a doc + // built from committed-but-unpushed code, held out of the shared cloud). Only + // the owner, on that branch, sees it. `project === ""` is a legitimate legacy/ + // default key, so test for presence (!== undefined), not truthiness — an + // empty-string project must still resolve its private docs. + if (opts.project !== undefined && opts.readerScope && opts.readerScope !== MAIN_SCOPE) { + const priv = readPrivateDoc(opts.project, opts.readerScope, docId); + if (priv) { + const banner = staleBanner(opts.git, priv.doc_id, priv.content, priv.source_fp); + const header = `# ${priv.doc_id}\nvisibility: private (this branch, not pushed) updated: ${priv.updated_at}\n---\n`; + return { kind: "ok", body: header + banner + priv.content }; + } + } + + const row = await getDocLatest(query, tableName, docId, { projectOrLegacy: opts.project, readerScope: opts.readerScope }); if (!row) return { kind: "not-found", message: `${subpath}: No such file or directory` }; + + const banner = staleBanner(opts.git, row.doc_id, row.content, row.source_fp); const header = `# ${row.doc_id}\n` + `version: ${row.version} tier: ${row.tier} status: ${row.status} updated: ${row.updated_at}\n` + `---\n`; - return { kind: "ok", body: header + row.content }; + return { kind: "ok", body: header + banner + row.content }; +} + +/** + * Read-time freshness verdict: compare the page's stored fingerprint to HEAD's + * current one. If member files changed since it was written, return a banner + * naming them so the reader confirms against source (on-demand posture); else "". + */ +function staleBanner(git: GitRunner | undefined, docId: string, content: string, source_fp?: string): string { + if (!git || !source_fp) return ""; + const files = docId.startsWith(WIKI_DOC_PREFIX) ? parseFilesIndex(content) : [docId]; + const changed = changedFiles(parseFingerprint(source_fp), computeFingerprint(git, files)); + if (changed.length === 0) return ""; + return ( + `> [!] This page may be stale — ${changed.length} source file(s) changed since it was written. ` + + `Confirm against the source before relying on it: ${changed.join(", ")}\n\n` + ); } diff --git a/src/docs/wiki-generate.ts b/src/docs/wiki-generate.ts index dc23229c..95c1e731 100644 --- a/src/docs/wiki-generate.ts +++ b/src/docs/wiki-generate.ts @@ -31,6 +31,8 @@ import { buildAnchor } from "./anchors.js"; import { selectTargets } from "./generate.js"; import { runPool } from "./pool.js"; import { upsertDoc } from "./write.js"; +import { computeFingerprint, serializeFingerprint } from "./fingerprint.js"; +import { defaultGit } from "./candidates.js"; import { groupFilesBySubsystem, type WikiGroup } from "./wiki-groups.js"; import type { DocEmbedder } from "./embed.js"; import type { DocAnchor, QueryFn } from "./read.js"; @@ -401,6 +403,7 @@ export async function generateWikiPages(args: WikiGenArgs): Promise try { const content_embedding = args.embed ? (await args.embed(content)) ?? undefined : undefined; + const source_fp = serializeFingerprint(computeFingerprint(defaultGit(args.repoRoot), group.files)); await upsertDoc(args.query, args.tableName, { doc_id: docId, path: defaultWikiVfsPath(project, group.key), @@ -409,6 +412,7 @@ export async function generateWikiPages(args: WikiGenArgs): Promise tier: "slow", project, scope, + source_fp, agent: args.agent ?? "docs-wiki", plugin_version: args.pluginVersion, content_embedding, diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index 9091d83a..02cb3f32 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -28,6 +28,10 @@ import { join } from "node:path"; import { commitRefresh, readRefreshMeta, releaseClaim, tryClaimTurn } from "./meta.js"; import { gateDocEdit } from "./gate.js"; import { buildUpdatePrompt, updateWikiPage, DEFAULT_WIKI_MAX_CHANGED_LINES, NO_CHANGE } from "./wiki-update.js"; +import { parseScope, trunkBranch, currentBranch } from "./branch-scope.js"; +import { sourcePushed, workingTreeClean } from "./fingerprint.js"; +import { promoteMergedOverlays } from "./promote.js"; +import { writePrivateDoc, deletePrivateDoc } from "./private-store.js"; import { appendFilesIndex, generateWikiPages, @@ -87,7 +91,7 @@ export interface WikiRefreshArgs { export interface WikiRefreshOutcome { doc_id: string; - action: "patched" | "mechanics_refreshed" | "no_change" | "regenerated" | "generated" | "failed" | "skipped"; + action: "patched" | "mechanics_refreshed" | "no_change" | "regenerated" | "generated" | "failed" | "skipped" | "held" | "promoted"; reasons?: string[]; } @@ -251,15 +255,29 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise | null = null; - if (lastSha !== "") { - const out = args.git(["diff", "--name-only", `${lastSha}..HEAD`]); + if (baseSha !== "") { + const out = args.git(["diff", "--name-only", `${baseSha}..HEAD`]); if (out !== null) { changed = new Set(out.split("\n").map((l) => l.trim()).filter(Boolean)); } else { - log(`diff ${lastSha}..HEAD unavailable — full-candidate cycle`); + log(`diff ${baseSha}..HEAD unavailable — full-candidate cycle`); } } else { log("first refresh cycle — full-candidate cycle"); @@ -280,20 +298,87 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise(); - for (const d of await listDocs(args.query, args.tableName, { project: args.project, status: "active", limit: 100000 })) { + // Read the branch's VIEW: on a feature branch `readerScope` resolves each page + // to its own overlay where one exists, and to main as the base everywhere + // else — so a first patch on the branch copies-on-write from main. + for (const d of await listDocs(args.query, args.tableName, { project: args.project, status: "active", limit: 100000, readerScope: scope })) { if (d.doc_id.startsWith(WIKI_DOC_PREFIX)) pages.set(d.doc_id, d); } const regenerate = args.regenerate ?? defaultRegenerate(args); const outcomes: WikiRefreshOutcome[] = []; let failures = 0; + // Pages written PRIVATELY (unpushed) have pending "publish on push" work. A + // push does NOT move HEAD, so if we advanced the cursor past them the next + // cycle would short-circuit as up-to-date and never publish them. Counting + // them keeps the cursor behind until they are pushed (then published). (Dirty/ + // detached holds don't need this: the commit that cleans them moves HEAD.) + let pendingPublish = 0; + + // Publish gate: on a branch, only pages whose source is already on + // origin/ may be written to the SHARED cloud table. A page built from + // an unpushed local commit is HELD — never leaked as a doc describing code the + // team can't see. (Readers on the branch fall back to main + the staleness + // banner until the code is pushed and the next cycle publishes the overlay.) + const parsedScope = parseScope(scope); + const branchName = parsedScope.kind === "branch" ? parsedScope.branch : null; + // Detached HEAD resolves to `main` scope (branchName === null) but has no + // branch identity. It must be computed BEFORE the promotion block below: a + // detached checkout points at an arbitrary commit, and promoting branch + // overlays into the canonical `main` corpus from it would bypass the branch + // gate. So detect it up front and both skip promotion and hold all writes. + const detached = branchName === null && currentBranch(args.git) === null; + + // On the trunk, first PROMOTE any branch overlays whose source now matches + // main (a merge landed their changes) — reuse the overlay instead of paying to + // regenerate. Promoted pages are then skipped by the loop below. NEVER on a + // detached HEAD (see above) — it has no branch identity to promote from. + const promotedIds = new Set(); + if (branchName === null && !detached) { + const groupFiles = new Map(groups.map((g) => [wikiDocId(g.key), g.files])); + for (const p of await promoteMergedOverlays(args.query, args.tableName, args.project, args.git, groupFiles, { agent: args.agent, pluginVersion: args.pluginVersion })) { + promotedIds.add(p.doc_id); + outcomes.push({ doc_id: p.doc_id, action: "promoted", reasons: [`from ${p.fromScope}`] }); + } + } + // A page may be written to the shared cloud only when it is committed-clean + // (content == committed HEAD, so it never documents uncommitted bytes) AND — + // on a branch — its source is already on origin/. Checked at the write + // sites (not for skipped pages). Returns the hold reason, or null if writable. + const holdReason = (files: string[]): string | null => { + if (detached) return "detached HEAD — ambiguous branch identity"; + if (!workingTreeClean(args.git, files)) return "uncommitted changes in member files"; + return null; + }; + // A committed-clean page on a branch not yet on origin is PRIVATE: written to + // the local store, never the shared cloud, until the source is pushed. + const isPrivate = (files: string[]): boolean => + branchName !== null && !sourcePushed(args.git, files, branchName); + const stampPrivate = (doc: { doc_id: string; path: string; content: string; source_fp: string; tier: "fast" | "slow" }): void => + writePrivateDoc(args.project, scope, { ...doc, updated_at: nowFn().toISOString() }); for (const group of groups) { const docId = wikiDocId(group.key); const page = pages.get(docId); + if (promotedIds.has(docId)) continue; // already promoted from a merged overlay + // New subsystem → fresh page. if (!page) { + const hr = holdReason(group.files); + if (hr) { + outcomes.push({ doc_id: docId, action: "held", reasons: [hr] }); + continue; + } + // A brand-new subsystem authored on an unpushed branch is held from the + // cloud (full regeneration writes to the shared table); it publishes once + // the source is pushed. (Private materialization covers UPDATES to existing + // pages via the patch path below.) + if (isPrivate(group.files)) { + outcomes.push({ doc_id: docId, action: "held", reasons: ["new subsystem on an unpushed branch — publishes on push"] }); + pendingPublish++; + continue; + } const res = await regenerate(group); if (res === "skipped") { outcomes.push({ doc_id: docId, action: "skipped", reasons: ["below min size — no page wanted"] }); @@ -301,7 +386,7 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise changed.has(f)); if (!touched && !membershipChanged) continue; - const diff = lastSha === "" ? null : args.git(["diff", `${lastSha}..HEAD`, "--", ...group.files]); + const hr = holdReason(group.files); + if (hr) { + outcomes.push({ doc_id: docId, action: "held", reasons: [hr] }); + continue; + } + + // Whether this page's source is unpushed (→ private, cloud writes forbidden). + // Computed BEFORE the regeneration branches so they never leak private + // content to the shared cloud via `regenerate()` → generateWikiPages/upsertDoc. + const priv = isPrivate(group.files); + + const diff = baseSha === "" ? null : args.git(["diff", `${baseSha}..HEAD`, "--", ...group.files]); if (diff === null && !membershipChanged) { - // No usable diff to patch from → regenerate rather than guess. + // No usable diff to patch from → normally regenerate. But a private page + // must NOT be regenerated to the cloud; hold it until pushed. + if (priv) { + outcomes.push({ doc_id: docId, action: "held", reasons: ["private page needs regeneration — publishes on push"] }); + pendingPublish++; + continue; + } const res = await regenerate(group); outcomes.push({ doc_id: docId, action: res === "failed" ? "failed" : "regenerated", reasons: ["no usable diff"] }); if (res === "failed") failures++; @@ -329,6 +431,8 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise 0) { - log(`${failures} page(s) failed — sha NOT advanced, next turn redoes the window`); + // Commit point: the sha advances ONLY on a fully clean cycle with nothing left + // to publish. Failures OR pending-private pages leave it untouched so the next + // turn redoes the window (idempotent by design) — critically, so a page that + // was private this cycle gets published once its source is pushed (the push + // doesn't move HEAD, so an advanced cursor would strand it forever). + if (failures > 0 || pendingPublish > 0) { + log(`${failures} failed, ${pendingPublish} pending publish — sha NOT advanced, next turn redoes the window`); // Free the lease so the retry does not have to wait out the 30-min TTL; // the untouched sha makes the redo idempotent. await releaseClaim(args.query, args.tableName, args.project, scope, { diff --git a/src/docs/wiki-update.ts b/src/docs/wiki-update.ts index 907d722e..07a39395 100644 --- a/src/docs/wiki-update.ts +++ b/src/docs/wiki-update.ts @@ -24,7 +24,9 @@ import { gateDocEdit } from "./gate.js"; import { unwrapModelOutput } from "./refresh-llm.js"; -import { editDoc } from "./write.js"; +import { editDoc, upsertDoc } from "./write.js"; +import { computeFingerprint, serializeFingerprint } from "./fingerprint.js"; +import { defaultGit } from "./candidates.js"; import { appendFilesIndex, collectWikiAnchors, stripFilesIndex, type RunPromptFn } from "./wiki-generate.js"; import type { DocEmbedder } from "./embed.js"; import type { DocRow, QueryFn } from "./read.js"; @@ -106,6 +108,20 @@ export interface WikiUpdateArgs { embed?: DocEmbedder; agent?: string; pluginVersion?: string; + /** + * The branch identity to WRITE to (`main`, or `b:`). When it differs + * from the resolved `page`'s scope, the patched content is a copy-on-write + * overlay for this branch: it is CREATED at the target scope from the (main) + * base, never overwriting main. When it matches, the row is patched in place. + * Default `main` — legacy in-place behavior. + */ + scope?: string; + /** + * When set, the patched page is written HERE (the local private store) + * instead of the shared cloud table — used for committed-but-unpushed branch + * code, which must never reach the cloud. The caller persists it locally. + */ + privateSink?: (doc: { doc_id: string; path: string; content: string; source_fp: string; tier: "fast" | "slow" }) => void; } export type WikiUpdateOutcome = @@ -176,20 +192,57 @@ export async function updateWikiPage(args: WikiUpdateArgs): Promise null); + const landed = await getDocLatest(query, tableName, input.doc_id, { project: input.project, scope: input.scope }).catch(() => null); if (landed) return { doc_id: landed.doc_id, version: landed.version }; if (attempt === retries) break; } @@ -266,20 +271,25 @@ export async function upsertDoc( // Clear any prior/partial row for this id first, then write exactly one. // The legacy bare-doc_id id is deleted too so pre-scope tables converge // to the namespaced id instead of accumulating a duplicate doc_id row — - // but ONLY within this project: in a shared org table another project - // can legitimately own a legacy row with the same bare doc_id. + // but ONLY within this project AND this scope: in a shared org table + // another project can legitimately own a legacy row with the same bare + // doc_id, and — critically for branch overlays — a SIBLING scope (e.g. + // the canonical `main` row, or another user's branch overlay) for the + // same (project, doc_id) must NOT be deleted when we write our scope. + // Without the scope guard, writing a branch overlay would wipe main. await query( `DELETE FROM "${safe}" WHERE id = '${sqlStr(id)}' ` + - `OR (doc_id = '${sqlStr(input.doc_id)}' AND project = '${sqlStr(input.project ?? "")}')`, + `OR (doc_id = '${sqlStr(input.doc_id)}' AND project = '${sqlStr(input.project ?? "")}' ` + + `AND scope = '${sqlStr(scope)}')`, ); const sql = `INSERT INTO "${safe}" ` + - `(id, doc_id, path, content, anchors, tier, status, project, scope, version, ` + + `(id, doc_id, path, content, anchors, tier, status, project, scope, source_fp, version, ` + `created_at, updated_at, agent, plugin_version, content_embedding) ` + `VALUES (` + `'${sqlStr(id)}', '${sqlStr(input.doc_id)}', '${sqlStr(input.path)}', ` + `E'${sqlStr(input.content)}', E'${sqlStr(anchors)}', '${sqlStr(tier)}', ` + - `'active', '${sqlStr(input.project ?? "")}', '${sqlStr(scope)}', 1, ` + + `'active', '${sqlStr(input.project ?? "")}', '${sqlStr(scope)}', E'${sqlStr(input.source_fp ?? "{}")}', 1, ` + `'${sqlStr(now)}', '${sqlStr(now)}', ` + `'${sqlStr(input.agent ?? "manual")}', '${sqlStr(input.plugin_version ?? "")}', ` + `${embeddingSqlLiteral(input.content_embedding)}` + @@ -306,12 +316,13 @@ export async function editDoc( query: QueryFn, tableName: string, input: EditDocInput, - opts: { project?: string } = {}, + opts: { project?: string; scope?: string } = {}, ): Promise { - // Optional project SELECTOR (distinct from input.project, the value to - // write) — in a shared org table an unscoped read can resolve the same - // doc_id to another project's row. - const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project }); + // Optional project + scope SELECTOR (distinct from input.project, the value + // to write) — in a shared org table an unscoped read can resolve the same + // doc_id to another project's row, or (with branch overlays) to a sibling + // scope's row. Passing scope confines the edit to one identity. + const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project, scope: opts.scope }); if (!previous) { throw new Error(`Doc not found: ${input.doc_id}`); } @@ -332,11 +343,12 @@ export async function setDoc( query: QueryFn, tableName: string, input: SetDocInput, - opts: { project?: string } = {}, + opts: { project?: string; scope?: string } = {}, ): Promise { - // Project SELECTOR (shared-table safety): without it the bare doc_id can - // resolve to another project's row and this write would version-bump THAT. - const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project }); + // Project + scope SELECTOR (shared-table safety): without it the bare doc_id + // can resolve to another project's row — or a sibling branch overlay — and + // this write would version-bump THAT. + const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project, scope: opts.scope }); if (!previous) { return insertDoc(query, tableName, { doc_id: input.doc_id, @@ -374,7 +386,7 @@ export async function archiveDoc( query: QueryFn, tableName: string, input: { doc_id: string; agent?: string; plugin_version?: string }, - opts: { project?: string } = {}, + opts: { project?: string; scope?: string } = {}, ): Promise { return editDoc(query, tableName, { doc_id: input.doc_id, @@ -434,6 +446,9 @@ async function updateInPlace( : next.content !== undefined && next.content !== previous.content ? `content_embedding = NULL, ` : ""}` + + // Fingerprint moves with the content: a patch that lands new bytes stamps + // the new source state so freshness reflects what the page now describes. + `${next.source_fp !== undefined ? `source_fp = E'${sqlStr(next.source_fp)}', ` : ""}` + `version = ${nextVersion}, ` + `updated_at = '${sqlStr(now)}', ` + `agent = '${sqlStr(next.agent ?? "manual")}', ` + diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 71d912e8..e2944503 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -16,6 +16,8 @@ import { type GrepParams, parseBashGrep, handleGrepDirect } from "./grep-direct. import { handleGraphVfs } from "../graph/vfs-handler.js"; import { handleDocsVfs } from "../docs/vfs-handler.js"; import { makeQueryEmbedder } from "../docs/embed.js"; +import { defaultGit } from "../docs/candidates.js"; +import { currentScope } from "../docs/branch-scope.js"; import { executeCompiledBashCommand } from "./bash-command-compiler.js"; import { findVirtualPaths, @@ -460,7 +462,9 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT if (virtualPath && (virtualPath === "/docs" || virtualPath.startsWith("/docs/")) && !virtualPath.endsWith("/")) { const subpath = virtualPath === "/docs" ? "" : virtualPath.slice("/docs/".length); logFn(`docs vfs: ${subpath || "(root)"}`); - const result = await handleDocsVfsFn(subpath, (sql) => api.query(sql), config.docsTableName, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(input.cwd ?? process.cwd()).key }); + const docsCwd = input.cwd ?? process.cwd(); + const docsGit = defaultGit(docsCwd); + const result = await handleDocsVfsFn(subpath, (sql) => api.query(sql), config.docsTableName, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(docsCwd).key, readerScope: currentScope(docsGit), git: docsGit }); const body = result.kind === "ok" ? result.body : `(${result.kind}) ${result.message}`; if (input.tool_name === "Read") { const file_path = writeReadCacheFileFn(input.session_id, virtualPath, body); diff --git a/tests/shared/docs-branch-scope.test.ts b/tests/shared/docs-branch-scope.test.ts new file mode 100644 index 00000000..9f777291 --- /dev/null +++ b/tests/shared/docs-branch-scope.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { + MAIN_SCOPE, + branchScope, + parseScope, + currentBranch, + trunkBranch, + currentScope, + pickByScopePrecedence, + type GitRunner, +} from "../../src/docs/branch-scope.js"; + +/** Build a git runner from a map of joined-args -> stdout (or null). */ +function fakeGit(responses: Record): GitRunner { + return (args: string[]) => { + const key = args.join(" "); + return key in responses ? responses[key] : null; + }; +} + +describe("branchScope / parseScope", () => { + it("encodes and round-trips a branch overlay scope", () => { + expect(branchScope("feature-x")).toBe("b:feature-x"); + expect(parseScope("b:feature-x")).toEqual({ kind: "branch", branch: "feature-x" }); + }); + + it("parses main and unknown/empty as main", () => { + expect(parseScope("main")).toEqual({ kind: "main" }); + expect(parseScope("")).toEqual({ kind: "main" }); + expect(parseScope(undefined)).toEqual({ kind: "main" }); + }); + + it("preserves branch names that contain slashes", () => { + expect(parseScope(branchScope("feat/auth/login"))).toEqual({ kind: "branch", branch: "feat/auth/login" }); + }); +}); + +describe("currentBranch", () => { + it("returns the branch name on a normal checkout", () => { + expect(currentBranch(fakeGit({ "rev-parse --abbrev-ref HEAD": "feature-x\n" }))).toBe("feature-x"); + }); + + it("returns null on a detached HEAD (no branch identity)", () => { + expect(currentBranch(fakeGit({ "rev-parse --abbrev-ref HEAD": "HEAD" }))).toBeNull(); + }); + + it("returns null when git fails (non-git dir)", () => { + expect(currentBranch(fakeGit({}))).toBeNull(); + }); +}); + +describe("trunkBranch", () => { + it("reads the default branch from origin/HEAD", () => { + expect(trunkBranch(fakeGit({ "symbolic-ref --short refs/remotes/origin/HEAD": "origin/master\n" }))).toBe("master"); + }); + + it("falls back to main when origin/HEAD is unset (no remote)", () => { + expect(trunkBranch(fakeGit({}))).toBe("main"); + }); +}); + +describe("currentScope", () => { + it("is main on the trunk branch", () => { + const git = fakeGit({ + "rev-parse --abbrev-ref HEAD": "main", + "symbolic-ref --short refs/remotes/origin/HEAD": "origin/main", + }); + expect(currentScope(git)).toBe(MAIN_SCOPE); + }); + + it("is a branch overlay off the trunk", () => { + const git = fakeGit({ + "rev-parse --abbrev-ref HEAD": "feature-x", + "symbolic-ref --short refs/remotes/origin/HEAD": "origin/main", + }); + expect(currentScope(git)).toBe("b:feature-x"); + }); + + it("treats the repo's real trunk (master) as main even when named differently", () => { + const git = fakeGit({ + "rev-parse --abbrev-ref HEAD": "master", + "symbolic-ref --short refs/remotes/origin/HEAD": "origin/master", + }); + expect(currentScope(git)).toBe(MAIN_SCOPE); + }); + + it("is main on a detached HEAD", () => { + expect(currentScope(fakeGit({ "rev-parse --abbrev-ref HEAD": "HEAD" }))).toBe(MAIN_SCOPE); + }); + + it("accepts an explicit trunk to avoid a second git call", () => { + const git = fakeGit({ "rev-parse --abbrev-ref HEAD": "dev" }); + expect(currentScope(git, "dev")).toBe(MAIN_SCOPE); + expect(currentScope(git, "main")).toBe("b:dev"); + }); +}); + +describe("pickByScopePrecedence", () => { + const row = (scope: string | undefined, version: number, tag: string) => ({ scope, version, tag }); + + it("prefers the reader's branch overlay over main", () => { + const rows = [row("main", 5, "main"), row("b:feat", 2, "overlay")]; + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("overlay"); + }); + + it("falls back to main when the reader's branch has no overlay", () => { + const rows = [row("main", 5, "main"), row("b:other", 9, "other")]; + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("main"); + }); + + it("never surfaces another branch's overlay to a main reader", () => { + const rows = [row("main", 3, "main"), row("b:feat", 99, "overlay")]; + expect(pickByScopePrecedence(rows, "main")?.tag).toBe("main"); + }); + + it("never surfaces a foreign branch overlay to a different branch reader", () => { + const rows = [row("b:alpha", 4, "alpha"), row("b:beta", 7, "beta")]; + // reader on beta sees beta; reader on gamma sees nothing (no main, no gamma) + expect(pickByScopePrecedence(rows, "b:beta")?.tag).toBe("beta"); + expect(pickByScopePrecedence(rows, "b:gamma")).toBeNull(); + }); + + it("within the winning scope, the highest version wins", () => { + const rows = [row("b:feat", 2, "old"), row("b:feat", 4, "new"), row("main", 100, "main")]; + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("new"); + }); + + it("treats a missing scope as main (legacy rows resolve unchanged)", () => { + const rows = [row(undefined, 1, "legacy")]; + expect(pickByScopePrecedence(rows, "main")?.tag).toBe("legacy"); + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("legacy"); // falls back to main + }); + + it("returns null on empty input", () => { + expect(pickByScopePrecedence([], "main")).toBeNull(); + }); +}); diff --git a/tests/shared/docs-fingerprint.test.ts b/tests/shared/docs-fingerprint.test.ts new file mode 100644 index 00000000..d315bfce --- /dev/null +++ b/tests/shared/docs-fingerprint.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + computeFingerprint, + computeFingerprintAt, + sourcePushed, + workingTreeClean, + serializeFingerprint, + parseFingerprint, + changedFiles, + isFresh, +} from "../../src/docs/fingerprint.js"; +import type { GitRunner } from "../../src/docs/branch-scope.js"; + +const gitLsTree = (lines: string | null): GitRunner => (args) => + args[0] === "ls-tree" ? lines : null; + +/** ls-tree runner keyed by ref (args[1]) → its output. */ +const gitByRef = (byRef: Record): GitRunner => (args) => + args[0] === "ls-tree" ? (byRef[args[1]] ?? null) : null; + +describe("computeFingerprint", () => { + it("parses ` blob \\t` lines into a file->sha map", () => { + const out = + "100644 blob aaaa\tpkg/core/a.ts\n" + + "100644 blob bbbb\tpkg/core/b.ts\n"; + expect(computeFingerprint(gitLsTree(out), ["pkg/core/a.ts", "pkg/core/b.ts"])).toEqual({ + "pkg/core/a.ts": "aaaa", + "pkg/core/b.ts": "bbbb", + }); + }); + + it("omits files git can't resolve (deleted/untracked) — they read as changed", () => { + const out = "100644 blob aaaa\tpkg/core/a.ts\n"; // b.ts absent + expect(computeFingerprint(gitLsTree(out), ["pkg/core/a.ts", "pkg/core/b.ts"])).toEqual({ + "pkg/core/a.ts": "aaaa", + }); + }); + + it("ignores non-blob entries (trees/submodules)", () => { + const out = "040000 tree cccc\tpkg/sub\n100644 blob aaaa\tpkg/a.ts\n"; + expect(computeFingerprint(gitLsTree(out), ["pkg/sub", "pkg/a.ts"])).toEqual({ "pkg/a.ts": "aaaa" }); + }); + + it("empty file list makes no git call and returns {}", () => { + let called = false; + const git: GitRunner = () => { called = true; return "x"; }; + expect(computeFingerprint(git, [])).toEqual({}); + expect(called).toBe(false); + }); + + it("no git (null) → empty map (treated as unknown/stale downstream)", () => { + expect(computeFingerprint(gitLsTree(null), ["a.ts"])).toEqual({}); + }); + + it("preserves paths that contain spaces after the tab", () => { + const out = "100644 blob aaaa\tpkg/with space.ts\n"; + expect(computeFingerprint(gitLsTree(out), ["pkg/with space.ts"])).toEqual({ "pkg/with space.ts": "aaaa" }); + }); +}); + +describe("serialize/parse round-trip", () => { + it("serializes with sorted keys and round-trips", () => { + const fp = { "z.ts": "2", "a.ts": "1" }; + const s = serializeFingerprint(fp); + expect(s).toBe(`{"a.ts":"1","z.ts":"2"}`); // sorted + expect(parseFingerprint(s)).toEqual(fp); + }); + it("parses an already-object cell and degrades garbage to {}", () => { + expect(parseFingerprint({ "a.ts": "1", bad: 5 })).toEqual({ "a.ts": "1" }); + expect(parseFingerprint("not json")).toEqual({}); + expect(parseFingerprint("")).toEqual({}); + expect(parseFingerprint(null)).toEqual({}); + expect(parseFingerprint(["a"])).toEqual({}); + }); +}); + +describe("changedFiles / isFresh", () => { + it("detects a changed blob", () => { + expect(changedFiles({ "a.ts": "1" }, { "a.ts": "2" })).toEqual(["a.ts"]); + expect(isFresh({ "a.ts": "1" }, { "a.ts": "2" })).toBe(false); + }); + it("detects added and removed files (membership drift)", () => { + expect(changedFiles({ "a.ts": "1" }, { "a.ts": "1", "b.ts": "9" })).toEqual(["b.ts"]); + expect(changedFiles({ "a.ts": "1", "b.ts": "9" }, { "a.ts": "1" })).toEqual(["b.ts"]); + }); + it("identical fingerprints are fresh (edit-then-revert / rebase to same bytes)", () => { + expect(isFresh({ "a.ts": "1", "b.ts": "2" }, { "b.ts": "2", "a.ts": "1" })).toBe(true); + expect(changedFiles({ "a.ts": "1" }, { "a.ts": "1" })).toEqual([]); + }); +}); + +describe("computeFingerprintAt / sourcePushed (publish gate)", () => { + it("reads a fingerprint at an explicit ref (origin/)", () => { + const git = gitByRef({ "origin/feat": "100644 blob abc\ta.ts\n" }); + expect(computeFingerprintAt(git, "origin/feat", ["a.ts"])).toEqual({ "a.ts": "abc" }); + }); + + it("pushed: HEAD blobs all match origin/", () => { + const git = gitByRef({ + HEAD: "100644 blob abc\ta.ts\n100644 blob def\tb.ts\n", + "origin/feat": "100644 blob abc\ta.ts\n100644 blob def\tb.ts\n", + }); + expect(sourcePushed(git, ["a.ts", "b.ts"], "feat")).toBe(true); + }); + + it("NOT pushed: a file's blob differs on origin (local-only commit)", () => { + const git = gitByRef({ + HEAD: "100644 blob NEW\ta.ts\n", + "origin/feat": "100644 blob OLD\ta.ts\n", + }); + expect(sourcePushed(git, ["a.ts"], "feat")).toBe(false); + }); + + it("NOT pushed: origin/ doesn't have the file at all (branch never pushed)", () => { + const git = gitByRef({ HEAD: "100644 blob abc\ta.ts\n", "origin/feat": null }); + expect(sourcePushed(git, ["a.ts"], "feat")).toBe(false); + }); + + it("empty file set is trivially pushed", () => { + expect(sourcePushed(gitByRef({}), [], "feat")).toBe(true); + }); +}); + +describe("workingTreeClean", () => { + const gitStatus = (out: string | null): GitRunner => (args) => + args[0] === "status" ? out : null; + + it("clean when `git status --porcelain` is empty", () => { + expect(workingTreeClean(gitStatus(""), ["a.ts"])).toBe(true); + }); + it("dirty when a member file is modified", () => { + expect(workingTreeClean(gitStatus(" M a.ts\n"), ["a.ts"])).toBe(false); + }); + it("dirty when a member file is untracked (?? — the leak codex flagged)", () => { + expect(workingTreeClean(gitStatus("?? a.ts\n"), ["a.ts"])).toBe(false); + }); + it("empty file set is clean without a git call", () => { + let called = false; + expect(workingTreeClean(() => { called = true; return "x"; }, [])).toBe(true); + expect(called).toBe(false); + }); + it("no git (null) → clean (non-git repo has no committed notion)", () => { + expect(workingTreeClean(gitStatus(null), ["a.ts"])).toBe(true); + }); +}); diff --git a/tests/shared/docs-private-store.test.ts b/tests/shared/docs-private-store.test.ts new file mode 100644 index 00000000..2afb1aed --- /dev/null +++ b/tests/shared/docs-private-store.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + readPrivateDoc, + writePrivateDoc, + listPrivateDocs, + deletePrivateDoc, + privateStoreRoot, + type PrivateDoc, +} from "../../src/docs/private-store.js"; + +const doc = (over: Partial = {}): PrivateDoc => ({ + doc_id: "wiki/pkg/core", path: "/docs/p/wiki/pkg/core.md", content: "# core\n\nbody", + source_fp: `{"pkg/core/a.ts":"SHA"}`, tier: "slow", updated_at: "t0", ...over, +}); + +describe("private-store", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "priv-store-")); + process.env.HIVEMIND_DOCS_PRIVATE_DIR = dir; + }); + afterEach(() => { + delete process.env.HIVEMIND_DOCS_PRIVATE_DIR; + rmSync(dir, { recursive: true, force: true }); + }); + + it("round-trips a private doc per (project, scope, doc_id)", () => { + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")).toBeNull(); + writePrivateDoc("p", "b:feat", doc({ content: "PRIVATE" })); + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")?.content).toBe("PRIVATE"); + expect(privateStoreRoot()).toBe(dir); + }); + + it("isolates by project and by scope (no cross-branch/-project bleed)", () => { + writePrivateDoc("p", "b:feat", doc({ content: "FEAT" })); + expect(readPrivateDoc("p", "b:other", "wiki/pkg/core")).toBeNull(); // other branch + expect(readPrivateDoc("q", "b:feat", "wiki/pkg/core")).toBeNull(); // other project + }); + + it("upserts (second write replaces) and lists", () => { + writePrivateDoc("p", "b:feat", doc({ doc_id: "wiki/a", content: "A1" })); + writePrivateDoc("p", "b:feat", doc({ doc_id: "wiki/a", content: "A2" })); + writePrivateDoc("p", "b:feat", doc({ doc_id: "wiki/b", content: "B" })); + expect(readPrivateDoc("p", "b:feat", "wiki/a")?.content).toBe("A2"); + expect(listPrivateDocs("p", "b:feat").map((d) => d.doc_id).sort()).toEqual(["wiki/a", "wiki/b"]); + }); + + it("deletes a doc (e.g. after promotion on push)", () => { + writePrivateDoc("p", "b:feat", doc()); + deletePrivateDoc("p", "b:feat", "wiki/pkg/core"); + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")).toBeNull(); + }); + + it("a corrupt store file degrades to empty, never throws", () => { + writePrivateDoc("p", "b:feat", doc()); + const files = readdirSync(dir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + writeFileSync(join(dir, files[0]), "{ not json"); + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")).toBeNull(); + expect(listPrivateDocs("p", "b:feat")).toEqual([]); + }); + + it("distinct scopes with slug-colliding names do NOT share a file (injective)", () => { + writePrivateDoc("p", "b:feat/x", doc({ content: "SLASH" })); + writePrivateDoc("p", "b:feat_x", doc({ content: "UNDERSCORE" })); + expect(readPrivateDoc("p", "b:feat/x", "wiki/pkg/core")?.content).toBe("SLASH"); + expect(readPrivateDoc("p", "b:feat_x", "wiki/pkg/core")?.content).toBe("UNDERSCORE"); + }); +}); diff --git a/tests/shared/docs-promote.test.ts b/tests/shared/docs-promote.test.ts new file mode 100644 index 00000000..af1d5613 --- /dev/null +++ b/tests/shared/docs-promote.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/docs/stable-read.js", () => ({ + stableUnionRows: (q: (sql: string) => unknown, sql: string) => q(sql), +})); + +import { planPromotions, promoteMergedOverlays, type PromoteRow } from "../../src/docs/promote.js"; +import { appendFilesIndex } from "../../src/docs/wiki-generate.js"; +import type { GitRunner } from "../../src/docs/branch-scope.js"; + +const FILES = ["pkg/core/a.ts"]; +const body = (n: string) => appendFilesIndex(`## Purpose\n${n}`, FILES); +const GROUP_FILES = new Map([["wiki/pkg/core", FILES]]); + +// HEAD has a.ts at blob SHA_MAIN. +const git: GitRunner = (args) => + args[0] === "ls-tree" && args[1] === "HEAD" ? "100644 blob SHA_MAIN\tpkg/core/a.ts\n" : null; + +function row(scope: string, source_fp: Record, content = body(scope)): PromoteRow { + return { doc_id: "wiki/pkg/core", path: "/docs/p/wiki/pkg/core.md", content, tier: "slow", scope, source_fp: JSON.stringify(source_fp) }; +} + +describe("planPromotions", () => { + it("promotes an overlay whose fingerprint equals main's current source", () => { + const rows = [ + row("main", { "pkg/core/a.ts": "SHA_OLD" }), // main is stale + row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" }), // overlay == what main now has + ]; + const plans = planPromotions(rows, git, GROUP_FILES); + expect(plans).toHaveLength(1); + expect(plans[0]).toMatchObject({ doc_id: "wiki/pkg/core", fromScope: "b:feat", mainFp: `{"pkg/core/a.ts":"SHA_MAIN"}` }); + }); + + it("does NOT promote an overlay whose fingerprint differs from main (merge combined changes)", () => { + const rows = [ + row("main", { "pkg/core/a.ts": "SHA_MAIN" }), + row("b:feat", { "pkg/core/a.ts": "SHA_BRANCH" }), // not what main has → regenerate, not promote + ]; + expect(planPromotions(rows, git, GROUP_FILES)).toHaveLength(0); + }); + + it("ignores non-wiki docs and pages with no overlay", () => { + const rows: PromoteRow[] = [ + { doc_id: "src/a.ts", path: "/p", content: "x", tier: "fast", scope: "b:feat", source_fp: `{"src/a.ts":"SHA_MAIN"}` }, + row("main", { "pkg/core/a.ts": "SHA_MAIN" }), // only main, no overlay + ]; + expect(planPromotions(rows, git, GROUP_FILES)).toHaveLength(0); + }); + + it("promotes even when main has no row yet (branch created a brand-new page now merged)", () => { + const plans = planPromotions([row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })], git, GROUP_FILES); + expect(plans).toHaveLength(1); + }); + + it("does NOT promote when the overlay's membership differs from the current group (codex #6)", () => { + // Overlay documents [a.ts] (its ## Files), but the group now has [a.ts, b.ts] + // (a member joined via the merge). Promoting would carry a stale file set → + // skip promotion so the normal refresh regenerates with the new membership. + const twoFileGroup = new Map([["wiki/pkg/core", ["pkg/core/a.ts", "pkg/core/b.ts"]]]); + const gitTwo: GitRunner = (args) => + args[0] === "ls-tree" && args[1] === "HEAD" + ? "100644 blob SHA_MAIN\tpkg/core/a.ts\n100644 blob SHA_B\tpkg/core/b.ts\n" + : null; + const rows = [row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })]; // overlay ## Files = [a.ts] only + expect(planPromotions(rows, gitTwo, twoFileGroup)).toHaveLength(0); + }); + + it("skips a page whose group no longer exists", () => { + expect(planPromotions([row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })], git, new Map())).toHaveLength(0); + }); +}); + +describe("promoteMergedOverlays", () => { + it("upserts the overlay content at main, then archives the overlay", async () => { + const calls: string[] = []; + // Full DocRow shape for editDoc's getDocLatest read (needs version etc.). + const overlayFull = { + id: "o", doc_id: "wiki/pkg/core", path: "/docs/p/wiki/pkg/core.md", content: body("b:feat"), + anchors: "[]", tier: "slow", status: "active", project: "p", scope: "b:feat", + source_fp: `{"pkg/core/a.ts":"SHA_MAIN"}`, version: 1, + created_at: "t0", updated_at: "t0", agent: "m", plugin_version: "0", + }; + const query = vi.fn(async (sql: string) => { + calls.push(sql); + const s = sql.trim(); + if (/^SELECT/i.test(s) && s.includes("status = 'active'")) { + // the promote scan — both scopes + return [ + { id: "m", ...row("main", { "pkg/core/a.ts": "SHA_OLD" }) }, + { id: "o", ...row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" }) }, + ]; + } + if (/^SELECT/i.test(s) && s.includes("scope = 'b:feat'")) { + return [overlayFull]; // editDoc's scoped read of the overlay + } + return []; + }); + const out = await promoteMergedOverlays(query, "hivemind_docs", "p", git, GROUP_FILES); + expect(out).toEqual([{ doc_id: "wiki/pkg/core", fromScope: "b:feat", action: "promoted" }]); + + // The promoted row is written at scope main with main's fingerprint... + const insert = calls.find((c) => /^INSERT/i.test(c) && c.includes("'p|main|wiki/pkg/core'")); + expect(insert).toBeTruthy(); + expect(insert).toContain(`{"pkg/core/a.ts":"SHA_MAIN"}`); + // ...and the overlay row is archived (an UPDATE to status archived on b:feat). + const archive = calls.find((c) => /^UPDATE/i.test(c) && c.includes("'archived'")); + expect(archive).toBeTruthy(); + }); +}); diff --git a/tests/shared/docs-vfs-handler.test.ts b/tests/shared/docs-vfs-handler.test.ts index 0625c0aa..e2fa0fd4 100644 --- a/tests/shared/docs-vfs-handler.test.ts +++ b/tests/shared/docs-vfs-handler.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; // Pass-through the read-stability gate so SQL shapes stay exact (see docs.test.ts). vi.mock("../../src/docs/stable-read.js", () => ({ @@ -73,6 +76,55 @@ describe("handleDocsVfs", () => { expect(calls.some((c) => c.includes("WHERE doc_id = 'src/graph/diff.ts'"))).toBe(true); }); + it("prepends a staleness banner when a member file's fingerprint drifted from HEAD", async () => { + const { query } = router([], [], [ + fullRow("src/graph/diff.ts", "# diff\n\nBody.", { source_fp: JSON.stringify({ "src/graph/diff.ts": "oldsha" }) }), + ]); + const git = (args: string[]) => (args[0] === "ls-tree" ? "100644 blob newsha\tsrc/graph/diff.ts\n" : null); + const r = await handleDocsVfs("src/graph/diff.ts.md", query, TBL, { readerScope: "main", git }); + expect(r.kind).toBe("ok"); + if (r.kind === "ok") { + expect(r.body).toContain("This page may be stale"); + expect(r.body).toContain("src/graph/diff.ts"); // names the changed file + expect(r.body).toContain("Body."); // still serves the content + } + }); + + it("no banner when the fingerprint still matches HEAD", async () => { + const { query } = router([], [], [ + fullRow("src/graph/diff.ts", "# diff\n\nBody.", { source_fp: JSON.stringify({ "src/graph/diff.ts": "samesha" }) }), + ]); + const git = (args: string[]) => (args[0] === "ls-tree" ? "100644 blob samesha\tsrc/graph/diff.ts\n" : null); + const r = await handleDocsVfs("src/graph/diff.ts.md", query, TBL, { readerScope: "main", git }); + expect(r.kind).toBe("ok"); + if (r.kind === "ok") expect(r.body).not.toContain("may be stale"); + }); + + it("serves THIS machine's private branch doc ahead of the cloud (and marks it private)", async () => { + const privDir = mkdtempSync(join(tmpdir(), "vfs-priv-")); + process.env.HIVEMIND_DOCS_PRIVATE_DIR = privDir; + try { + const { writePrivateDoc } = await import("../../src/docs/private-store.js"); + writePrivateDoc("p", "b:feat", { + doc_id: "src/graph/diff.ts", path: "/docs/p/src/graph/diff.ts.md", + content: "# private\n\nMy unpushed doc.", source_fp: `{"src/graph/diff.ts":"h"}`, tier: "fast", updated_at: "t1", + }); + // Cloud has a main row; the private one must win for a b:feat reader. + const { query } = router([], [], [fullRow("src/graph/diff.ts", "# cloud main", { scope: "main" })]); + const git = (a: string[]) => (a[0] === "ls-tree" ? "100644 blob h\tsrc/graph/diff.ts\n" : null); + const r = await handleDocsVfs("src/graph/diff.ts.md", query, TBL, { project: "p", readerScope: "b:feat", git }); + expect(r.kind).toBe("ok"); + if (r.kind === "ok") { + expect(r.body).toContain("My unpushed doc."); + expect(r.body).toContain("visibility: private"); + expect(r.body).not.toContain("cloud main"); + } + } finally { + delete process.env.HIVEMIND_DOCS_PRIVATE_DIR; + rmSync(privDir, { recursive: true, force: true }); + } + }); + it("returns not-found for a leaf whose doc does not exist", async () => { const { query } = router([], [], []); const r = await handleDocsVfs("src/graph/nope.ts.md", query, TBL); diff --git a/tests/shared/docs-wiki-freshness-hint.test.ts b/tests/shared/docs-wiki-freshness-hint.test.ts new file mode 100644 index 00000000..3c3a5ae3 --- /dev/null +++ b/tests/shared/docs-wiki-freshness-hint.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { wikiFreshnessHint } from "../../src/commands/docs.js"; + +// The hint is the passive fallback shown after `docs wiki` when the user +// declines (or can't be asked about) auto refresh. Each of the four +// (autoEnabled, hookInstalled) states must yield the RIGHT message — the bug +// this guards is "correct text for the wrong state", so we assert both what +// each message says and what it must NOT say. +describe("wikiFreshnessHint", () => { + it("stays silent when fully wired (auto on + hook)", () => { + expect(wikiFreshnessHint({ autoEnabled: true, hookInstalled: true, subject: "This wiki" })).toBeNull(); + }); + + it("auto ON, no hook → offers per-commit upgrade, never claims it's stale", () => { + const h = wikiFreshnessHint({ autoEnabled: true, hookInstalled: false, subject: "This wiki" }); + expect(h).not.toBeNull(); + expect(h!).toContain("graph init"); + expect(h!).not.toMatch(/NOT stay fresh/); + expect(h!).not.toContain("docs auto on"); + }); + + it("auto OFF, hook present → warns it won't refresh, names docs auto on, NOT graph init", () => { + const h = wikiFreshnessHint({ autoEnabled: false, hookInstalled: true, subject: "This wiki" }); + expect(h).not.toBeNull(); + expect(h!).toMatch(/NOT stay fresh/); + expect(h!).toContain("docs auto on"); + expect(h!).not.toContain("graph init"); + }); + + it("auto OFF, no hook → names BOTH remedies", () => { + const h = wikiFreshnessHint({ autoEnabled: false, hookInstalled: false, subject: "This wiki" }); + expect(h).not.toBeNull(); + expect(h!).toMatch(/NOT stay fresh/); + expect(h!).toContain("docs auto on"); + expect(h!).toContain("graph init"); + }); + + it("subject drives the noun — per-file path must not say 'wiki'", () => { + const wiki = wikiFreshnessHint({ autoEnabled: false, hookInstalled: false, subject: "This wiki" }); + const perFile = wikiFreshnessHint({ autoEnabled: false, hookInstalled: false, subject: "These docs" }); + expect(wiki!).toContain("This wiki will NOT stay fresh"); + expect(perFile!).toContain("These docs will NOT stay fresh"); + expect(perFile!).not.toContain("wiki"); + }); +}); diff --git a/tests/shared/docs-wiki-refresh.test.ts b/tests/shared/docs-wiki-refresh.test.ts index 25678a5e..974363c7 100644 --- a/tests/shared/docs-wiki-refresh.test.ts +++ b/tests/shared/docs-wiki-refresh.test.ts @@ -11,6 +11,7 @@ vi.mock("../../src/docs/stable-read.js", () => ({ import { runWikiRefreshCycle, runLocalWikiRefresh, DEFAULT_MIN_PERIOD_MS, type WikiRefreshArgs } from "../../src/docs/wiki-refresh.js"; import { appendFilesIndex, collectWikiAnchors } from "../../src/docs/wiki-generate.js"; import { docRowId } from "../../src/docs/write.js"; +import { readPrivateDoc } from "../../src/docs/private-store.js"; import type { GitRunner } from "../../src/docs/candidates.js"; import type { GraphNode, GraphSnapshot } from "../../src/graph/types.js"; @@ -33,14 +34,14 @@ function snap(nodes: GraphNode[]): GraphSnapshot { * the modules under test. SELECTs are answered from state; DELETE/INSERT and * UPDATE mutate the meta row so lease semantics run for real. */ -function makeBackend(opts: { meta?: Record | null; metaUpdatedAt?: string; pages?: Array> }) { +function makeBackend(opts: { meta?: Record | null; metaUpdatedAt?: string; pages?: Array>; scope?: string }) { const state = { meta: opts.meta === undefined ? null : opts.meta, metaUpdatedAt: opts.metaUpdatedAt ?? "2026-07-08T00:00:00.000Z", pages: opts.pages ?? [], }; const calls: string[] = []; - const metaId = docRowId(P, "main", "_meta"); + const metaId = docRowId(P, opts.scope ?? "main", "_meta"); const query = vi.fn(async (sql: string) => { calls.push(sql); const s = sql.trim(); @@ -108,6 +109,173 @@ describe("runWikiRefreshCycle", () => { ...extra, }); + it("branch first cycle seeds the window from the merge-base with trunk (not full-candidate)", async () => { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: "", claimed_by: null, claimed_at: null, patch_counts: {} }, // fresh branch cursor + pages: [ + pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo."), + pageRow("pkg/io", ["pkg/io/b.ts"], "## Purpose\nbar."), + ], + }); + const MB = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee"; + const gitCalls: string[] = []; + const git: GitRunner = (args) => { + gitCalls.push(args.join(" ")); + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "merge-base") return `${MB}\n`; + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; // only core changed vs MB + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const report = await runWikiRefreshCycle( + baseArgs(backend, git, { scope: "b:feat", force: true, run: async () => "## Purpose\nfoo v2." }), + ); + // The window is seeded from the merge-base, not treated as full-candidate. + expect(gitCalls).toContain("merge-base HEAD origin/main"); + expect(gitCalls).toContain(`diff --name-only ${MB}..HEAD`); + // Only the branch-changed page is a candidate; the untouched page is skipped free. + const acted = report.outcomes.map((o) => o.doc_id); + expect(acted).toContain("wiki/pkg/core"); + expect(acted).not.toContain("wiki/pkg/io"); + }); + + it("private: on a branch, a committed-clean UNPUSHED page is written to the local store, not the cloud", async () => { + const privDir = mkdtempSync(join(tmpdir(), "wref-priv-")); + process.env.HIVEMIND_DOCS_PRIVATE_DIR = privDir; + try { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const git: GitRunner = (args) => { + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; // committed-clean + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "ls-tree" && args[1] === "HEAD") return "100644 blob NEW\tpkg/core/a.ts\n"; // local commit + if (args[0] === "ls-tree" && args[1] === "origin/feat") return null; // never pushed + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const oneGroup = snap([node("pkg/core/a.ts:foo:function", "pkg/core/a.ts")]); + const report = await runWikiRefreshCycle( + baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, run: async () => "## Purpose\nfoo v2 (private)." }), + ); + expect(report.outcomes).toEqual([{ doc_id: "wiki/pkg/core", action: "patched" }]); + // No cloud write for the page ROW (the _meta lease row references the + // doc_id in its patch_counts JSON, so filter on the page's namespaced id). + const pageRowId = docRowId(P, "b:feat", "wiki/pkg/core"); + const pageWrites = backend.calls.filter((c) => /^(INSERT|UPDATE)/i.test(c) && c.includes(pageRowId)); + expect(pageWrites).toHaveLength(0); + // The private doc landed in the local store for (project, branch). + const priv = readPrivateDoc(P, "b:feat", "wiki/pkg/core"); + expect(priv?.content).toContain("foo v2 (private)"); + // Cursor must NOT advance while a page is pending publish — else after push + // (HEAD unchanged) the next cycle short-circuits and never publishes it. + expect(report.status).toBe("incomplete"); + expect((backend.state.meta as { last_refresh_sha: string }).last_refresh_sha).toBe(PREV); + } finally { + delete process.env.HIVEMIND_DOCS_PRIVATE_DIR; + rmSync(privDir, { recursive: true, force: true }); + } + }); + + it("private page with no usable diff is HELD, never regenerated to the cloud (codex r3 leak)", async () => { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const regenerate = vi.fn(async () => "created" as const); // the CLOUD write seam + const git: GitRunner = (args) => { + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; // clean + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "diff") return null; // per-group diff unavailable → would regenerate + if (args[0] === "ls-tree" && args[1] === "HEAD") return "100644 blob NEW\tpkg/core/a.ts\n"; + if (args[0] === "ls-tree" && args[1] === "origin/feat") return null; // unpushed + return null; + }; + const oneGroup = snap([node("pkg/core/a.ts:foo:function", "pkg/core/a.ts")]); + const report = await runWikiRefreshCycle(baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, regenerate, run: async () => "x" })); + expect(regenerate).not.toHaveBeenCalled(); // NEVER regenerate a private page to cloud + expect(report.outcomes[0].action).toBe("held"); + expect(report.status).toBe("incomplete"); // pending publish → cursor not advanced + }); + + // The promotion read has a distinctive column list; its presence in the + // captured SQL tells us whether the promotion block ran. + const PROMOTE_SELECT = "SELECT id, doc_id, path, content, tier, scope, source_fp"; + + it("detached HEAD never promotes overlays into main (CodeRabbit Major)", async () => { + const backend = makeBackend({ + scope: "main", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const git: GitRunner = (args) => { + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") return "HEAD\n"; // DETACHED + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "ls-tree") return "100644 blob X\tpkg/core/a.ts\n"; + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const report = await runWikiRefreshCycle(baseArgs(backend, git, { scope: "main" })); + // The promotion block must be skipped entirely on a detached HEAD. + expect(backend.calls.some((c) => c.includes(PROMOTE_SELECT))).toBe(false); + expect(report.outcomes.every((o) => o.action !== "promoted")).toBe(true); + }); + + it("trunk (not detached) DOES run the promotion block (control for the detached guard)", async () => { + const backend = makeBackend({ + scope: "main", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const git: GitRunner = (args) => { + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") return "main\n"; // ON TRUNK + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "ls-tree") return "100644 blob X\tpkg/core/a.ts\n"; + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + await runWikiRefreshCycle(baseArgs(backend, git, { scope: "main" })); + expect(backend.calls.some((c) => c.includes(PROMOTE_SELECT))).toBe(true); + }); + + it("dirty working tree is held (never documents uncommitted bytes)", async () => { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + let ran = false; + const git: GitRunner = (args) => { + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return " M pkg/core/a.ts\n"; // DIRTY + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const oneGroup = snap([node("pkg/core/a.ts:foo:function", "pkg/core/a.ts")]); + const report = await runWikiRefreshCycle( + baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, run: async () => { ran = true; return "x"; } }), + ); + expect(report.outcomes).toEqual([{ doc_id: "wiki/pkg/core", action: "held", reasons: ["uncommitted changes in member files"] }]); + expect(ran).toBe(false); + }); + it("HEAD == last_refresh_sha → up-to-date, no lease taken, no LLM", async () => { const backend = makeBackend({ meta: { last_refresh_sha: HEAD, claimed_by: null, claimed_at: null, patch_counts: {} } }); const run = vi.fn(async () => "NO_CHANGE"); diff --git a/tests/shared/docs-wiki-update.test.ts b/tests/shared/docs-wiki-update.test.ts index bf7b076b..86610fa0 100644 --- a/tests/shared/docs-wiki-update.test.ts +++ b/tests/shared/docs-wiki-update.test.ts @@ -138,6 +138,27 @@ describe("updateWikiPage", () => { expect(calls.filter((c) => /^UPDATE/i.test(c))).toHaveLength(1); }); + it("on a branch, patches a main-based page as a copy-on-write overlay (never UPDATEs main)", async () => { + // page resolved to scope 'main' (the base), but we write to 'b:feat'. The + // write must CREATE the overlay via upsert (DELETE+INSERT at the overlay id), + // and must not UPDATE the main row. + const p = freshPage("## Purpose\nfoo returns 0."); + const { calls, query } = mockQuery([]); + const out = await updateWikiPage({ + query, tableName: "hivemind_docs", page: p, scope: "b:feat", pageKey: "pkg/core", + files: FILES, snap: SNAP(), repoRoot: dir, diff: "- return 0\n+ return 1", + run: async () => "## Purpose\nfoo returns 1.", escalation: noEscalation, + }); + expect(out.action).toBe("patched"); + expect(calls.some((c) => /^UPDATE/i.test(c))).toBe(false); // main untouched + const insert = calls.find((c) => /^INSERT/i.test(c))!; + expect(insert).toContain("'p|b:feat|wiki/pkg/core'"); // overlay row id + expect(insert).toContain("foo returns 1."); + const del = calls.find((c) => /^DELETE/i.test(c))!; + expect(del).toContain("scope = 'b:feat'"); // delete confined to the overlay scope + expect(del).not.toContain("scope = 'main'"); // main row is safe + }); + it("strips a chatty preamble before the first heading — caught live in the tick e2e", async () => { const p = freshPage("## Purpose\nfoo returns 0."); const { calls, query } = mockQuery([[rowFor(p)]]); diff --git a/tests/shared/docs.test.ts b/tests/shared/docs.test.ts index 8e67a84f..5c999a6a 100644 --- a/tests/shared/docs.test.ts +++ b/tests/shared/docs.test.ts @@ -251,18 +251,36 @@ describe("upsertDoc", () => { expect(calls).toHaveLength(2); // The DELETE clears the namespaced id AND the legacy bare-doc_id row, so // pre-scope tables converge instead of accumulating a duplicate doc_id. - // The legacy clause is constrained to THIS project — another project's - // legacy row with the same bare doc_id must survive in a shared table. - // The second clause sweeps by doc_id+project: it converges BOTH legacy - // bare-id rows AND setDoc's random-UUID rows onto the composite id, - // without ever touching another project's rows. + // The legacy clause is constrained to THIS project AND THIS scope — another + // project's legacy row, or a SIBLING scope (a branch overlay / the canonical + // main row) with the same bare doc_id, must survive in a shared table. expect(calls[0]).toBe( - `DELETE FROM "hivemind_docs" WHERE id = 'p|main|src/a.ts' OR (doc_id = 'src/a.ts' AND project = 'p')`, + `DELETE FROM "hivemind_docs" WHERE id = 'p|main|src/a.ts' OR (doc_id = 'src/a.ts' AND project = 'p' AND scope = 'main')`, ); expect(calls[1]).toMatch(/^INSERT INTO "hivemind_docs"/); // id column is the deterministic composite, NOT a random uuid expect(calls[1]).toContain(`'p|main|src/a.ts', 'src/a.ts',`); - expect(calls[1]).toContain(`'p', 'main', 1, `); // project, scope, always version 1 + expect(calls[1]).toContain(`'p', 'main', E'{}', 1, `); // project, scope, source_fp, version 1 + }); + + it("writing a branch overlay scopes the DELETE — never touches the main row", async () => { + // The regression that motivated the scope guard: a branch/user overlay + // (scope = u:|b:) shares (project, doc_id) with the canonical + // main row. Without the scope guard, the legacy convergence clause would + // delete main when writing the overlay, silently destroying the shared doc. + const { calls, query } = mockQuery([() => [], () => []]); + await upsertDoc(query, TBL, { + doc_id: "src/a.ts", path: "/docs/p/a.ts.md", content: "x", project: "p", + scope: "u:alice|b:feature", + }); + // DELETE targets ONLY this overlay's id + same-scope duplicates. The main + // row (scope='main') is not in the predicate, so it survives. + expect(calls[0]).toBe( + `DELETE FROM "hivemind_docs" WHERE id = 'p|u:alice|b:feature|src/a.ts' ` + + `OR (doc_id = 'src/a.ts' AND project = 'p' AND scope = 'u:alice|b:feature')`, + ); + expect(calls[0]).not.toContain("scope = 'main'"); + expect(calls[1]).toContain(`'p', 'u:alice|b:feature', E'{}', 1, `); // project, overlay scope, source_fp }); it("retry after a timeout re-runs DELETE+INSERT — never forks a second row", async () => { @@ -496,6 +514,26 @@ describe("listDocs", () => { expect(rows.map(r => r.doc_id).sort()).toEqual(["A", "B"]); }); + it("readerScope resolves each doc to the reader's overlay, else main (branch view)", async () => { + const { calls, query } = mockQuery([ + () => [ + // page A: main + a b:feat overlay -> reader on b:feat sees the overlay + fakeRow({ id: "am", doc_id: "A", version: 9, content: "A main", scope: "main" }), + fakeRow({ id: "ao", doc_id: "A", version: 1, content: "A overlay", scope: "b:feat" }), + // page B: only main -> falls back to main + fakeRow({ id: "bm", doc_id: "B", version: 1, content: "B main", scope: "main" }), + // page C: only a FOREIGN branch overlay -> hidden (no row surfaces) + fakeRow({ id: "co", doc_id: "C", version: 1, content: "C other", scope: "b:other" }), + ], + ]); + const rows = await listDocs(query, TBL, { readerScope: "b:feat" }); + const byId = new Map(rows.map(r => [r.doc_id, r.content])); + expect(byId.get("A")).toBe("A overlay"); + expect(byId.get("B")).toBe("B main"); + expect(byId.has("C")).toBe(false); // foreign overlay never surfaces + expect(calls[0]).toContain(", scope, source_fp FROM"); // scope column selected in this mode + }); + it("union order cannot resurrect a stale version: v1 seen FIRST, v2 still wins", async () => { // stableUnionRows returns first-seen order across re-reads — the SQL // ORDER BY does not survive it. The latest pick must be by comparison. @@ -607,6 +645,54 @@ describe("getDocLatest", () => { await getDocLatest(query, TBL, "X", { project: "p2" }); expect(calls[0]).toContain(`doc_id = 'X' AND project = 'p2'`); }); + + it("optional scope selector confines resolution to one identity (branch overlay safety)", async () => { + // With branch overlays, the same (project, doc_id) exists at scope 'main' + // AND at 'b:'. A write-resolution read must pin the scope so editing + // an overlay never resolves (and then version-bumps) the main row. + const { calls, query } = mockQuery([() => []]); + await getDocLatest(query, TBL, "X", { project: "p2", scope: "b:feat" }); + expect(calls[0]).toContain(`doc_id = 'X' AND project = 'p2' AND scope = 'b:feat'`); + }); + + it("readerScope selects the scope column and returns the reader's overlay over main", async () => { + // Read-side precedence: the SELECT must include `scope` (not filter by it — + // all candidates are fetched), and the reader on b:feat gets the overlay + // even though main has a higher version. + const { calls, query } = mockQuery([() => [ + fakeRow({ id: "m", doc_id: "X", version: 7, content: "MAIN", scope: "main" }), + fakeRow({ id: "o", doc_id: "X", version: 1, content: "OVERLAY", scope: "b:feat" }), + ]]); + const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); + expect(calls[0]).toContain(", scope, source_fp FROM"); // scope column is selected + expect(calls[0]).not.toContain("AND scope ="); // NOT filtered — all scopes fetched + expect(row?.content).toBe("OVERLAY"); + }); + + it("readerScope falls back to main when the reader's branch has no overlay", async () => { + const { query } = mockQuery([() => [ + fakeRow({ id: "m", doc_id: "X", version: 3, content: "MAIN", scope: "main" }), + fakeRow({ id: "o", doc_id: "X", version: 9, content: "OTHER", scope: "b:other" }), + ]]); + const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); + expect(row?.content).toBe("MAIN"); // never another branch's overlay + }); + + it("readerScope degrades to a scope-less read when the column is missing", async () => { + // First SELECT (with scope) throws 'column does not exist'; the catch retries + // the scope-less SELECT, and every row reads as main. + let call = 0; + const calls: string[] = []; + const query = vi.fn(async (sql: string) => { + calls.push(sql); + if (call++ === 0 && sql.includes(", scope, source_fp FROM")) throw new Error(`column "scope" does not exist`); + return [fakeRow({ doc_id: "X", version: 2, content: "LEGACY" })]; + }); + const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); + expect(row?.content).toBe("LEGACY"); + expect(calls[0]).toContain(", scope, source_fp FROM"); + expect(calls[1]).not.toContain(", scope, source_fp FROM"); + }); }); // ── listDocMeta (light index read — no content) ────────────────────────────── @@ -649,6 +735,18 @@ describe("listDocMeta", () => { // ── listDocsByIds (filtered read for index summaries + the scale path) ──────── describe("listDocsByIds", () => { + it("readerScope resolves per doc_id by branch precedence, never a foreign overlay", async () => { + const { query } = mockQuery([() => [ + fakeRow({ id: "am", doc_id: "a.ts", version: 9, content: "a main", scope: "main" }), + fakeRow({ id: "ao", doc_id: "a.ts", version: 1, content: "a overlay", scope: "b:feat" }), + fakeRow({ id: "bx", doc_id: "b.ts", version: 5, content: "b other", scope: "b:other" }), // foreign only + ]]); + const rows = await listDocsByIds(query, TBL, ["a.ts", "b.ts"], { readerScope: "b:feat" }); + const byId = new Map(rows.map((r) => [r.doc_id, r.content])); + expect(byId.get("a.ts")).toBe("a overlay"); // reader's overlay wins over main + expect(byId.has("b.ts")).toBe(false); // foreign overlay hidden + }); + it("builds a de-duplicated IN list and returns latest-per-doc", async () => { const { calls, query } = mockQuery([ () => [