// Poke → `fs:any` → the existing HMR heal — Sync v2 Increment 2 (DDR-226 §6).
//
// This is the last hop of the fix, and it deliberately adds no new UI path: the
// studio already knows how to repoint a broken `
` when a media file lands
// (`canvas-hmr {mode:'asset'}`, DDR-224) and how to hot-swap a stylesheet. All
// that was ever missing in a container was the EVENT — `fs.watch` does not fire
// for the hub process's atomic tmp+rename writes, so the child never learned.
//
// So: the hub pokes, this asks the journal WHICH paths moved, and emits the
// `fs:any` the watcher owed us. Everything downstream is unchanged.
//
// WHY IT ASKS INSTEAD OF BEING TOLD. The poke carries a head and nothing else,
// on purpose (DDR-054 — a frame carrying a path would be a path the hub chose).
// The journal read is authenticated and scope-filtered, its rows are re-shaped
// on arrival (`journal-client.ts`), and this only ever emits a bus event for a
// path — it materializes nothing. A hostile hub's best case here is making the
// child re-read files it already has.
//
// A LOST POKE COSTS LATENCY, NEVER CORRECTNESS. The cursor only ever moves
// forward on a page we actually parsed, and the 20 s reconciler poll is still
// underneath. If the channel is down for an hour, the heal is late by an hour;
// nothing diverges.
import { fetchJournal } from './journal-client.ts';
/** Coalesce a burst of pokes into one journal read. */
const READ_DEBOUNCE_MS = 150;
export interface CtlHealerOptions {
hubUrl: string;
token: string;
/** Emit the `fs:any` the container's watcher failed to (one per path). */
emit: (rel: string) => void;
fetchImpl?: typeof fetch;
log?: Pick;
debounceMs?: number;
setTimeoutImpl?: typeof setTimeout;
clearTimeoutImpl?: typeof clearTimeout;
}
export interface CtlHealer {
/** A poke arrived. `head` is the hub's hint; the journal is the answer. */
onPoke(head: number): void;
/**
* Set the baseline from the hub's CURRENT head, replaying nothing.
*
* Without this the cursor is adopted from the FIRST POKE — and the hub pokes
* only when the journal appends, so that first poke IS a change this child
* has not seen. Adopting its head as the baseline therefore swallowed exactly
* one change per boot: the first asset a peer delivered after a cell started
* never healed an open canvas, and looked like the channel was dead. Called
* once at attach; a failure is harmless (the first poke still anchors, as
* before).
*/
anchor(): Promise;
/** Read now (tests; boot). Resolves once the pass is done. */
drain(): Promise;
stop(): void;
/** Paths announced so far — the receiver half of the honesty counters. */
healed(): number;
/** Pokes whose head was at or below the cursor: pure noise, and expected. */
ignored(): number;
}
/**
* Turn pokes into heal events.
*
* The cursor starts at the FIRST head we are told about rather than at 0. A
* child that has just booted has already read the checkout from disk — every
* row before now describes a file it can already see, so replaying them would
* be a reload storm on every cell wake for zero benefit. What matters is
* everything AFTER the child started looking.
*/
export function createCtlHealer(opts: CtlHealerOptions): CtlHealer {
const log = opts.log ?? console;
const debounceMs = opts.debounceMs ?? READ_DEBOUNCE_MS;
const setTimeoutImpl = opts.setTimeoutImpl ?? setTimeout;
const clearTimeoutImpl = opts.clearTimeoutImpl ?? clearTimeout;
let cursor: number | null = null;
let epoch: string | null = null;
let timer: ReturnType | null = null;
let inFlight: Promise | null = null;
let again = false;
let stopped = false;
let healed = 0;
let ignored = 0;
/**
* Is there anything to read?
*
* Without this, `drain()` would fire a request every time it is called even
* when nothing has moved — which on a quiet project is a poll we did not ask
* for, against a route that is rate-limited. Only `schedule()` sets it, and
* only a completed read clears it.
*/
let dirty = false;
async function readOnce(): Promise {
if (stopped || cursor === null || !dirty) return;
dirty = false;
const page = await fetchJournal({
hubUrl: opts.hubUrl,
token: opts.token,
since: cursor,
epoch,
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
});
// Unreachable / refused / unparseable — ask again on the next poke or the
// next poll. Never advance the cursor on a page we did not read, and stay
// dirty so the retry actually retries rather than short-circuiting.
if (page === null) {
dirty = true;
return;
}
if (page.reanchor) {
// The log no longer contains our cursor (an epoch rotation, or a
// compaction past it). For the HEAL path specifically there is nothing to
// replay — the child re-reads the tree from disk on demand anyway — so
// the honest move is to jump to the new head and say so, rather than
// pretend a page arrived.
log.warn?.(
`[sync/ctl] the hub asked us to re-anchor (${page.reason ?? 'cursor not in this log'}); heal cursor moves to ${page.head}.`
);
cursor = page.head;
epoch = page.epoch;
return;
}
epoch = page.epoch;
// WHAT THIS PASS ACTUALLY ANNOUNCED. The counters exist but nothing reads
// them, so "the poke arrived but the canvas never repainted" was a question
// with no evidence on either side of it. One line per non-empty pass, named
// paths, capped — the receiving half of the "N poke(s) folded" line the
// sender already prints.
const announced: string[] = [];
for (const entry of page.entries) {
// A tombstone is not a heal — Increment 6 owns deletion, and emitting
// `fs:any` for a vanished path would make the canvas layer look for a
// file that is deliberately gone.
if (entry.deleted) continue;
try {
opts.emit(entry.path);
healed += 1;
announced.push(entry.path);
} catch (err) {
log.warn?.(`[sync/ctl] heal emit failed for ${entry.path}: ${(err as Error).message}`);
}
}
if (announced.length > 0) {
const shown = announced.slice(0, 5).join(', ');
log.log?.(
`[sync/ctl] healed ${announced.length} path(s) from the journal: ${shown}${
announced.length > 5 ? `, +${announced.length - 5} more` : ''
}`
);
}
// Advance only over what we actually consumed. `truncated` means the next
// pass has more, and the poke that follows (or the next drain) takes it.
const last = page.entries.at(-1);
cursor = last ? last.seq : page.head;
if (page.truncated) schedule();
}
function schedule(): void {
if (stopped) return;
dirty = true;
if (timer !== null) return;
timer = setTimeoutImpl(() => {
timer = null;
void drain();
}, debounceMs);
timer.unref?.();
}
async function drain(): Promise {
if (inFlight) {
again = true;
return inFlight;
}
inFlight = (async () => {
try {
await readOnce();
} finally {
inFlight = null;
if (again) {
again = false;
void drain();
}
}
})();
return inFlight;
}
return {
async anchor(): Promise {
if (stopped || cursor !== null) return;
const page = await fetchJournal({
hubUrl: opts.hubUrl,
token: opts.token,
since: 0,
epoch: null,
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
});
// Unreachable → stay unanchored; `onPoke` still has the old fallback.
if (page === null || cursor !== null) return;
cursor = page.head;
epoch = page.epoch;
},
onPoke(head: number): void {
if (stopped) return;
// A HEAD BELOW THE CURSOR IS A QUESTION FOR THE JOURNAL, NOT NOISE.
//
// `reanchor` recovers from an epoch rotation, a compaction, or a
// restore-from-backup — and every one of those moves the head BACKWARD,
// which the "at or below the cursor is noise" rule below then swallowed.
// The recovery path was therefore unreachable from precisely the states it
// was written for, and a cursor parked above the log (an over-large head,
// honest or not) left the healer permanently deaf.
//
// We do NOT move the cursor here — a coalesced or reordered frame can
// carry a stale head, and trusting it would rewind a healthy cursor on the
// hub's say-so. We only ASK: `GET /api/journal` answers `reanchor` when
// `since > head` (its own rule), and the branch in `readOnce` then takes
// the new head and epoch from a page we actually read.
if (cursor !== null && Number.isFinite(head) && head < cursor) {
schedule();
return;
}
if (cursor === null) {
// First contact: adopt the hub's head as the baseline. Everything
// before it is already on disk and already rendered.
cursor = head;
return;
}
if (head <= cursor) {
ignored += 1;
return;
}
schedule();
},
drain,
stop(): void {
stopped = true;
if (timer !== null) {
clearTimeoutImpl(timer);
timer = null;
}
},
healed: () => healed,
ignored: () => ignored,
};
}