// The cell child's file-event wiring — Sync v2 Increment 2/3 (DDR-226 §4/§6). // // The doorbell, both buttons, assembled once at boot: // // resolveCellCtl → do we have a loopback hub and a token? (workspace mode) // createCtlProvider → hold the read-only `maude.files` channel open // createCtlHealer → hub → child: poke ⇒ read the journal ⇒ emit the // `fs:any` the container's watcher owed us // createCellWriteNudge → child → hub: we wrote ⇒ name the paths ⇒ the hub // re-reads its own disk and journals them // // The two directions are deliberately symmetric and deliberately unequal in // what they carry: the hub's frame carries a head and no path (DDR-054 — a path // would be a path the hub chose), and the child's carries paths and no content // (the hub re-stats and re-hashes for itself). Neither end can make the other // believe something about bytes it did not look at. // // Downstream of the heal, everything is machinery that already exists: // `fs:any` → `createHmrBroadcaster` → `canvas-hmr {mode:'asset'|'css'|'module'}` // → the iframe repoints a broken `` (DDR-224) or swaps a stylesheet. No new // UI path, no new reload semantics. // // GATED ON WORKSPACE MODE, NOT ON CELL PAIRING — the whole point. Pairing is a // one-tenant pilot allowlist; the watcher gap is on every cell. See the header // of `ctl-provider.ts` for why the pairing preconditions do not apply to a // read-only stateless channel. // // Locally this never starts: outside a container `fs.watch` fires and a second // event source would double-reload every canvas on every edit, exactly the // reason `createContainerWriteBridge` carries the same gate. import type { Context } from '../context.ts'; import { createCellWriteNudge } from './cell-write-nudge.ts'; import { createCtlHealer } from './ctl-heal.ts'; import { createCtlProvider, resolveCellCtl } from './ctl-provider.ts'; export interface CellFileEvents { stop(): void; /** Pokes received on the channel. */ received(): number; /** Paths announced onward as `fs:any`. */ healed(): number; /** Nudge requests the hub accepted. */ nudged(): number; /** Paths named in those requests. */ nudgedPaths(): number; } /** * Start the hub→child file-event bridge. Returns null when this process is not * a cell studio child (which is every desktop, and every local dev server). * * Never throws: the studio must start whether or not it has a doorbell. */ export function startCellFileEvents( ctx: Context, env: Record = process.env ): CellFileEvents | null { const target = resolveCellCtl(env); if (!target) return null; const nudge = createCellWriteNudge({ hubUrl: target.url, token: target.token, }); const healer = createCtlHealer({ hubUrl: target.url, token: target.token, // The one side effect: announce a path onto the bus. Nothing here writes a // file, and nothing here trusts the hub's row beyond its shape — the // canvas layer re-reads the real disk to decide what to do about it. // // The mute is what stops us telling the hub a fact it just told us. It is // not a loop guard — `recordWrite` is a no-op on identical bytes, so the // echo would die on its own — it is simply not paying for the round trip. emit: (rel) => { nudge.mute(rel); ctx.bus.emit('fs:any', rel); }, }); // Baseline the heal cursor from the hub's head BEFORE the first poke — see // `anchor`. Fire-and-forget: the studio must start whether or not the hub // answers, and an unanchored healer still works the way it always did. void healer.anchor(); const provider = createCtlProvider({ url: target.url, token: target.token, onPoke: (head) => healer.onPoke(head), // The paired cell runtime does not open a second control provider. Route // metadata invalidation to its bounded discovery queue, not the journal. onDocuments: () => ctx.bus.emit('sync:documents-changed'), }); // Every write this process makes surfaces here, because both synthetic-event // sources (`createContainerWriteBridge` off `activity:suppress`, and // `announceWrite` off the projector) converge on `fs:any`. See the module // header for why that is the complete set and why incompleteness would only // cost latency. const offFsAny = ctx.bus.on('fs:any', (rel: string) => nudge.note(rel)); console.log( '[sync/ctl] file-event channel attached to this cell’s own hub — hub-process writes heal open canvases without a reload, and writes made here reach the journal at once instead of on the walk-import belt.' ); return { stop() { offFsAny(); provider.stop(); healer.stop(); nudge.stop(); }, received: () => provider.received(), healed: () => healer.healed(), nudged: () => nudge.sent(), nudgedPaths: () => nudge.named(), }; }