mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Add the content service and MCP server (phase 2)
services/content watches the vault's Site folder, runs every change through fixed rules, keeps the last accepted version of each note with its history, and serves one write API. The HTTP API, the MCP server for agents, and the admin page and editing tools to come all write through it. - SQLite store with history, review queue, redirects, links and full-text search - Rules: invalid, misplaced, duplicate name or slug, text loss, secrets, agent contact details, agent edits, agent publishing, agent batches - Watcher with debounce and a periodic full scan - HTTP API with reader, agent, editor and admin tokens - MCP tools: search, get_note, get_schema, list_held, set_status, add_library_item, update_fields, draft_post - Tests, including taking in all 545 migrated notes with nothing held Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { evaluate, defaultRules } from "../src/rules.ts";
|
||||
import { book, long, post, rig, type Rig } from "./helpers.ts";
|
||||
|
||||
let r: Rig;
|
||||
beforeEach(() => {
|
||||
r = rig();
|
||||
});
|
||||
afterEach(() => r.done());
|
||||
|
||||
const ok = (o: { status: string }) => expect(o.status).toBe("accepted");
|
||||
|
||||
describe("from the vault", () => {
|
||||
it("takes in valid notes and indexes them for search and links", () => {
|
||||
r.put(book("Mistborn", { creator: "Brandon Sanderson" }), "Allomancy, heists and [[Elantris]].");
|
||||
r.put(book("Elantris"));
|
||||
expect(r.service.scan()).toEqual({ changed: 2, held: 0 });
|
||||
expect(r.store.search("allomancy").map((n) => n.title)).toEqual(["Mistborn"]);
|
||||
expect(r.store.backlinks("Elantris").map((n) => n.title)).toEqual(["Mistborn"]);
|
||||
expect(r.service.scan()).toEqual({ changed: 0, held: 0 });
|
||||
});
|
||||
|
||||
it("holds a broken edit and keeps showing the last good version", () => {
|
||||
const path = r.put(book("Mistborn"));
|
||||
ok(r.service.ingest(path));
|
||||
r.putText(path, "---\ntype: library-item\ntitle: Mistborn\nmedium: scroll\n---\n");
|
||||
const o = r.service.ingest(path);
|
||||
expect(o.status).toBe("held");
|
||||
expect(o.status === "held" && o.held.reasons[0]!.code).toBe("invalid");
|
||||
expect(r.store.get(path)!.data.medium).toBe("book");
|
||||
});
|
||||
|
||||
it("holds a large loss of text until it is accepted", () => {
|
||||
const path = r.put(post("Building a bench"), long());
|
||||
ok(r.service.ingest(path));
|
||||
r.put(post("Building a bench"), "Short now.");
|
||||
const o = r.service.ingest(path);
|
||||
expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["text-loss"]);
|
||||
expect(r.store.get(path)!.body.trim()).toBe(long());
|
||||
ok(r.service.acceptHeld(o.status === "held" ? o.held.id : 0));
|
||||
expect(r.store.get(path)!.body.trim()).toBe("Short now.");
|
||||
});
|
||||
|
||||
it("holds a secret pasted into a note", () => {
|
||||
const path = r.put(post("Homelab notes"), "Setup.");
|
||||
ok(r.service.ingest(path));
|
||||
r.put(post("Homelab notes"), "Setup. api_key = abcd1234efgh5678");
|
||||
expect(r.service.ingest(path).status).toBe("held");
|
||||
});
|
||||
|
||||
it("unpublishes a deleted note but keeps its history for a rollback", () => {
|
||||
const path = r.put(book("Mistborn"));
|
||||
ok(r.service.ingest(path));
|
||||
rmSync(join(r.site, path));
|
||||
r.service.scan();
|
||||
expect(r.store.get(path)!.removed).toBe(true);
|
||||
expect(r.store.list({ publishedOnly: true })).toHaveLength(0);
|
||||
ok(r.service.rollback(path, 1));
|
||||
expect(r.store.get(path)!.removed).toBe(false);
|
||||
expect(r.vault.exists(path)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps old addresses working after a slug change and a move", () => {
|
||||
const path = r.put(post("Bench build", { slug: "bench" }));
|
||||
ok(r.service.ingest(path));
|
||||
r.put(post("Bench build", { slug: "workbench" }));
|
||||
ok(r.service.ingest(path));
|
||||
expect(r.store.redirect("post", "bench")).toBe(path);
|
||||
|
||||
// Renaming the file: the old path goes, the same note appears under a new name.
|
||||
renameSync(join(r.site, path), join(r.site, "Posts/Workbench build.md"));
|
||||
r.service.scan();
|
||||
expect(r.store.redirect("post", "bench")).toBe("Posts/Workbench build.md");
|
||||
});
|
||||
|
||||
it("holds a second note that takes a used address", () => {
|
||||
ok(r.service.ingest(r.put(post("Bench build", { slug: "bench" }))));
|
||||
const o = r.service.ingest(r.put(post("Another bench", { slug: "bench" })));
|
||||
expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["duplicate-slug"]);
|
||||
});
|
||||
|
||||
it("ignores conflict copies and template folders", () => {
|
||||
r.putText("Posts/Bench (conflicted copy 2026-10-11).md", "junk");
|
||||
r.putText("_templates/Post.md", "junk");
|
||||
expect(r.service.scan()).toEqual({ changed: 0, held: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the write API", () => {
|
||||
it("writes an accepted change into the vault and skips it when the watcher sees it", () => {
|
||||
const o = r.service.apply({ op: "create", data: book("Warbreaker") }, "agent");
|
||||
ok(o);
|
||||
const text = readFileSync(join(r.site, o.path!), "utf8");
|
||||
expect(text).toContain("updatedBy: agent");
|
||||
expect(r.service.ingest(o.path!)).toEqual({ status: "unchanged", path: o.path });
|
||||
});
|
||||
|
||||
it("rejects invalid input at once instead of holding it", () => {
|
||||
const o = r.service.apply({ op: "create", data: { type: "library-item", title: "X", medium: "scroll", state: "now" } }, "agent");
|
||||
expect(o.status).toBe("rejected");
|
||||
expect(r.store.heldList()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses paths outside the Site folder and type changes", () => {
|
||||
expect(r.service.apply({ op: "update", path: "../secrets.md", fields: {} }, "admin").status).toBe("rejected");
|
||||
const path = r.put(book("Mistborn"));
|
||||
r.service.ingest(path);
|
||||
expect(r.service.apply({ op: "update", path, fields: { type: "post" } }, "admin").status).toBe("rejected");
|
||||
});
|
||||
|
||||
it("takes in an Obsidian edit before applying an API change on top of it", () => {
|
||||
const path = r.put(book("Mistborn"));
|
||||
r.service.ingest(path);
|
||||
r.put(book("Mistborn", { verdict: "Great heist." }));
|
||||
ok(r.service.apply({ op: "update", path, fields: { state: "now" } }, "greg"));
|
||||
expect(r.store.get(path)!.data).toMatchObject({ verdict: "Great heist.", state: "now" });
|
||||
});
|
||||
|
||||
it("holds an agent editing Greg's note, and writes it to the vault only once accepted", () => {
|
||||
const path = r.put(book("Mistborn"));
|
||||
r.service.ingest(path);
|
||||
const before = readFileSync(join(r.site, path), "utf8");
|
||||
const o = r.service.apply({ op: "update", path, fields: { verdict: "Meh." } }, "agent");
|
||||
expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["agent-edit"]);
|
||||
expect(readFileSync(join(r.site, path), "utf8")).toBe(before);
|
||||
ok(r.service.acceptHeld(o.status === "held" ? o.held.id : 0));
|
||||
expect(readFileSync(join(r.site, path), "utf8")).toContain("verdict: Meh.");
|
||||
});
|
||||
|
||||
it("lets an agent edit a note marked agentEdits, and its own notes", () => {
|
||||
const path = r.put(book("Mistborn", { agentEdits: true }));
|
||||
r.service.ingest(path);
|
||||
ok(r.service.apply({ op: "update", path, fields: { progress: "2/3" } }, "agent"));
|
||||
const own = r.service.apply({ op: "create", data: book("Warbreaker") }, "agent");
|
||||
ok(r.service.apply({ op: "update", path: own.path!, fields: { state: "now" } }, "agent"));
|
||||
});
|
||||
|
||||
it("holds an agent publishing a post", () => {
|
||||
const o = r.service.apply({ op: "create", data: post("Draft", { publish: false }), body: "Hi." }, "agent");
|
||||
ok(o);
|
||||
const pub = r.service.apply({ op: "update", path: o.path!, fields: { publish: true } }, "agent");
|
||||
expect(pub.status === "held" && pub.held.reasons.map((x) => x.code)).toEqual(["agent-publish"]);
|
||||
});
|
||||
|
||||
it("holds an agent adding contact details", () => {
|
||||
const o = r.service.apply({ op: "create", data: post("Draft", { publish: false }), body: "Mail me at someone@example.com" }, "agent");
|
||||
expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["contact"]);
|
||||
});
|
||||
|
||||
it("holds an agent making too many changes at once", () => {
|
||||
for (let i = 0; i < 10; i++) ok(r.service.apply({ op: "create", data: book(`Book ${i}`) }, "agent"));
|
||||
expect(r.service.apply({ op: "create", data: book("Book 10") }, "agent").status).toBe("held");
|
||||
r.clock.advance(11);
|
||||
ok(r.service.apply({ op: "create", data: book("Book 11") }, "agent"));
|
||||
});
|
||||
|
||||
it("refuses a change while another waits for review", () => {
|
||||
const path = r.put(book("Mistborn"));
|
||||
r.service.ingest(path);
|
||||
r.service.apply({ op: "update", path, fields: { verdict: "Meh." } }, "agent");
|
||||
expect(r.service.apply({ op: "update", path, fields: { verdict: "Good." } }, "greg").status).toBe("rejected");
|
||||
});
|
||||
|
||||
it("rejecting a held vault edit can restore the live version into the vault", () => {
|
||||
const path = r.put(book("Mistborn"));
|
||||
r.service.ingest(path);
|
||||
const good = readFileSync(join(r.site, path), "utf8");
|
||||
r.putText(path, "---\ntype: library-item\n---\n");
|
||||
const o = r.service.ingest(path);
|
||||
r.service.rejectHeld(o.status === "held" ? o.held.id : 0, { restore: true });
|
||||
expect(readFileSync(join(r.site, path), "utf8")).toBe(good);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rules", () => {
|
||||
it("only flags secrets that are new in this edit", () => {
|
||||
const text = "---\ntype: post\n---\npassword = hunter2hunter2";
|
||||
const checked = { ok: true as const, type: "post", data: { type: "post", title: "x", publish: false }, body: "" };
|
||||
const current = { text, removed: false, author: "greg", data: {}, publish: false } as never;
|
||||
const v = evaluate(
|
||||
{ path: "Posts/x.md", name: "x", slug: "x", text, author: "greg", checked: checked as never },
|
||||
{ current, nameTakenBy: null, slugTakenBy: null, recentAgentChanges: 0, settings: defaultRules },
|
||||
);
|
||||
expect(v.decision).toBe("accept");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user