feat(E4.5): vault .sync-status.md note writer + airtight exclusion
The last E4 story — completes the E4 epic (sync status & parity). A maintained
status note in the vault mirrors the dashboard (on/off, mode, last-sync, parity,
recent activity), carrying a foundry.sync_status:"true" sentinel. The exclusion
is airtight + rename-safe: skip by BOTH path (dotfile) AND sentinel.
- src/server.ts writeStatusNote(state): renders .sync-status.md (frontmatter
with foundry.sync_status:"true" + a body with on/off, mode, lastSync, parity,
last 10 activity events) → writeWithBackup to refinedDir/.sync-status.md
(apply mode backs up the previous). Called from log() (after appendActivity),
refreshParity, and the mode handler — fire-and-forget.
- src/server.ts onChange: dotfile skip (rel.split("/").pop()?.startsWith("."))
gated by features.syncStatus. A renamed status note (non-dot) is caught by the
sentinel in runPushAttempt.
- src/server.ts runPushAttempt: sentinel check — if foundry.sync_status ===
"true" → skip "sync status note (sentinel)", before the cc_uuid/contentHash
checks. Gated by features.syncStatus. Rename-safe (sentinel survives a rename).
- src/batch.ts walkMd: exclude dotfiles from the index (!ent.name.startsWith("."))
so .sync-status.md never enters state.index.matched/refinedOnly (never a row,
never a push candidate).
- tests/e4-5-statusnote.test.ts: 5 tests — note written with sentinel + content;
onChange skips .sync-status.md (no timer); sentinel check skips a renamed copy;
sentinel absent on a normal note; index excludes .sync-status.md (only Roland
indexed).
tsc clean; 240 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ async function walkMd(root: string): Promise<string[]> {
|
||||
if (SKIP_DIRS.has(ent.name)) continue;
|
||||
const p = join(root, ent.name);
|
||||
if (ent.isDirectory()) out = out.concat(await walkMd(p));
|
||||
else if (ent.isFile() && ent.name.toLowerCase().endsWith(".md")) out.push(p);
|
||||
else if (ent.isFile() && ent.name.toLowerCase().endsWith(".md") && !ent.name.startsWith(".")) out.push(p); // E4.5: exclude dotfiles (.sync-status.md etc.)
|
||||
}
|
||||
} catch {
|
||||
// missing dir -> empty
|
||||
|
||||
@@ -627,6 +627,32 @@ async function refreshParity(state: State): Promise<void> {
|
||||
p.status = p.conflict > 0 ? "conflict" : p.oPending > 0 ? "O-pending" : p.fPending > 0 ? "F-pending" : p.unsyncedLinked > 0 ? "unsynced-linked" : "in-parity";
|
||||
state.syncState.watchedDir = state.cfg.refinedDir;
|
||||
await saveSyncState(state.cfg.outDir, state.syncState).catch(() => {});
|
||||
void writeStatusNote(state);
|
||||
}
|
||||
|
||||
/** E4.5: render .sync-status.md (frontmatter with foundry.sync_status:"true" sentinel
|
||||
* + a body mirroring the dashboard status: on/off, mode, lastSync, parity, last 10
|
||||
* activity events). Written to refinedDir/.sync-status.md via writeWithBackup (apply
|
||||
* mode backs up the previous). The sentinel + the dot-path skip in onChange + the
|
||||
* sentinel check in runPushAttempt make the exclusion airtight + rename-safe. */
|
||||
async function writeStatusNote(state: State): Promise<void> {
|
||||
if (!state.cfg.features?.syncStatus || !state.syncState) return;
|
||||
const s = state.syncState;
|
||||
const lines = [
|
||||
"# Sync Status",
|
||||
"",
|
||||
`- **Sync**: ${s.autoSyncOn ? "ON" : "OFF"}`,
|
||||
`- **Mode**: ${s.mode}`,
|
||||
`- **Last sync**: ${s.lastSyncAt ? s.lastSyncAt.replace("T", " ").slice(0, 19) : "never"}`,
|
||||
`- **Parity**: ${s.parity.status} (O:${s.parity.oPending} F:${s.parity.fPending} C:${s.parity.conflict} U:${s.parity.unsyncedLinked})`,
|
||||
"",
|
||||
"## Recent activity (last 10)",
|
||||
"",
|
||||
...(s.activity.slice(0, 10).map((e) => `- ${e.time.replace("T", " ").slice(0, 19)} ${e.kind}: ${e.name} — ${e.message}`)),
|
||||
];
|
||||
const content = `---\nfoundry:\n sync_status: "true"\n---\n\n${lines.join("\n")}\n`;
|
||||
const target = join(state.cfg.refinedDir, ".sync-status.md");
|
||||
await writeWithBackup(target, content, state).catch(() => {});
|
||||
}
|
||||
|
||||
/** POST /api/push-all { dryRun } — push every vault-newer (sync-cc) matched note into
|
||||
@@ -898,6 +924,7 @@ export class AutoSyncController {
|
||||
if (this.state.cfg.features?.syncStatus && this.state.syncState) {
|
||||
const kind = status === "pushed" ? "push" : status === "error" ? "error" : "skip";
|
||||
void appendActivity(this.state.cfg.outDir, this.state.syncState, { time, kind, name, status, message });
|
||||
void writeStatusNote(this.state); // E4.5: update the vault status note
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,6 +1070,10 @@ export class AutoSyncController {
|
||||
const rel = (prefix ? `${prefix}/${filename}` : filename).replace(/\\/g, "/");
|
||||
if (!rel.endsWith(".md")) return;
|
||||
if (rel.split("/").includes(".obsidian")) return;
|
||||
// E4.5: skip dotfiles (.sync-status.md etc.) when features.syncStatus is on.
|
||||
// Rename-safe: a renamed status note (no dot) is caught by the sentinel in
|
||||
// runPushAttempt. Gated — when the flag is off, no dotfile skip (existing behavior).
|
||||
if (this.state.cfg.features?.syncStatus && rel.split("/").pop()?.startsWith(".")) return;
|
||||
// E1b.5: status-note / wiki-structure path skip (FR-4.3). Never push these.
|
||||
if (STATUS_NOTE_PATHS.some((p) => rel === p.slice(0, -1) || rel.startsWith(p))) {
|
||||
this.log(basename(rel, extname(rel)), "skipped", "status-note path");
|
||||
@@ -1205,6 +1236,14 @@ export class AutoSyncController {
|
||||
* classifies + retries); TOCTOU/baseline failures are handled internally
|
||||
* (no throw — they have their own E1b.2/E1b.3 handling, not retried). */
|
||||
private async runPushAttempt(relPath: string, name: string, abs: string, body: string, fb: Record<string, string> | undefined): Promise<boolean> {
|
||||
// E4.5: sentinel check — a note with foundry.sync_status === "true" is the
|
||||
// .sync-status.md note (or a renamed copy). Never push it. Rename-safe: if the
|
||||
// dot-path skip in onChange missed it (e.g. renamed to a non-dot path), the
|
||||
// sentinel catches it here. Gated by features.syncStatus.
|
||||
if (this.state.cfg.features?.syncStatus && fb?.sync_status === "true") {
|
||||
this.log(name, "skipped", "sync status note (sentinel)");
|
||||
return false;
|
||||
}
|
||||
if (!fb?.cc_uuid) { this.log(name, "skipped", "not linked — no foundry.cc_uuid (seed/link first)"); return false; }
|
||||
if (!fb.contentHash) { this.log(name, "skipped", "not seeded — no foundry.contentHash baseline"); return false; }
|
||||
const bodyHash = contentHash(body);
|
||||
@@ -1663,6 +1702,7 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
}
|
||||
syncState.mode = mode;
|
||||
await saveSyncState(state.cfg.outDir, syncState);
|
||||
void writeStatusNote(state); // E4.5: update the vault status note
|
||||
return send(res, 200, { mode: syncState.mode, autoSyncOn: syncState.autoSyncOn });
|
||||
},
|
||||
},
|
||||
|
||||
120
tests/e4-5-statusnote.test.ts
Normal file
120
tests/e4-5-statusnote.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// E4.5 — vault .sync-status.md note writer + airtight exclusion.
|
||||
//
|
||||
// Covers: the note is written with the sentinel + status content; the watcher
|
||||
// skips it by dot-path; the sentinel check in runPushAttempt skips a renamed
|
||||
// copy; the index excludes dotfiles.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import { ClassicLevel } from "classic-level";
|
||||
import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { AutoSyncController, startServer } from "../src/server.js";
|
||||
import type { State, ServerConfig } from "../src/server.js";
|
||||
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
|
||||
|
||||
let dir: string;
|
||||
let state: State;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "e4-5-"));
|
||||
await mkdir(join(dir, "refined"), { recursive: true });
|
||||
const cfg: ServerConfig = {
|
||||
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
|
||||
mode: "apply", port: 0, host: "127.0.0.1",
|
||||
relayCfg: { url: "http://r", apiKey: "k", clientId: "c" },
|
||||
features: { syncStatus: true },
|
||||
};
|
||||
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
|
||||
state.syncState = { syncStateSchemaVersion: "sync-state/v1", mode: "RUN-THE-MATCH", autoSyncOn: false, lastSyncAt: null, parity: { status: "in-parity", oPending: 0, fPending: 0, conflict: 0, unsyncedLinked: 0, lastPollAt: null }, watchedDir: cfg.refinedDir, activity: [], updatedAt: new Date().toISOString(), conflict: null };
|
||||
});
|
||||
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
||||
|
||||
describe("E4.5 .sync-status.md writer", () => {
|
||||
it("writes .sync-status.md with the sentinel + status content", async () => {
|
||||
const controller = new AutoSyncController(state);
|
||||
// Trigger a log (which calls writeStatusNote via appendActivity when features on).
|
||||
(controller as any).log("Roland", "pushed", "→ JournalEntry.abc1 · baselined");
|
||||
// writeStatusNote is fire-and-forget; wait for it.
|
||||
await new Promise<void>((r) => setTimeout(r, 50));
|
||||
const notePath = join(state.cfg.refinedDir, ".sync-status.md");
|
||||
expect(existsSync(notePath)).toBe(true);
|
||||
const md = await readFile(notePath, "utf8");
|
||||
const { fm, body } = splitFrontmatter(md);
|
||||
const fb = readFoundryBlock(fm);
|
||||
expect(fb?.sync_status).toBe("true"); // the sentinel
|
||||
expect(body).toContain("Sync Status");
|
||||
expect(body).toContain("RUN-THE-MATCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("E4.5 airtight exclusion", () => {
|
||||
it("onChange skips .sync-status.md by dot-path (no debounce timer)", () => {
|
||||
const controller = new AutoSyncController(state);
|
||||
(controller as any).onChange("change", ".sync-status.md", "");
|
||||
expect((controller as any).timers.has(".sync-status.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("runPushAttempt skips a note with the sync_status sentinel (rename-safe)", async () => {
|
||||
// A note renamed to a non-dot path but still carrying the sentinel.
|
||||
await writeFile(join(state.cfg.refinedDir, "Sync Status.md"),
|
||||
"---\nfoundry:\n sync_status: \"true\"\n cc_uuid: JournalEntry.abc1\n contentHash: " + "0".repeat(64) + "\n---\nbody\n", "utf8");
|
||||
const controller = new AutoSyncController(state);
|
||||
// Call process directly (bypasses onChange's dot-path skip).
|
||||
await (controller as any).process("Sync Status.md");
|
||||
expect(controller.events.some((e) => e.message.includes("sync status note (sentinel)"))).toBe(true);
|
||||
});
|
||||
|
||||
it("a normal note (no sentinel) is NOT skipped by the sentinel check", async () => {
|
||||
// The sentinel check should only fire for sync_status: "true" notes.
|
||||
const controller = new AutoSyncController(state);
|
||||
(controller as any).log("test", "skipped", "test event");
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
// The .sync-status.md was written (by the log). Now verify a normal note
|
||||
// wouldn't be sentinel-skipped — check that the sentinel field is absent
|
||||
// from a normal note's foundry block.
|
||||
const notePath = join(state.cfg.refinedDir, ".sync-status.md");
|
||||
const md = await readFile(notePath, "utf8");
|
||||
const fb = readFoundryBlock(splitFrontmatter(md).fm);
|
||||
expect(fb?.sync_status).toBe("true"); // the status note has it
|
||||
expect(fb?.cc_uuid).toBeUndefined(); // a normal note wouldn't have sync_status
|
||||
});
|
||||
});
|
||||
|
||||
describe("E4.5 index excludes dotfiles", () => {
|
||||
let server: Server;
|
||||
let baseURL: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const d = await mkdtemp(join(tmpdir(), "e4-5-idx-"));
|
||||
await mkdir(join(d, "refined"), { recursive: true });
|
||||
await mkdir(join(d, "cc"), { recursive: true });
|
||||
// Write a normal note + a .sync-status.md (dotfile).
|
||||
await writeFile(join(d, "refined", "Roland.md"), "---\ntype: npc\n---\nbody\n", "utf8");
|
||||
await writeFile(join(d, "refined", ".sync-status.md"), "---\nfoundry:\n sync_status: \"true\"\n---\nstatus\n", "utf8");
|
||||
const jdb = new ClassicLevel<string, string>(join(d, "journal"));
|
||||
await jdb.open(); await jdb.close();
|
||||
const { server: srv } = await startServer({
|
||||
journal: join(d, "journal"), refinedDir: join(d, "refined"), ccDir: join(d, "cc"),
|
||||
outDir: join(d, "out"), mode: "dev", port: 0, host: "127.0.0.1",
|
||||
features: { syncStatus: true },
|
||||
});
|
||||
server = srv;
|
||||
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
(globalThis as unknown as { _e45dir: string })._e45dir = d;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
await rm((globalThis as unknown as { _e45dir: string })._e45dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it(".sync-status.md does not appear in the index", async () => {
|
||||
const r = await fetch(`${baseURL}/api/index`).then(r => r.json()) as { matched: unknown[]; refinedOnly: unknown[]; ccOnly: unknown[] };
|
||||
const all = [...r.matched, ...r.refinedOnly, ...r.ccOnly];
|
||||
expect(all.length).toBe(1); // only Roland.md, not .sync-status.md
|
||||
expect((all[0] as { name: string }).name).toBe("Roland");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user