In PREP, auto-sync is blocked (the DM is still curating/seeding/linking — must not push half-curated notes to live Foundry). In RUN-THE-MATCH, auto-sync is unblocked. The toggle is the only writer of `mode` in sync-state.json. Gated by features.syncStatus (default off → existing behavior unchanged). - src/server.ts setEnabled: PREP gate — if features.syncStatus + mode===PREP → throw "auto-sync is blocked in PREP mode — switch to RUN-THE-MATCH first". Stacks on top of the E1b.5 apply-mode gate (both must pass). - src/server.ts POST /api/sync-state/mode (gated by features.syncStatus; 404 when off): flips mode in sync-state.json; switching to PREP while auto-sync is ON tears down the watcher (stop()) + flips autoSyncOn=false first. /api/status exposes syncMode + featuresSyncStatus. - src/server.ts boot reconcile: if mode===PREP + autoSyncOn=true → autoSyncOn flipped to false + "PREP mode auto-sync disabled on boot" event (doesn't call setEnabled, which would throw the PREP message). - src/dashboard.html: PREP⇄RUN toggle button in the header (gated by featuresSyncStatus from /api/status); autoSyncBtn disabled in PREP with a tooltip "Switch to RUN-THE-MATCH mode first"; toggleSyncMode() POSTs the flip. - tests/e4-2-mode.test.ts: 8 tests — setEnabled in PREP throws / in RUN proceeds / flag-off no gate; POST mode flip; switching to PREP while ON → stop + off; boot reconcile PREP + autoSyncOn → off + event; invalid mode → 400; features off → 404. tsc clean; 230 passing project-wide (18 pre-existing fixture-missing unchanged). Co-Authored-By: Claude <noreply@anthropic.com>
138 lines
6.9 KiB
TypeScript
138 lines
6.9 KiB
TypeScript
// E4.2 — PREP / RUN-THE-MATCH mode flag (gates AutoSyncController).
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { ClassicLevel } from "classic-level";
|
|
import { mkdtemp, mkdir, rm, readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import type { Server } from "node:http";
|
|
|
|
import { AutoSyncController } from "../src/server.js";
|
|
import type { State, ServerConfig } from "../src/server.js";
|
|
import { saveSyncState, defaultSyncState, type SyncState } from "../src/sync-state.js";
|
|
import { startServer } from "../src/server.js";
|
|
|
|
let dir: string;
|
|
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "e4-2-")); await mkdir(join(dir, "refined"), { recursive: true }); });
|
|
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
|
|
|
function makeState(opts: { syncStatus?: boolean; syncMode?: string; mode?: "dev" | "apply" } = {}): State {
|
|
const cfg: ServerConfig = {
|
|
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
|
|
mode: opts.mode ?? "apply", port: 0, host: "127.0.0.1",
|
|
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
|
|
features: { syncStatus: opts.syncStatus ?? true },
|
|
};
|
|
const state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
|
|
state.syncState = { ...defaultSyncState(cfg.refinedDir), mode: opts.syncMode ?? "PREP" } as SyncState;
|
|
return state;
|
|
}
|
|
|
|
describe("E4.2 setEnabled PREP/RUN gate", () => {
|
|
it("setEnabled(true) in PREP mode (features.syncStatus on) → throws 'blocked in PREP mode'", async () => {
|
|
const state = makeState({ syncStatus: true, syncMode: "PREP", mode: "apply" });
|
|
const controller = new AutoSyncController(state);
|
|
await expect(controller.setEnabled(true)).rejects.toThrow(/blocked in PREP mode/);
|
|
expect(controller.enabled).toBe(false);
|
|
});
|
|
|
|
it("setEnabled(true) in RUN-THE-MATCH mode → proceeds (enabled=true)", async () => {
|
|
const state = makeState({ syncStatus: true, syncMode: "RUN-THE-MATCH", mode: "apply" });
|
|
const controller = new AutoSyncController(state);
|
|
await controller.setEnabled(true);
|
|
expect(controller.enabled).toBe(true);
|
|
controller.stop();
|
|
});
|
|
|
|
it("features.syncStatus OFF → no PREP gate (setEnabled works in apply mode even with mode=PREP)", async () => {
|
|
const state = makeState({ syncStatus: false, syncMode: "PREP", mode: "apply" });
|
|
const controller = new AutoSyncController(state);
|
|
await controller.setEnabled(true); // no PREP gate (flag off)
|
|
expect(controller.enabled).toBe(true);
|
|
controller.stop();
|
|
});
|
|
});
|
|
|
|
describe("E4.2 POST /api/sync-state/mode + boot reconcile", () => {
|
|
async function bootWithState(stateOverrides: Partial<SyncState>): Promise<{ server: Server; state: State }> {
|
|
const outDir = join(dir, "out");
|
|
await mkdir(outDir, { recursive: true });
|
|
const full = { ...defaultSyncState(join(dir, "refined")), ...stateOverrides } as SyncState;
|
|
await saveSyncState(outDir, full);
|
|
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
|
|
await jdb.open(); await jdb.close();
|
|
const { server, state } = await startServer({
|
|
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
|
|
outDir, mode: "apply", port: 0, host: "127.0.0.1",
|
|
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
|
|
features: { syncStatus: true },
|
|
});
|
|
return { server, state };
|
|
}
|
|
|
|
async function postMode(server: Server, mode: string): Promise<{ code: number; body: unknown }> {
|
|
const port = (server.address() as { port: number }).port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/sync-state/mode`, {
|
|
method: "POST", headers: { "content-type": "application/json", origin: `http://127.0.0.1:${port}` },
|
|
body: JSON.stringify({ mode }),
|
|
});
|
|
return { code: r.status, body: await r.json().catch(() => null) };
|
|
}
|
|
|
|
it("POST /api/sync-state/mode flips PREP → RUN-THE-MATCH", async () => {
|
|
const { server, state } = await bootWithState({ mode: "PREP" });
|
|
const r = await postMode(server, "RUN-THE-MATCH");
|
|
expect(r.code).toBe(200);
|
|
expect((r.body as { mode: string }).mode).toBe("RUN-THE-MATCH");
|
|
// Persisted to sync-state.json.
|
|
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState;
|
|
expect(saved.mode).toBe("RUN-THE-MATCH");
|
|
state.autosync.stop();
|
|
await new Promise<void>((res) => server.close(() => res()));
|
|
});
|
|
|
|
it("switching to PREP while auto-sync is ON → stop() + autoSyncOn=false", async () => {
|
|
const { server, state } = await bootWithState({ mode: "RUN-THE-MATCH", autoSyncOn: true });
|
|
expect(state.autosync.enabled).toBe(true); // auto-sync restored ON in RUN mode
|
|
const r = await postMode(server, "PREP");
|
|
expect(r.code).toBe(200);
|
|
expect(state.autosync.enabled).toBe(false); // stopped
|
|
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState;
|
|
expect(saved.autoSyncOn).toBe(false);
|
|
expect(saved.mode).toBe("PREP");
|
|
await new Promise<void>((res) => server.close(() => res()));
|
|
});
|
|
|
|
it("boot reconcile: PREP + autoSyncOn=true → autoSyncOn=false + 'PREP mode auto-sync disabled on boot' event", async () => {
|
|
const { server, state } = await bootWithState({ mode: "PREP", autoSyncOn: true });
|
|
expect(state.autosync.enabled).toBe(false); // not restored (PREP blocks)
|
|
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState;
|
|
expect(saved.autoSyncOn).toBe(false);
|
|
expect(saved.activity.some((e) => e.message.includes("PREP mode auto-sync disabled on boot"))).toBe(true);
|
|
await new Promise<void>((res) => server.close(() => res()));
|
|
});
|
|
|
|
it("invalid mode → 400", async () => {
|
|
const { server } = await bootWithState({ mode: "PREP" });
|
|
const r = await postMode(server, "INVALID");
|
|
expect(r.code).toBe(400);
|
|
await new Promise<void>((res) => server.close(() => res()));
|
|
});
|
|
|
|
it("features.syncStatus OFF → POST /api/sync-state/mode → 404", async () => {
|
|
const outDir = join(dir, "out");
|
|
await mkdir(outDir, { recursive: true });
|
|
await saveSyncState(outDir, { ...defaultSyncState(join(dir, "refined")), mode: "PREP" } as SyncState);
|
|
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
|
|
await jdb.open(); await jdb.close();
|
|
const { server } = await startServer({
|
|
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
|
|
outDir, mode: "apply", port: 0, host: "127.0.0.1",
|
|
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
|
|
features: { syncStatus: false }, // OFF → endpoint 404
|
|
});
|
|
const r = await postMode(server, "RUN-THE-MATCH");
|
|
expect(r.code).toBe(404);
|
|
await new Promise<void>((res) => server.close(() => res()));
|
|
});
|
|
}); |