@1agh/maude 0.60.2 → 0.60.4

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.
Files changed (38) hide show
  1. package/apps/studio/api.ts +14 -0
  2. package/apps/studio/client/app.jsx +44 -9
  3. package/apps/studio/client/panels/SyncPanel.jsx +53 -11
  4. package/apps/studio/context.ts +9 -0
  5. package/apps/studio/dist/client.bundle.js +525 -525
  6. package/apps/studio/http.ts +48 -7
  7. package/apps/studio/server.ts +11 -0
  8. package/apps/studio/sync/asset-pull.ts +210 -0
  9. package/apps/studio/sync/asset-push.ts +118 -75
  10. package/apps/studio/sync/connection-state.ts +23 -0
  11. package/apps/studio/sync/discovery.ts +139 -0
  12. package/apps/studio/sync/file-membership.ts +290 -0
  13. package/apps/studio/sync/file-pull.ts +330 -0
  14. package/apps/studio/sync/index.ts +995 -166
  15. package/apps/studio/sync/remote-docs.ts +98 -4
  16. package/apps/studio/sync/status.ts +25 -0
  17. package/apps/studio/sync/tombstone-apply.ts +131 -0
  18. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
  19. package/apps/studio/test/exporters/jobs.test.ts +10 -4
  20. package/apps/studio/test/git-cloud-posture.test.ts +11 -2
  21. package/apps/studio/test/sync-asset-pull.test.ts +161 -0
  22. package/apps/studio/test/sync-asset-push.test.ts +144 -3
  23. package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
  24. package/apps/studio/test/sync-file-membership.test.ts +331 -0
  25. package/apps/studio/test/sync-file-pull.test.ts +333 -0
  26. package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
  27. package/apps/studio/test/sync-panel-surface.test.ts +10 -0
  28. package/apps/studio/test/sync-remote-docs.test.ts +118 -8
  29. package/apps/studio/test/sync-resync-routes.test.ts +43 -0
  30. package/apps/studio/test/sync-status.test.ts +18 -0
  31. package/apps/studio/test/sync-supervisor.test.ts +4 -0
  32. package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
  33. package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
  34. package/apps/studio/whats-new.json +18 -0
  35. package/cli/commands/doctor.mjs +141 -6
  36. package/cli/lib/gitignore-drift.mjs +149 -0
  37. package/cli/lib/gitignore-drift.test.mjs +156 -0
  38. package/package.json +8 -8
@@ -29,6 +29,8 @@ 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 { pullAssets } from './asset-pull.ts';
33
+ import { isPushableAssetRel } from './asset-push.ts';
32
34
  import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
33
35
  import { atomicWrite } from './atomic-write.ts';
34
36
  import { createAutoCommit } from './autocommit.ts';
@@ -39,8 +41,10 @@ import {
39
41
  createConnectionMonitor,
40
42
  type ProviderStatus,
41
43
  } from './connection-state.ts';
44
+ import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
42
45
  import { createDocNameResolver } from './doc-name.ts';
43
46
  import { createEchoGuard } from './echo-guard.ts';
47
+ import { type FilePullResult, pullFiles } from './file-pull.ts';
44
48
  import { createFsReader, type FsReader } from './fs-mirror.ts';
45
49
  import { getHubRecord } from './hubs-config.ts';
46
50
  import { loadJournal, type SyncJournal } from './journal.ts';
@@ -52,11 +56,16 @@ import { createDocProjection, type DocProjection } from './projection.ts';
52
56
  import {
53
57
  describeRemoteDiff,
54
58
  diffRemoteDocs,
55
- fetchRemoteDocs,
59
+ fetchRemoteListing,
56
60
  pullTargets,
61
+ type RemoteTombstone,
57
62
  resolvePulledTarget,
63
+ slugFromDocName,
64
+ stateDocumentGone,
65
+ tombstonedSlugs,
58
66
  } from './remote-docs.ts';
59
67
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
68
+ import { quarantineCanvas } from './tombstone-apply.ts';
60
69
  import { writeUntrustedMarkers } from './untrusted.ts';
61
70
 
62
71
  /** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
@@ -122,6 +131,46 @@ export const RENEW_MIN_INTERVAL_MS = 60_000;
122
131
  export const RENEW_MAX_WITHOUT_PROGRESS = 3;
123
132
  /** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
124
133
  * far-future expiry into a tight renewal loop. Clamp before we hand it over. */
134
+ /**
135
+ * Quiet window before a canvas-set change triggers a rescan.
136
+ *
137
+ * Slightly longer than `canvas-list-watch.ts`'s own 150 ms, on purpose: a create
138
+ * writes the `.tsx` and then the `.meta.json`, and attaching in the gap would
139
+ * sync a canvas whose sidecar is about to say `syncable: false`. Letting the
140
+ * cheaper watcher settle first means the rescan sees a finished canvas.
141
+ */
142
+ export const DISCOVERY_DEBOUNCE_MS = 400;
143
+
144
+ /**
145
+ * How often a peer re-asks the hub what the project contains.
146
+ *
147
+ * The floor is set by what a person would call "immediately" for a canvas
148
+ * somebody else just made, and the ceiling by the fact that this runs per peer,
149
+ * per project, forever. 20 s is a name-and-size listing — a few hundred bytes —
150
+ * and it is the ONLY discovery lane a hub of any version can serve.
151
+ */
152
+ export const REMOTE_POLL_MS = 20_000;
153
+
154
+ /**
155
+ * Settling delay for an OFF-SCHEDULE poll (reconnect).
156
+ *
157
+ * Not zero: a reconnect is a burst — every provider re-handshakes — and asking
158
+ * the hub for the listing in the middle of that buys a slower answer and a
159
+ * needless second one. Short enough that a person does not experience it.
160
+ */
161
+ export const REMOTE_POLL_SOON_MS = 1_500;
162
+
163
+ /**
164
+ * How many previously-unknown canvases one listing may land.
165
+ *
166
+ * A ceiling on the TOTAL is not enough on its own: a hub that answers with
167
+ * thousands of names would still create thousands of files, providers and
168
+ * `_untrusted` entries inside a single tick before anything else got to run.
169
+ * A real project gains canvases a few at a time; a burst larger than this is a
170
+ * hub behaving unlike any project, and the rest simply arrive on later polls.
171
+ */
172
+ export const MAX_PULLS_PER_POLL = 25;
173
+
125
174
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
126
175
  /** F2 — a hub-reported `expiresAt` further out than this is not believed for
127
176
  * scheduling (cloud cells issue ≤ 12 h; a month is already implausible). */
@@ -197,6 +246,41 @@ export type ProviderFactory = (args: {
197
246
  export interface SyncRuntime {
198
247
  start(): Promise<void>;
199
248
  stop(): Promise<void>;
249
+ /**
250
+ * Take ownership of canvases discovered AFTER `start()` — the incremental
251
+ * half of `start()`'s boot set. Returns how many were newly attached
252
+ * (already-attached slugs are skipped, not re-opened).
253
+ *
254
+ * Deliberately NOT a restart: a restart re-links every canvas in the project
255
+ * and costs a full handshake fan-out, which is why the Resync button is
256
+ * rate-limited. Adoption is per-canvas and cheap, so it can run on every
257
+ * discovery without the user authorising anything.
258
+ */
259
+ adopt(canvases: readonly CanvasDescriptor[]): Promise<number>;
260
+ /**
261
+ * Give up canvases that left the project. Returns how many were released.
262
+ *
263
+ * NOT A DELETION, and the distinction is the whole delete lane: this says
264
+ * "this machine stopped carrying it", which the project is right to ignore.
265
+ * Stating that a canvas is GONE travels on the `canvas-deleted` bus event that
266
+ * `api.ts` emits from its privileged delete route — the one signal that
267
+ * carries intent rather than a filesystem observation.
268
+ */
269
+ release(slugs: readonly string[]): Promise<number>;
270
+ /**
271
+ * Run the local canvas rescan immediately instead of waiting for the debounce.
272
+ *
273
+ * The discovery loop is event-driven (`canvas-list-update`); this is the seam
274
+ * tests use to make it deterministic, and the hook a caller can use to force a
275
+ * check without paying for a full runtime cycle the way Resync does.
276
+ */
277
+ rescanNow(): Promise<void>;
278
+ /**
279
+ * Ask the hub what the project contains right now, and pull down anything
280
+ * this peer does not have — the remote half of `rescanNow`, off the poll's
281
+ * schedule. Test seam, and the hook behind a manual "check now".
282
+ */
283
+ pullRemoteNow(): Promise<void>;
200
284
  /** Number of active per-canvas agents. */
201
285
  size(): number;
202
286
  /** Test inspection — get the agent for a slug if one was created. */
@@ -233,6 +317,8 @@ export interface CreateSyncRuntimeOptions {
233
317
  connectionMonitor?: ConnectionMonitor;
234
318
  /** Override the status store (Task 8 test injection). */
235
319
  statusStore?: SyncStatusStore;
320
+ /** Override the asset-sweep runner (test injection — no child process). */
321
+ assetSweepRunner?: typeof runAssetSweep;
236
322
  /**
237
323
  * DDR-102 — auth-failure + boot-settle knobs (test injection, mirrors the
238
324
  * connection-state injectable-timer pattern).
@@ -431,6 +517,16 @@ export function createSyncRuntime(
431
517
  // hub body, so a bogus/far-future stamp must not schedule a renewal at boot.
432
518
  let tokenExpiresAt: number | null = validExpiry(storedRecord?.expiresAt);
433
519
 
520
+ // feature-sync-file-plane — Plane B, behind its flag. The flag gates ONLY
521
+ // the new plane (the downward file pull here + the widened sweep inside
522
+ // `listPushableAssets`); with it off, behavior is today's, byte-for-byte.
523
+ const syncFilesOn = linkedHub.syncFiles === true || process.env.MAUDE_SYNC_FILES === '1';
524
+ // The owner-hub gate for `code-module` entries, decided from LOCAL state
525
+ // only: the role this machine's credential store vouched for at sign-in
526
+ // (never a hub-supplied claim), or the hub being this cell's own loopback
527
+ // pairing — where the hub and the checkout are the same trust domain.
528
+ const allowCodeModules = cellPairing !== null || storedRecord?.role === 'owner';
529
+
434
530
  // DDR-102 — the default factory multiplexes every provider over ONE shared
435
531
  // WebSocket per hub URL; the runtime owns its disposal (stop(), after the
436
532
  // providers detach). An injected test factory has no shared socket.
@@ -444,8 +540,31 @@ export function createSyncRuntime(
444
540
  // per run (chosen by useSharedDoc).
445
541
  const projections = new Map<string, DocProjection>();
446
542
  const providers = new Map<string, SyncProvider>();
447
- const awarenessDetaches: Array<() => void> = [];
448
- const statusDetaches: Array<() => void> = [];
543
+ // KEYED BY SLUG, not flat arrays.
544
+ //
545
+ // These used to be two bare lists drained only by `stop()`, which was right
546
+ // while the canvas set was fixed at boot. Continuous discovery makes a canvas
547
+ // leave the runtime on its own (deleted on disk, moved out of a synced group),
548
+ // and releasing one means running exactly ITS closures — a flat list cannot
549
+ // say which those are. `stop()` still drains everything, in the same two
550
+ // phases and the same order as before.
551
+ const awarenessDetaches = new Map<string, Array<() => void>>();
552
+ const statusDetaches = new Map<string, Array<() => void>>();
553
+ const noteDetach = (map: Map<string, Array<() => void>>, slug: string, fn: () => void): void => {
554
+ const list = map.get(slug);
555
+ if (list) list.push(fn);
556
+ else map.set(slug, [fn]);
557
+ };
558
+ const runDetaches = (map: Map<string, Array<() => void>>, slug: string): void => {
559
+ for (const detach of map.get(slug) ?? []) {
560
+ try {
561
+ detach();
562
+ } catch {
563
+ /* best-effort — registry teardown also clears bridges on destroyAll */
564
+ }
565
+ }
566
+ map.delete(slug);
567
+ };
449
568
  // Phase 9.2 (DDR-064) — slugs pinned in the registry because a provider is
450
569
  // attached to their shared doc; released on stop(). Empty unless sharedDoc.
451
570
  const pinnedSlugs = new Set<string>();
@@ -459,6 +578,72 @@ export function createSyncRuntime(
459
578
  let stopped = false;
460
579
  /** The live asset sweep, so `stop()` can end it and the panel can cancel it. */
461
580
  let assetSweep: AssetSweepHandle | null = null;
581
+ /** Debounce for the on-change sweep — see `scheduleAssetSweep`. */
582
+ let assetSweepTimer: ReturnType<typeof setTimeout> | null = null;
583
+ /** A change that arrived while a sweep was running: sweep once more after. */
584
+ let assetSweepAgain = false;
585
+
586
+ /**
587
+ * Run the asset sweep now, unless one is already running.
588
+ *
589
+ * OUT OF PROCESS since feature-sync-resync-and-out-of-process-sweep: the
590
+ * sweep segfaults Bun when it runs alongside the dev server (proven by
591
+ * isolation — the identical sweep against the same hub completes standalone).
592
+ * A dead child is a reported failed sweep, not a dead editor.
593
+ */
594
+ function startAssetSweep(hubUrl: string): void {
595
+ if (stopped || assetSweep) return;
596
+ const handle = (opts.assetSweepRunner ?? runAssetSweep)({
597
+ designRoot: ctx.paths.designRoot,
598
+ hubUrl,
599
+ // Read at call time — a silent renewal mid-sweep must reach the child
600
+ // (the parent re-writes its credential file when this changes).
601
+ token: () => token,
602
+ // feature-sync-progress-modal — ride the same `sync:status` payload the
603
+ // doc counts use, so the Sync panel has one source. `statusStore`, not
604
+ // start()'s local `store` alias: this helper is runtime-scoped so it can
605
+ // also be called from the fs watcher, and the alias does not exist here.
606
+ // Guarded on `stopped`: a late emit must not write `_sync.json`
607
+ // post-teardown.
608
+ onProgress: (p) => {
609
+ if (!stopped) statusStore?.updateAssets?.(p);
610
+ },
611
+ });
612
+ assetSweep = handle;
613
+ handle.done.finally(() => {
614
+ if (assetSweep === handle) assetSweep = null;
615
+ // A file that changed WHILE this sweep ran was not in its list — the
616
+ // trailing re-run is what keeps "I pasted two images quickly" from
617
+ // uploading only the first. Same single-flight-with-trailing-run shape
618
+ // the hub's own sweeper uses.
619
+ if (assetSweepAgain && !stopped) {
620
+ assetSweepAgain = false;
621
+ scheduleAssetSweep(hubUrl);
622
+ }
623
+ });
624
+ }
625
+
626
+ /**
627
+ * Coalesce a burst of asset writes into one sweep.
628
+ *
629
+ * Dragging six images onto a canvas is six `fs:any` events inside a second,
630
+ * and each sweep costs one presence probe over the wire. The debounce makes
631
+ * that one probe; `assetSweepAgain` makes a change during a sweep a second
632
+ * pass rather than a lost upload.
633
+ */
634
+ function scheduleAssetSweep(hubUrl: string): void {
635
+ if (stopped || cellPairing) return;
636
+ if (assetSweep) {
637
+ assetSweepAgain = true;
638
+ return;
639
+ }
640
+ if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
641
+ assetSweepTimer = setTimeout(() => {
642
+ assetSweepTimer = null;
643
+ startAssetSweep(hubUrl);
644
+ }, ASSET_SWEEP_DEBOUNCE_MS);
645
+ assetSweepTimer.unref?.();
646
+ }
462
647
 
463
648
  // Task 8 — offline-mode status surface, initialized in start() once the
464
649
  // canvas count is known. The store writes `_sync.json` + broadcasts
@@ -525,6 +710,173 @@ export function createSyncRuntime(
525
710
  * that no longer exists. */
526
711
  const announceTimers = new Map<string, ReturnType<typeof setTimeout>>();
527
712
 
713
+ /**
714
+ * Assigned by `start()`. Adopting a canvas needs the boot closure (the
715
+ * journal, the history, the status store, `connectCanvas`), so the function
716
+ * is built there and published here for `adopt()` to reach. Null in solo mode
717
+ * and after `stop()`.
718
+ */
719
+ let attachOne: ((canvas: CanvasDescriptor, boot: boolean) => Promise<void>) | null = null;
720
+
721
+ /** Continuous local discovery — armed at the end of `start()`, torn down in
722
+ * `stop()`. See the block that creates it and `discovery.ts`. */
723
+ let discoveryRescan: RescanScheduler | null = null;
724
+ let discoveryUnsub: (() => void) | null = null;
725
+ /** The outbound delete-lane subscriptions — see `noteToHub`. */
726
+ let deletedUnsub: (() => void) | null = null;
727
+ let createdUnsub: (() => void) | null = null;
728
+ /** Periodic remote-document poll — the hub-side half of discovery. */
729
+ let remotePollTimer: ReturnType<typeof setInterval> | null = null;
730
+ /** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
731
+ let remotePull: (() => Promise<void>) | null = null;
732
+ /**
733
+ * Ask for an off-schedule remote poll, coalesced.
734
+ *
735
+ * Reachable before `start()` finishes wiring `remotePull` (the monitor is
736
+ * built first and can emit during boot), so it is a no-op until there is
737
+ * something to call — the boot pull has just run at that point anyway.
738
+ */
739
+ let remotePollSoonTimer: ReturnType<typeof setTimeout> | null = null;
740
+ function pollRemoteSoon(): void {
741
+ if (stopped || remotePollSoonTimer !== null) return;
742
+ remotePollSoonTimer = setTimeout(() => {
743
+ remotePollSoonTimer = null;
744
+ if (stopped) return;
745
+ void remotePull?.().catch(() => {
746
+ /* the scheduled poll retries — a missed opportunistic one is not news */
747
+ });
748
+ }, REMOTE_POLL_SOON_MS);
749
+ remotePollSoonTimer.unref?.();
750
+ }
751
+ /** Every slug this run brought down, so the panel's "came down from the
752
+ * project" list accumulates instead of being replaced by the latest batch. */
753
+ const everPulled = new Set<string>();
754
+
755
+ /**
756
+ * Every slug the PROJECT has deleted, as this run has learned it.
757
+ *
758
+ * The hub drops its `documents` row best-effort while the tombstone is the
759
+ * durable part, so a deleted canvas can still appear in one more listing. This
760
+ * set is what stops the pull lane from treating that listing as an invitation
761
+ * to write the canvas back — the resurrection, arrived at from the other side.
762
+ */
763
+ const tombstoned = new Set<string>();
764
+
765
+ /**
766
+ * The LIVE descriptor set, by slug.
767
+ *
768
+ * `start()`'s `canvases` array is the BOOT set and stops being the truth the
769
+ * moment a canvas is adopted or released. The DDR-054 §3 F3 untrusted markers
770
+ * must describe what is actually attached — a marker naming a canvas that
771
+ * left, or silently omitting one that arrived, is the control pointing at a
772
+ * phantom. Descriptors are held BY REFERENCE: `relocatePulled` mutates them in
773
+ * place, and the map must see that.
774
+ */
775
+ const descriptors = new Map<string, CanvasDescriptor>();
776
+
777
+ /** Warnings already emitted, so a per-poll refusal is said once, not forever. */
778
+ const warnedOnce = new Set<string>();
779
+ function warnOnce(key: string, message: string): void {
780
+ if (warnedOnce.has(key)) return;
781
+ warnedOnce.add(key);
782
+ console.warn(message);
783
+ }
784
+
785
+ /**
786
+ * May a HUB-AUTHORED body of this shape be written to this disk at all?
787
+ *
788
+ * The local lane has asked this since 9.1-B: `scanCanvases` admits a `.tsx`
789
+ * only when the cross-origin sandbox is active AND the project has not opted
790
+ * out (`walk` → `resolveSyncable`; DDR-060 couples the two, DDR-079 sets the
791
+ * default). The PULL lane never asked it — so a peer running with the sandbox
792
+ * off, or with `linkedHub.syncTsx: false` set precisely to keep hub `.tsx` off
793
+ * this machine, still received hub-authored `.tsx` bodies and rendered them.
794
+ * With the sandbox off that render is on the MAIN origin, which is the exact
795
+ * execution the coupling exists to prevent.
796
+ *
797
+ * The gap predates continuous discovery — it was reachable once per connect —
798
+ * but a lane that re-asks every 20 s makes "the opt-out holds for a moment"
799
+ * indistinguishable from "the opt-out does nothing".
800
+ */
801
+ function admitPulledBody(slug: string, bodyAbs: string): boolean {
802
+ if (!bodyAbs.toLowerCase().endsWith('.tsx')) return true;
803
+ const splitActive = !!ctx.canvasOrigin;
804
+ const projectSyncTsx = linkedHub.syncTsx !== false;
805
+ if (splitActive && projectSyncTsx) return true;
806
+ warnOnce(
807
+ `pull-tsx-refused:${splitActive ? 'opt-out' : 'sandbox-off'}`,
808
+ `[sync] refusing hub-authored .tsx canvases (first: ${slug}) — ` +
809
+ (splitActive
810
+ ? 'this project set linkedHub.syncTsx: false.'
811
+ : 'the cross-origin sandbox is off (MAUDE_CANVAS_ORIGIN_SPLIT=0), and TSX sync with it — DDR-060.') +
812
+ ' The same gate the local scan applies, now applied to what the hub sends.'
813
+ );
814
+ return false;
815
+ }
816
+
817
+ /**
818
+ * Give up ONE canvas — the inverse of `attachOne`.
819
+ *
820
+ * Order is deliberate and is NOT `stop()`'s order. `stop()` unpins before it
821
+ * flushes because the whole process is going away and the rooms are being
822
+ * torn down anyway; here the room OUTLIVES the release, so the doc→file
823
+ * projection must flush FIRST and the pin is dropped last. Unpinning early
824
+ * would hand the room to the last-browser-leaves drop while the projector was
825
+ * still writing through it.
826
+ *
827
+ * The shared WebSocket is deliberately untouched: it belongs to the runtime,
828
+ * not to a canvas, and the next adopt will need it. Only `stop()` disposes it.
829
+ */
830
+ async function releaseOne(slug: string): Promise<boolean> {
831
+ const known = agents.has(slug) || projections.has(slug) || providers.has(slug);
832
+ if (!known) return false;
833
+ runDetaches(awarenessDetaches, slug);
834
+ runDetaches(statusDetaches, slug);
835
+ const agent = agents.get(slug);
836
+ if (agent) {
837
+ try {
838
+ await agent.flush();
839
+ agent.stop();
840
+ } catch {
841
+ /* best-effort */
842
+ }
843
+ agents.delete(slug);
844
+ }
845
+ const projection = projections.get(slug);
846
+ if (projection) {
847
+ try {
848
+ await projection.flush();
849
+ projection.stop();
850
+ } catch {
851
+ /* best-effort */
852
+ }
853
+ projections.delete(slug);
854
+ }
855
+ const provider = providers.get(slug);
856
+ if (provider) {
857
+ try {
858
+ provider.destroy();
859
+ } catch {
860
+ /* best-effort */
861
+ }
862
+ providers.delete(slug);
863
+ }
864
+ if (pinnedSlugs.delete(slug)) {
865
+ try {
866
+ opts.registry?.unpin?.(slug);
867
+ } catch {
868
+ /* best-effort */
869
+ }
870
+ }
871
+ descriptors.delete(slug);
872
+ // Drop the row too — a released canvas that stayed `pending` in the status
873
+ // payload would be a permanent "still syncing" the user can never clear.
874
+ monitor?.forgetDoc(slug);
875
+ rejectedPermanent.delete(slug);
876
+ rejectedReasons.delete(slug);
877
+ return true;
878
+ }
879
+
528
880
  async function start(): Promise<void> {
529
881
  if (started || stopped) return;
530
882
  started = true;
@@ -575,10 +927,20 @@ export function createSyncRuntime(
575
927
  // design root — see `pullTargets` for why flat); local-only canvases go up
576
928
  // as they always did. Best-effort: an older hub without the listing route,
577
929
  // or an unreachable one, syncs exactly as before.
578
- const remoteDocs = await fetchRemoteDocs(linkedHub.url, resolvedToken);
930
+ const remoteListing = await fetchRemoteListing(linkedHub.url, resolvedToken);
931
+ // BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
932
+ // lives further down (it needs the live descriptor map), so this boot pass
933
+ // only has to make sure the pull does not fetch a canvas the project has
934
+ // deleted — the first poll then quarantines whatever is still on disk. Doing
935
+ // it in the other order would materialise a deleted canvas on every launch
936
+ // and delete it again seconds later, which is worse than the bug.
937
+ for (const stone of remoteListing?.tombstones ?? []) {
938
+ const slug = slugFromDocName(stone.name);
939
+ if (slug) tombstoned.add(slug);
940
+ }
579
941
  const remoteDiff = diffRemoteDocs(
580
942
  localCanvases.map((c) => docNameFor(c.slug)),
581
- remoteDocs
943
+ remoteListing?.documents ?? null
582
944
  );
583
945
  // PROVISIONAL targets. The listing carries names and byte counts only — a
584
946
  // document's own `syncMeta.path` lives INSIDE it, so every target here is
@@ -683,12 +1045,48 @@ export function createSyncRuntime(
683
1045
  // in the same place: `system-colors_and_type` falls back to
684
1046
  // `system/colors_and_type.tsx` whether or not a path arrives. Checking only
685
1047
  // 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));
1048
+ // The boot pull asks the SAME two questions the incremental one does — the
1049
+ // sandbox/opt-out gate on a hub-authored body, and the pinned-room ceiling.
1050
+ // Both were missing here too; the incremental lane just made their absence
1051
+ // permanent instead of momentary. See `admitPulledBody` and MAX_PULLS_PER_POLL.
1052
+ const admittedPulls = pulled
1053
+ // A canvas the project deleted is never pulled, however it is still listed.
1054
+ .filter((t) => !tombstoned.has(t.slug))
1055
+ .filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
1056
+ .filter((t) => admitPulledBody(t.slug, t.bodyAbs))
1057
+ .slice(0, Math.max(0, maxPinnedRooms() - localCanvases.length));
687
1058
  const pulledSlugs = new Set(admittedPulls.map((t) => t.slug));
1059
+ for (const t of admittedPulls) everPulled.add(t.slug);
1060
+ /**
1061
+ * Tell the panel EVERYTHING that came down this run, boot included.
1062
+ *
1063
+ * `notePulled` REPLACES the list, and the boot pull used to pass its own
1064
+ * slugs directly — so the first mid-session pull erased every boot-pulled
1065
+ * canvas from the surface, which is precisely what `everPulled` exists to
1066
+ * stop. One accumulating set, one caller.
1067
+ */
1068
+ const notePulledAll = (): void => mon.notePulled([...everPulled]);
1069
+ /**
1070
+ * Slugs pulled AFTER boot, which never get the fresh-link relaxation.
1071
+ *
1072
+ * `allowUndeclaredGroup` exists for exactly one moment: the first connect of
1073
+ * a bare folder somebody just pointed at a project, which has declared no
1074
+ * canvas groups and would otherwise refuse every incoming path. It closes as
1075
+ * soon as one group is learned. A mid-session pull is not that moment — and
1076
+ * because `relocatePulled` reads `pathOpts` at call time, a project that
1077
+ * never learned a group would otherwise leave the relaxation open for every
1078
+ * later arrival, which is an unbounded directory-creation primitive for the
1079
+ * hub. Membership in this set overrides the flag, per canvas.
1080
+ */
1081
+ const strictPullSlugs = new Set<string>();
688
1082
  const canvases = [
689
1083
  ...localCanvases,
690
1084
  ...admittedPulls.map((t) => descriptorFor(t.slug, t.bodyAbs)),
691
1085
  ];
1086
+ /**
1087
+ * Seed the live descriptor set from the boot set — see `descriptors`.
1088
+ */
1089
+ for (const c of canvases) descriptors.set(c.slug, c);
692
1090
  // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
693
1091
  // content, so the whole set is untrusted Claude-context. Mark it (writes
694
1092
  // `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
@@ -712,8 +1110,9 @@ export function createSyncRuntime(
712
1110
  //
713
1111
  // So it is written twice: once now (so the markers exist before any provider
714
1112
  // is built) and once after the pulls settle, from the final descriptors.
1113
+ // Reads the LIVE set, not the boot array — see `descriptors`.
715
1114
  const markUntrusted = (): void => {
716
- if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
1115
+ if (!cellPairing) writeUntrustedMarkers(ctx, [...descriptors.values()], linkedHub.url);
717
1116
  };
718
1117
  markUntrusted();
719
1118
  if (canvases.length === 0) {
@@ -723,6 +1122,31 @@ export function createSyncRuntime(
723
1122
  // loudly: a warn, a `_sync.json` the CLI + browser banner read, and a bus
724
1123
  // broadcast so open tabs render it immediately.
725
1124
  surfaceNoSyncable(ctx, linkedHub.url, scan.tsxCount);
1125
+ // A PROJECT WITH NOTHING IN IT YET IS STILL A PROJECT.
1126
+ //
1127
+ // This return happens before the status store, the monitor and the fs
1128
+ // reader exist, so the continuous-discovery machinery at the bottom of
1129
+ // start() is never reached — and a person who links an empty project and
1130
+ // then makes their first canvas would be back to "nothing syncs until you
1131
+ // restart", which is the whole bug, reached from its emptiest corner.
1132
+ //
1133
+ // A full cycle IS the right answer here and only here: there is no runtime
1134
+ // state to preserve, and one is exactly what a first canvas needs. The
1135
+ // supervisor owns cycling (it holds the serialization), so this asks
1136
+ // rather than acts — see `server.ts`.
1137
+ const watchForFirst = createRescanScheduler({
1138
+ debounceMs: DISCOVERY_DEBOUNCE_MS,
1139
+ onError: (err) => console.error('[sync] first-canvas watch failed:', err),
1140
+ run: async () => {
1141
+ if (stopped || opts.canvases) return;
1142
+ const again = await scanCanvases(ctx);
1143
+ if (admitCanvases(again.canvases, useSharedDoc).length === 0) return;
1144
+ console.log('[sync] the project has its first syncable canvas — starting sync.');
1145
+ ctx.bus.emit('sync:needs-restart');
1146
+ },
1147
+ });
1148
+ discoveryRescan = watchForFirst;
1149
+ discoveryUnsub = ctx.bus.on('canvas-list-update', () => watchForFirst.schedule());
726
1150
  return;
727
1151
  }
728
1152
 
@@ -789,6 +1213,16 @@ export function createSyncRuntime(
789
1213
 
790
1214
  busUnsub = ctx.bus.on('fs:any', (rel: string) => {
791
1215
  reader.notify(rel);
1216
+ // AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
1217
+ //
1218
+ // The sweep used to fire from exactly one place — `start()` — so a picture
1219
+ // pasted into an annotation reached the cloud only on the next boot or
1220
+ // Resync. Meanwhile the annotation itself syncs through the doc in
1221
+ // milliseconds, so the other side rendered an `<image>` pointing at bytes
1222
+ // nobody had sent: a permanent empty frame that looked like a broken path.
1223
+ // Reported three times in one day on alligators, each time with a
1224
+ // different asset, which is what finally named it.
1225
+ if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep(linkedHub.url);
792
1226
  });
793
1227
 
794
1228
  /**
@@ -1235,7 +1669,9 @@ export function createSyncRuntime(
1235
1669
  resolve: path.resolve,
1236
1670
  sep: path.sep,
1237
1671
  realpath: realpathOfDeepestExisting,
1238
- allowUndeclaredGroup: pathOpts.allowUndeclaredGroup,
1672
+ // The fresh-link relaxation is boot-only, per canvas — see
1673
+ // `strictPullSlugs` for why reading `pathOpts` alone is not enough.
1674
+ allowUndeclaredGroup: pathOpts.allowUndeclaredGroup && !strictPullSlugs.has(canvas.slug),
1239
1675
  onRefused: (reason) => pathOpts.onRefused(canvas.slug, reason),
1240
1676
  });
1241
1677
  if (!resolved) return;
@@ -1288,7 +1724,19 @@ export function createSyncRuntime(
1288
1724
  canvas: CanvasDescriptor,
1289
1725
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
1290
1726
  document?: Y.Doc,
1291
- setup?: (provider: SyncProvider) => void
1727
+ setup?: (provider: SyncProvider) => void,
1728
+ /**
1729
+ * Does this connect belong to the BOOT set?
1730
+ *
1731
+ * Only a boot connect may enqueue a `bootWaits` entry. The boot summary
1732
+ * (`Promise.allSettled(bootWaits)`) is a one-shot report about the set the
1733
+ * runtime opened at start; a canvas adopted later must never be able to
1734
+ * join a list that has already been awaited — and, once discovery is
1735
+ * continuous, an unbounded stream of them would grow that array for the
1736
+ * life of the process. Defaults true so the re-probe path (which has
1737
+ * always pushed) is byte-for-byte unchanged.
1738
+ */
1739
+ boot = true
1292
1740
  ): Promise<SyncProvider> => {
1293
1741
  const provider = await providerFactory({
1294
1742
  url: linkedHub.url,
@@ -1321,8 +1769,30 @@ export function createSyncRuntime(
1321
1769
  if (!deferSetup) setup?.(provider);
1322
1770
 
1323
1771
  // Task 8 — feed this provider's WS status into the offline monitor.
1772
+ // Per-provider, so a socket that never dropped never triggers a poll.
1773
+ let wasDisconnected = false;
1324
1774
  if (provider.onStatus) {
1325
- statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
1775
+ noteDetach(
1776
+ statusDetaches,
1777
+ canvas.slug,
1778
+ provider.onStatus((s) => {
1779
+ mon.noteProviderStatus(canvas.slug, s);
1780
+ // COMING BACK IS THE MOMENT MOST LIKELY TO HAVE MISSED SOMETHING.
1781
+ //
1782
+ // A peer that was away for an hour has an hour of other people's
1783
+ // canvases to learn about, and making it sit out the poll interval
1784
+ // ON TOP of the outage is the one wait that is both longest and
1785
+ // least excusable. The signal is the socket returning, taken from
1786
+ // the provider rather than from the monitor's snapshot: a caller
1787
+ // that injects its own monitor (every test, and anything later)
1788
+ // would otherwise silently lose this.
1789
+ //
1790
+ // A reconnect storm is N providers reporting at once —
1791
+ // `pollRemoteSoon` coalesces them into one request.
1792
+ if (s === 'connected' && wasDisconnected) pollRemoteSoon();
1793
+ wasDisconnected = s !== 'connected';
1794
+ })
1795
+ );
1326
1796
  } else {
1327
1797
  // No status events (test stub) — treat as connected so the monitor
1328
1798
  // doesn't sit in the boot 'connecting' state forever.
@@ -1330,7 +1800,9 @@ export function createSyncRuntime(
1330
1800
  }
1331
1801
  // DDR-102 — classify + aggregate hub auth rejections.
1332
1802
  if (provider.onAuthFailed) {
1333
- statusDetaches.push(
1803
+ noteDetach(
1804
+ statusDetaches,
1805
+ canvas.slug,
1334
1806
  provider.onAuthFailed(({ reason }) =>
1335
1807
  handleAuthFailure(canvas, canvasPaths, provider, reason)
1336
1808
  )
@@ -1340,7 +1812,11 @@ export function createSyncRuntime(
1340
1812
  // browser cursors relay cross-machine. No-op when the provider exposes
1341
1813
  // no awareness or no registry was passed (file-sync-only tests).
1342
1814
  if (opts.registry && provider.awareness) {
1343
- awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
1815
+ noteDetach(
1816
+ awarenessDetaches,
1817
+ canvas.slug,
1818
+ opts.registry.attachHubAwareness(canvas.slug, provider.awareness)
1819
+ );
1344
1820
  }
1345
1821
  // Cold-start reconcile fires once the provider has hub state.
1346
1822
  const synced = provider.onceSynced().then(() => {
@@ -1350,12 +1826,25 @@ export function createSyncRuntime(
1350
1826
  }
1351
1827
  return runHandleSynced(canvas, canvasPaths, provider);
1352
1828
  });
1353
- bootWaits.push(settleWait(synced));
1829
+ if (boot) bootWaits.push(settleWait(synced));
1354
1830
  return provider;
1355
1831
  };
1356
1832
 
1357
- for (const canvas of canvases) {
1833
+ /**
1834
+ * Take ownership of ONE canvas: pin its shared doc if there is one, open a
1835
+ * provider, and build the disk handler behind it.
1836
+ *
1837
+ * Extracted from the boot loop so the SAME path can be reached after boot —
1838
+ * document discovery is continuous, not a snapshot taken at `start()`, and a
1839
+ * canvas that appears later must be adopted by exactly this code rather than
1840
+ * by a second, drifting copy of it. Every branch below is the boot
1841
+ * behaviour, unchanged; `boot` only decides whether the connect joins the
1842
+ * one-shot boot summary.
1843
+ */
1844
+ const attachCanvas = async (canvas: CanvasDescriptor, boot: boolean): Promise<void> => {
1358
1845
  try {
1846
+ // By reference — `relocatePulled` mutates this object in place.
1847
+ descriptors.set(canvas.slug, canvas);
1359
1848
  // Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
1360
1849
  // the collab room's single Y.Doc (registry.getDoc) instead of a fresh
1361
1850
  // one, so browser edits flow straight into the doc that syncs to the
@@ -1377,129 +1866,143 @@ export function createSyncRuntime(
1377
1866
  css: canvas.css,
1378
1867
  };
1379
1868
  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
- },
1869
+ await connectCanvas(
1870
+ canvas,
1871
+ canvasPaths,
1872
+ sharedYDoc,
1873
+ (provider) => {
1874
+ // Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
1875
+ // loop-free projection (html/css/meta doc→file + all-types file→doc;
1876
+ // the collab room keeps comments/annotations doc→file, so no
1877
+ // double-write). Flag-OFF keeps the proven two-doc agent. Created
1878
+ // ONCE here (first connect) — a DDR-102 re-probe swaps only the
1879
+ // provider; everything below is doc-scoped and survives.
1880
+ let agent: CanvasSyncAgent | undefined;
1881
+ if (useSharedDoc && sharedYDoc) {
1882
+ const projection = createDocProjection({
1883
+ slug: canvas.slug,
1884
+ doc: provider.document,
1885
+ paths: canvasPaths,
1886
+ echoGuard,
1887
+ journal: journal ?? undefined,
1888
+ // Cell pairing only — see the DocProjectionOptions.onWrote doc.
1889
+ // The synthetic event is delayed by the same margin the container
1890
+ // write bridge uses, so a watcher that DOES fire wins the race and
1891
+ // the HMR broadcaster's per-file coalescing collapses the pair
1892
+ // into one `canvas-hmr`. The projector's own echo guard drops the
1893
+ // resulting file→doc read, so this cannot loop.
1894
+ ...(cellPairing ? { onWrote: announceWrite } : {}),
1895
+ });
1896
+ projection.start();
1897
+ projections.set(canvas.slug, projection);
1898
+ } else {
1899
+ const relBody = path.relative(ctx.paths.repoRoot, canvas.html);
1900
+ agent = createCanvasSyncAgent({
1901
+ slug: canvas.slug,
1902
+ doc: provider.document,
1903
+ paths: canvasPaths,
1904
+ echoGuard,
1905
+ adopt: adoptOnce,
1906
+ journal: journal ?? undefined,
1907
+ // Wrap the writer rather than adding a new hook: every path the
1908
+ // agent materializes to disk goes through it, so a future write
1909
+ // surface is committed automatically instead of being forgotten.
1910
+ ...(autoCommit
1911
+ ? {
1912
+ writer: (file: string, bytes: string | Uint8Array) => {
1913
+ atomicWrite(file, bytes);
1914
+ autoCommit.note(
1915
+ path.relative(ctx.paths.repoRoot, file),
1916
+ editorOf(canvas.slug)
1917
+ );
1918
+ },
1919
+ }
1920
+ : {}),
1921
+ snapshot: async (content, reason) => {
1922
+ try {
1923
+ const snap = await history.writeSnapshot(relBody, content, reason);
1924
+ return snap.ts;
1925
+ } catch {
1926
+ return null; // best-effort — resolution proceeds without refs
1426
1927
  }
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
- }
1928
+ },
1929
+ onConflict: (info) => store.addConflict(info),
1930
+ });
1931
+ agent.start();
1932
+ agents.set(canvas.slug, agent);
1933
+ }
1441
1934
 
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
- }
1935
+ // Count local edits (agent-origin doc updates) toward queuedOps while
1936
+ // the hub is unreachable — the banner's "N edits queued" figure. Under
1937
+ // sharedDoc there is no agent origin to key off (browser edits carry a
1938
+ // RoomConn origin); queued-edit counting in that mode is a known gap
1939
+ // (offline-banner accuracy only, not data) deferred past Phase C.
1940
+ if (agent) {
1941
+ const agentOrigin = agent.origin;
1942
+ const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
1943
+ if (origin === agentOrigin) mon.noteLocalEdit();
1944
+ };
1945
+ provider.document.on('update', onLocalUpdate);
1946
+ noteDetach(statusDetaches, canvas.slug, () =>
1947
+ provider.document.off('update', onLocalUpdate)
1948
+ );
1949
+ }
1455
1950
 
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
- });
1951
+ // Relay hub-pushed comment/annotation changes straight into the live
1952
+ // room — IN-PROCESS + synchronous, so the room's in-memory doc is
1953
+ // updated BEFORE its 800ms persist timer can flush stale pre-sync state
1954
+ // back over the file (the disk-mediated re-seed in createCollab loses
1955
+ // that race under an actively-edited peer; this is the tight path that
1956
+ // actually closes the "comment reverts" clobber). Wholesale-replace via
1957
+ // syncRoomFrom* → no duplication. Skip agent-origin updates (our own
1958
+ // disk→doc apply — the file is authoritative there; a local design:edit
1959
+ // reaches the room via createCollab's fs hook instead).
1960
+ //
1961
+ // CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
1962
+ // whole-doc update. A whole-doc relay re-applies BOTH types on every
1963
+ // change, so a comment sync would re-push the (stale) annotation and
1964
+ // clobber an annotation the peer just drew but hasn't synced yet — and
1965
+ // vice versa. Per-type observers keep the two lanes independent.
1966
+ //
1967
+ // Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
1968
+ // room's doc, so there is no second doc to relay into — the room already
1969
+ // has every change. Skipping the relay is what RETIRES the
1970
+ // wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
1971
+ // CRDT merge handles concurrency, no last-writer-wins blob copy.
1972
+ const reg = opts.registry;
1973
+ if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
1974
+ const agentOrigin = agent.origin;
1975
+ const slug = canvas.slug;
1976
+ const provComments = provider.document.getArray(Y_TYPES.comments);
1977
+ const provAnn = provider.document.getMap(Y_TYPES.annotations);
1978
+ const onComments = (_e: unknown, tx: { origin: unknown }) => {
1979
+ if (tx.origin === agentOrigin) return;
1980
+ reg.syncRoomFromComments?.(slug, provComments.toArray());
1981
+ };
1982
+ const onAnn = (_e: unknown, tx: { origin: unknown }) => {
1983
+ if (tx.origin === agentOrigin) return;
1984
+ const svg = provAnn.get('svg');
1985
+ if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
1986
+ };
1987
+ provComments.observe(onComments);
1988
+ provAnn.observe(onAnn);
1989
+ noteDetach(statusDetaches, canvas.slug, () => {
1990
+ provComments.unobserve(onComments);
1991
+ provAnn.unobserve(onAnn);
1992
+ });
1993
+ }
1994
+ },
1995
+ boot
1996
+ );
1500
1997
  } catch (err) {
1501
1998
  console.error(`[sync/${canvas.slug}] failed to start:`, err);
1502
1999
  }
2000
+ };
2001
+
2002
+ attachOne = attachCanvas;
2003
+
2004
+ for (const canvas of canvases) {
2005
+ await attachCanvas(canvas, true);
1503
2006
  }
1504
2007
 
1505
2008
  // Persist an initial status snapshot so `_sync.json` exists — and `maude
@@ -1544,7 +2047,7 @@ export function createSyncRuntime(
1544
2047
  // the pull, so it named exactly the canvases that had just arrived and
1545
2048
  // were sitting on disk. `pulled` is the same list under the name that is
1546
2049
  // true, and it is the fact the user is told to act on.
1547
- mon.notePulled(admittedPulls.map((t) => t.slug));
2050
+ notePulledAll();
1548
2051
  // Re-mark from the FINAL descriptors — `relocatePulled` mutates them in
1549
2052
  // place after each handshake, and the markers are the one consumer that
1550
2053
  // read them before that and would otherwise never read them again.
@@ -1555,27 +2058,310 @@ export function createSyncRuntime(
1555
2058
  // under pairing: a cell's assets are already on the cell. Fire-and-forget
1556
2059
  // — a miss is retried on the next boot for free.
1557
2060
  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
- });
2061
+ startAssetSweep(linkedHub.url);
1576
2062
  }
1577
2063
  });
1578
2064
 
2065
+ // ─── DISCOVERY IS CONTINUOUS FROM HERE ────────────────────────────────
2066
+ //
2067
+ // Everything above is the BOOT set. `canvas-list-watch.ts` already notices
2068
+ // when the openable-canvas set changes on disk from ANY source (the API, the
2069
+ // ACP agent, a terminal `cp`, `git checkout`, or the hub's own workspace
2070
+ // agent writing a peer's new canvas into a cell's checkout) and emits
2071
+ // `canvas-list-update`. Nothing was listening on behalf of sync. This is
2072
+ // that listener.
2073
+ //
2074
+ // The payload is used ONLY as a nudge — never as data. See `discovery.ts`
2075
+ // for why the authoritative answer is a full rescan through the same
2076
+ // `scanCanvases` boot used.
2077
+ const rescan = createRescanScheduler({
2078
+ debounceMs: DISCOVERY_DEBOUNCE_MS,
2079
+ onError: (err) => console.error('[sync] canvas rescan failed:', err),
2080
+ run: async () => {
2081
+ if (stopped) return;
2082
+ // An explicit canvas list (test injection) means the caller owns
2083
+ // membership; rescanning would silently overrule them.
2084
+ if (opts.canvases) return;
2085
+ const fresh = await scanCanvases(ctx);
2086
+ const admitted = admitCanvases(fresh.canvases, useSharedDoc);
2087
+ const bySlug = new Map(admitted.map((c) => [c.slug, c]));
2088
+ // THE PULL PIN IS A RACE GUARD, NOT A PERMANENT EXEMPTION.
2089
+ //
2090
+ // A pulled canvas is kept out of `removed` because its body is written
2091
+ // AFTER the handshake, so a scan taken in that window is not evidence
2092
+ // it left the project. That window closes the moment the file exists —
2093
+ // and the pin was never released, so it did not close at all. The cost
2094
+ // is exactly the control this file calls "a security opt-out a hub must
2095
+ // not be able to flip": a pulled canvas whose `.meta.json` says
2096
+ // `syncable: false` was dropped by the scan, held by the pin, and kept
2097
+ // receiving hub writes for the life of the process. On a cell that is
2098
+ // days. Once the body is on disk the ordinary rules apply to it.
2099
+ for (const slug of [...pulledSlugs]) {
2100
+ const body = descriptors.get(slug)?.html;
2101
+ if (body && existsSync(body)) pulledSlugs.delete(slug);
2102
+ }
2103
+ const { added, removed } = diffCanvasSet(
2104
+ [...agents.keys(), ...projections.keys()],
2105
+ bySlug.keys(),
2106
+ pulledSlugs
2107
+ );
2108
+ if (added.length === 0 && removed.length === 0) return;
2109
+ if (removed.length > 0) {
2110
+ for (const slug of removed) await releaseOne(slug);
2111
+ console.log(`[sync] released ${removed.length} canvas(es): ${removed.join(', ')}`);
2112
+ // The marker set shrank. It describes what a peer can WRITE to, so a
2113
+ // stale entry over-lists — the safe direction, and still wrong: it is
2114
+ // the one mitigation standing between an untrusted pull and what the
2115
+ // agent reads.
2116
+ markUntrusted();
2117
+ }
2118
+ const incoming = added.map((slug) => bySlug.get(slug)).filter((c) => !!c);
2119
+ for (const canvas of incoming) {
2120
+ if (agents.has(canvas.slug) || projections.has(canvas.slug)) continue;
2121
+ if (providers.has(canvas.slug)) continue;
2122
+ await attachCanvas(canvas, false);
2123
+ }
2124
+ if (incoming.length > 0) {
2125
+ console.log(
2126
+ `[sync] adopted ${incoming.length} new canvas(es): ${incoming.map((c) => c.slug).join(', ')}`
2127
+ );
2128
+ // The set the DDR-054 §3 F3 markers describe just grew.
2129
+ markUntrusted();
2130
+ }
2131
+ },
2132
+ });
2133
+ discoveryRescan = rescan;
2134
+ discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
2135
+
2136
+ // The OUTBOUND half of the delete lane. `api.ts` emits these two only from
2137
+ // its privileged create/delete routes, never from the filesystem watcher —
2138
+ // see the comment at the emit site for why that distinction is what makes
2139
+ // them safe to act on.
2140
+ const noteToHub = (slug: unknown, revive: boolean): void => {
2141
+ if (typeof slug !== 'string' || !slug) return;
2142
+ if (revive) tombstoned.delete(slug);
2143
+ else tombstoned.add(slug);
2144
+ void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
2145
+ if (!ok) {
2146
+ console.warn(
2147
+ `[sync] could not tell the project that ${slug} was ${revive ? 're-created' : 'deleted'} — it stays ${revive ? 'buried' : 'in the project'} for other peers until this succeeds.`
2148
+ );
2149
+ }
2150
+ });
2151
+ };
2152
+ deletedUnsub = ctx.bus.on('canvas-deleted', (p: { slug?: unknown }) =>
2153
+ noteToHub(p?.slug, false)
2154
+ );
2155
+ createdUnsub = ctx.bus.on('canvas-created', (p: { slug?: unknown }) =>
2156
+ noteToHub(p?.slug, true)
2157
+ );
2158
+
2159
+ /**
2160
+ * Apply the project's deletions to this machine.
2161
+ *
2162
+ * The runtime releases the canvas first and moves the bytes second: a live
2163
+ * agent flushing its Y.Doc onto a path we are about to rename is how a
2164
+ * "deleted" canvas comes back as a half-written file.
2165
+ *
2166
+ * `tombstoned` outlives the individual poll. The hub drops the `documents`
2167
+ * row on a best-effort basis, so a tombstone and a still-listed document can
2168
+ * coexist for a tick; without a local memory of what was deleted, that
2169
+ * window is enough for the pull lane to fetch the canvas straight back.
2170
+ */
2171
+ const applyTombstones = async (stones: readonly RemoteTombstone[]): Promise<void> => {
2172
+ if (stones.length === 0) return;
2173
+ // Remember EVERY deletion, including names this peer never had — that is
2174
+ // what makes the pull lane below refuse a document the project deleted but
2175
+ // whose row has not gone yet.
2176
+ for (const stone of stones) {
2177
+ const slug = slugFromDocName(stone.name);
2178
+ if (slug) tombstoned.add(slug);
2179
+ }
2180
+ const gone = tombstonedSlugs(stones, descriptors.keys());
2181
+ if (gone.length === 0) return;
2182
+ for (const slug of gone) {
2183
+ const canvas = descriptors.get(slug);
2184
+ await releaseOne(slug);
2185
+ if (canvas) {
2186
+ quarantineCanvas({
2187
+ designRoot: ctx.paths.designRoot,
2188
+ slug,
2189
+ lanes: {
2190
+ html: canvas.html,
2191
+ meta: canvas.meta,
2192
+ css: canvas.css,
2193
+ annotations: canvas.annotations,
2194
+ },
2195
+ });
2196
+ }
2197
+ descriptors.delete(slug);
2198
+ }
2199
+ console.log(`[sync] the project deleted ${gone.length} canvas(es): ${gone.join(', ')}`);
2200
+ // The set the DDR-054 §3 F3 markers describe just shrank.
2201
+ markUntrusted();
2202
+ };
2203
+
2204
+ // ─── THE HUB HALF OF DISCOVERY ────────────────────────────────────────
2205
+ //
2206
+ // The rescan above sees this DISK. A document that exists only on the hub
2207
+ // is invisible to it — Yjs has no enumeration, so a peer learns of a
2208
+ // document only by being told its name. Boot asks once
2209
+ // (`GET /api/documents`), which is why a canvas created in the cloud after
2210
+ // this peer connected could never arrive: the desktop had already asked.
2211
+ //
2212
+ // So keep asking. This is deliberately a POLL and not a push: the listing
2213
+ // route is the ONLY document-enumeration surface every hub version exposes,
2214
+ // including self-hosted ones nobody is going to upgrade, and a fix that
2215
+ // required a new hub would leave exactly the installations that reported the
2216
+ // bug still broken. It is cheap (names + byte counts, one request), it
2217
+ // inherits `fetchRemoteDocs`'s never-fatal posture, and it rides the same
2218
+ // scope gate the sync itself does — a token that may not open a document is
2219
+ // not told the document exists.
2220
+ const pullRemoteOnce = async (): Promise<void> => {
2221
+ if (stopped) return;
2222
+ // Read `token` at call time: a silent renewal swaps it in place.
2223
+ const listing = await fetchRemoteListing(linkedHub.url, token);
2224
+ // null = unreachable, refused, or a hub without the route. Not an error
2225
+ // here any more than it is at boot — sync continues, we ask again later.
2226
+ if (listing === null) return;
2227
+ // ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
2228
+ // the pull runs, or a slug that is tombstoned AND still listed (the window
2229
+ // between the tombstone and the row actually going) would be trashed and
2230
+ // immediately pulled back — the resurrection this lane exists to end,
2231
+ // reintroduced inside one tick.
2232
+ await applyTombstones(listing.tombstones);
2233
+ const diff = diffRemoteDocs(
2234
+ [...descriptors.keys()].map((slug) => docNameFor(slug)),
2235
+ listing.documents
2236
+ );
2237
+ if (diff.hubOnly.length === 0) return;
2238
+ const targets = pullTargets(
2239
+ diff.hubOnly,
2240
+ ctx.paths.designRoot,
2241
+ path.join,
2242
+ path.resolve,
2243
+ path.sep,
2244
+ {
2245
+ ...pathOpts,
2246
+ // NEVER the fresh-link relaxation after boot. See `strictPullSlugs`.
2247
+ allowUndeclaredGroup: false,
2248
+ realpath: realpathOfDeepestExisting,
2249
+ }
2250
+ );
2251
+ const admitted = targets
2252
+ // A canvas the project deleted is not a canvas to fetch, even while the
2253
+ // hub is still listing it — see `tombstoned`.
2254
+ .filter((t) => !tombstoned.has(t.slug))
2255
+ .filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
2256
+ .filter((t) => admitPulledBody(t.slug, t.bodyAbs));
2257
+ const fresh = admitted.filter(
2258
+ (t) => !agents.has(t.slug) && !projections.has(t.slug) && !providers.has(t.slug)
2259
+ );
2260
+ if (fresh.length === 0) return;
2261
+ // VOLUME IS A SECURITY PROPERTY HERE, NOT A PERFORMANCE ONE.
2262
+ //
2263
+ // Every accepted name becomes a real file in the design root, a provider,
2264
+ // a pinned Y.Doc, and (on a desktop) something autocommit puts into the
2265
+ // person's git history and `_untrusted/INDEX.json` offers to Claude. The
2266
+ // hub is untrusted to peers (DDR-054), and before continuous discovery
2267
+ // the damage was bounded by there being exactly ONE listing, at connect.
2268
+ // Asking every 20 s for the life of the process removes that bound: a
2269
+ // hostile hub can drip distinct names forever. So the pull lane gets the
2270
+ // ceiling the LOCAL lane has always had (`admitCanvases` → DDR-064 A6),
2271
+ // plus a per-poll cap so one answer cannot land thousands at once.
2272
+ const room = Math.max(0, maxPinnedRooms() - (agents.size + projections.size));
2273
+ const budget = Math.min(room, MAX_PULLS_PER_POLL);
2274
+ const accepted = fresh.slice(0, budget);
2275
+ if (accepted.length < fresh.length) {
2276
+ // Named loudly. A silent cap reads as "sync is broken" with no cause —
2277
+ // the same reason `admitCanvases` shouts about its own ceiling.
2278
+ warnOnce(
2279
+ `pull-cap:${room === 0 ? 'ceiling' : 'batch'}`,
2280
+ `[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.`
2281
+ );
2282
+ }
2283
+ if (accepted.length === 0) return;
2284
+ for (const target of accepted) {
2285
+ pulledSlugs.add(target.slug);
2286
+ strictPullSlugs.add(target.slug);
2287
+ everPulled.add(target.slug);
2288
+ await attachCanvas(descriptorFor(target.slug, target.bodyAbs), false);
2289
+ }
2290
+ console.log(
2291
+ `[sync] pulled ${accepted.length} canvas(es) down from the project: ${accepted
2292
+ .map((t) => t.slug)
2293
+ .join(', ')}`
2294
+ );
2295
+ // Both controls describe the set, and the set just grew.
2296
+ notePulledAll();
2297
+ markUntrusted();
2298
+ };
2299
+ /**
2300
+ * Fetch the referenced assets this machine is missing.
2301
+ *
2302
+ * RUNS ON EVERY PEER, cell included — unlike the PUSH sweep, which is a
2303
+ * desktop job because the desktop is the side that has the bytes. Wanting an
2304
+ * asset you can see referenced and do not hold is symmetric, and so is the
2305
+ * fix; making this desktop-only would rebuild the same one-way street facing
2306
+ * the other way.
2307
+ *
2308
+ * After the document poll, deliberately: a canvas that arrives in this tick
2309
+ * brings its references with it, and this is the pass that resolves them.
2310
+ */
2311
+ const pullAssetsOnce = async (): Promise<void> => {
2312
+ if (stopped) return;
2313
+ await pullAssets({
2314
+ designRoot: ctx.paths.designRoot,
2315
+ hubUrl: linkedHub.url,
2316
+ token: () => token,
2317
+ });
2318
+ };
2319
+ // Cumulative per boot — the Sync panel's one line. `synced` is the last
2320
+ // pass's converged count; `pulled`/`conflicts` accumulate.
2321
+ const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
2322
+ const noteFilePull = (result: FilePullResult): void => {
2323
+ fileTotals.synced = result.skipped + result.pulled.length;
2324
+ fileTotals.pulled += result.pulled.length;
2325
+ fileTotals.conflicts += result.conflicts.length;
2326
+ statusStore?.updateFiles?.({ ...fileTotals });
2327
+ };
2328
+ /**
2329
+ * Plane B's downward pass — after the doc poll and the asset pull, so a
2330
+ * canvas that arrived this tick has its design system resolved in the
2331
+ * same tick. Flag-gated; a no-op when off.
2332
+ *
2333
+ * On a cell the hub shares the checkout, so every manifest entry is
2334
+ * hash-equal by construction and the pass skips itself — deliberately
2335
+ * NOT special-cased: the invariant covers it, and a special case would
2336
+ * be one more branch that can drift.
2337
+ */
2338
+ const pullFilesOnce = async (): Promise<void> => {
2339
+ if (stopped || !syncFilesOn) return;
2340
+ const result = await pullFiles({
2341
+ designRoot: ctx.paths.designRoot,
2342
+ hubUrl: linkedHub.url,
2343
+ token: () => token,
2344
+ canvasGroups: ctx.cfg.canvasGroups,
2345
+ allowCodeModules,
2346
+ });
2347
+ noteFilePull(result);
2348
+ };
2349
+ const pollRemote = (): void => {
2350
+ void pullRemoteOnce()
2351
+ .then(() => pullAssetsOnce())
2352
+ .then(() => pullFilesOnce())
2353
+ .catch((err) => console.error('[sync] remote poll failed:', err));
2354
+ };
2355
+ remotePull = async () => {
2356
+ await pullRemoteOnce();
2357
+ await pullAssetsOnce();
2358
+ await pullFilesOnce();
2359
+ };
2360
+ remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
2361
+ // `setInterval` keeps a Bun process alive; a poll is not a reason for the
2362
+ // dev server to refuse to exit.
2363
+ remotePollTimer.unref?.();
2364
+
1579
2365
  // Arm the pre-expiry renewal from the credential that just booted. Placed
1580
2366
  // last — the timer needs nothing from boot, and boot needs nothing from it
1581
2367
  // (a credential that dies mid-boot lands in the invalid-token path, which
@@ -1586,9 +2372,27 @@ export function createSyncRuntime(
1586
2372
  async function stop(): Promise<void> {
1587
2373
  if (stopped) return;
1588
2374
  stopped = true;
2375
+ // Nothing may be adopted into a runtime that is going away.
2376
+ attachOne = null;
2377
+ discoveryUnsub?.();
2378
+ discoveryUnsub = null;
2379
+ deletedUnsub?.();
2380
+ deletedUnsub = null;
2381
+ createdUnsub?.();
2382
+ createdUnsub = null;
2383
+ discoveryRescan?.stop();
2384
+ discoveryRescan = null;
2385
+ if (remotePollTimer !== null) clearInterval(remotePollTimer);
2386
+ remotePollTimer = null;
2387
+ if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
2388
+ remotePollSoonTimer = null;
2389
+ remotePull = null;
1589
2390
  // A sweep that outlives its runtime keeps uploading a project the person
1590
2391
  // just closed — and `restart()` (the Resync button) calls stop() on every
1591
2392
  // press, so without this each press would leave another sweep running.
2393
+ if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
2394
+ assetSweepTimer = null;
2395
+ assetSweepAgain = false;
1592
2396
  assetSweep?.cancel();
1593
2397
  assetSweep = null;
1594
2398
  // Commit whatever is still inside the quiescence window BEFORE tearing
@@ -1603,14 +2407,8 @@ export function createSyncRuntime(
1603
2407
  }
1604
2408
  autoCommit.stop();
1605
2409
  }
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;
2410
+ for (const slug of [...awarenessDetaches.keys()]) runDetaches(awarenessDetaches, slug);
2411
+ awarenessDetaches.clear();
1614
2412
  // Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
1615
2413
  // / destroyed normally on shutdown. Empty unless sharedDoc was active.
1616
2414
  for (const slug of pinnedSlugs) {
@@ -1621,14 +2419,8 @@ export function createSyncRuntime(
1621
2419
  }
1622
2420
  }
1623
2421
  pinnedSlugs.clear();
1624
- for (const detach of statusDetaches) {
1625
- try {
1626
- detach();
1627
- } catch {
1628
- /* best-effort */
1629
- }
1630
- }
1631
- statusDetaches.length = 0;
2422
+ for (const slug of [...statusDetaches.keys()]) runDetaches(statusDetaches, slug);
2423
+ statusDetaches.clear();
1632
2424
  monitor?.stop();
1633
2425
  journal?.stop(); // flushes the pending debounce
1634
2426
  journal = null;
@@ -1688,9 +2480,43 @@ export function createSyncRuntime(
1688
2480
  ownedFactory?.dispose();
1689
2481
  }
1690
2482
 
2483
+ // One chain for every membership change, so an adopt cannot interleave with a
2484
+ // release of the same slug (rename arrives as remove+add) or with `stop()`
2485
+ // tearing the maps down underneath it. This is the runtime's own ordering and
2486
+ // is separate from the SUPERVISOR's chain, which serializes whole start/stop
2487
+ // cycles — an adopt is not a cycle and must not make `busy()` true.
2488
+ let membership: Promise<unknown> = Promise.resolve();
2489
+ function serializeMembership<T>(work: () => Promise<T>): Promise<T> {
2490
+ const next = membership.then(work, work);
2491
+ membership = next.catch(() => {});
2492
+ return next;
2493
+ }
2494
+
1691
2495
  return {
1692
2496
  start,
1693
2497
  stop,
2498
+ adopt: (incoming) =>
2499
+ serializeMembership(async () => {
2500
+ if (stopped || !attachOne) return 0;
2501
+ let attached = 0;
2502
+ for (const canvas of incoming) {
2503
+ // Already ours — adopting twice would open a second provider on the
2504
+ // same document and give this peer two votes in every merge.
2505
+ if (agents.has(canvas.slug) || projections.has(canvas.slug)) continue;
2506
+ if (providers.has(canvas.slug)) continue;
2507
+ await attachOne(canvas, false);
2508
+ attached += 1;
2509
+ }
2510
+ return attached;
2511
+ }),
2512
+ release: (slugs) =>
2513
+ serializeMembership(async () => {
2514
+ let released = 0;
2515
+ for (const slug of slugs) if (await releaseOne(slug)) released += 1;
2516
+ return released;
2517
+ }),
2518
+ rescanNow: () => discoveryRescan?.flush() ?? Promise.resolve(),
2519
+ pullRemoteNow: () => remotePull?.() ?? Promise.resolve(),
1694
2520
  // Under sharedDoc the per-canvas handler is a projection, not an agent;
1695
2521
  // count both so size() reflects the synced-canvas count in either mode.
1696
2522
  size: () => agents.size + projections.size,
@@ -1720,6 +2546,9 @@ export function createSyncRuntime(
1720
2546
  * `MAUDE_MAX_PINNED_ROOMS` if a real project ever meets it — and if one does,
1721
2547
  * that is a signal worth reading rather than a number worth bumping.
1722
2548
  */
2549
+ /** Coalesce a burst of asset writes (dragging six images on) into one sweep. */
2550
+ const ASSET_SWEEP_DEBOUNCE_MS = 1500;
2551
+
1723
2552
  export const DEFAULT_MAX_PINNED_ROOMS = 500;
1724
2553
 
1725
2554
  function maxPinnedRooms(): number {