@1agh/maude 0.60.2 → 0.60.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,7 @@ import type { Context, LinkedHub } from '../context.ts';
29
29
  import { createHistory } from '../history.ts';
30
30
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
31
31
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
32
+ import { isPushableAssetRel } from './asset-push.ts';
32
33
  import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
33
34
  import { atomicWrite } from './atomic-write.ts';
34
35
  import { createAutoCommit } from './autocommit.ts';
@@ -39,6 +40,7 @@ import {
39
40
  createConnectionMonitor,
40
41
  type ProviderStatus,
41
42
  } from './connection-state.ts';
43
+ import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
42
44
  import { createDocNameResolver } from './doc-name.ts';
43
45
  import { createEchoGuard } from './echo-guard.ts';
44
46
  import { createFsReader, type FsReader } from './fs-mirror.ts';
@@ -122,6 +124,46 @@ export const RENEW_MIN_INTERVAL_MS = 60_000;
122
124
  export const RENEW_MAX_WITHOUT_PROGRESS = 3;
123
125
  /** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
124
126
  * far-future expiry into a tight renewal loop. Clamp before we hand it over. */
127
+ /**
128
+ * Quiet window before a canvas-set change triggers a rescan.
129
+ *
130
+ * Slightly longer than `canvas-list-watch.ts`'s own 150 ms, on purpose: a create
131
+ * writes the `.tsx` and then the `.meta.json`, and attaching in the gap would
132
+ * sync a canvas whose sidecar is about to say `syncable: false`. Letting the
133
+ * cheaper watcher settle first means the rescan sees a finished canvas.
134
+ */
135
+ export const DISCOVERY_DEBOUNCE_MS = 400;
136
+
137
+ /**
138
+ * How often a peer re-asks the hub what the project contains.
139
+ *
140
+ * The floor is set by what a person would call "immediately" for a canvas
141
+ * somebody else just made, and the ceiling by the fact that this runs per peer,
142
+ * per project, forever. 20 s is a name-and-size listing — a few hundred bytes —
143
+ * and it is the ONLY discovery lane a hub of any version can serve.
144
+ */
145
+ export const REMOTE_POLL_MS = 20_000;
146
+
147
+ /**
148
+ * Settling delay for an OFF-SCHEDULE poll (reconnect).
149
+ *
150
+ * Not zero: a reconnect is a burst — every provider re-handshakes — and asking
151
+ * the hub for the listing in the middle of that buys a slower answer and a
152
+ * needless second one. Short enough that a person does not experience it.
153
+ */
154
+ export const REMOTE_POLL_SOON_MS = 1_500;
155
+
156
+ /**
157
+ * How many previously-unknown canvases one listing may land.
158
+ *
159
+ * A ceiling on the TOTAL is not enough on its own: a hub that answers with
160
+ * thousands of names would still create thousands of files, providers and
161
+ * `_untrusted` entries inside a single tick before anything else got to run.
162
+ * A real project gains canvases a few at a time; a burst larger than this is a
163
+ * hub behaving unlike any project, and the rest simply arrive on later polls.
164
+ */
165
+ export const MAX_PULLS_PER_POLL = 25;
166
+
125
167
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
126
168
  /** F2 — a hub-reported `expiresAt` further out than this is not believed for
127
169
  * scheduling (cloud cells issue ≤ 12 h; a month is already implausible). */
@@ -197,6 +239,33 @@ export type ProviderFactory = (args: {
197
239
  export interface SyncRuntime {
198
240
  start(): Promise<void>;
199
241
  stop(): Promise<void>;
242
+ /**
243
+ * Take ownership of canvases discovered AFTER `start()` — the incremental
244
+ * half of `start()`'s boot set. Returns how many were newly attached
245
+ * (already-attached slugs are skipped, not re-opened).
246
+ *
247
+ * Deliberately NOT a restart: a restart re-links every canvas in the project
248
+ * and costs a full handshake fan-out, which is why the Resync button is
249
+ * rate-limited. Adoption is per-canvas and cheap, so it can run on every
250
+ * discovery without the user authorising anything.
251
+ */
252
+ adopt(canvases: readonly CanvasDescriptor[]): Promise<number>;
253
+ /** Give up canvases that left the project. Returns how many were released. */
254
+ release(slugs: readonly string[]): Promise<number>;
255
+ /**
256
+ * Run the local canvas rescan immediately instead of waiting for the debounce.
257
+ *
258
+ * The discovery loop is event-driven (`canvas-list-update`); this is the seam
259
+ * tests use to make it deterministic, and the hook a caller can use to force a
260
+ * check without paying for a full runtime cycle the way Resync does.
261
+ */
262
+ rescanNow(): Promise<void>;
263
+ /**
264
+ * Ask the hub what the project contains right now, and pull down anything
265
+ * this peer does not have — the remote half of `rescanNow`, off the poll's
266
+ * schedule. Test seam, and the hook behind a manual "check now".
267
+ */
268
+ pullRemoteNow(): Promise<void>;
200
269
  /** Number of active per-canvas agents. */
201
270
  size(): number;
202
271
  /** Test inspection — get the agent for a slug if one was created. */
@@ -233,6 +302,8 @@ export interface CreateSyncRuntimeOptions {
233
302
  connectionMonitor?: ConnectionMonitor;
234
303
  /** Override the status store (Task 8 test injection). */
235
304
  statusStore?: SyncStatusStore;
305
+ /** Override the asset-sweep runner (test injection — no child process). */
306
+ assetSweepRunner?: typeof runAssetSweep;
236
307
  /**
237
308
  * DDR-102 — auth-failure + boot-settle knobs (test injection, mirrors the
238
309
  * connection-state injectable-timer pattern).
@@ -444,8 +515,31 @@ export function createSyncRuntime(
444
515
  // per run (chosen by useSharedDoc).
445
516
  const projections = new Map<string, DocProjection>();
446
517
  const providers = new Map<string, SyncProvider>();
447
- const awarenessDetaches: Array<() => void> = [];
448
- const statusDetaches: Array<() => void> = [];
518
+ // KEYED BY SLUG, not flat arrays.
519
+ //
520
+ // These used to be two bare lists drained only by `stop()`, which was right
521
+ // while the canvas set was fixed at boot. Continuous discovery makes a canvas
522
+ // leave the runtime on its own (deleted on disk, moved out of a synced group),
523
+ // and releasing one means running exactly ITS closures — a flat list cannot
524
+ // say which those are. `stop()` still drains everything, in the same two
525
+ // phases and the same order as before.
526
+ const awarenessDetaches = new Map<string, Array<() => void>>();
527
+ const statusDetaches = new Map<string, Array<() => void>>();
528
+ const noteDetach = (map: Map<string, Array<() => void>>, slug: string, fn: () => void): void => {
529
+ const list = map.get(slug);
530
+ if (list) list.push(fn);
531
+ else map.set(slug, [fn]);
532
+ };
533
+ const runDetaches = (map: Map<string, Array<() => void>>, slug: string): void => {
534
+ for (const detach of map.get(slug) ?? []) {
535
+ try {
536
+ detach();
537
+ } catch {
538
+ /* best-effort — registry teardown also clears bridges on destroyAll */
539
+ }
540
+ }
541
+ map.delete(slug);
542
+ };
449
543
  // Phase 9.2 (DDR-064) — slugs pinned in the registry because a provider is
450
544
  // attached to their shared doc; released on stop(). Empty unless sharedDoc.
451
545
  const pinnedSlugs = new Set<string>();
@@ -459,6 +553,72 @@ export function createSyncRuntime(
459
553
  let stopped = false;
460
554
  /** The live asset sweep, so `stop()` can end it and the panel can cancel it. */
461
555
  let assetSweep: AssetSweepHandle | null = null;
556
+ /** Debounce for the on-change sweep — see `scheduleAssetSweep`. */
557
+ let assetSweepTimer: ReturnType<typeof setTimeout> | null = null;
558
+ /** A change that arrived while a sweep was running: sweep once more after. */
559
+ let assetSweepAgain = false;
560
+
561
+ /**
562
+ * Run the asset sweep now, unless one is already running.
563
+ *
564
+ * OUT OF PROCESS since feature-sync-resync-and-out-of-process-sweep: the
565
+ * sweep segfaults Bun when it runs alongside the dev server (proven by
566
+ * isolation — the identical sweep against the same hub completes standalone).
567
+ * A dead child is a reported failed sweep, not a dead editor.
568
+ */
569
+ function startAssetSweep(hubUrl: string): void {
570
+ if (stopped || assetSweep) return;
571
+ const handle = (opts.assetSweepRunner ?? runAssetSweep)({
572
+ designRoot: ctx.paths.designRoot,
573
+ hubUrl,
574
+ // Read at call time — a silent renewal mid-sweep must reach the child
575
+ // (the parent re-writes its credential file when this changes).
576
+ token: () => token,
577
+ // feature-sync-progress-modal — ride the same `sync:status` payload the
578
+ // doc counts use, so the Sync panel has one source. `statusStore`, not
579
+ // start()'s local `store` alias: this helper is runtime-scoped so it can
580
+ // also be called from the fs watcher, and the alias does not exist here.
581
+ // Guarded on `stopped`: a late emit must not write `_sync.json`
582
+ // post-teardown.
583
+ onProgress: (p) => {
584
+ if (!stopped) statusStore?.updateAssets?.(p);
585
+ },
586
+ });
587
+ assetSweep = handle;
588
+ handle.done.finally(() => {
589
+ if (assetSweep === handle) assetSweep = null;
590
+ // A file that changed WHILE this sweep ran was not in its list — the
591
+ // trailing re-run is what keeps "I pasted two images quickly" from
592
+ // uploading only the first. Same single-flight-with-trailing-run shape
593
+ // the hub's own sweeper uses.
594
+ if (assetSweepAgain && !stopped) {
595
+ assetSweepAgain = false;
596
+ scheduleAssetSweep(hubUrl);
597
+ }
598
+ });
599
+ }
600
+
601
+ /**
602
+ * Coalesce a burst of asset writes into one sweep.
603
+ *
604
+ * Dragging six images onto a canvas is six `fs:any` events inside a second,
605
+ * and each sweep costs one presence probe over the wire. The debounce makes
606
+ * that one probe; `assetSweepAgain` makes a change during a sweep a second
607
+ * pass rather than a lost upload.
608
+ */
609
+ function scheduleAssetSweep(hubUrl: string): void {
610
+ if (stopped || cellPairing) return;
611
+ if (assetSweep) {
612
+ assetSweepAgain = true;
613
+ return;
614
+ }
615
+ if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
616
+ assetSweepTimer = setTimeout(() => {
617
+ assetSweepTimer = null;
618
+ startAssetSweep(hubUrl);
619
+ }, ASSET_SWEEP_DEBOUNCE_MS);
620
+ assetSweepTimer.unref?.();
621
+ }
462
622
 
463
623
  // Task 8 — offline-mode status surface, initialized in start() once the
464
624
  // canvas count is known. The store writes `_sync.json` + broadcasts
@@ -525,6 +685,160 @@ export function createSyncRuntime(
525
685
  * that no longer exists. */
526
686
  const announceTimers = new Map<string, ReturnType<typeof setTimeout>>();
527
687
 
688
+ /**
689
+ * Assigned by `start()`. Adopting a canvas needs the boot closure (the
690
+ * journal, the history, the status store, `connectCanvas`), so the function
691
+ * is built there and published here for `adopt()` to reach. Null in solo mode
692
+ * and after `stop()`.
693
+ */
694
+ let attachOne: ((canvas: CanvasDescriptor, boot: boolean) => Promise<void>) | null = null;
695
+
696
+ /** Continuous local discovery — armed at the end of `start()`, torn down in
697
+ * `stop()`. See the block that creates it and `discovery.ts`. */
698
+ let discoveryRescan: RescanScheduler | null = null;
699
+ let discoveryUnsub: (() => void) | null = null;
700
+ /** Periodic remote-document poll — the hub-side half of discovery. */
701
+ let remotePollTimer: ReturnType<typeof setInterval> | null = null;
702
+ /** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
703
+ let remotePull: (() => Promise<void>) | null = null;
704
+ /**
705
+ * Ask for an off-schedule remote poll, coalesced.
706
+ *
707
+ * Reachable before `start()` finishes wiring `remotePull` (the monitor is
708
+ * built first and can emit during boot), so it is a no-op until there is
709
+ * something to call — the boot pull has just run at that point anyway.
710
+ */
711
+ let remotePollSoonTimer: ReturnType<typeof setTimeout> | null = null;
712
+ function pollRemoteSoon(): void {
713
+ if (stopped || remotePollSoonTimer !== null) return;
714
+ remotePollSoonTimer = setTimeout(() => {
715
+ remotePollSoonTimer = null;
716
+ if (stopped) return;
717
+ void remotePull?.().catch(() => {
718
+ /* the scheduled poll retries — a missed opportunistic one is not news */
719
+ });
720
+ }, REMOTE_POLL_SOON_MS);
721
+ remotePollSoonTimer.unref?.();
722
+ }
723
+ /** Every slug this run brought down, so the panel's "came down from the
724
+ * project" list accumulates instead of being replaced by the latest batch. */
725
+ const everPulled = new Set<string>();
726
+
727
+ /**
728
+ * The LIVE descriptor set, by slug.
729
+ *
730
+ * `start()`'s `canvases` array is the BOOT set and stops being the truth the
731
+ * moment a canvas is adopted or released. The DDR-054 §3 F3 untrusted markers
732
+ * must describe what is actually attached — a marker naming a canvas that
733
+ * left, or silently omitting one that arrived, is the control pointing at a
734
+ * phantom. Descriptors are held BY REFERENCE: `relocatePulled` mutates them in
735
+ * place, and the map must see that.
736
+ */
737
+ const descriptors = new Map<string, CanvasDescriptor>();
738
+
739
+ /** Warnings already emitted, so a per-poll refusal is said once, not forever. */
740
+ const warnedOnce = new Set<string>();
741
+ function warnOnce(key: string, message: string): void {
742
+ if (warnedOnce.has(key)) return;
743
+ warnedOnce.add(key);
744
+ console.warn(message);
745
+ }
746
+
747
+ /**
748
+ * May a HUB-AUTHORED body of this shape be written to this disk at all?
749
+ *
750
+ * The local lane has asked this since 9.1-B: `scanCanvases` admits a `.tsx`
751
+ * only when the cross-origin sandbox is active AND the project has not opted
752
+ * out (`walk` → `resolveSyncable`; DDR-060 couples the two, DDR-079 sets the
753
+ * default). The PULL lane never asked it — so a peer running with the sandbox
754
+ * off, or with `linkedHub.syncTsx: false` set precisely to keep hub `.tsx` off
755
+ * this machine, still received hub-authored `.tsx` bodies and rendered them.
756
+ * With the sandbox off that render is on the MAIN origin, which is the exact
757
+ * execution the coupling exists to prevent.
758
+ *
759
+ * The gap predates continuous discovery — it was reachable once per connect —
760
+ * but a lane that re-asks every 20 s makes "the opt-out holds for a moment"
761
+ * indistinguishable from "the opt-out does nothing".
762
+ */
763
+ function admitPulledBody(slug: string, bodyAbs: string): boolean {
764
+ if (!bodyAbs.toLowerCase().endsWith('.tsx')) return true;
765
+ const splitActive = !!ctx.canvasOrigin;
766
+ const projectSyncTsx = linkedHub.syncTsx !== false;
767
+ if (splitActive && projectSyncTsx) return true;
768
+ warnOnce(
769
+ `pull-tsx-refused:${splitActive ? 'opt-out' : 'sandbox-off'}`,
770
+ `[sync] refusing hub-authored .tsx canvases (first: ${slug}) — ` +
771
+ (splitActive
772
+ ? 'this project set linkedHub.syncTsx: false.'
773
+ : 'the cross-origin sandbox is off (MAUDE_CANVAS_ORIGIN_SPLIT=0), and TSX sync with it — DDR-060.') +
774
+ ' The same gate the local scan applies, now applied to what the hub sends.'
775
+ );
776
+ return false;
777
+ }
778
+
779
+ /**
780
+ * Give up ONE canvas — the inverse of `attachOne`.
781
+ *
782
+ * Order is deliberate and is NOT `stop()`'s order. `stop()` unpins before it
783
+ * flushes because the whole process is going away and the rooms are being
784
+ * torn down anyway; here the room OUTLIVES the release, so the doc→file
785
+ * projection must flush FIRST and the pin is dropped last. Unpinning early
786
+ * would hand the room to the last-browser-leaves drop while the projector was
787
+ * still writing through it.
788
+ *
789
+ * The shared WebSocket is deliberately untouched: it belongs to the runtime,
790
+ * not to a canvas, and the next adopt will need it. Only `stop()` disposes it.
791
+ */
792
+ async function releaseOne(slug: string): Promise<boolean> {
793
+ const known = agents.has(slug) || projections.has(slug) || providers.has(slug);
794
+ if (!known) return false;
795
+ runDetaches(awarenessDetaches, slug);
796
+ runDetaches(statusDetaches, slug);
797
+ const agent = agents.get(slug);
798
+ if (agent) {
799
+ try {
800
+ await agent.flush();
801
+ agent.stop();
802
+ } catch {
803
+ /* best-effort */
804
+ }
805
+ agents.delete(slug);
806
+ }
807
+ const projection = projections.get(slug);
808
+ if (projection) {
809
+ try {
810
+ await projection.flush();
811
+ projection.stop();
812
+ } catch {
813
+ /* best-effort */
814
+ }
815
+ projections.delete(slug);
816
+ }
817
+ const provider = providers.get(slug);
818
+ if (provider) {
819
+ try {
820
+ provider.destroy();
821
+ } catch {
822
+ /* best-effort */
823
+ }
824
+ providers.delete(slug);
825
+ }
826
+ if (pinnedSlugs.delete(slug)) {
827
+ try {
828
+ opts.registry?.unpin?.(slug);
829
+ } catch {
830
+ /* best-effort */
831
+ }
832
+ }
833
+ descriptors.delete(slug);
834
+ // Drop the row too — a released canvas that stayed `pending` in the status
835
+ // payload would be a permanent "still syncing" the user can never clear.
836
+ monitor?.forgetDoc(slug);
837
+ rejectedPermanent.delete(slug);
838
+ rejectedReasons.delete(slug);
839
+ return true;
840
+ }
841
+
528
842
  async function start(): Promise<void> {
529
843
  if (started || stopped) return;
530
844
  started = true;
@@ -683,12 +997,46 @@ export function createSyncRuntime(
683
997
  // in the same place: `system-colors_and_type` falls back to
684
998
  // `system/colors_and_type.tsx` whether or not a path arrives. Checking only
685
999
  // the carried path leaves every one of these reachable with no path at all.
686
- const admittedPulls = pulled.filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs));
1000
+ // The boot pull asks the SAME two questions the incremental one does — the
1001
+ // sandbox/opt-out gate on a hub-authored body, and the pinned-room ceiling.
1002
+ // Both were missing here too; the incremental lane just made their absence
1003
+ // permanent instead of momentary. See `admitPulledBody` and MAX_PULLS_PER_POLL.
1004
+ const admittedPulls = pulled
1005
+ .filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
1006
+ .filter((t) => admitPulledBody(t.slug, t.bodyAbs))
1007
+ .slice(0, Math.max(0, maxPinnedRooms() - localCanvases.length));
687
1008
  const pulledSlugs = new Set(admittedPulls.map((t) => t.slug));
1009
+ for (const t of admittedPulls) everPulled.add(t.slug);
1010
+ /**
1011
+ * Tell the panel EVERYTHING that came down this run, boot included.
1012
+ *
1013
+ * `notePulled` REPLACES the list, and the boot pull used to pass its own
1014
+ * slugs directly — so the first mid-session pull erased every boot-pulled
1015
+ * canvas from the surface, which is precisely what `everPulled` exists to
1016
+ * stop. One accumulating set, one caller.
1017
+ */
1018
+ const notePulledAll = (): void => mon.notePulled([...everPulled]);
1019
+ /**
1020
+ * Slugs pulled AFTER boot, which never get the fresh-link relaxation.
1021
+ *
1022
+ * `allowUndeclaredGroup` exists for exactly one moment: the first connect of
1023
+ * a bare folder somebody just pointed at a project, which has declared no
1024
+ * canvas groups and would otherwise refuse every incoming path. It closes as
1025
+ * soon as one group is learned. A mid-session pull is not that moment — and
1026
+ * because `relocatePulled` reads `pathOpts` at call time, a project that
1027
+ * never learned a group would otherwise leave the relaxation open for every
1028
+ * later arrival, which is an unbounded directory-creation primitive for the
1029
+ * hub. Membership in this set overrides the flag, per canvas.
1030
+ */
1031
+ const strictPullSlugs = new Set<string>();
688
1032
  const canvases = [
689
1033
  ...localCanvases,
690
1034
  ...admittedPulls.map((t) => descriptorFor(t.slug, t.bodyAbs)),
691
1035
  ];
1036
+ /**
1037
+ * Seed the live descriptor set from the boot set — see `descriptors`.
1038
+ */
1039
+ for (const c of canvases) descriptors.set(c.slug, c);
692
1040
  // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
693
1041
  // content, so the whole set is untrusted Claude-context. Mark it (writes
694
1042
  // `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
@@ -712,8 +1060,9 @@ export function createSyncRuntime(
712
1060
  //
713
1061
  // So it is written twice: once now (so the markers exist before any provider
714
1062
  // is built) and once after the pulls settle, from the final descriptors.
1063
+ // Reads the LIVE set, not the boot array — see `descriptors`.
715
1064
  const markUntrusted = (): void => {
716
- if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
1065
+ if (!cellPairing) writeUntrustedMarkers(ctx, [...descriptors.values()], linkedHub.url);
717
1066
  };
718
1067
  markUntrusted();
719
1068
  if (canvases.length === 0) {
@@ -723,6 +1072,31 @@ export function createSyncRuntime(
723
1072
  // loudly: a warn, a `_sync.json` the CLI + browser banner read, and a bus
724
1073
  // broadcast so open tabs render it immediately.
725
1074
  surfaceNoSyncable(ctx, linkedHub.url, scan.tsxCount);
1075
+ // A PROJECT WITH NOTHING IN IT YET IS STILL A PROJECT.
1076
+ //
1077
+ // This return happens before the status store, the monitor and the fs
1078
+ // reader exist, so the continuous-discovery machinery at the bottom of
1079
+ // start() is never reached — and a person who links an empty project and
1080
+ // then makes their first canvas would be back to "nothing syncs until you
1081
+ // restart", which is the whole bug, reached from its emptiest corner.
1082
+ //
1083
+ // A full cycle IS the right answer here and only here: there is no runtime
1084
+ // state to preserve, and one is exactly what a first canvas needs. The
1085
+ // supervisor owns cycling (it holds the serialization), so this asks
1086
+ // rather than acts — see `server.ts`.
1087
+ const watchForFirst = createRescanScheduler({
1088
+ debounceMs: DISCOVERY_DEBOUNCE_MS,
1089
+ onError: (err) => console.error('[sync] first-canvas watch failed:', err),
1090
+ run: async () => {
1091
+ if (stopped || opts.canvases) return;
1092
+ const again = await scanCanvases(ctx);
1093
+ if (admitCanvases(again.canvases, useSharedDoc).length === 0) return;
1094
+ console.log('[sync] the project has its first syncable canvas — starting sync.');
1095
+ ctx.bus.emit('sync:needs-restart');
1096
+ },
1097
+ });
1098
+ discoveryRescan = watchForFirst;
1099
+ discoveryUnsub = ctx.bus.on('canvas-list-update', () => watchForFirst.schedule());
726
1100
  return;
727
1101
  }
728
1102
 
@@ -789,6 +1163,16 @@ export function createSyncRuntime(
789
1163
 
790
1164
  busUnsub = ctx.bus.on('fs:any', (rel: string) => {
791
1165
  reader.notify(rel);
1166
+ // AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
1167
+ //
1168
+ // The sweep used to fire from exactly one place — `start()` — so a picture
1169
+ // pasted into an annotation reached the cloud only on the next boot or
1170
+ // Resync. Meanwhile the annotation itself syncs through the doc in
1171
+ // milliseconds, so the other side rendered an `<image>` pointing at bytes
1172
+ // nobody had sent: a permanent empty frame that looked like a broken path.
1173
+ // Reported three times in one day on alligators, each time with a
1174
+ // different asset, which is what finally named it.
1175
+ if (isPushableAssetRel(rel)) scheduleAssetSweep(linkedHub.url);
792
1176
  });
793
1177
 
794
1178
  /**
@@ -1235,7 +1619,9 @@ export function createSyncRuntime(
1235
1619
  resolve: path.resolve,
1236
1620
  sep: path.sep,
1237
1621
  realpath: realpathOfDeepestExisting,
1238
- allowUndeclaredGroup: pathOpts.allowUndeclaredGroup,
1622
+ // The fresh-link relaxation is boot-only, per canvas — see
1623
+ // `strictPullSlugs` for why reading `pathOpts` alone is not enough.
1624
+ allowUndeclaredGroup: pathOpts.allowUndeclaredGroup && !strictPullSlugs.has(canvas.slug),
1239
1625
  onRefused: (reason) => pathOpts.onRefused(canvas.slug, reason),
1240
1626
  });
1241
1627
  if (!resolved) return;
@@ -1288,7 +1674,19 @@ export function createSyncRuntime(
1288
1674
  canvas: CanvasDescriptor,
1289
1675
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
1290
1676
  document?: Y.Doc,
1291
- setup?: (provider: SyncProvider) => void
1677
+ setup?: (provider: SyncProvider) => void,
1678
+ /**
1679
+ * Does this connect belong to the BOOT set?
1680
+ *
1681
+ * Only a boot connect may enqueue a `bootWaits` entry. The boot summary
1682
+ * (`Promise.allSettled(bootWaits)`) is a one-shot report about the set the
1683
+ * runtime opened at start; a canvas adopted later must never be able to
1684
+ * join a list that has already been awaited — and, once discovery is
1685
+ * continuous, an unbounded stream of them would grow that array for the
1686
+ * life of the process. Defaults true so the re-probe path (which has
1687
+ * always pushed) is byte-for-byte unchanged.
1688
+ */
1689
+ boot = true
1292
1690
  ): Promise<SyncProvider> => {
1293
1691
  const provider = await providerFactory({
1294
1692
  url: linkedHub.url,
@@ -1321,8 +1719,30 @@ export function createSyncRuntime(
1321
1719
  if (!deferSetup) setup?.(provider);
1322
1720
 
1323
1721
  // Task 8 — feed this provider's WS status into the offline monitor.
1722
+ // Per-provider, so a socket that never dropped never triggers a poll.
1723
+ let wasDisconnected = false;
1324
1724
  if (provider.onStatus) {
1325
- statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
1725
+ noteDetach(
1726
+ statusDetaches,
1727
+ canvas.slug,
1728
+ provider.onStatus((s) => {
1729
+ mon.noteProviderStatus(canvas.slug, s);
1730
+ // COMING BACK IS THE MOMENT MOST LIKELY TO HAVE MISSED SOMETHING.
1731
+ //
1732
+ // A peer that was away for an hour has an hour of other people's
1733
+ // canvases to learn about, and making it sit out the poll interval
1734
+ // ON TOP of the outage is the one wait that is both longest and
1735
+ // least excusable. The signal is the socket returning, taken from
1736
+ // the provider rather than from the monitor's snapshot: a caller
1737
+ // that injects its own monitor (every test, and anything later)
1738
+ // would otherwise silently lose this.
1739
+ //
1740
+ // A reconnect storm is N providers reporting at once —
1741
+ // `pollRemoteSoon` coalesces them into one request.
1742
+ if (s === 'connected' && wasDisconnected) pollRemoteSoon();
1743
+ wasDisconnected = s !== 'connected';
1744
+ })
1745
+ );
1326
1746
  } else {
1327
1747
  // No status events (test stub) — treat as connected so the monitor
1328
1748
  // doesn't sit in the boot 'connecting' state forever.
@@ -1330,7 +1750,9 @@ export function createSyncRuntime(
1330
1750
  }
1331
1751
  // DDR-102 — classify + aggregate hub auth rejections.
1332
1752
  if (provider.onAuthFailed) {
1333
- statusDetaches.push(
1753
+ noteDetach(
1754
+ statusDetaches,
1755
+ canvas.slug,
1334
1756
  provider.onAuthFailed(({ reason }) =>
1335
1757
  handleAuthFailure(canvas, canvasPaths, provider, reason)
1336
1758
  )
@@ -1340,7 +1762,11 @@ export function createSyncRuntime(
1340
1762
  // browser cursors relay cross-machine. No-op when the provider exposes
1341
1763
  // no awareness or no registry was passed (file-sync-only tests).
1342
1764
  if (opts.registry && provider.awareness) {
1343
- awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
1765
+ noteDetach(
1766
+ awarenessDetaches,
1767
+ canvas.slug,
1768
+ opts.registry.attachHubAwareness(canvas.slug, provider.awareness)
1769
+ );
1344
1770
  }
1345
1771
  // Cold-start reconcile fires once the provider has hub state.
1346
1772
  const synced = provider.onceSynced().then(() => {
@@ -1350,12 +1776,25 @@ export function createSyncRuntime(
1350
1776
  }
1351
1777
  return runHandleSynced(canvas, canvasPaths, provider);
1352
1778
  });
1353
- bootWaits.push(settleWait(synced));
1779
+ if (boot) bootWaits.push(settleWait(synced));
1354
1780
  return provider;
1355
1781
  };
1356
1782
 
1357
- for (const canvas of canvases) {
1783
+ /**
1784
+ * Take ownership of ONE canvas: pin its shared doc if there is one, open a
1785
+ * provider, and build the disk handler behind it.
1786
+ *
1787
+ * Extracted from the boot loop so the SAME path can be reached after boot —
1788
+ * document discovery is continuous, not a snapshot taken at `start()`, and a
1789
+ * canvas that appears later must be adopted by exactly this code rather than
1790
+ * by a second, drifting copy of it. Every branch below is the boot
1791
+ * behaviour, unchanged; `boot` only decides whether the connect joins the
1792
+ * one-shot boot summary.
1793
+ */
1794
+ const attachCanvas = async (canvas: CanvasDescriptor, boot: boolean): Promise<void> => {
1358
1795
  try {
1796
+ // By reference — `relocatePulled` mutates this object in place.
1797
+ descriptors.set(canvas.slug, canvas);
1359
1798
  // Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
1360
1799
  // the collab room's single Y.Doc (registry.getDoc) instead of a fresh
1361
1800
  // one, so browser edits flow straight into the doc that syncs to the
@@ -1377,129 +1816,143 @@ export function createSyncRuntime(
1377
1816
  css: canvas.css,
1378
1817
  };
1379
1818
  mon.noteDocState(canvas.slug, 'pending');
1380
- await connectCanvas(canvas, canvasPaths, sharedYDoc, (provider) => {
1381
- // Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
1382
- // loop-free projection (html/css/meta doc→file + all-types file→doc;
1383
- // the collab room keeps comments/annotations doc→file, so no
1384
- // double-write). Flag-OFF keeps the proven two-doc agent. Created
1385
- // ONCE here (first connect) — a DDR-102 re-probe swaps only the
1386
- // provider; everything below is doc-scoped and survives.
1387
- let agent: CanvasSyncAgent | undefined;
1388
- if (useSharedDoc && sharedYDoc) {
1389
- const projection = createDocProjection({
1390
- slug: canvas.slug,
1391
- doc: provider.document,
1392
- paths: canvasPaths,
1393
- echoGuard,
1394
- journal: journal ?? undefined,
1395
- // Cell pairing only — see the DocProjectionOptions.onWrote doc.
1396
- // The synthetic event is delayed by the same margin the container
1397
- // write bridge uses, so a watcher that DOES fire wins the race and
1398
- // the HMR broadcaster's per-file coalescing collapses the pair
1399
- // into one `canvas-hmr`. The projector's own echo guard drops the
1400
- // resulting file→doc read, so this cannot loop.
1401
- ...(cellPairing ? { onWrote: announceWrite } : {}),
1402
- });
1403
- projection.start();
1404
- projections.set(canvas.slug, projection);
1405
- } else {
1406
- const relBody = path.relative(ctx.paths.repoRoot, canvas.html);
1407
- agent = createCanvasSyncAgent({
1408
- slug: canvas.slug,
1409
- doc: provider.document,
1410
- paths: canvasPaths,
1411
- echoGuard,
1412
- adopt: adoptOnce,
1413
- journal: journal ?? undefined,
1414
- // Wrap the writer rather than adding a new hook: every path the
1415
- // agent materializes to disk goes through it, so a future write
1416
- // surface is committed automatically instead of being forgotten.
1417
- ...(autoCommit
1418
- ? {
1419
- writer: (file: string, bytes: string | Uint8Array) => {
1420
- atomicWrite(file, bytes);
1421
- autoCommit.note(
1422
- path.relative(ctx.paths.repoRoot, file),
1423
- editorOf(canvas.slug)
1424
- );
1425
- },
1819
+ await connectCanvas(
1820
+ canvas,
1821
+ canvasPaths,
1822
+ sharedYDoc,
1823
+ (provider) => {
1824
+ // Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
1825
+ // loop-free projection (html/css/meta doc→file + all-types file→doc;
1826
+ // the collab room keeps comments/annotations doc→file, so no
1827
+ // double-write). Flag-OFF keeps the proven two-doc agent. Created
1828
+ // ONCE here (first connect) — a DDR-102 re-probe swaps only the
1829
+ // provider; everything below is doc-scoped and survives.
1830
+ let agent: CanvasSyncAgent | undefined;
1831
+ if (useSharedDoc && sharedYDoc) {
1832
+ const projection = createDocProjection({
1833
+ slug: canvas.slug,
1834
+ doc: provider.document,
1835
+ paths: canvasPaths,
1836
+ echoGuard,
1837
+ journal: journal ?? undefined,
1838
+ // Cell pairing only — see the DocProjectionOptions.onWrote doc.
1839
+ // The synthetic event is delayed by the same margin the container
1840
+ // write bridge uses, so a watcher that DOES fire wins the race and
1841
+ // the HMR broadcaster's per-file coalescing collapses the pair
1842
+ // into one `canvas-hmr`. The projector's own echo guard drops the
1843
+ // resulting file→doc read, so this cannot loop.
1844
+ ...(cellPairing ? { onWrote: announceWrite } : {}),
1845
+ });
1846
+ projection.start();
1847
+ projections.set(canvas.slug, projection);
1848
+ } else {
1849
+ const relBody = path.relative(ctx.paths.repoRoot, canvas.html);
1850
+ agent = createCanvasSyncAgent({
1851
+ slug: canvas.slug,
1852
+ doc: provider.document,
1853
+ paths: canvasPaths,
1854
+ echoGuard,
1855
+ adopt: adoptOnce,
1856
+ journal: journal ?? undefined,
1857
+ // Wrap the writer rather than adding a new hook: every path the
1858
+ // agent materializes to disk goes through it, so a future write
1859
+ // surface is committed automatically instead of being forgotten.
1860
+ ...(autoCommit
1861
+ ? {
1862
+ writer: (file: string, bytes: string | Uint8Array) => {
1863
+ atomicWrite(file, bytes);
1864
+ autoCommit.note(
1865
+ path.relative(ctx.paths.repoRoot, file),
1866
+ editorOf(canvas.slug)
1867
+ );
1868
+ },
1869
+ }
1870
+ : {}),
1871
+ snapshot: async (content, reason) => {
1872
+ try {
1873
+ const snap = await history.writeSnapshot(relBody, content, reason);
1874
+ return snap.ts;
1875
+ } catch {
1876
+ return null; // best-effort — resolution proceeds without refs
1426
1877
  }
1427
- : {}),
1428
- snapshot: async (content, reason) => {
1429
- try {
1430
- const snap = await history.writeSnapshot(relBody, content, reason);
1431
- return snap.ts;
1432
- } catch {
1433
- return null; // best-effort — resolution proceeds without refs
1434
- }
1435
- },
1436
- onConflict: (info) => store.addConflict(info),
1437
- });
1438
- agent.start();
1439
- agents.set(canvas.slug, agent);
1440
- }
1878
+ },
1879
+ onConflict: (info) => store.addConflict(info),
1880
+ });
1881
+ agent.start();
1882
+ agents.set(canvas.slug, agent);
1883
+ }
1441
1884
 
1442
- // Count local edits (agent-origin doc updates) toward queuedOps while
1443
- // the hub is unreachable — the banner's "N edits queued" figure. Under
1444
- // sharedDoc there is no agent origin to key off (browser edits carry a
1445
- // RoomConn origin); queued-edit counting in that mode is a known gap
1446
- // (offline-banner accuracy only, not data) deferred past Phase C.
1447
- if (agent) {
1448
- const agentOrigin = agent.origin;
1449
- const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
1450
- if (origin === agentOrigin) mon.noteLocalEdit();
1451
- };
1452
- provider.document.on('update', onLocalUpdate);
1453
- statusDetaches.push(() => provider.document.off('update', onLocalUpdate));
1454
- }
1885
+ // Count local edits (agent-origin doc updates) toward queuedOps while
1886
+ // the hub is unreachable — the banner's "N edits queued" figure. Under
1887
+ // sharedDoc there is no agent origin to key off (browser edits carry a
1888
+ // RoomConn origin); queued-edit counting in that mode is a known gap
1889
+ // (offline-banner accuracy only, not data) deferred past Phase C.
1890
+ if (agent) {
1891
+ const agentOrigin = agent.origin;
1892
+ const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
1893
+ if (origin === agentOrigin) mon.noteLocalEdit();
1894
+ };
1895
+ provider.document.on('update', onLocalUpdate);
1896
+ noteDetach(statusDetaches, canvas.slug, () =>
1897
+ provider.document.off('update', onLocalUpdate)
1898
+ );
1899
+ }
1455
1900
 
1456
- // Relay hub-pushed comment/annotation changes straight into the live
1457
- // room — IN-PROCESS + synchronous, so the room's in-memory doc is
1458
- // updated BEFORE its 800ms persist timer can flush stale pre-sync state
1459
- // back over the file (the disk-mediated re-seed in createCollab loses
1460
- // that race under an actively-edited peer; this is the tight path that
1461
- // actually closes the "comment reverts" clobber). Wholesale-replace via
1462
- // syncRoomFrom* → no duplication. Skip agent-origin updates (our own
1463
- // disk→doc apply — the file is authoritative there; a local design:edit
1464
- // reaches the room via createCollab's fs hook instead).
1465
- //
1466
- // CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
1467
- // whole-doc update. A whole-doc relay re-applies BOTH types on every
1468
- // change, so a comment sync would re-push the (stale) annotation and
1469
- // clobber an annotation the peer just drew but hasn't synced yet — and
1470
- // vice versa. Per-type observers keep the two lanes independent.
1471
- //
1472
- // Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
1473
- // room's doc, so there is no second doc to relay into — the room already
1474
- // has every change. Skipping the relay is what RETIRES the
1475
- // wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
1476
- // CRDT merge handles concurrency, no last-writer-wins blob copy.
1477
- const reg = opts.registry;
1478
- if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
1479
- const agentOrigin = agent.origin;
1480
- const slug = canvas.slug;
1481
- const provComments = provider.document.getArray(Y_TYPES.comments);
1482
- const provAnn = provider.document.getMap(Y_TYPES.annotations);
1483
- const onComments = (_e: unknown, tx: { origin: unknown }) => {
1484
- if (tx.origin === agentOrigin) return;
1485
- reg.syncRoomFromComments?.(slug, provComments.toArray());
1486
- };
1487
- const onAnn = (_e: unknown, tx: { origin: unknown }) => {
1488
- if (tx.origin === agentOrigin) return;
1489
- const svg = provAnn.get('svg');
1490
- if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
1491
- };
1492
- provComments.observe(onComments);
1493
- provAnn.observe(onAnn);
1494
- statusDetaches.push(() => {
1495
- provComments.unobserve(onComments);
1496
- provAnn.unobserve(onAnn);
1497
- });
1498
- }
1499
- });
1901
+ // Relay hub-pushed comment/annotation changes straight into the live
1902
+ // room — IN-PROCESS + synchronous, so the room's in-memory doc is
1903
+ // updated BEFORE its 800ms persist timer can flush stale pre-sync state
1904
+ // back over the file (the disk-mediated re-seed in createCollab loses
1905
+ // that race under an actively-edited peer; this is the tight path that
1906
+ // actually closes the "comment reverts" clobber). Wholesale-replace via
1907
+ // syncRoomFrom* → no duplication. Skip agent-origin updates (our own
1908
+ // disk→doc apply — the file is authoritative there; a local design:edit
1909
+ // reaches the room via createCollab's fs hook instead).
1910
+ //
1911
+ // CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
1912
+ // whole-doc update. A whole-doc relay re-applies BOTH types on every
1913
+ // change, so a comment sync would re-push the (stale) annotation and
1914
+ // clobber an annotation the peer just drew but hasn't synced yet — and
1915
+ // vice versa. Per-type observers keep the two lanes independent.
1916
+ //
1917
+ // Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
1918
+ // room's doc, so there is no second doc to relay into — the room already
1919
+ // has every change. Skipping the relay is what RETIRES the
1920
+ // wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
1921
+ // CRDT merge handles concurrency, no last-writer-wins blob copy.
1922
+ const reg = opts.registry;
1923
+ if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
1924
+ const agentOrigin = agent.origin;
1925
+ const slug = canvas.slug;
1926
+ const provComments = provider.document.getArray(Y_TYPES.comments);
1927
+ const provAnn = provider.document.getMap(Y_TYPES.annotations);
1928
+ const onComments = (_e: unknown, tx: { origin: unknown }) => {
1929
+ if (tx.origin === agentOrigin) return;
1930
+ reg.syncRoomFromComments?.(slug, provComments.toArray());
1931
+ };
1932
+ const onAnn = (_e: unknown, tx: { origin: unknown }) => {
1933
+ if (tx.origin === agentOrigin) return;
1934
+ const svg = provAnn.get('svg');
1935
+ if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
1936
+ };
1937
+ provComments.observe(onComments);
1938
+ provAnn.observe(onAnn);
1939
+ noteDetach(statusDetaches, canvas.slug, () => {
1940
+ provComments.unobserve(onComments);
1941
+ provAnn.unobserve(onAnn);
1942
+ });
1943
+ }
1944
+ },
1945
+ boot
1946
+ );
1500
1947
  } catch (err) {
1501
1948
  console.error(`[sync/${canvas.slug}] failed to start:`, err);
1502
1949
  }
1950
+ };
1951
+
1952
+ attachOne = attachCanvas;
1953
+
1954
+ for (const canvas of canvases) {
1955
+ await attachCanvas(canvas, true);
1503
1956
  }
1504
1957
 
1505
1958
  // Persist an initial status snapshot so `_sync.json` exists — and `maude
@@ -1544,7 +1997,7 @@ export function createSyncRuntime(
1544
1997
  // the pull, so it named exactly the canvases that had just arrived and
1545
1998
  // were sitting on disk. `pulled` is the same list under the name that is
1546
1999
  // true, and it is the fact the user is told to act on.
1547
- mon.notePulled(admittedPulls.map((t) => t.slug));
2000
+ notePulledAll();
1548
2001
  // Re-mark from the FINAL descriptors — `relocatePulled` mutates them in
1549
2002
  // place after each handshake, and the markers are the one consumer that
1550
2003
  // read them before that and would otherwise never read them again.
@@ -1555,27 +2008,176 @@ export function createSyncRuntime(
1555
2008
  // under pairing: a cell's assets are already on the cell. Fire-and-forget
1556
2009
  // — a miss is retried on the next boot for free.
1557
2010
  if (!cellPairing && !stopped) {
1558
- // OUT OF PROCESS since feature-sync-resync-and-out-of-process-sweep:
1559
- // the sweep segfaults Bun when it runs alongside the dev server (proven
1560
- // by isolation — identical sweep, same hub, completes standalone). The
1561
- // child dying is now a failed sweep the panel reports, not a dead
1562
- // editor. See `asset-sweep.ts`.
1563
- assetSweep = runAssetSweep({
1564
- designRoot: ctx.paths.designRoot,
1565
- hubUrl: linkedHub.url,
1566
- // Read at call time — a silent renewal mid-sweep must reach the child
1567
- // (the parent re-writes its credential file when this changes).
1568
- token: () => token,
1569
- // feature-sync-progress-modal — ride the same `sync:status` payload
1570
- // the doc counts use, so the Sync panel has one source. Guarded on
1571
- // `stopped`: a late emit must not write `_sync.json` post-teardown.
1572
- onProgress: (p) => {
1573
- if (!stopped) store.updateAssets?.(p);
1574
- },
1575
- });
2011
+ startAssetSweep(linkedHub.url);
1576
2012
  }
1577
2013
  });
1578
2014
 
2015
+ // ─── DISCOVERY IS CONTINUOUS FROM HERE ────────────────────────────────
2016
+ //
2017
+ // Everything above is the BOOT set. `canvas-list-watch.ts` already notices
2018
+ // when the openable-canvas set changes on disk from ANY source (the API, the
2019
+ // ACP agent, a terminal `cp`, `git checkout`, or the hub's own workspace
2020
+ // agent writing a peer's new canvas into a cell's checkout) and emits
2021
+ // `canvas-list-update`. Nothing was listening on behalf of sync. This is
2022
+ // that listener.
2023
+ //
2024
+ // The payload is used ONLY as a nudge — never as data. See `discovery.ts`
2025
+ // for why the authoritative answer is a full rescan through the same
2026
+ // `scanCanvases` boot used.
2027
+ const rescan = createRescanScheduler({
2028
+ debounceMs: DISCOVERY_DEBOUNCE_MS,
2029
+ onError: (err) => console.error('[sync] canvas rescan failed:', err),
2030
+ run: async () => {
2031
+ if (stopped) return;
2032
+ // An explicit canvas list (test injection) means the caller owns
2033
+ // membership; rescanning would silently overrule them.
2034
+ if (opts.canvases) return;
2035
+ const fresh = await scanCanvases(ctx);
2036
+ const admitted = admitCanvases(fresh.canvases, useSharedDoc);
2037
+ const bySlug = new Map(admitted.map((c) => [c.slug, c]));
2038
+ // THE PULL PIN IS A RACE GUARD, NOT A PERMANENT EXEMPTION.
2039
+ //
2040
+ // A pulled canvas is kept out of `removed` because its body is written
2041
+ // AFTER the handshake, so a scan taken in that window is not evidence
2042
+ // it left the project. That window closes the moment the file exists —
2043
+ // and the pin was never released, so it did not close at all. The cost
2044
+ // is exactly the control this file calls "a security opt-out a hub must
2045
+ // not be able to flip": a pulled canvas whose `.meta.json` says
2046
+ // `syncable: false` was dropped by the scan, held by the pin, and kept
2047
+ // receiving hub writes for the life of the process. On a cell that is
2048
+ // days. Once the body is on disk the ordinary rules apply to it.
2049
+ for (const slug of [...pulledSlugs]) {
2050
+ const body = descriptors.get(slug)?.html;
2051
+ if (body && existsSync(body)) pulledSlugs.delete(slug);
2052
+ }
2053
+ const { added, removed } = diffCanvasSet(
2054
+ [...agents.keys(), ...projections.keys()],
2055
+ bySlug.keys(),
2056
+ pulledSlugs
2057
+ );
2058
+ if (added.length === 0 && removed.length === 0) return;
2059
+ if (removed.length > 0) {
2060
+ for (const slug of removed) await releaseOne(slug);
2061
+ console.log(`[sync] released ${removed.length} canvas(es): ${removed.join(', ')}`);
2062
+ // The marker set shrank. It describes what a peer can WRITE to, so a
2063
+ // stale entry over-lists — the safe direction, and still wrong: it is
2064
+ // the one mitigation standing between an untrusted pull and what the
2065
+ // agent reads.
2066
+ markUntrusted();
2067
+ }
2068
+ const incoming = added.map((slug) => bySlug.get(slug)).filter((c) => !!c);
2069
+ for (const canvas of incoming) {
2070
+ if (agents.has(canvas.slug) || projections.has(canvas.slug)) continue;
2071
+ if (providers.has(canvas.slug)) continue;
2072
+ await attachCanvas(canvas, false);
2073
+ }
2074
+ if (incoming.length > 0) {
2075
+ console.log(
2076
+ `[sync] adopted ${incoming.length} new canvas(es): ${incoming.map((c) => c.slug).join(', ')}`
2077
+ );
2078
+ // The set the DDR-054 §3 F3 markers describe just grew.
2079
+ markUntrusted();
2080
+ }
2081
+ },
2082
+ });
2083
+ discoveryRescan = rescan;
2084
+ discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
2085
+
2086
+ // ─── THE HUB HALF OF DISCOVERY ────────────────────────────────────────
2087
+ //
2088
+ // The rescan above sees this DISK. A document that exists only on the hub
2089
+ // is invisible to it — Yjs has no enumeration, so a peer learns of a
2090
+ // document only by being told its name. Boot asks once
2091
+ // (`GET /api/documents`), which is why a canvas created in the cloud after
2092
+ // this peer connected could never arrive: the desktop had already asked.
2093
+ //
2094
+ // So keep asking. This is deliberately a POLL and not a push: the listing
2095
+ // route is the ONLY document-enumeration surface every hub version exposes,
2096
+ // including self-hosted ones nobody is going to upgrade, and a fix that
2097
+ // required a new hub would leave exactly the installations that reported the
2098
+ // bug still broken. It is cheap (names + byte counts, one request), it
2099
+ // inherits `fetchRemoteDocs`'s never-fatal posture, and it rides the same
2100
+ // scope gate the sync itself does — a token that may not open a document is
2101
+ // not told the document exists.
2102
+ const pullRemoteOnce = async (): Promise<void> => {
2103
+ if (stopped) return;
2104
+ // Read `token` at call time: a silent renewal swaps it in place.
2105
+ const docs = await fetchRemoteDocs(linkedHub.url, token);
2106
+ // null = unreachable, refused, or a hub without the route. Not an error
2107
+ // here any more than it is at boot — sync continues, we ask again later.
2108
+ if (docs === null) return;
2109
+ const diff = diffRemoteDocs(
2110
+ [...descriptors.keys()].map((slug) => docNameFor(slug)),
2111
+ docs
2112
+ );
2113
+ if (diff.hubOnly.length === 0) return;
2114
+ const targets = pullTargets(
2115
+ diff.hubOnly,
2116
+ ctx.paths.designRoot,
2117
+ path.join,
2118
+ path.resolve,
2119
+ path.sep,
2120
+ {
2121
+ ...pathOpts,
2122
+ // NEVER the fresh-link relaxation after boot. See `strictPullSlugs`.
2123
+ allowUndeclaredGroup: false,
2124
+ realpath: realpathOfDeepestExisting,
2125
+ }
2126
+ );
2127
+ const admitted = targets
2128
+ .filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
2129
+ .filter((t) => admitPulledBody(t.slug, t.bodyAbs));
2130
+ const fresh = admitted.filter(
2131
+ (t) => !agents.has(t.slug) && !projections.has(t.slug) && !providers.has(t.slug)
2132
+ );
2133
+ if (fresh.length === 0) return;
2134
+ // VOLUME IS A SECURITY PROPERTY HERE, NOT A PERFORMANCE ONE.
2135
+ //
2136
+ // Every accepted name becomes a real file in the design root, a provider,
2137
+ // a pinned Y.Doc, and (on a desktop) something autocommit puts into the
2138
+ // person's git history and `_untrusted/INDEX.json` offers to Claude. The
2139
+ // hub is untrusted to peers (DDR-054), and before continuous discovery
2140
+ // the damage was bounded by there being exactly ONE listing, at connect.
2141
+ // Asking every 20 s for the life of the process removes that bound: a
2142
+ // hostile hub can drip distinct names forever. So the pull lane gets the
2143
+ // ceiling the LOCAL lane has always had (`admitCanvases` → DDR-064 A6),
2144
+ // plus a per-poll cap so one answer cannot land thousands at once.
2145
+ const room = Math.max(0, maxPinnedRooms() - (agents.size + projections.size));
2146
+ const budget = Math.min(room, MAX_PULLS_PER_POLL);
2147
+ const accepted = fresh.slice(0, budget);
2148
+ if (accepted.length < fresh.length) {
2149
+ // Named loudly. A silent cap reads as "sync is broken" with no cause —
2150
+ // the same reason `admitCanvases` shouts about its own ceiling.
2151
+ warnOnce(
2152
+ `pull-cap:${room === 0 ? 'ceiling' : 'batch'}`,
2153
+ `[sync] the project offers ${fresh.length} more canvas(es) than this peer will take in one pass (${accepted.length} accepted; ceiling ${maxPinnedRooms()}, per-pass cap ${MAX_PULLS_PER_POLL}). Raise MAUDE_MAX_PINNED_ROOMS if this project is genuinely this large.`
2154
+ );
2155
+ }
2156
+ if (accepted.length === 0) return;
2157
+ for (const target of accepted) {
2158
+ pulledSlugs.add(target.slug);
2159
+ strictPullSlugs.add(target.slug);
2160
+ everPulled.add(target.slug);
2161
+ await attachCanvas(descriptorFor(target.slug, target.bodyAbs), false);
2162
+ }
2163
+ console.log(
2164
+ `[sync] pulled ${accepted.length} canvas(es) down from the project: ${accepted
2165
+ .map((t) => t.slug)
2166
+ .join(', ')}`
2167
+ );
2168
+ // Both controls describe the set, and the set just grew.
2169
+ notePulledAll();
2170
+ markUntrusted();
2171
+ };
2172
+ const pollRemote = (): void => {
2173
+ void pullRemoteOnce().catch((err) => console.error('[sync] remote poll failed:', err));
2174
+ };
2175
+ remotePull = pullRemoteOnce;
2176
+ remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
2177
+ // `setInterval` keeps a Bun process alive; a poll is not a reason for the
2178
+ // dev server to refuse to exit.
2179
+ remotePollTimer.unref?.();
2180
+
1579
2181
  // Arm the pre-expiry renewal from the credential that just booted. Placed
1580
2182
  // last — the timer needs nothing from boot, and boot needs nothing from it
1581
2183
  // (a credential that dies mid-boot lands in the invalid-token path, which
@@ -1586,9 +2188,23 @@ export function createSyncRuntime(
1586
2188
  async function stop(): Promise<void> {
1587
2189
  if (stopped) return;
1588
2190
  stopped = true;
2191
+ // Nothing may be adopted into a runtime that is going away.
2192
+ attachOne = null;
2193
+ discoveryUnsub?.();
2194
+ discoveryUnsub = null;
2195
+ discoveryRescan?.stop();
2196
+ discoveryRescan = null;
2197
+ if (remotePollTimer !== null) clearInterval(remotePollTimer);
2198
+ remotePollTimer = null;
2199
+ if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
2200
+ remotePollSoonTimer = null;
2201
+ remotePull = null;
1589
2202
  // A sweep that outlives its runtime keeps uploading a project the person
1590
2203
  // just closed — and `restart()` (the Resync button) calls stop() on every
1591
2204
  // press, so without this each press would leave another sweep running.
2205
+ if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
2206
+ assetSweepTimer = null;
2207
+ assetSweepAgain = false;
1592
2208
  assetSweep?.cancel();
1593
2209
  assetSweep = null;
1594
2210
  // Commit whatever is still inside the quiescence window BEFORE tearing
@@ -1603,14 +2219,8 @@ export function createSyncRuntime(
1603
2219
  }
1604
2220
  autoCommit.stop();
1605
2221
  }
1606
- for (const detach of awarenessDetaches) {
1607
- try {
1608
- detach();
1609
- } catch {
1610
- /* best-effort — registry teardown also clears bridges on destroyAll */
1611
- }
1612
- }
1613
- awarenessDetaches.length = 0;
2222
+ for (const slug of [...awarenessDetaches.keys()]) runDetaches(awarenessDetaches, slug);
2223
+ awarenessDetaches.clear();
1614
2224
  // Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
1615
2225
  // / destroyed normally on shutdown. Empty unless sharedDoc was active.
1616
2226
  for (const slug of pinnedSlugs) {
@@ -1621,14 +2231,8 @@ export function createSyncRuntime(
1621
2231
  }
1622
2232
  }
1623
2233
  pinnedSlugs.clear();
1624
- for (const detach of statusDetaches) {
1625
- try {
1626
- detach();
1627
- } catch {
1628
- /* best-effort */
1629
- }
1630
- }
1631
- statusDetaches.length = 0;
2234
+ for (const slug of [...statusDetaches.keys()]) runDetaches(statusDetaches, slug);
2235
+ statusDetaches.clear();
1632
2236
  monitor?.stop();
1633
2237
  journal?.stop(); // flushes the pending debounce
1634
2238
  journal = null;
@@ -1688,9 +2292,43 @@ export function createSyncRuntime(
1688
2292
  ownedFactory?.dispose();
1689
2293
  }
1690
2294
 
2295
+ // One chain for every membership change, so an adopt cannot interleave with a
2296
+ // release of the same slug (rename arrives as remove+add) or with `stop()`
2297
+ // tearing the maps down underneath it. This is the runtime's own ordering and
2298
+ // is separate from the SUPERVISOR's chain, which serializes whole start/stop
2299
+ // cycles — an adopt is not a cycle and must not make `busy()` true.
2300
+ let membership: Promise<unknown> = Promise.resolve();
2301
+ function serializeMembership<T>(work: () => Promise<T>): Promise<T> {
2302
+ const next = membership.then(work, work);
2303
+ membership = next.catch(() => {});
2304
+ return next;
2305
+ }
2306
+
1691
2307
  return {
1692
2308
  start,
1693
2309
  stop,
2310
+ adopt: (incoming) =>
2311
+ serializeMembership(async () => {
2312
+ if (stopped || !attachOne) return 0;
2313
+ let attached = 0;
2314
+ for (const canvas of incoming) {
2315
+ // Already ours — adopting twice would open a second provider on the
2316
+ // same document and give this peer two votes in every merge.
2317
+ if (agents.has(canvas.slug) || projections.has(canvas.slug)) continue;
2318
+ if (providers.has(canvas.slug)) continue;
2319
+ await attachOne(canvas, false);
2320
+ attached += 1;
2321
+ }
2322
+ return attached;
2323
+ }),
2324
+ release: (slugs) =>
2325
+ serializeMembership(async () => {
2326
+ let released = 0;
2327
+ for (const slug of slugs) if (await releaseOne(slug)) released += 1;
2328
+ return released;
2329
+ }),
2330
+ rescanNow: () => discoveryRescan?.flush() ?? Promise.resolve(),
2331
+ pullRemoteNow: () => remotePull?.() ?? Promise.resolve(),
1694
2332
  // Under sharedDoc the per-canvas handler is a projection, not an agent;
1695
2333
  // count both so size() reflects the synced-canvas count in either mode.
1696
2334
  size: () => agents.size + projections.size,
@@ -1720,6 +2358,9 @@ export function createSyncRuntime(
1720
2358
  * `MAUDE_MAX_PINNED_ROOMS` if a real project ever meets it — and if one does,
1721
2359
  * that is a signal worth reading rather than a number worth bumping.
1722
2360
  */
2361
+ /** Coalesce a burst of asset writes (dragging six images on) into one sweep. */
2362
+ const ASSET_SWEEP_DEBOUNCE_MS = 1500;
2363
+
1723
2364
  export const DEFAULT_MAX_PINNED_ROOMS = 500;
1724
2365
 
1725
2366
  function maxPinnedRooms(): number {