From a050657233078cbe7c454a663bc89f30ff1a7bc8 Mon Sep 17 00:00:00 2001 From: mavaali <40620108+mavaali@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:52:40 -0700 Subject: [PATCH 1/2] fix(okf): preflight imports and report incomplete operations --- src/okf/import.ts | 299 ++++++++++++++++++++++++------- src/okf/index.ts | 5 + test/okf/import-failures.test.ts | 128 +++++++++++++ test/okf/import.test.ts | 138 +++++++++++++- 4 files changed, 500 insertions(+), 70 deletions(-) create mode 100644 test/okf/import-failures.test.ts diff --git a/src/okf/import.ts b/src/okf/import.ts index e5704a6e..8162c5ba 100644 --- a/src/okf/import.ts +++ b/src/okf/import.ts @@ -9,15 +9,16 @@ // git is Daftari's version layer — and the SQLite index is rebuilt so search // sees the new docs immediately. `--dry-run` reports the plan and writes nothing. -import { mkdir, writeFile } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; +import { lstatSync, realpathSync } from "node:fs"; +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; import { parseDocument } from "../frontmatter/parser.js"; import { validateFrontmatter } from "../frontmatter/schema.js"; import { err, ok, type Result } from "../frontmatter/types.js"; import { reindexVault } from "../search/reindex.js"; -import { listFiles, readFile } from "../storage/local.js"; +import { directoryExists, listFiles, readFile, resolveVaultPath } from "../storage/local.js"; import { serializeDocument } from "../tools/write.js"; -import { commit } from "../utils/git.js"; +import { catFileBlob, commit } from "../utils/git.js"; import { hasDaftariSidecar, isAttestedComputation, okfToDaftari } from "./map.js"; import { OKF_RESERVED_FILES } from "./types.js"; @@ -51,7 +52,49 @@ export interface ImportResult { const DEFAULT_IMPORT_AGENT = "agent:okf-import"; function isReserved(relPath: string): boolean { - return (OKF_RESERVED_FILES as readonly string[]).includes(basename(relPath)); + return (OKF_RESERVED_FILES as readonly string[]).includes(relPath); +} + +// Check canonical targets too: an otherwise ordinary filename may be an alias +// into a control directory. Reuse storage's physical confinement boundary. +function importPath(root: string, relPath: string) { + const resolved = resolveVaultPath(root, relPath); + if (!resolved.ok) return resolved; + // A dangling link is not a missing output: writes would follow its target. + // Check each existing component, including not-yet-created descendants. + let component = resolve(root); + for (const part of relative(component, resolved.value.absPath).split("/")) { + component = join(component, part); + try { + const entry = lstatSync(component); + if (entry.isSymbolicLink()) { + try { + realpathSync(component); + } catch { + return err(new Error(`unresolvable import path: ${relPath}`)); + } + } + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + return err(new Error(`cannot inspect import path: ${relPath}`)); + } + } + } + const parts = resolved.value.relPath.split("/"); + if ( + !resolved.value.relPath.endsWith(".md") || + parts.some((part) => part.startsWith(".") || part === "node_modules") + ) { + return err(new Error(`not an importable document path: ${relPath}`)); + } + return resolved; +} + +interface PreparedDocument { + relPath: string; + canonicalPath: string; + text: string; + unchanged: boolean; } export async function importBundle( @@ -59,6 +102,9 @@ export async function importBundle( vaultRoot: string, options: ImportOptions = {}, ): Promise> { + if (!(await directoryExists(bundleDir)) || !(await directoryExists(vaultRoot))) { + return err(new Error("import requires existing bundle and vault directories")); + } const listed = await listFiles(bundleDir); if (!listed.ok) return err(listed.error); @@ -69,54 +115,95 @@ export async function importBundle( const warnings: string[] = []; const plan: ImportPlanItem[] = []; const writtenPaths: string[] = []; - let skipped = 0; + const skipped = 0; + const prepared: PreparedDocument[] = []; + const destinations = new Set(); - for (const relPath of listed.value) { - if (isReserved(relPath)) continue; // structural, not a concept doc + // Complete the read/validate/serialize plan before creating directories, + // writing documents, initializing Git, or opening the index. + try { + for (const relPath of listed.value) { + if (isReserved(relPath)) continue; // structural, not a concept doc - const raw = await readFile(join(bundleDir, relPath)); - if (!raw.ok) { - warnings.push(`could not read ${relPath}: ${raw.error.message}`); - skipped++; - continue; - } + const source = importPath(bundleDir, relPath); + if (!source.ok) return err(new Error(`invalid import source: ${source.error.message}`)); + if (!(await stat(source.value.absPath)).isFile()) { + return err(new Error(`import source is not a regular file: ${relPath}`)); + } + const target = importPath(vaultRoot, relPath); + if (!target.ok) return err(new Error(`invalid import destination: ${target.error.message}`)); + if (destinations.has(target.value.relPath)) { + return err(new Error(`duplicate import destination: ${relPath}`)); + } + destinations.add(target.value.relPath); + let existing: string | undefined; + try { + if (!(await stat(target.value.absPath)).isFile()) { + return err(new Error(`import destination is not a regular file: ${relPath}`)); + } + const current = await readFile(target.value.absPath); + if (!current.ok) return current; + existing = current.value; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + const raw = await readFile(source.value.absPath); + if (!raw.ok) return err(new Error(`could not read ${relPath}: ${raw.error.message}`)); - const parsed = parseDocument(raw.value); - if (!parsed.ok) { - warnings.push(`could not parse ${relPath}: ${parsed.error.message}`); - skipped++; - continue; - } + const parsed = parseDocument(raw.value); + if (!parsed.ok) { + return err(new Error(`could not parse ${relPath}: ${parsed.error.message}`)); + } - const okfRaw = parsed.value.raw; - const daftariRaw = okfToDaftari(okfRaw, { relPath, today, updatedBy: agent }); - const { frontmatter } = validateFrontmatter(daftariRaw); + const okfRaw = parsed.value.raw; + const daftariRaw = okfToDaftari(okfRaw, { relPath, today, updatedBy: agent }); + const { frontmatter, report } = validateFrontmatter(daftariRaw); + if (!report.valid) { + const issues = report.issues.map((issue) => `${issue.field}: ${issue.message}`).join("; "); + return err(new Error(`invalid imported frontmatter in ${relPath}: ${issues}`)); + } - const roundTrip = hasDaftariSidecar(okfRaw); + const roundTrip = hasDaftariSidecar(okfRaw); - // Advisory only, never enforcement: a bundle's self-declared type must not - // buy write protection. The operator reviews and elevates deliberately. - if (isAttestedComputation(okfRaw.type) && !roundTrip) { - warnings.push( - `${relPath}: Attested Computation imported WITHOUT write protection — ` + - `review it, then elevate with vault_set_tier (tier: source) if this vault should enforce it`, - ); - } + // Advisory only, never enforcement: a bundle's self-declared type must not + // buy write protection. The operator reviews and elevates deliberately. + if (isAttestedComputation(okfRaw.type) && !roundTrip) { + warnings.push( + `${relPath}: Attested Computation imported WITHOUT write protection — ` + + `review it, then elevate with vault_set_tier (tier: source) if this vault should enforce it`, + ); + } - plan.push({ - relPath, - collection: frontmatter.collection, - title: frontmatter.title, - roundTrip, - }); + plan.push({ + relPath, + collection: frontmatter.collection, + title: frontmatter.title, + roundTrip, + }); - if (dryRun) continue; + const fileText = serializeDocument(frontmatter, parsed.value.content, [], daftariRaw); + const output = parseDocument(fileText); + if (!output.ok) + return err(new Error(`invalid import output for ${relPath}: ${output.error.message}`)); + prepared.push({ + relPath, + canonicalPath: target.value.relPath, + text: fileText, + unchanged: existing === fileText, + }); + } + } catch (e) { + return err(new Error(`import preflight failed: ${e instanceof Error ? e.message : String(e)}`)); + } - const fileText = serializeDocument(frontmatter, parsed.value.content, [], daftariRaw); - const targetPath = join(vaultRoot, relPath); - await mkdir(dirname(targetPath), { recursive: true }); - await writeFile(targetPath, fileText, "utf-8"); - writtenPaths.push(relPath); + for (const path of destinations) { + let parent = dirname(path); + while (parent !== ".") { + if (destinations.has(parent)) { + return err(new Error(`import destination is also a planned directory: ${parent}`)); + } + parent = dirname(parent); + } } if (dryRun) { @@ -132,33 +219,107 @@ export async function importBundle( }); } + // Resolve the whole plan once more before mutation, then each destination + // immediately before its write. Never silently redirect a prepared write. + for (const doc of prepared) { + const target = importPath(vaultRoot, doc.relPath); + if (!target.ok) return target; + if (target.value.relPath !== doc.canonicalPath) { + return err(new Error(`import destination changed after preflight: ${doc.relPath}`)); + } + } + for (const doc of prepared) { + if (doc.unchanged) continue; + try { + const target = importPath(vaultRoot, doc.relPath); + if (!target.ok) throw target.error; + if (target.value.relPath !== doc.canonicalPath) { + throw new Error(`import destination changed after preflight: ${doc.relPath}`); + } + await mkdir(dirname(target.value.absPath), { recursive: true }); + await writeFile(target.value.absPath, doc.text, "utf-8"); + writtenPaths.push(doc.canonicalPath); + } catch (e) { + return err( + new Error( + `import incomplete: write failed for ${doc.relPath} after ${writtenPaths.length} document(s) written; ` + + `files may have changed, no import commit or reindex completed: ${e instanceof Error ? e.message : String(e)}`, + ), + ); + } + } + let commitHash: string | null = null; - if (writtenPaths.length > 0) { - const committed = await commit( + let phase = "commit"; + try { + // Existing bytes are not proof of a completed import: a previous attempt + // may have failed its commit or reindex. Retry those steps even when there + // is nothing to rewrite, without manufacturing an empty Git commit. + const commitPaths: string[] = []; + for (const doc of prepared) { + const prior = await catFileBlob(vaultRoot, `HEAD:./${doc.canonicalPath}`); + if (!prior.ok || prior.value !== doc.text) commitPaths.push(doc.canonicalPath); + } + if (commitPaths.length > 0) { + const committed = await commit( + vaultRoot, + commitPaths, + `okf import: ${commitPaths.length} document(s)`, + agent, + ); + if (committed.ok) commitHash = committed.value.hash; + else + return err( + new Error( + `import incomplete: ${writtenPaths.length} document(s) written but commit failed; files remain on disk: ${committed.error.message}`, + ), + ); + } + + phase = "reindex"; + let reindexed = false; + if (prepared.length > 0) { + const reindex = await reindexVault(vaultRoot); + if (reindex.ok) { + const failedImports = reindex.value.skipped.filter((doc) => destinations.has(doc.path)); + if (failedImports.length > 0) { + return err( + new Error( + `import incomplete: reindex skipped imported documents: ${failedImports.map((doc) => `${doc.path}: ${doc.reason}`).join("; ")}`, + ), + ); + } + for (const doc of reindex.value.skipped) { + warnings.push(`reindex skipped ${doc.path}: ${doc.reason}`); + } + for (const doc of reindex.value.invalidFrontmatter) { + warnings.push(`reindex validation warning for ${doc.path}: ${doc.reason}`); + } + reindexed = true; + } else + return err( + new Error( + `import incomplete: documents ${commitHash ? `committed as ${commitHash}` : "already committed"}, but reindex failed: ${reindex.error.message}`, + ), + ); + } + + return ok({ vaultRoot, - writtenPaths, - `okf import: ${writtenPaths.length} document(s)`, - agent, + imported: writtenPaths.length, + skipped, + commit: commitHash, + reindexed, + dryRun: false, + warnings, + plan, + }); + } catch (e) { + return err( + new Error( + `import incomplete: ${phase} failed after ${writtenPaths.length} document(s) written` + + `${commitHash ? ` (commit ${commitHash})` : ""}; files remain on disk: ${e instanceof Error ? e.message : String(e)}`, + ), ); - if (committed.ok) commitHash = committed.value.hash; - else warnings.push(`could not commit import: ${committed.error.message}`); - } - - let reindexed = false; - if (writtenPaths.length > 0) { - const reindex = await reindexVault(vaultRoot); - if (reindex.ok) reindexed = true; - else warnings.push(`could not reindex vault: ${reindex.error.message}`); } - - return ok({ - vaultRoot, - imported: writtenPaths.length, - skipped, - commit: commitHash, - reindexed, - dryRun: false, - warnings, - plan, - }); } diff --git a/src/okf/index.ts b/src/okf/index.ts index 00fec83f..eb889cf2 100644 --- a/src/okf/index.ts +++ b/src/okf/index.ts @@ -51,6 +51,11 @@ import — adopt an OKF bundle into a vault (auto-commits + reindexes): self-declared type is not an authorization; review it and elevate with vault_set_tier. Unmapped OKF fields are preserved under okf_* keys. + The complete batch is validated before any document is written, including + during --dry-run. Invalid batches fail without applying earlier documents. + Write, commit, or indexing failures return a nonzero status; files already + written remain on disk. Rerun after fixing the error to finish the import. + --help, -h Show this help. `; diff --git a/test/okf/import-failures.test.ts b/test/okf/import-failures.test.ts new file mode 100644 index 00000000..f7573c5b --- /dev/null +++ b/test/okf/import-failures.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { err } from "../../src/frontmatter/types.js"; +import { importBundle } from "../../src/okf/import.js"; +import { runOkf } from "../../src/okf/index.js"; +import { reindexVault } from "../../src/search/reindex.js"; +import * as git from "../../src/utils/git.js"; + +vi.mock("../../src/search/reindex.js", () => ({ reindexVault: vi.fn() })); +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, writeFile: vi.fn(original.writeFile) }; +}); + +describe("OKF import failure reporting and recovery", () => { + let bundle: string; + let vault: string; + + beforeEach(() => { + vi.mocked(fs.writeFile).mockReset(); + bundle = mkdtempSync(join(tmpdir(), "okf-failure-bundle-")); + vault = mkdtempSync(join(tmpdir(), "okf-failure-vault-")); + writeFileSync(join(bundle, "a.md"), "---\ntype: Note\ntitle: A\n---\nBody.\n"); + vi.mocked(reindexVault).mockReset(); + vi.mocked(reindexVault).mockResolvedValue({ + ok: true, + value: { + documentCount: 1, + chunkCount: 1, + vectorEnabled: false, + skipped: [], + invalidFrontmatter: [], + indexedAt: new Date().toISOString(), + embeddedCount: 0, + cacheHits: 0, + orphansRemoved: 0, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(bundle, { recursive: true, force: true }); + rmSync(vault, { recursive: true, force: true }); + }); + + it("reports a commit failure and retries it when bytes already match", async () => { + const commit = vi + .spyOn(git, "commit") + .mockResolvedValueOnce(err(new Error("commit unavailable"))); + const failed = await importBundle(bundle, vault); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error.message).toContain("written but commit failed"); + expect(reindexVault).not.toHaveBeenCalled(); + expect(readFileSync(join(vault, "a.md"), "utf8")).toContain("Body."); + const retry = await importBundle(bundle, vault); + expect(retry.ok && retry.value.commit).toBeTruthy(); + expect(commit).toHaveBeenCalledTimes(2); + expect(reindexVault).toHaveBeenCalledTimes(1); + }); + + it("reports reindex failure after commit, then retries indexing without another commit", async () => { + const commit = vi.spyOn(git, "commit"); + vi.mocked(reindexVault).mockResolvedValueOnce(err(new Error("index unavailable"))); + const failed = await importBundle(bundle, vault); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error.message).toContain("but reindex failed"); + const retry = await importBundle(bundle, vault); + expect(retry.ok && retry.value.reindexed).toBe(true); + expect(commit).toHaveBeenCalledTimes(1); + expect(reindexVault).toHaveBeenCalledTimes(2); + }); + + it("returns a Result for a mid-batch write failure and reports partial mutation", async () => { + writeFileSync(join(bundle, "b.md"), "---\ntype: Note\ntitle: B\n---\nBody B.\n"); + const original = (await vi.importActual("node:fs/promises")) + .writeFile; + vi.mocked(fs.writeFile).mockImplementation(async (...args) => { + if (args[0] === join(vault, "b.md")) throw new Error("disk full"); + return original(...args); + }); + const commit = vi.spyOn(git, "commit"); + const failed = await importBundle(bundle, vault); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error.message).toContain("after 1 document(s) written"); + expect(commit).not.toHaveBeenCalled(); + expect(reindexVault).not.toHaveBeenCalled(); + }); + + it("returns a Result when an indexing dependency throws", async () => { + vi.mocked(reindexVault).mockRejectedValueOnce(new Error("unexpected index failure")); + const failed = await importBundle(bundle, vault); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error.message).toContain("reindex failed"); + }); + + it("does not report success when reindex skips an imported document", async () => { + vi.mocked(reindexVault).mockResolvedValueOnce({ + ok: true, + value: { + documentCount: 0, + chunkCount: 0, + vectorEnabled: false, + skipped: [{ path: "a.md", reason: "unreadable" }], + invalidFrontmatter: [], + indexedAt: new Date().toISOString(), + embeddedCount: 0, + cacheHits: 0, + orphansRemoved: 0, + }, + }); + const failed = await importBundle(bundle, vault); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error.message).toContain("reindex skipped imported documents"); + }); + + it("returns a nonzero CLI status without printing success after commit failure", async () => { + vi.spyOn(git, "commit").mockResolvedValueOnce(err(new Error("commit unavailable"))); + const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true); + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + expect(await runOkf(["import", bundle, "--into", vault])).toBe(1); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining("import incomplete")); + }); +}); diff --git a/test/okf/import.test.ts b/test/okf/import.test.ts index b4b71613..44b39095 100644 --- a/test/okf/import.test.ts +++ b/test/okf/import.test.ts @@ -1,4 +1,13 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import matter from "gray-matter"; @@ -37,6 +46,133 @@ One row per order. for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + it.each([false, true])( + "rejects an invalid batch before changing any files (dryRun=%s)", + async (dryRun) => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + writeDoc(bundle, "a.md", foreignDoc); + writeDoc(bundle, "z.md", "---\ntitle: [broken\n---\n"); + writeDoc(vault, "a.md", "original bytes"); + const result = await importBundle(bundle, vault, { dryRun }); + expect(result.ok).toBe(false); + expect(readFileSync(join(vault, "a.md"), "utf8")).toBe("original bytes"); + expect(readdirSync(vault)).toEqual(["a.md"]); + }, + ); + + it.each(["source", "destination"])( + "preflights %s confinement for the entire batch", + async (side) => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + const outside = mkTmp("okf-outside-"); + writeDoc(bundle, "a.md", foreignDoc); + writeDoc(vault, "a.md", "original bytes"); + writeDoc(outside, "z.md", foreignDoc); + if (side === "source") { + symlinkSync(join(outside, "z.md"), join(bundle, "z.md")); + } else { + writeDoc(bundle, "nested/z.md", foreignDoc); + symlinkSync(outside, join(vault, "nested")); + } + const before = readFileSync(join(outside, "z.md"), "utf8"); + const result = await importBundle(bundle, vault); + expect(result.ok).toBe(false); + expect(readFileSync(join(vault, "a.md"), "utf8")).toBe("original bytes"); + expect(readFileSync(join(outside, "z.md"), "utf8")).toBe(before); + expect(existsSync(join(vault, ".git"))).toBe(false); + }, + ); + + it("rejects two planned destinations resolving to the same file", async () => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + writeDoc(bundle, "a.md", foreignDoc); + writeDoc(bundle, "b.md", foreignDoc.replace("Orders", "Different")); + writeDoc(vault, "a.md", "original bytes"); + symlinkSync("a.md", join(vault, "b.md")); + const result = await importBundle(bundle, vault); + expect(result.ok).toBe(false); + expect(readFileSync(join(vault, "a.md"), "utf8")).toBe("original bytes"); + }); + + it("rejects directory destinations before writing earlier documents", async () => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + writeDoc(bundle, "a.md", foreignDoc); + writeDoc(bundle, "z.md", foreignDoc); + mkdirSync(join(vault, "z.md")); + const result = await importBundle(bundle, vault); + expect(result.ok).toBe(false); + expect(existsSync(join(vault, "a.md"))).toBe(false); + }); + + it("rejects a dangling destination link before writing earlier documents", async () => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + const outside = mkTmp("okf-outside-"); + writeDoc(bundle, "a.md", foreignDoc); + writeDoc(bundle, "z.md", foreignDoc); + symlinkSync(join(outside, "missing.md"), join(vault, "z.md")); + const result = await importBundle(bundle, vault); + expect(result.ok).toBe(false); + expect(existsSync(join(vault, "a.md"))).toBe(false); + expect(readdirSync(outside)).toEqual([]); + }); + + it("allows confined destination links and commits the actual document", async () => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + writeDoc(bundle, "a.md", foreignDoc); + writeDoc(vault, "actual.md", "original"); + symlinkSync("actual.md", join(vault, "a.md")); + const result = await importBundle(bundle, vault); + expect(result.ok && result.value.commit).toBeTruthy(); + expect(readFileSync(join(vault, "actual.md"), "utf8")).toContain("One row per order."); + }); + + it("reimports identical documents successfully without an empty commit", async () => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + writeDoc(bundle, "a.md", foreignDoc); + expect((await importBundle(bundle, vault)).ok).toBe(true); + const repeat = await importBundle(bundle, vault); + expect(repeat.ok).toBe(true); + if (!repeat.ok) return; + expect(repeat.value.commit).toBeNull(); + expect(repeat.value.imported).toBe(0); + expect(repeat.value.reindexed).toBe(true); + }); + + it("rejects invalid mapped frontmatter instead of silently coercing it", async () => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + writeDoc(bundle, "a.md", foreignDoc); + writeDoc( + bundle, + "z.md", + matter.stringify("body", { + type: "note", + daftari: { title: "Invalid", status: "bogus" }, + }), + ); + const result = await importBundle(bundle, vault); + expect(result.ok).toBe(false); + expect(readdirSync(vault)).toEqual([]); + }); + + it("preserves nested documents named index.md and log.md", async () => { + const bundle = mkTmp("okf-bundle-"); + const vault = mkTmp("okf-vault-"); + writeDoc(bundle, "notes/index.md", foreignDoc); + writeDoc(bundle, "notes/log.md", foreignDoc); + const result = await importBundle(bundle, vault); + expect(result.ok && result.value.imported).toBe(2); + expect(existsSync(join(vault, "notes/index.md"))).toBe(true); + expect(existsSync(join(vault, "notes/log.md"))).toBe(true); + }); + it("dry-run reports the plan and writes nothing", async () => { const bundle = mkTmp("okf-bundle-"); const vault = mkTmp("okf-vault-"); From 94249d862acdb545d8de54b11a04a487266fff98 Mon Sep 17 00:00:00 2001 From: mavaali <40620108+mavaali@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:59:02 -0700 Subject: [PATCH 2/2] chore(release): prepare 3.13.1 --- CHANGELOG.md | 12 ++++++++++++ manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb7ba31..1cdda4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.13.1] - 2026-09-06 + +### Security + +- **OKF import confinement and validation** (#540) — preflight the complete import batch before mutation, reject unsafe source and destination paths and conflicting destinations, and validate imported frontmatter before writing. Includes regression coverage for path confinement and failure recovery. +- **Dependency fixes** (#557) — override sharp and adm-zip to patched versions. + +### Fixed + +- **OKF failure reporting and retries** (#540) — report incomplete writes, commits, and indexing as failures. Retrying unchanged documents completes pending commit or indexing work without an empty commit. Runtime write failures can still leave partial files; the CLI reports that state explicitly. +- **Webhook registration** (#558, #559) — persist two-phase webhook registration state before provider callbacks, support plaintext validation echoes, and avoid replacing pending webhook state while an existing webhook remains fresh. + ## [3.13.0] - 2026-09-05 ### Added diff --git a/manifest.json b/manifest.json index d395f7f2..bc7aa062 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": "0.2", "name": "daftari", - "version": "3.13.0", + "version": "3.13.1", "display_name": "Daftari", "description": "A persistent cortex Claude reads, writes, and curates over time.", "long_description": "An external cortex for AI agents: a persistent markdown vault that agents read, write, and curate over time. Plain text on disk, git-versioned, indexed for BM25 + vector search. Agents promote drafts to canonical knowledge, surface contradictions as tensions, and lint for staleness. Runs offline by default.", diff --git a/package-lock.json b/package-lock.json index 91b28dbf..0d4ca5dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daftari", - "version": "3.13.0", + "version": "3.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daftari", - "version": "3.13.0", + "version": "3.13.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.122.0", diff --git a/package.json b/package.json index a9d427b1..6b3c7f8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daftari", - "version": "3.13.0", + "version": "3.13.1", "description": "An open-source, multi-user knowledge vault exposed to AI agents via an MCP server.", "mcpName": "io.github.mavaali/daftari", "license": "MIT",