feat(E4.1): persistent sync-state.json + boot reconcile (Slice 1 foundation)
The first Slice 1 story. A durable aggregate (on/off, mode, parity, activity,
last-sync) that survives restarts — the single status source E2/E3/E4 read
from. E4.1 only delivers load/save + the boot reconcile; the dashboard migration
to read sync-state.json is E4.2-E4.6 (gated behind features.syncStatus, defined
here, default off).
- src/sync-state.ts: loadSyncState/saveSyncState (atomic: tmp+rename, no
truncation on crash), the shape {syncStateSchemaVersion, mode, autoSyncOn,
lastSyncAt, parity, watchedDir, activity, updatedAt, conflict:null} (conflict
reserved for E3 — E4 forces it null), appendActivity (newest-first, trimmed to
200), defaultSyncState (fresh install: autoSyncOn=false, mode=PREP). Schema
mismatch (≠ SYNC_STATE_SCHEMA_VERSION from E0.3) → back up to
sync-state.json.bak-<stamp> + fresh defaults + an error event. (Reconciliation:
E0.3 froze SYNC_STATE_SCHEMA_VERSION = "sync-state/v1" — E4.1's AC "= 1" is
superseded; E4.1 persists autoSyncOn, superseding E1b.5's "no persistence until
E3" — a fresh install still defaults OFF; a user who toggled ON is restored ON.)
- src/server.ts: features.syncStatus flag (env OFS_SYNC_STATUS=1, default off)
on ServerConfig/State. startServer loads sync-state.json + reconciles
AutoSyncController.enabled with autoSyncOn — if true, setEnabled(true); on
throw (dev mode apply-gate / no relay) → autoSyncOn=false + persist + an
"auto-sync could not resume" error event. State.syncState holds the aggregate.
- tests/e4-1-syncstate.test.ts: 10 tests — load creates defaults; atomic save
(no .tmp left); schema-mismatch backup + fresh + error event; restart survival
(autoSyncOn + 17 activity preserved); activity trimmed to 200; conflict forced
null; boot reconcile (autoSyncOn=true + apply + relay → enabled restored; no
relay → flipped off + error; dev mode → flipped off; fresh → stays off).
tsc clean; 222 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import { contentHash } from "./normalize.js";
|
||||
import { SyncLock, relPathLockKey } from "./synclock.js";
|
||||
import { ccHash } from "./cchash.js";
|
||||
import { FLAGS_SCHEMA_VERSION } from "./schema-version.js";
|
||||
import { loadSyncState, appendActivity, type SyncState } from "./sync-state.js";
|
||||
import { backupStamp } from "./write.js";
|
||||
import type { RelayConfig, FoundryHostConfig } from "./config.js";
|
||||
import type { Mode, JournalEntry } from "./types.js";
|
||||
@@ -241,10 +242,12 @@ export interface ServerConfig {
|
||||
outDir: string;
|
||||
mode: Mode; // dev | apply — fixed at startup
|
||||
port: number;
|
||||
host: string; // bind address (0.0.0.0 by default to expose on the tailnet; 127.0.0.1 to restrict to localhost)
|
||||
host: string; // bind address (127.0.0.1 by default; --host 0.0.0.0 to expose, needs a token when ENABLE_AUTH_MIDDLEWARE=on)
|
||||
// Live push path (optional). When relayCfg is set, /api/push and /api/refresh work.
|
||||
relayCfg?: RelayConfig;
|
||||
foundryCfg?: FoundryHostConfig;
|
||||
// E4.1: feature flags read once at boot. syncStatus gates E4.2-E4.6 (default off).
|
||||
features?: { syncStatus: boolean };
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
@@ -262,6 +265,8 @@ export interface State {
|
||||
cfg: ServerConfig;
|
||||
index: IndexResult | null;
|
||||
autosync: AutoSyncController;
|
||||
// E4.1: the persisted sync-state aggregate (loaded at boot, saved on mutation).
|
||||
syncState?: import("./sync-state.js").SyncState;
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, code: number, body: unknown): void {
|
||||
@@ -1450,6 +1455,26 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
await state.autosync.migrateFlagsSchemaVersion();
|
||||
}
|
||||
|
||||
// E4.1: features.syncStatus flag (gates E4.2-E4.6; default off). Read once at boot.
|
||||
if (!state.cfg.features) state.cfg.features = { syncStatus: process.env.OFS_SYNC_STATUS === "1" };
|
||||
|
||||
// E4.1: load the persisted sync-state.json (or create defaults). Reconcile the
|
||||
// controller's `enabled` with the persisted `autoSyncOn` — a user who toggled
|
||||
// auto-sync ON is restored ON across restarts (their choice; a fresh install
|
||||
// defaults OFF). If start() throws (dev mode / no relay), flip autoSyncOn off +
|
||||
// persist + log an error event.
|
||||
const { state: syncState, freshened } = await loadSyncState(cfg.outDir, cfg.refinedDir);
|
||||
state.syncState = syncState;
|
||||
if (syncState.autoSyncOn) {
|
||||
try {
|
||||
await state.autosync.setEnabled(true);
|
||||
} catch (e) {
|
||||
syncState.autoSyncOn = false;
|
||||
await appendActivity(cfg.outDir, syncState, { time: new Date().toISOString(), kind: "error", name: "(auto-sync)", status: "error", message: `auto-sync could not resume — ${(e as Error).message}` }).catch(() => {});
|
||||
}
|
||||
}
|
||||
void freshened;
|
||||
|
||||
// E7.1: the route table. Every endpoint is declared here with its auth/CSRF
|
||||
// requirement + handler; the dispatch loop consults the table before calling
|
||||
// the handler. Read routes are requireAuth:false; mutation (POST) routes are
|
||||
|
||||
133
src/sync-state.ts
Normal file
133
src/sync-state.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// E4.1 — persistent sync-state.json (the single status source E2/E3/E4 read from).
|
||||
//
|
||||
// The AutoSyncController keeps an in-memory view (the dashboard's fast poll); this
|
||||
// module is the DURABLE aggregate that survives restarts: on/off, mode, parity,
|
||||
// activity (last 200), last-sync. Atomic writes (tmp + rename) so a crash mid-
|
||||
// write never leaves a truncated file. Schema-versioned: a mismatch backs up the
|
||||
// old file + writes fresh defaults + an error event.
|
||||
//
|
||||
// E4.1 does NOT change the existing /api/autosync or the dashboard panel — those
|
||||
// migrate to read sync-state.json in E4.2-E4.6 (gated behind features.syncStatus,
|
||||
// defined here). E4.1 only delivers load/save + the boot reconcile.
|
||||
//
|
||||
// Reconciliations: (1) E0.3 froze SYNC_STATE_SCHEMA_VERSION = "sync-state/v1"
|
||||
// (string); E4.1's AC said "= 1" (number) — E0.3 wins, so the file's
|
||||
// syncStateSchemaVersion is the E0.3 string. (2) E4.1 persists autoSyncOn across
|
||||
// restarts, superseding E1b.5's "no persistence until E3" — Slice 1 introduces the
|
||||
// persistence layer. A fresh install (no sync-state.json) still defaults
|
||||
// autoSyncOn=false; a user who explicitly toggled ON is restored ON (their choice).
|
||||
|
||||
import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { FLAGS_SCHEMA_VERSION, SYNC_STATE_SCHEMA_VERSION } from "./schema-version.js";
|
||||
import { backupStamp } from "./write.js";
|
||||
|
||||
export interface SyncStateActivityEvent {
|
||||
time: string;
|
||||
kind: string; // push | pull | baseline | skip | error | mode | status-note
|
||||
name: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SyncStateParity {
|
||||
status: string; // in-parity | O-pending | F-pending | conflict | unsynced-linked
|
||||
oPending: number;
|
||||
fPending: number;
|
||||
conflict: number;
|
||||
unsyncedLinked: number;
|
||||
lastPollAt: string | null;
|
||||
}
|
||||
|
||||
export interface SyncState {
|
||||
syncStateSchemaVersion: string;
|
||||
mode: string; // PREP | RUN-THE-MATCH
|
||||
autoSyncOn: boolean;
|
||||
lastSyncAt: string | null;
|
||||
parity: SyncStateParity;
|
||||
watchedDir: string;
|
||||
activity: SyncStateActivityEvent[];
|
||||
updatedAt: string;
|
||||
conflict: null; // reserved for E3 — E4 MUST NOT populate this
|
||||
}
|
||||
|
||||
/** Default state for a fresh install (or after a schema-mismatch reset). */
|
||||
export function defaultSyncState(watchedDir: string): SyncState {
|
||||
return {
|
||||
syncStateSchemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
||||
mode: "PREP",
|
||||
autoSyncOn: false, // fresh install — OFF (opt-in)
|
||||
lastSyncAt: null,
|
||||
parity: { status: "in-parity", oPending: 0, fPending: 0, conflict: 0, unsyncedLinked: 0, lastPollAt: null },
|
||||
watchedDir,
|
||||
activity: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
conflict: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Trim activity to the last 200 (newest first) on each append. */
|
||||
export const MAX_ACTIVITY = 200;
|
||||
|
||||
/** E4.1: load sync-state.json from <outDir>/sync-state.json. If absent, create
|
||||
* defaults. If the schema version mismatches, back up the old file to
|
||||
* sync-state.json.bak-<stamp> and write fresh defaults (with an error event).
|
||||
* Returns { state, freshened } where `freshened` is true if a reset happened. */
|
||||
export async function loadSyncState(outDir: string, watchedDir: string): Promise<{ state: SyncState; freshened: boolean }> {
|
||||
const path = join(outDir, "sync-state.json");
|
||||
let raw: string | null = null;
|
||||
try { raw = await readFile(path, "utf8"); } catch { /* absent */ }
|
||||
if (!raw) {
|
||||
const state = defaultSyncState(watchedDir);
|
||||
await saveSyncState(outDir, state);
|
||||
return { state, freshened: false };
|
||||
}
|
||||
let parsed: Partial<SyncState>;
|
||||
try { parsed = JSON.parse(raw) as Partial<SyncState>; }
|
||||
catch { parsed = {}; }
|
||||
if (parsed.syncStateSchemaVersion !== SYNC_STATE_SCHEMA_VERSION) {
|
||||
// Schema mismatch — back up the old file + write fresh defaults + an error event.
|
||||
try { await rename(path, `${path}.bak-${backupStamp()}`); } catch { /* best-effort */ }
|
||||
const state = defaultSyncState(watchedDir);
|
||||
state.activity.unshift({ time: new Date().toISOString(), kind: "error", name: "(state)", status: "error", message: `sync-state.json schema reset: ${parsed.syncStateSchemaVersion ?? "(absent)"} → ${SYNC_STATE_SCHEMA_VERSION}` });
|
||||
await saveSyncState(outDir, state);
|
||||
return { state, freshened: true };
|
||||
}
|
||||
// Merge with defaults so missing fields don't break readers (forward-compat).
|
||||
const state: SyncState = {
|
||||
...defaultSyncState(watchedDir),
|
||||
...parsed,
|
||||
parity: { ...defaultSyncState(watchedDir).parity, ...(parsed.parity ?? {}) },
|
||||
activity: Array.isArray(parsed.activity) ? parsed.activity.slice(0, MAX_ACTIVITY) : [],
|
||||
conflict: null, // E3 owns this; force null in E4
|
||||
};
|
||||
return { state, freshened: false };
|
||||
}
|
||||
|
||||
/** E4.1: atomically write sync-state.json (tmp + rename). Never throws out of a
|
||||
* state-mutation path — callers catch + log (the flag-off-failure-path rule). */
|
||||
export async function saveSyncState(outDir: string, state: SyncState): Promise<void> {
|
||||
const path = join(outDir, "sync-state.json");
|
||||
const tmp = `${path}.tmp`;
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
state.updatedAt = new Date().toISOString();
|
||||
await writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
/** E4.1: append an activity event + save (atomic). The activity array is newest-
|
||||
* first, trimmed to MAX_ACTIVITY. Best-effort (never throws). */
|
||||
export async function appendActivity(outDir: string, state: SyncState, event: SyncStateActivityEvent): Promise<void> {
|
||||
state.activity.unshift(event);
|
||||
if (state.activity.length > MAX_ACTIVITY) state.activity.length = MAX_ACTIVITY;
|
||||
await saveSyncState(outDir, state).catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
/** Read the sync-state.json mtime (for tests / diagnostics), or null if absent. */
|
||||
export async function syncStateMtime(outDir: string): Promise<number | null> {
|
||||
try { return (await stat(join(outDir, "sync-state.json"))).mtimeMs; } catch { return null; }
|
||||
}
|
||||
|
||||
// Re-export so E1b's flagsSchemaVersion migration + E4's syncStateSchemaVersion
|
||||
// are both reachable from one import (distinct names, distinct owners).
|
||||
export { FLAGS_SCHEMA_VERSION };
|
||||
136
tests/e4-1-syncstate.test.ts
Normal file
136
tests/e4-1-syncstate.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// E4.1 — persistent sync-state.json (load/save/atomic/schema-mismatch/restart/reconcile).
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { ClassicLevel } from "classic-level";
|
||||
import { mkdtemp, mkdir, rm, readFile, readdir } 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 { loadSyncState, saveSyncState, appendActivity, defaultSyncState, MAX_ACTIVITY, type SyncState } from "../src/sync-state.js";
|
||||
import { SYNC_STATE_SCHEMA_VERSION } from "../src/schema-version.js";
|
||||
import { startServer } from "../src/server.js";
|
||||
|
||||
let dir: string;
|
||||
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "e4-1-")); await mkdir(join(dir, "refined"), { recursive: true }); });
|
||||
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
||||
|
||||
describe("E4.1 SyncState load/save", () => {
|
||||
it("load creates defaults if absent (autoSyncOn=false, mode=PREP) + writes the file", async () => {
|
||||
const { state, freshened } = await loadSyncState(dir, "/vault/refined");
|
||||
expect(freshened).toBe(false);
|
||||
expect(state.autoSyncOn).toBe(false);
|
||||
expect(state.mode).toBe("PREP");
|
||||
expect(state.syncStateSchemaVersion).toBe(SYNC_STATE_SCHEMA_VERSION);
|
||||
expect(state.activity).toEqual([]);
|
||||
expect(state.conflict).toBeNull();
|
||||
expect(existsSync(join(dir, "sync-state.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("save is atomic (tmp+rename — no .tmp left after)", async () => {
|
||||
const { state } = await loadSyncState(dir, "/vault/refined");
|
||||
state.autoSyncOn = true;
|
||||
await saveSyncState(dir, state);
|
||||
const files = await readdir(dir);
|
||||
expect(files).toContain("sync-state.json");
|
||||
expect(files.some((f) => f.endsWith(".tmp"))).toBe(false);
|
||||
const reloaded = JSON.parse(await readFile(join(dir, "sync-state.json"), "utf8")) as SyncState;
|
||||
expect(reloaded.autoSyncOn).toBe(true);
|
||||
});
|
||||
|
||||
it("schema mismatch → backs up the old file + writes fresh defaults + an error event", async () => {
|
||||
// Write a stale-version file.
|
||||
const stale = { ...defaultSyncState("/vault/refined"), syncStateSchemaVersion: "sync-state/v0" };
|
||||
await saveSyncState(dir, stale);
|
||||
const { state, freshened } = await loadSyncState(dir, "/vault/refined");
|
||||
expect(freshened).toBe(true);
|
||||
expect(state.syncStateSchemaVersion).toBe(SYNC_STATE_SCHEMA_VERSION);
|
||||
expect(state.autoSyncOn).toBe(false); // fresh defaults
|
||||
expect(state.activity.length).toBe(1);
|
||||
expect(state.activity[0].kind).toBe("error");
|
||||
expect(state.activity[0].message).toMatch(/schema reset/);
|
||||
// The old file was backed up (.bak-<stamp>).
|
||||
const files = await readdir(dir);
|
||||
expect(files.some((f) => f.startsWith("sync-state.json.bak-"))).toBe(true);
|
||||
});
|
||||
|
||||
it("restart survival: autoSyncOn + activity preserved across reload", async () => {
|
||||
const { state } = await loadSyncState(dir, "/vault/refined");
|
||||
state.autoSyncOn = true;
|
||||
state.lastSyncAt = "2026-06-23T01:00:00.000Z";
|
||||
for (let i = 0; i < 17; i++) state.activity.push({ time: `2026-06-23T0:${i}:00.000Z`, kind: "push", name: `N${i}`, status: "pushed", message: `m${i}` });
|
||||
await saveSyncState(dir, state);
|
||||
// Reload — the persisted state is restored.
|
||||
const { state: reloaded } = await loadSyncState(dir, "/vault/refined");
|
||||
expect(reloaded.autoSyncOn).toBe(true);
|
||||
expect(reloaded.lastSyncAt).toBe("2026-06-23T01:00:00.000Z");
|
||||
expect(reloaded.activity.length).toBe(17);
|
||||
});
|
||||
|
||||
it("activity is trimmed to MAX_ACTIVITY on append", async () => {
|
||||
const { state } = await loadSyncState(dir, "/vault/refined");
|
||||
for (let i = 0; i < MAX_ACTIVITY + 50; i++) {
|
||||
await appendActivity(dir, state, { time: `t${i}`, kind: "push", name: `N${i}`, status: "pushed", message: `m${i}` });
|
||||
}
|
||||
expect(state.activity.length).toBe(MAX_ACTIVITY);
|
||||
const reloaded = JSON.parse(await readFile(join(dir, "sync-state.json"), "utf8")) as SyncState;
|
||||
expect(reloaded.activity.length).toBe(MAX_ACTIVITY);
|
||||
});
|
||||
|
||||
it("conflict field is forced to null by E4 (reserved for E3)", async () => {
|
||||
// Even if a file has a non-null conflict, E4 forces it null on load.
|
||||
const { state } = await loadSyncState(dir, "/vault/refined");
|
||||
(state as unknown as { conflict: unknown }).conflict = { foo: "bar" };
|
||||
await saveSyncState(dir, state);
|
||||
const { state: reloaded } = await loadSyncState(dir, "/vault/refined");
|
||||
expect(reloaded.conflict).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("E4.1 boot reconcile (startServer restores autoSyncOn)", () => {
|
||||
async function bootWithState(state: Partial<SyncState>, opts: { relay?: boolean; mode?: "dev" | "apply" } = {}): Promise<{ server: Server; enabledAfter: boolean; stateAfter: SyncState }> {
|
||||
// Pre-write sync-state.json in outDir.
|
||||
const outDir = join(dir, "out");
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const full = { ...defaultSyncState(join(dir, "refined")), ...state } as SyncState;
|
||||
await saveSyncState(outDir, full);
|
||||
// Create an empty journal LevelDB.
|
||||
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
|
||||
await jdb.open(); await jdb.close();
|
||||
const { server, state: srvState } = await startServer({
|
||||
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
|
||||
outDir, mode: opts.mode ?? "apply", port: 0, host: "127.0.0.1",
|
||||
relayCfg: opts.relay ? { url: "http://relay.test", apiKey: "k", clientId: "c" } : undefined,
|
||||
});
|
||||
const enabledAfter = srvState.autosync.enabled;
|
||||
const stateAfter = JSON.parse(await readFile(join(outDir, "sync-state.json"), "utf8")) as SyncState;
|
||||
// Clean up the watcher (auto-sync may be ON).
|
||||
srvState.autosync.stop();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
return { server, enabledAfter, stateAfter };
|
||||
}
|
||||
|
||||
it("autoSyncOn=true + apply mode + relay → controller.enabled restored to true", async () => {
|
||||
const { enabledAfter } = await bootWithState({ autoSyncOn: true }, { relay: true, mode: "apply" });
|
||||
expect(enabledAfter).toBe(true);
|
||||
});
|
||||
|
||||
it("autoSyncOn=true + no relay → autoSyncOn flipped to false + error event", async () => {
|
||||
const { enabledAfter, stateAfter } = await bootWithState({ autoSyncOn: true }, { relay: false, mode: "apply" });
|
||||
expect(enabledAfter).toBe(false); // start() threw (no relay) → flipped off
|
||||
expect(stateAfter.autoSyncOn).toBe(false);
|
||||
expect(stateAfter.activity.some((e) => e.message.includes("auto-sync could not resume"))).toBe(true);
|
||||
});
|
||||
|
||||
it("autoSyncOn=true + dev mode (no apply) → autoSyncOn flipped to false (apply-mode gate)", async () => {
|
||||
const { enabledAfter, stateAfter } = await bootWithState({ autoSyncOn: true }, { relay: true, mode: "dev" });
|
||||
expect(enabledAfter).toBe(false); // start() threw (dev mode) → flipped off
|
||||
expect(stateAfter.autoSyncOn).toBe(false);
|
||||
});
|
||||
|
||||
it("autoSyncOn=false (fresh install) → stays off", async () => {
|
||||
const { enabledAfter } = await bootWithState({ autoSyncOn: false }, { relay: true, mode: "apply" });
|
||||
expect(enabledAfter).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user