@1agh/maude 0.60.6 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -6
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +919 -214
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +90 -16
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +99 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -17,10 +17,18 @@
17
17
  // the provider lib didn't install for some reason) prints a useful error
18
18
  // instead of crashing the dev-server boot.
19
19
 
20
- import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ readFileSync,
25
+ realpathSync,
26
+ renameSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
21
29
  import { readdir } from 'node:fs/promises';
30
+ import { hostname } from 'node:os';
22
31
  import path from 'node:path';
23
-
24
32
  import type { Awareness } from 'y-protocols/awareness';
25
33
  import * as Y from 'yjs';
26
34
  import { renewHubCredential } from '../cloud/renew.ts';
@@ -29,25 +37,33 @@ import type { Context, LinkedHub } from '../context.ts';
29
37
  import { createHistory } from '../history.ts';
30
38
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
31
39
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
32
- import { pullAssets } from './asset-pull.ts';
33
- import { isPushableAssetRel } from './asset-push.ts';
34
- import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
40
+ import { isPushableAssetRel, pushAssets } from './asset-push.ts';
35
41
  import { atomicWrite } from './atomic-write.ts';
36
- import { createAutoCommit } from './autocommit.ts';
37
42
  import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
38
- import { canvasPathFromDoc, stampCanvasPath } from './codec.ts';
43
+ import {
44
+ canvasPathFromDoc,
45
+ clearMovedTo,
46
+ movedToFromDoc,
47
+ stampCanvasPath,
48
+ stampMovedTo,
49
+ } from './codec.ts';
39
50
  import {
40
51
  type ConnectionMonitor,
41
52
  createConnectionMonitor,
42
53
  type ProviderStatus,
43
54
  } from './connection-state.ts';
55
+ import { createCtlProvider } from './ctl-provider.ts';
44
56
  import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
45
57
  import { createDocNameResolver } from './doc-name.ts';
46
58
  import { createEchoGuard } from './echo-guard.ts';
59
+ import { createFileLedger } from './file-ledger.ts';
60
+ import { createFilePlane } from './file-plane.ts';
47
61
  import { type FilePullResult, pullFiles } from './file-pull.ts';
48
62
  import { createFsReader, type FsReader } from './fs-mirror.ts';
63
+ import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
49
64
  import { getHubRecord } from './hubs-config.ts';
50
65
  import { loadJournal, type SyncJournal } from './journal.ts';
66
+ import { hasLedger, hubCapabilities } from './journal-client.ts';
51
67
  import { isLoopbackHost } from './loopback.ts';
52
68
  import { migrateFlatFallback } from './migrate-flat-fallback.ts';
53
69
  import { migrateSeed } from './migrate-seed.ts';
@@ -151,6 +167,15 @@ export const DISCOVERY_DEBOUNCE_MS = 400;
151
167
  */
152
168
  export const REMOTE_POLL_MS = 20_000;
153
169
 
170
+ /**
171
+ * Settle window before a local write triggers a file-plane pass.
172
+ *
173
+ * Long enough that saving a file (which many editors do as several writes)
174
+ * costs one pass, short enough that "I dropped a picture in" still feels
175
+ * immediate.
176
+ */
177
+ export const FILE_PASS_DEBOUNCE_MS = 400;
178
+
154
179
  /**
155
180
  * Settling delay for an OFF-SCHEDULE poll (reconnect).
156
181
  *
@@ -160,6 +185,22 @@ export const REMOTE_POLL_MS = 20_000;
160
185
  */
161
186
  export const REMOTE_POLL_SOON_MS = 1_500;
162
187
 
188
+ /**
189
+ * The floor between two POKE-DRIVEN passes — DDR-226 §9's promised cooldown.
190
+ *
191
+ * `pollRemoteSoon` coalesces a burst, which is a debounce, not a cooldown: it
192
+ * caps how many passes a burst collapses into and says nothing about sustained
193
+ * rate. A hub emitting a poke every 1.5 s therefore drove a full document poll
194
+ * + asset pass + `.design/` tree walk at roughly 13x the intended cadence,
195
+ * indefinitely, with no counter that tripped — sustained CPU, disk and battery
196
+ * on the victim, from the component DDR-054 calls untrusted, and the multiplier
197
+ * that made the conflict-copy amplification practical.
198
+ *
199
+ * Half the scheduled poll: fast enough that a poke is still the reason cloud
200
+ * edits arrive in seconds, bounded enough that spam buys almost nothing.
201
+ */
202
+ export const POKE_COOLDOWN_MS = REMOTE_POLL_MS / 2;
203
+
163
204
  /**
164
205
  * How many previously-unknown canvases one listing may land.
165
206
  *
@@ -294,6 +335,17 @@ export interface SyncRuntime {
294
335
  * meaningful gesture, killing a multi-hundred-megabyte upload is.
295
336
  */
296
337
  cancelAssetSweep(): boolean;
338
+ /**
339
+ * The MOVE protocol's first half (codec `stampMovedTo`): stamp this slug's
340
+ * document retired-by-move to `toRel`, push the stamp to the hub, then
341
+ * release the canvas. Called by `moveCanvas` BEFORE it renames the file —
342
+ * which is also why this must not quarantine anything: the file at the old
343
+ * path is about to be renamed by the caller, not thrown away.
344
+ *
345
+ * Returns false when this runtime does not carry the slug (not synced, or
346
+ * flag-off) — the caller then proceeds with the plain local move.
347
+ */
348
+ retireForMove(fromSlug: string, toRel: string): Promise<boolean>;
297
349
  }
298
350
 
299
351
  export interface CreateSyncRuntimeOptions {
@@ -317,8 +369,6 @@ export interface CreateSyncRuntimeOptions {
317
369
  connectionMonitor?: ConnectionMonitor;
318
370
  /** Override the status store (Task 8 test injection). */
319
371
  statusStore?: SyncStatusStore;
320
- /** Override the asset-sweep runner (test injection — no child process). */
321
- assetSweepRunner?: typeof runAssetSweep;
322
372
  /**
323
373
  * DDR-102 — auth-failure + boot-settle knobs (test injection, mirrors the
324
374
  * connection-state injectable-timer pattern).
@@ -469,33 +519,27 @@ export function createSyncRuntime(
469
519
  return null;
470
520
  }
471
521
 
472
- // Cloud Phase 3 Task 1 — in a workspace cell, a disk write is only half the
473
- // save: nobody is at a keyboard to commit, so the cell does it. Off entirely
474
- // outside workspace mode, where the developer's own git IS the history and
475
- // committing under them would be an intrusion (DDR-119).
522
+ // NO autocommit lives in this runtime (Sync v2 Increment 0, DDR-226).
476
523
  //
477
- // AND OFF UNDER CELL PAIRING, which is the guard DDR-209's core fear asks for.
478
- // The hub's `afterStoreDocument` already commits every stored document; a
479
- // second committer inside the studio child would race it over one working
480
- // tree and one `.git/index`. This is structural rather than conditional on
481
- // purpose — under pairing the object is never CONSTRUCTED, so there is no
482
- // later branch that could accidentally reach a commit. `cell-pairing.ts`
483
- // refuses to pair at all unless MAUDE_SYNC_NO_AUTOCOMMIT says so out loud, so
484
- // the two halves of this invariant can never disagree.
485
- const autoCommit =
486
- workspaceMode && !cellPairing
487
- ? createAutoCommit({
488
- repoRoot: ctx.paths.repoRoot,
489
- run: async (args, { cwd }) => {
490
- const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
491
- const [stdout, stderr] = await Promise.all([
492
- new Response(proc.stdout).text(),
493
- new Response(proc.stderr).text(),
494
- ]);
495
- return { code: await proc.exited, stdout, stderr };
496
- },
497
- })
498
- : null;
524
+ // Cloud Phase 3 Task 1 built one here for workspace cells, gated
525
+ // `workspaceMode && !cellPairing` — but that condition is UNREACHABLE: the
526
+ // gate at the top of this function already returns null for exactly
527
+ // `workspaceMode && !cellPairing`, so the object was always null and the
528
+ // writer-wrap / editorOf / stop-flush that depended on it never ran. Two
529
+ // independent readers confirmed it before the wiring was removed.
530
+ //
531
+ // It is not coming back on either side of the split:
532
+ // - In a CELL the hub is the sole committer (`afterStoreDocument` →
533
+ // workspace-agent), and `cell-pairing.ts` refuses to pair at all without
534
+ // MAUDE_SYNC_NO_AUTOCOMMIT=1 — DDR-198/209/213.
535
+ // - On a DESKTOP the developer's own git IS the history and committing
536
+ // under them would be an intrusion — DDR-119.
537
+ //
538
+ // The ENGINE itself (`./autocommit.ts`) is very much alive: the hub imports
539
+ // it (`apps/hub/src/workspace-agent.mjs`, copied into the image by
540
+ // `apps/hub/Dockerfile`) so there is exactly ONE copy of the append-only
541
+ // commit rules — DDR-198's single-engine rule. Do not delete that module
542
+ // when removing wiring from this file.
499
543
 
500
544
  // Under pairing the credential is the hub's own derived cell token, handed to
501
545
  // this process in its environment. `~/.config/maude/hubs.json` is a PERSON's
@@ -520,12 +564,32 @@ export function createSyncRuntime(
520
564
  // feature-sync-file-plane — Plane B, behind its flag. The flag gates ONLY
521
565
  // the new plane (the downward file pull here + the widened sweep inside
522
566
  // `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';
567
+ // DEFAULT ON (Increment 4). The plane ships enabled once its security gate
568
+ // closed: the whole set of findings from the two-seat review is fixed, the
569
+ // door gates scope + role on the real landing path, the receiver defends its
570
+ // own root, budgets charge real bytes, and the storms — poke, re-anchor,
571
+ // first-anchor, mass-delete — all have breakers that hold rather than act.
572
+ //
573
+ // `linkedHub.syncFiles: false` is the per-project opt-out and stays the
574
+ // documented rollback: a config key, not a terminal command (DDR-177).
575
+ const syncFilesOn =
576
+ linkedHub.syncFiles !== false &&
577
+ (process.env.MAUDE_SYNC_FILES !== '0' || linkedHub.syncFiles === true);
578
+ // The gate for `code-module` entries — genuinely local state, at last.
579
+ //
580
+ // This used to read `storedRecord?.role === 'owner'`, described in the
581
+ // receiving lane as "never anything the hub said". It was exactly what the
582
+ // hub said: `role` is copied from the sign-in response on every login, so a
583
+ // hostile hub answering `user.role: "owner"` once set its own receive gate
584
+ // forever after. That matters more than the canvas case it resembles — a
585
+ // `.tsx` renders in the sandboxed canvas origin, but a `.ts`/`.mjs` landing
586
+ // outside the canvas-owned lane is read by the AGENT and by every
587
+ // `maude design *` helper.
588
+ //
589
+ // Now: an explicit per-hub consent recorded at link time and never rewritten
590
+ // by a login response, or this cell's own loopback pairing — where the hub
591
+ // and the checkout are one trust domain and there is no remote party.
592
+ const allowCodeModules = cellPairing !== null || storedRecord?.codeModulesAllowed === true;
529
593
 
530
594
  // DDR-102 — the default factory multiplexes every provider over ONE shared
531
595
  // WebSocket per hub URL; the runtime owns its disposal (stop(), after the
@@ -576,73 +640,113 @@ export function createSyncRuntime(
576
640
  let busUnsub: (() => void) | null = null;
577
641
  let started = false;
578
642
  let stopped = false;
579
- /** The live asset sweep, so `stop()` can end it and the panel can cancel it. */
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
-
643
+ // ---- THE LEGACY PUSH CLIENT (journal-less hubs only) --------------------
644
+ //
645
+ // Sync v2 Increment 5 deleted the transfer engines: the out-of-process asset
646
+ // sweep, its worker child, the per-file fast push and the reference-derived
647
+ // pull. Their replacement is the journal file plane — one lane, both
648
+ // directions, one decision function (`file-plane.ts`). What remains HERE is
649
+ // the compat client for hubs that carry NO journal (self-hosted, not yet
650
+ // upgraded): the same bounded, sequential `pushAssets` pass the pre-v2
651
+ // desktop ran, retained at least two releases after the burn-down (Open
652
+ // decision 4) and gated on the SAME capability probe that builds the plane —
653
+ // a journal hub never sees it, so the two lanes cannot overlap.
654
+ //
655
+ // IN-PROCESS is safe now. The 2026-08-11 crash the out-of-process boundary
656
+ // was built for was an HTTP/1.1 keep-alive desync after a refused PUT, fixed
657
+ // at the transport (UPLOAD_CONNECTION_HEADERS in `asset-push.ts`), and the
658
+ // pass is sequential with a per-request time budget — the same class of work
659
+ // every poll already does in-process.
660
+ let legacyPushTimer: ReturnType<typeof setTimeout> | null = null;
661
+ let legacyPushRunning = false;
662
+ let legacyPushAgain = false;
663
+ /** Set by the panel's cancel (a multi-hundred-MB upload must be killable). */
664
+ let legacyPushCancel = false;
586
665
  /**
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.
666
+ * The push-lane verdict, decided ONCE per boot (compat matrix §10):
667
+ * `null` = the capability probe is still in flight (pushes wait);
668
+ * `false` = the hub carries a journal and the plane owns pushes;
669
+ * `true` = journal-less hub ⇒ this legacy client carries the upward lane.
593
670
  */
594
- function startAssetSweep(hubUrl: string): void {
595
- if (stopped || assetSweep) return;
596
- const handle = (opts.assetSweepRunner ?? runAssetSweep)({
671
+ let legacyLane: boolean | null = null;
672
+ /** A pass was owed before the lane was decided — run it on the verdict. */
673
+ let legacyBootPush: string | null = null;
674
+
675
+ /** Run the legacy push now — single-flight with a trailing re-run. */
676
+ function runLegacyPush(hubUrl: string): void {
677
+ if (stopped || cellPairing || legacyLane !== true) return; // the cell never pushes
678
+ if (legacyPushRunning) {
679
+ legacyPushAgain = true;
680
+ return;
681
+ }
682
+ legacyPushRunning = true;
683
+ legacyPushCancel = false;
684
+ pushAssets({
597
685
  designRoot: ctx.paths.designRoot,
598
686
  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).
687
+ // Read at call time — silent renewal swaps the credential in place.
601
688
  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.
689
+ canvasGroups: ctx.cfg.canvasGroups,
690
+ cancelled: () => legacyPushCancel || stopped,
691
+ // feature-sync-progress-modal — same `sync:status` payload as ever, so
692
+ // the Sync panel needs no idea which lane fed it. Guarded on `stopped`:
693
+ // a late emit must not write `_sync.json` post-teardown.
608
694
  onProgress: (p) => {
609
695
  if (!stopped) statusStore?.updateAssets?.(p);
610
696
  },
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
- });
697
+ })
698
+ .catch(() => {
699
+ /* pushAssets never throws; this is a belt for the promise chain */
700
+ })
701
+ .finally(() => {
702
+ legacyPushRunning = false;
703
+ // A file that changed WHILE this pass ran was not in its list — the
704
+ // trailing re-run keeps "I pasted two images quickly" from uploading
705
+ // only the first.
706
+ if (legacyPushAgain && !stopped) {
707
+ legacyPushAgain = false;
708
+ scheduleLegacyPush(hubUrl);
709
+ }
710
+ });
624
711
  }
625
712
 
626
713
  /**
627
- * Coalesce a burst of asset writes into one sweep.
714
+ * Coalesce a burst of asset writes into one pass.
628
715
  *
629
716
  * 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
717
+ * and each pass costs one presence probe over the wire. The debounce makes
718
+ * that one probe; the trailing re-run makes a change during a pass a second
632
719
  * pass rather than a lost upload.
633
720
  */
634
- function scheduleAssetSweep(hubUrl: string): void {
721
+ function scheduleLegacyPush(hubUrl: string): void {
635
722
  if (stopped || cellPairing) return;
636
- if (assetSweep) {
637
- assetSweepAgain = true;
723
+ if (legacyLane === null) {
724
+ // Undecided — remember that a pass is owed; the verdict honours it.
725
+ legacyBootPush = hubUrl;
726
+ return;
727
+ }
728
+ if (legacyLane === false) return; // the plane owns pushes on this hub
729
+ if (legacyPushRunning) {
730
+ legacyPushAgain = true;
638
731
  return;
639
732
  }
640
- if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
641
- assetSweepTimer = setTimeout(() => {
642
- assetSweepTimer = null;
643
- startAssetSweep(hubUrl);
733
+ if (legacyPushTimer !== null) clearTimeout(legacyPushTimer);
734
+ legacyPushTimer = setTimeout(() => {
735
+ legacyPushTimer = null;
736
+ runLegacyPush(hubUrl);
644
737
  }, ASSET_SWEEP_DEBOUNCE_MS);
645
- assetSweepTimer.unref?.();
738
+ legacyPushTimer.unref?.();
739
+ }
740
+
741
+ /** The capability probe settled (or could not run): decide the push lane. */
742
+ function decidePushLane(legacy: boolean): void {
743
+ if (legacyLane !== null) return; // first verdict wins — one lane per boot
744
+ legacyLane = legacy;
745
+ if (legacy && legacyBootPush !== null && !stopped) {
746
+ const url = legacyBootPush;
747
+ legacyBootPush = null;
748
+ runLegacyPush(url);
749
+ }
646
750
  }
647
751
 
648
752
  // Task 8 — offline-mode status surface, initialized in start() once the
@@ -727,8 +831,67 @@ export function createSyncRuntime(
727
831
  let createdUnsub: (() => void) | null = null;
728
832
  /** Periodic remote-document poll — the hub-side half of discovery. */
729
833
  let remotePollTimer: ReturnType<typeof setInterval> | null = null;
834
+ /**
835
+ * The file-event control channel (Sync v2 Increment 2), when the hub
836
+ * advertises one. Null on a journal-less hub, in a cell (the child holds it
837
+ * there), and whenever `linkedHub.fileEvents` is false.
838
+ */
839
+ let fileEventsCtl: import('./ctl-provider.ts').CtlProvider | null = null;
840
+ /**
841
+ * Cancels the boot-time capability probe. A `stop()` that leaves a `/health`
842
+ * fetch in flight is a timer keeping a dying process alive and a promise
843
+ * landing in torn-down state — the class of thing that shows up as a flaky
844
+ * suite long before it shows up as a bug.
845
+ */
846
+ let fileEventsProbe: AbortController | null = null;
847
+ /**
848
+ * The Sync v2 file plane — ONE lane, both directions, one decision table.
849
+ *
850
+ * Null on a journal-less hub (the compat matrix keeps the v1 manifest pull
851
+ * for those), and null when `linkedHub.syncFiles` is off. Built once the
852
+ * capability probe answers, so a hub that cannot support it never sees a
853
+ * request it does not understand.
854
+ */
855
+ let filePlane: import('./file-plane.ts').FilePlane | null = null;
856
+ let fileLedger: import('./file-ledger.ts').FileLedger | null = null;
857
+ /** Cumulative files sent up this boot — the doručenka's push half. */
858
+ let filePushed = 0;
859
+ /** Debounce for a plane pass triggered by a local write. */
860
+ let filePassTimer: ReturnType<typeof setTimeout> | null = null;
861
+ /**
862
+ * The HONESTY COUNTER. Pokes received this run, beside the polls that found
863
+ * work anyway. Relaxing the 20 s poll to 60 s is gated on this proving the
864
+ * channel is not silently missing events in dogfood (DDR-226 §10) — a
865
+ * number, not a feeling, and deliberately reported rather than assumed.
866
+ */
867
+ let pokesSeen = 0;
730
868
  /** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
731
869
  let remotePull: (() => Promise<void>) | null = null;
870
+ /**
871
+ * Run a file-plane pass shortly, coalesced.
872
+ *
873
+ * THE PUSH HALF'S TRIGGER. A local write does not upload anything by itself
874
+ * any more: it invalidates the stat cache for that path and asks for a pass,
875
+ * and the pass decides — push, pull, conflict or nothing — through the same
876
+ * table every other trigger uses. That is the difference from the fast lane
877
+ * it replaces, which had its own idea of what a change meant and needed a
878
+ * probe-guard to keep from re-uploading what the pull had just written.
879
+ */
880
+ function schedulePlanePass(): void {
881
+ if (stopped || filePassTimer !== null) return;
882
+ filePassTimer = setTimeout(() => {
883
+ filePassTimer = null;
884
+ if (stopped || !filePlane) return;
885
+ void filePlane
886
+ .reconcile()
887
+ .then((r) => planeResultSink?.(r))
888
+ .catch((err) => console.error('[sync/files] pass failed:', err));
889
+ }, FILE_PASS_DEBOUNCE_MS);
890
+ filePassTimer.unref?.();
891
+ }
892
+ /** Assigned by `start()` so a pass reports into the same status the poll does. */
893
+ let planeResultSink: ((r: import('./file-plane.ts').FilePlaneResult) => void) | null = null;
894
+
732
895
  /**
733
896
  * Ask for an off-schedule remote poll, coalesced.
734
897
  *
@@ -737,11 +900,42 @@ export function createSyncRuntime(
737
900
  * something to call — the boot pull has just run at that point anyway.
738
901
  */
739
902
  let remotePollSoonTimer: ReturnType<typeof setTimeout> | null = null;
740
- function pollRemoteSoon(): void {
903
+ /** When the last poke-driven pass actually started. */
904
+ let lastPokePassAt = 0;
905
+ /** Pokes refused by the cooldown since the last one that ran. */
906
+ let pokesThrottled = 0;
907
+
908
+ /**
909
+ * @param opts.cooled apply the anti-spam floor. True for hub-driven pokes
910
+ * AND reconnect-driven passes (F-12 — a churned socket
911
+ * is hub-controlled too; a genuine one-off reconnect
912
+ * still runs immediately because nothing poked
913
+ * recently); false only for boot.
914
+ */
915
+ function pollRemoteSoon(opts: { cooled?: boolean } = {}): void {
741
916
  if (stopped || remotePollSoonTimer !== null) return;
917
+ if (opts.cooled) {
918
+ const since = Date.now() - lastPokePassAt;
919
+ if (since < POKE_COOLDOWN_MS) {
920
+ // Folded into the scheduled tick rather than dropped: the poll
921
+ // underneath is still the reconciler, so a throttled poke costs
922
+ // latency and never correctness.
923
+ pokesThrottled += 1;
924
+ return;
925
+ }
926
+ }
742
927
  remotePollSoonTimer = setTimeout(() => {
743
928
  remotePollSoonTimer = null;
744
929
  if (stopped) return;
930
+ if (opts.cooled) {
931
+ lastPokePassAt = Date.now();
932
+ if (pokesThrottled > 0) {
933
+ console.warn(
934
+ `[sync/ctl] ${pokesThrottled} poke(s) folded into this pass — the hub is poking faster than once per ${POKE_COOLDOWN_MS / 1000}s.`
935
+ );
936
+ pokesThrottled = 0;
937
+ }
938
+ }
745
939
  void remotePull?.().catch(() => {
746
940
  /* the scheduled poll retries — a missed opportunistic one is not news */
747
941
  });
@@ -827,6 +1021,199 @@ export function createSyncRuntime(
827
1021
  * The shared WebSocket is deliberately untouched: it belongs to the runtime,
828
1022
  * not to a canvas, and the next adopt will need it. Only `stop()` disposes it.
829
1023
  */
1024
+ /**
1025
+ * Slugs THIS process is retiring via `retireForMove` right now. The
1026
+ * retirement watcher below fires on every doc update — including our own
1027
+ * stamp — and its job on a PASSIVE peer (quarantine the stale local file)
1028
+ * would destroy the very file `moveCanvas` is about to rename here.
1029
+ */
1030
+ const movingLocally = new Set<string>();
1031
+ /** Retirements already being handled, so the per-update watcher fires once. */
1032
+ const retiring = new Set<string>();
1033
+ /**
1034
+ * Documents this runtime has SEEN retired. The hub goes on listing a retired
1035
+ * document (nothing deletes docs until Increment 6), so without this memory
1036
+ * the remote pull re-fetched the same retired docs every poll — connect,
1037
+ * learn the fact, release, repeat — an infinite churn that saturated the
1038
+ * membership queue, held every LIVE document in `pending` forever, and
1039
+ * showed up to the user as "HUB SYNC stalled" with annotations not crossing.
1040
+ */
1041
+ const retiredDocs = new Set<string>();
1042
+
1043
+ /**
1044
+ * What the hub's last listing said each document HOLDS, in bytes.
1045
+ *
1046
+ * The one fact that separates "this canvas is mine and the hub has never
1047
+ * heard of it" from "this canvas is the hub's, already written to my disk by
1048
+ * something else". On a cell BOTH are true of a brand-new file: the hub's
1049
+ * workspace agent projects every document onto the checkout, and the studio
1050
+ * child then scans that checkout and finds a canvas it has no descriptor for.
1051
+ * Seeding the doc from it is the DDR-102 F1 collision — two machines each
1052
+ * clear-and-rebuild their own replica, and the merge CONCATENATES the two
1053
+ * runs, so the body arrives doubled (two `export default`s, `0 ARTBOARDS`)
1054
+ * on every peer. Consulted by the adopt guard in `migrateSeed`.
1055
+ */
1056
+ let hubDocIndex: ReadonlyMap<string, number> = new Map<string, number>();
1057
+
1058
+ /**
1059
+ * Slugs whose refusal has already asked for a rescan. Once per slug per
1060
+ * process — see `nudgeRescanFor`, which fires on a REPEATING poll.
1061
+ */
1062
+ const rescanNudged = new Set<string>();
1063
+
1064
+ /**
1065
+ * Slugs whose pull this runtime has refused. `retiredDocs` exists to stop a
1066
+ * hub-listed document being re-fetched forever; a REFUSED one needs the same
1067
+ * memo. Without it `releaseOne` drops the descriptor, the next poll sees the
1068
+ * document as hub-only again, and every 20 s buys a full handshake plus a Y
1069
+ * state transfer that ends in the same refusal — the churn shape that named
1070
+ * `retiredDocs` in the first place, on a lane whose own comment says volume
1071
+ * is a security property. Bounded like the sets beside it.
1072
+ *
1073
+ * Keyed slug → the path that blocked it, because the refusal is not forever:
1074
+ * `admitPullTarget` refuses on a file that EXISTS, and a user may delete it.
1075
+ * Re-checking that one path costs a `statSync`; re-checking by handshake costs
1076
+ * a document transfer. So the memo is dropped the moment its reason is gone.
1077
+ */
1078
+ const refusedPulls = new Map<string, string>();
1079
+
1080
+ /** Distinct hub-invented names are not a budget this process pays forever. */
1081
+ const NUDGE_MEMO_CAP = 512;
1082
+
1083
+ /**
1084
+ * The hub is advertising a document we are not syncing, and there is a file
1085
+ * in the way. Ask the scan.
1086
+ *
1087
+ * On a cell this is the ordinary case, not an edge one: the hub's workspace
1088
+ * agent projects every document onto the checkout, so a canvas created on
1089
+ * another machine arrives as a FILE this process never wrote. The chain that
1090
+ * is supposed to notice — `fs:any` → `canvas-list-watch` → rescan — is driven
1091
+ * by `fs.watch` on a desktop and by the ctl healer in a container, and the
1092
+ * healer announces JOURNAL rows. A canvas body is not journaled (it is Plane
1093
+ * A), so in a container nothing announced it and the child never adopted it:
1094
+ * the canvas showed up in the tree, and an edit made to it in the cloud was
1095
+ * silently reverted by the hub's next projection, because no provider on this
1096
+ * side was carrying the change up.
1097
+ *
1098
+ * The refusal itself is the signal — it means "a file is already there" — and
1099
+ * `scanCanvases` is the authority on whether that file belongs in the sync
1100
+ * set (`syncable: false` and the sandbox gate are ITS rules, so a genuinely
1101
+ * opted-out canvas stays out and simply refuses again next poll).
1102
+ */
1103
+ const nudgeRescanFor = (slug: string): void => {
1104
+ if (rescanNudged.has(slug) || rescanNudged.size >= NUDGE_MEMO_CAP) return;
1105
+ rescanNudged.add(slug);
1106
+ discoveryRescan?.schedule();
1107
+ };
1108
+
1109
+ /**
1110
+ * Record one listing's byte counts, keyed by the FULL document name.
1111
+ *
1112
+ * NOT by slug. `slugFromDocName` strips `ws/<workspace>/<branch>/`, and this
1113
+ * map answers "does the hub hold MY document" — so a flattened key makes a
1114
+ * `hero` on `main` answer for a DIFFERENT peer's `hero` on `feat/x`, which
1115
+ * defers that peer's seed forever while logging that state is on its way.
1116
+ * Nothing would be coming. A peer token is commonly `scope: '*'`, so one
1117
+ * listing spans every namespace on the hub and no hostile hub is required —
1118
+ * one member creating a same-named canvas on another branch is enough.
1119
+ * `diffRemoteDocs`, twelve lines below, compares namespaced names for exactly
1120
+ * this reason; one input must not have two key spaces.
1121
+ *
1122
+ * A FAILED listing leaves the previous answer standing rather than clearing
1123
+ * it: an empty map means "the hub holds nothing", which re-opens the DDR-102
1124
+ * F1 doubling this guard exists to close — and it would be re-opened by the
1125
+ * party the guard defends against simply refusing to answer.
1126
+ */
1127
+ const noteHubListing = (docs: readonly HubDocRow[] | null): void => {
1128
+ if (docs === null) return;
1129
+ hubDocIndex = indexHubDocs(docs);
1130
+ };
1131
+
1132
+ /**
1133
+ * A retired document arrived (another machine moved this canvas): release
1134
+ * the canvas and QUARANTINE the stale local copy into `_trash/` — never
1135
+ * unlink, the DDR-102 recoverability spine. Safe against a plain deletion
1136
+ * ambiguity because `movedTo` is an explicit statement that the content
1137
+ * lives on at the new path (see codec stampMovedTo).
1138
+ */
1139
+ async function onRetirementSeen(slug: string): Promise<void> {
1140
+ const desc = descriptors.get(slug);
1141
+ const provider = providers.get(slug);
1142
+ const movedTo = provider ? movedToFromDoc(provider.document) : null;
1143
+ await releaseOne(slug);
1144
+ if (!desc) return;
1145
+ // HOLD until the canvas provably lives at its new path — parking the old
1146
+ // copy while the new document is still materialising would leave a window
1147
+ // with NO visible copy at all, and on a cell (where this process shares
1148
+ // the checkout with the hub) it can even race the mover's own rename.
1149
+ // The doc guards already made the old file write-inert, so waiting costs
1150
+ // nothing but tidiness. If the new path never shows up, the stale file
1151
+ // stays — a recoverable ghost beats a lost canvas.
1152
+ if (movedTo) {
1153
+ const norm = movedTo.replace(/\\/g, '/');
1154
+ const newAbs = path.resolve(ctx.paths.designRoot, norm);
1155
+ const contained =
1156
+ newAbs === ctx.paths.designRoot || newAbs.startsWith(ctx.paths.designRoot + path.sep);
1157
+ if (!contained) return; // hostile path — never act on it
1158
+ // A DOCUMENT THAT MOVED TO WHERE IT ALREADY IS DID NOT MOVE.
1159
+ //
1160
+ // `retireForMove` stamps the OLD slug's doc, and the moved file is then
1161
+ // adopted under the NEW slug — whose doc carries the same `movedTo`, now
1162
+ // pointing at its own path. Without this guard the receiver reads that as
1163
+ // "this canvas moved elsewhere", waits for the destination (it exists —
1164
+ // it IS the destination), and parks the file it just created. The canvas
1165
+ // vanishes from the UI on the very machine that moved it, on both sides,
1166
+ // and the trash entry is named after the NEW slug — which is what makes
1167
+ // the logs read as a delivery failure rather than as self-deletion.
1168
+ if (desc.html && path.resolve(desc.html) === newAbs) return;
1169
+ const deadline = Date.now() + 60_000;
1170
+ while (!existsSync(newAbs) && Date.now() < deadline && !stopped) {
1171
+ await new Promise((r) => setTimeout(r, 1_000));
1172
+ }
1173
+ if (!existsSync(newAbs)) {
1174
+ console.warn(
1175
+ `[sync/${slug}] retired document's new path never appeared (${norm}) — keeping the old copy in place.`
1176
+ );
1177
+ return;
1178
+ }
1179
+ }
1180
+ try {
1181
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1182
+ const trashDir = path.join(ctx.paths.designRoot, '_trash', `${stamp}__moved-${slug}`);
1183
+ let any = false;
1184
+ for (const abs of [desc.html, desc.meta, desc.css, desc.annotations]) {
1185
+ if (!abs || !existsSync(abs)) continue;
1186
+ if (!any) mkdirSync(trashDir, { recursive: true });
1187
+ any = true;
1188
+ renameSync(abs, path.join(trashDir, path.basename(abs)));
1189
+ }
1190
+ if (any) {
1191
+ console.log(
1192
+ `[sync/${slug}] canvas was moved on another machine — stale local copy parked in _trash/ (recoverable).`
1193
+ );
1194
+ ctx.bus.emit('canvas-list-update');
1195
+ }
1196
+ } catch (err) {
1197
+ // Quarantine is best-effort: the doc guards already made the file
1198
+ // write-inert, so a failed park costs tidiness, not correctness.
1199
+ console.warn(`[sync/${slug}] could not park the pre-move copy:`, err);
1200
+ }
1201
+ }
1202
+
1203
+ /** Watch one provider's doc for a retirement stamp arriving off the wire. */
1204
+ function watchForRetirement(slug: string, doc: Y.Doc): void {
1205
+ const onUpdate = () => {
1206
+ if (retiring.has(slug) || movingLocally.has(slug)) return;
1207
+ if (movedToFromDoc(doc) === null) return;
1208
+ retiredDocs.add(slug);
1209
+ retiring.add(slug);
1210
+ doc.off('update', onUpdate);
1211
+ void onRetirementSeen(slug).finally(() => retiring.delete(slug));
1212
+ };
1213
+ doc.on('update', onUpdate);
1214
+ noteDetach(statusDetaches, slug, () => doc.off('update', onUpdate));
1215
+ }
1216
+
830
1217
  async function releaseOne(slug: string): Promise<boolean> {
831
1218
  const known = agents.has(slug) || projections.has(slug) || providers.has(slug);
832
1219
  if (!known) return false;
@@ -877,6 +1264,43 @@ export function createSyncRuntime(
877
1264
  return true;
878
1265
  }
879
1266
 
1267
+ /**
1268
+ * The move protocol's sending half — see the SyncRuntime interface doc.
1269
+ *
1270
+ * Ordering is the whole design: stamp FIRST (through the live provider, so
1271
+ * the hub and every peer receive the statement), give the socket a moment to
1272
+ * actually send it, THEN release. Releasing first would destroy the provider
1273
+ * with the stamp still in its outbox, and the old document would live on as
1274
+ * if the move never happened — which is exactly the resurrection bug.
1275
+ */
1276
+ async function retireForMove(fromSlug: string, toRel: string): Promise<boolean> {
1277
+ const provider = providers.get(fromSlug);
1278
+ if (!provider) return false;
1279
+ movingLocally.add(fromSlug);
1280
+ try {
1281
+ stampMovedTo(provider.document, toRel, ORIGINS.DISK_PROJECTION);
1282
+ // Best-effort delivery wait. Hocuspocus exposes no per-update ack; an
1283
+ // unsynced-changes probe where available, a short grace where not. A
1284
+ // stamp that misses this window still lands via the hub's own store of
1285
+ // the doc IF any other peer holds it — and if none does, the old doc
1286
+ // has no audience to resurrect for.
1287
+ const p = provider as unknown as { hasUnsyncedChanges?: boolean };
1288
+ const deadline = Date.now() + 1_500;
1289
+ while (p.hasUnsyncedChanges === true && Date.now() < deadline) {
1290
+ await new Promise((r) => setTimeout(r, 50));
1291
+ }
1292
+ if (typeof p.hasUnsyncedChanges !== 'boolean') {
1293
+ await new Promise((r) => setTimeout(r, 400));
1294
+ }
1295
+ retiredDocs.add(fromSlug);
1296
+ await releaseOne(fromSlug);
1297
+ console.log(`[sync/${fromSlug}] retired for move → ${toRel}`);
1298
+ return true;
1299
+ } finally {
1300
+ movingLocally.delete(fromSlug);
1301
+ }
1302
+ }
1303
+
880
1304
  async function start(): Promise<void> {
881
1305
  if (started || stopped) return;
882
1306
  started = true;
@@ -927,7 +1351,11 @@ export function createSyncRuntime(
927
1351
  // design root — see `pullTargets` for why flat); local-only canvases go up
928
1352
  // as they always did. Best-effort: an older hub without the listing route,
929
1353
  // or an unreachable one, syncs exactly as before.
930
- const remoteListing = await fetchRemoteListing(linkedHub.url, resolvedToken);
1354
+ // `token`, not the boot-time `resolvedToken`: silent renewal swaps the live
1355
+ // credential in place, so reading it at call time is what every other hub
1356
+ // call here does. Identical at boot; correct if start() ever re-runs after
1357
+ // a renewal (and it types, which `resolvedToken`'s `string | null` did not).
1358
+ const remoteListing = await fetchRemoteListing(linkedHub.url, token);
931
1359
  // BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
932
1360
  // lives further down (it needs the live descriptor map), so this boot pass
933
1361
  // only has to make sure the pull does not fetch a canvas the project has
@@ -938,6 +1366,7 @@ export function createSyncRuntime(
938
1366
  const slug = slugFromDocName(stone.name);
939
1367
  if (slug) tombstoned.add(slug);
940
1368
  }
1369
+ noteHubListing(remoteListing?.documents ?? null);
941
1370
  const remoteDiff = diffRemoteDocs(
942
1371
  localCanvases.map((c) => docNameFor(c.slug)),
943
1372
  remoteListing?.documents ?? null
@@ -1112,7 +1541,17 @@ export function createSyncRuntime(
1112
1541
  // is built) and once after the pulls settle, from the final descriptors.
1113
1542
  // Reads the LIVE set, not the boot array — see `descriptors`.
1114
1543
  const markUntrusted = (): void => {
1115
- if (!cellPairing) writeUntrustedMarkers(ctx, [...descriptors.values()], linkedHub.url);
1544
+ if (cellPairing) return;
1545
+ // Plane B's landed set comes from the ledger, which is the only place
1546
+ // that knows what the hub actually delivered here. A path that never
1547
+ // arrived is not marked (the markers pointing at a phantom is the exact
1548
+ // failure the two-write dance above exists to avoid).
1549
+ const planeFiles = fileLedger
1550
+ ? Object.entries(fileLedger.rows())
1551
+ .filter(([, row]) => row.syncedHash !== null)
1552
+ .map(([rel]) => rel)
1553
+ : [];
1554
+ writeUntrustedMarkers(ctx, [...descriptors.values()], linkedHub.url, planeFiles);
1116
1555
  };
1117
1556
  markUntrusted();
1118
1557
  if (canvases.length === 0) {
@@ -1150,23 +1589,10 @@ export function createSyncRuntime(
1150
1589
  return;
1151
1590
  }
1152
1591
 
1153
- // DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
1154
- // ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
1155
- // contains execution but not that lane) to every synced canvas. The default
1156
- // traded a footgun (silent 0-syncable) for this surface, so the surface must
1157
- // be LOUD: a banner on every `serve` naming the count + the opt-outs. Fires
1158
- // unless explicitly opted out (`syncTsx: false`); loopback hubs (local dev)
1159
- // skip it — no remote exfil concern.
1160
- const tsxBodyCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
1161
- if (linkedHub.syncTsx !== false && tsxBodyCount > 0 && !isLoopbackHubUrl(linkedHub.url)) {
1162
- console.warn(
1163
- `[sync] ${tsxBodyCount} TSX canvas BODIES will sync to ${linkedHub.url} (TSX sync is ON by default — DDR-079). The sandbox contains execution, but a WebRTC/self-nav exfil residual applies to every synced canvas — link only hubs you operate or trust. Opt out: linkedHub.syncTsx=false (whole project) or a canvas .meta.json "syncable": false (one canvas).`
1164
- );
1165
- }
1166
-
1167
- // DDR-064 pre-cutover A7 — one-time notice, before any doc is attached.
1168
- if (useSharedDoc) noticeSharedDocOnce(linkedHub.url, !!cellPairing);
1169
-
1592
+ // The store is created BEFORE the consent notices below so they land in
1593
+ // the payload (feature-before-first-external-users Task 1): a notice that
1594
+ // exists only as a console.warn never reaches a terminal-free desktop
1595
+ // user — the same disease the breaker `held` field cured.
1170
1596
  statusStore =
1171
1597
  opts.statusStore ??
1172
1598
  createSyncStatusStore({
@@ -1180,6 +1606,33 @@ export function createSyncRuntime(
1180
1606
  broadcast: (payload) => ctx.bus.emit('sync:status', payload),
1181
1607
  });
1182
1608
  const store = statusStore;
1609
+
1610
+ // DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
1611
+ // ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
1612
+ // contains execution but not that lane) to every synced canvas. The default
1613
+ // traded a footgun (silent 0-syncable) for this surface, so the surface must
1614
+ // be LOUD: a banner on every `serve` naming the count + the opt-outs. Fires
1615
+ // unless explicitly opted out (`syncTsx: false`); loopback hubs (local dev)
1616
+ // skip it — no remote exfil concern.
1617
+ const tsxBodyCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
1618
+ if (linkedHub.syncTsx !== false && tsxBodyCount > 0 && !isLoopbackHubUrl(linkedHub.url)) {
1619
+ const tsxNotice = `${tsxBodyCount} TSX canvas ${tsxBodyCount === 1 ? 'body' : 'bodies'} will sync to ${linkedHub.url} (TSX sync is ON by default — DDR-079). The sandbox contains execution, but a WebRTC/self-nav exfil residual applies to every synced canvas — link only hubs you operate or trust. Opt out: linkedHub.syncTsx=false (whole project) or a canvas .meta.json "syncable": false (one canvas).`;
1620
+ console.warn(`[sync] ${tsxNotice}`);
1621
+ store.notice({ id: 'tsx-bodies', severity: 'warn', text: tsxNotice });
1622
+ }
1623
+
1624
+ // DDR-064 pre-cutover A7 — one-time notice, before any doc is attached.
1625
+ // Terminal once per process; the payload notice fires every boot (the
1626
+ // client keeps its own per-(id, hub) dismiss ack, so re-announcing after a
1627
+ // restart costs nothing and a NEW hub url resurfaces it by design).
1628
+ if (useSharedDoc && !cellPairing) {
1629
+ noticeSharedDocOnce(linkedHub.url, false);
1630
+ store.notice({
1631
+ id: 'shared-doc',
1632
+ severity: 'warn',
1633
+ text: sharedDocNoticeText(linkedHub.url),
1634
+ });
1635
+ }
1183
1636
  monitor =
1184
1637
  opts.connectionMonitor ?? createConnectionMonitor({ onChange: (snap) => store.update(snap) });
1185
1638
  const mon = monitor;
@@ -1215,14 +1668,27 @@ export function createSyncRuntime(
1215
1668
  reader.notify(rel);
1216
1669
  // AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
1217
1670
  //
1218
- // The sweep used to fire from exactly one place — `start()` — so a picture
1671
+ // The push used to fire from exactly one place — `start()` — so a picture
1219
1672
  // pasted into an annotation reached the cloud only on the next boot or
1220
1673
  // Resync. Meanwhile the annotation itself syncs through the doc in
1221
1674
  // milliseconds, so the other side rendered an `<image>` pointing at bytes
1222
1675
  // nobody had sent: a permanent empty frame that looked like a broken path.
1223
1676
  // Reported three times in one day on alligators, each time with a
1224
1677
  // different asset, which is what finally named it.
1225
- if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep(linkedHub.url);
1678
+ //
1679
+ // On a Sync v2 hub the plane's own trigger below carries this moment;
1680
+ // the legacy schedule is a no-op there (`legacyLane === false`).
1681
+ if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) {
1682
+ scheduleLegacyPush(linkedHub.url);
1683
+ }
1684
+ // Sync v2 — the file plane's own trigger. Invalidating the stat cache
1685
+ // first is the exact, cheap version of the mtime-granularity guard: the
1686
+ // watcher KNOWS this path moved, so the next scan must read it rather
1687
+ // than trust a timestamp that a same-length edit could have left alone.
1688
+ if (fileLedger) {
1689
+ fileLedger.noteChanged(rel.split('\\').join('/'));
1690
+ schedulePlanePass();
1691
+ }
1226
1692
  });
1227
1693
 
1228
1694
  /**
@@ -1491,6 +1957,60 @@ export function createSyncRuntime(
1491
1957
  provider: SyncProvider
1492
1958
  ): Promise<void> => {
1493
1959
  if (stopped) return;
1960
+ // A RETIRED document (codec stampMovedTo) has nothing to reconcile —
1961
+ // its canvas moved to a new path in a new document. Seen here mostly by
1962
+ // a machine that pulled the project down after the move: connect, learn
1963
+ // the fact, let go. The watcher handles a stale local copy.
1964
+ const movedTo = movedToFromDoc(provider.document);
1965
+ if (movedTo !== null) {
1966
+ // A DOCUMENT THAT SAYS IT MOVED TO WHERE IT ALREADY IS DID NOT MOVE.
1967
+ //
1968
+ // The stamp lives inside the document, and a move renames the canvas's
1969
+ // `_state/<slug>.ydoc.bin` cache onto the new slug — so the NEW document
1970
+ // opened with the OLD one's last word, "I have moved away". Every peer
1971
+ // released it as retired, the destination path never appeared anywhere
1972
+ // but on the machine that did the move, and the whole thing read as
1973
+ // "folders don't sync": each side showed its own move and the other's
1974
+ // canvas still at the root. `canvas-artifacts.ts` stops producing this
1975
+ // (the cache is dropped, not carried); clearing the stamp is what
1976
+ // REPAIRS the trees that already have it, and because it is a doc edit
1977
+ // the correction reaches every peer that believed it.
1978
+ const movedAbs = path.resolve(
1979
+ ctx.paths.designRoot,
1980
+ movedTo.replace(/\\/g, '/').replace(/^\/+/, '')
1981
+ );
1982
+ // ONLY A LOCAL DESCRIPTOR MAY BE REPAIRED. For a PULLED canvas
1983
+ // `canvas.html` is the provisional slug-derived target, not a path this
1984
+ // disk chose — and `relocatePulled` returns early for a retired document,
1985
+ // so it never went through `resolvePulledTarget`, the `canvas-path.ts`
1986
+ // rules, or the re-ask of `admitPullTarget`. A hub picks the document
1987
+ // NAME (hence that provisional path) and writes `movedTo`, so it would
1988
+ // control BOTH sides of this equality — turning a repair into a write to
1989
+ // an unvalidated path, which is the resurrection primitive the retirement
1990
+ // release exists to deny. The repair is for the machine that did the
1991
+ // move, whose descriptor came from its own scan.
1992
+ if (
1993
+ !pulledSlugs.has(canvas.slug) &&
1994
+ canvas.html &&
1995
+ path.resolve(canvas.html) === movedAbs
1996
+ ) {
1997
+ if (clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
1998
+ console.log(
1999
+ `[sync/${canvas.slug}] this document is stamped as moved to its OWN path — clearing the stale retirement and keeping the canvas.`
2000
+ );
2001
+ }
2002
+ } else {
2003
+ // Remembered, so the remote pull never fetches this document again —
2004
+ // logging this line every 20 s forever was the symptom that found the
2005
+ // churn.
2006
+ retiredDocs.add(canvas.slug);
2007
+ console.log(
2008
+ `[sync/${canvas.slug}] document is retired (canvas moved to ${movedTo}) — releasing.`
2009
+ );
2010
+ void onRetirementSeen(canvas.slug);
2011
+ return;
2012
+ }
2013
+ }
1494
2014
  clearRejection(canvas.slug);
1495
2015
  // Not `connected` yet — the reconcile below is what makes that true. But
1496
2016
  // no longer refused, and the difference is the whole point: `pending` says
@@ -1521,9 +2041,15 @@ export function createSyncRuntime(
1521
2041
  }
1522
2042
  },
1523
2043
  onConflict: (info) => store.addConflict(info),
2044
+ hubHasState: (slug) => hubHolds(hubDocIndex, docNameFor(slug)),
1524
2045
  });
1525
2046
  if (result === 'local-adopt') {
1526
2047
  console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
2048
+ } else if (result === 'defer-hub-state') {
2049
+ console.log(
2050
+ `[sync/${canvas.slug}] shared-doc: not seeding — the hub already holds this ` +
2051
+ 'document; waiting for its state to arrive.'
2052
+ );
1527
2053
  } else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
1528
2054
  console.warn(
1529
2055
  `[sync/${canvas.slug}] shared-doc: diverged — kept the ${
@@ -1611,35 +2137,6 @@ export function createSyncRuntime(
1611
2137
  * permanent-rejection re-probe (which passes the EXISTING doc so the
1612
2138
  * agent/projection wiring — doc-scoped — survives the provider swap).
1613
2139
  */
1614
- /**
1615
- * Who is editing this canvas right now, from hub awareness.
1616
- *
1617
- * Presence carries a display name and no address, so the address is
1618
- * synthesized and clearly marked as derived — inventing a plausible-looking
1619
- * real address would put an unverified identity into permanent git history.
1620
- * Absent a remote peer the answer is null, which `autocommit` turns into
1621
- * "Unknown editor" rather than attributing the work to the server.
1622
- */
1623
- const editorOf = (slug: string): { name: string; email: string } | null => {
1624
- const awareness = providers.get(slug)?.awareness;
1625
- if (!awareness) return null;
1626
- for (const [clientId, state] of awareness.getStates() as Map<
1627
- number,
1628
- { name?: string } | undefined
1629
- >) {
1630
- if (clientId === awareness.clientID) continue; // that's us, the cell
1631
- const name = state?.name?.trim();
1632
- if (name) {
1633
- const slugified = name
1634
- .toLowerCase()
1635
- .replace(/[^a-z0-9]+/g, '-')
1636
- .replace(/^-|-$/g, '');
1637
- return { name, email: `${slugified || 'peer'}@peers.maude.local` };
1638
- }
1639
- }
1640
- return null;
1641
- };
1642
-
1643
2140
  /**
1644
2141
  * Re-decide where a PULLED canvas goes, now that its document has synced.
1645
2142
  *
@@ -1657,8 +2154,13 @@ export function createSyncRuntime(
1657
2154
  canvas: CanvasDescriptor,
1658
2155
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
1659
2156
  doc: Y.Doc
1660
- ): void => {
1661
- if (!pulledSlugs.has(canvas.slug)) return;
2157
+ ): boolean => {
2158
+ if (!pulledSlugs.has(canvas.slug)) return true;
2159
+ // A retired document (codec stampMovedTo) is not a canvas to place — its
2160
+ // content lives at the new path in a different document. Deciding a
2161
+ // location here would materialise a ghost on a machine pulling the
2162
+ // project down fresh; handleSynced releases it a moment later.
2163
+ if (movedToFromDoc(doc) !== null) return true;
1662
2164
  const resolved = resolvePulledTarget({
1663
2165
  slug: canvas.slug,
1664
2166
  path: canvasPathFromDoc(doc),
@@ -1674,7 +2176,17 @@ export function createSyncRuntime(
1674
2176
  allowUndeclaredGroup: pathOpts.allowUndeclaredGroup && !strictPullSlugs.has(canvas.slug),
1675
2177
  onRefused: (reason) => pathOpts.onRefused(canvas.slug, reason),
1676
2178
  });
1677
- if (!resolved) return;
2179
+ // A DOCUMENT WHOSE PATH WAS REFUSED IS NOT A DOCUMENT TO WRITE SOMEWHERE
2180
+ // ELSE. Falling through here left the descriptor on the PROVISIONAL
2181
+ // target — `<group>/<rest-of-slug>.tsx` — and the reconcile below then
2182
+ // materialised the document there. Since `canvasSlugFromRel` is lossy
2183
+ // (`ui/Desk A.tsx` → `ui-desk_a`), that provisional name is a DIFFERENT
2184
+ // file from the one the document names, so the peer ended up holding the
2185
+ // canvas twice: `ui/Desk A.tsx` (correct) and `ui/desk_a.tsx` (a ghost).
2186
+ // Both then flatten to one slug, which is a collision — and a collision
2187
+ // takes the canvas OUT of sync on that machine entirely. The refusal has
2188
+ // to end the pull, not redirect it.
2189
+ if (!resolved) return false;
1678
2190
 
1679
2191
  // NEVER ONTO A FILE THAT ALREADY EXISTS.
1680
2192
  //
@@ -1688,7 +2200,7 @@ export function createSyncRuntime(
1688
2200
  // let a hub overwrite exactly the canvas the user opted OUT of syncing.
1689
2201
  // The same admission the provisional target already passed, re-asked of
1690
2202
  // the destination the document actually chose.
1691
- if (resolved.fromPath && !admitPullTarget(ctx, canvas.slug, resolved.bodyAbs)) return;
2203
+ if (resolved.fromPath && !admitPullTarget(ctx, canvas.slug, resolved.bodyAbs)) return false;
1692
2204
  // The TOP-level component only — `canvasGroups` names a group, not every
1693
2205
  // folder inside it (`ui/2026/social/x.tsx` declares `ui`). A body that
1694
2206
  // landed at the design root has no group and teaches nothing.
@@ -1696,7 +2208,7 @@ export function createSyncRuntime(
1696
2208
  .relative(ctx.paths.designRoot, resolved.bodyAbs)
1697
2209
  .split(path.sep);
1698
2210
  if (group && rest.length > 0) noteLearnedGroup(group);
1699
- if (resolved.bodyAbs === canvas.html) return;
2211
+ if (resolved.bodyAbs === canvas.html) return true;
1700
2212
  const next = descriptorFor(canvas.slug, resolved.bodyAbs);
1701
2213
  // Mutated in place: the descriptor and the paths object are already held
1702
2214
  // by the status surfaces and by the setup closure below, and handing them
@@ -1718,6 +2230,7 @@ export function createSyncRuntime(
1718
2230
  // One small write per relocation is the right price for a marker that is
1719
2231
  // never wrong.
1720
2232
  markUntrusted();
2233
+ return true;
1721
2234
  };
1722
2235
 
1723
2236
  const connectCanvas = async (
@@ -1789,7 +2302,15 @@ export function createSyncRuntime(
1789
2302
  //
1790
2303
  // A reconnect storm is N providers reporting at once —
1791
2304
  // `pollRemoteSoon` coalesces them into one request.
1792
- if (s === 'connected' && wasDisconnected) pollRemoteSoon();
2305
+ //
2306
+ // COOLED (F-12, post-1.0 burn-down): a reconnect is not purely
2307
+ // locally caused — a hub that churns the WebSocket drives this
2308
+ // trigger at whatever the provider's backoff allows, and the poke
2309
+ // cooldown never saw it. The cooled path already gives the shape
2310
+ // wanted here: a genuine one-off reconnect (nothing poked
2311
+ // recently) runs immediately; a churn folds into the scheduled
2312
+ // tick, costing latency and never correctness.
2313
+ if (s === 'connected' && wasDisconnected) pollRemoteSoon({ cooled: true });
1793
2314
  wasDisconnected = s !== 'connected';
1794
2315
  })
1795
2316
  );
@@ -1821,7 +2342,17 @@ export function createSyncRuntime(
1821
2342
  // Cold-start reconcile fires once the provider has hub state.
1822
2343
  const synced = provider.onceSynced().then(() => {
1823
2344
  if (deferSetup) {
1824
- relocatePulled(canvas, canvasPaths, provider.document);
2345
+ // Abandon before `setup?.()`, so no projection and no agent is ever
2346
+ // built for a canvas we are not going to place — nothing exists that
2347
+ // could flush the document onto the provisional path on the way out.
2348
+ if (!relocatePulled(canvas, canvasPaths, provider.document)) {
2349
+ pulledSlugs.delete(canvas.slug);
2350
+ if (canvas.html) refusedPulls.set(canvas.slug, canvas.html);
2351
+ // The document names a path we would not write to — which on a cell
2352
+ // usually means the file is already there. See `nudgeRescanFor`.
2353
+ nudgeRescanFor(canvas.slug);
2354
+ return releaseOne(canvas.slug).then(() => undefined);
2355
+ }
1825
2356
  setup?.(provider);
1826
2357
  }
1827
2358
  return runHandleSynced(canvas, canvasPaths, provider);
@@ -1904,20 +2435,6 @@ export function createSyncRuntime(
1904
2435
  echoGuard,
1905
2436
  adopt: adoptOnce,
1906
2437
  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
2438
  snapshot: async (content, reason) => {
1922
2439
  try {
1923
2440
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -1931,6 +2448,12 @@ export function createSyncRuntime(
1931
2448
  agent.start();
1932
2449
  agents.set(canvas.slug, agent);
1933
2450
  }
2451
+ // The move protocol's receiving half — a `movedTo` stamp arriving
2452
+ // on this doc means another machine moved the canvas; park the
2453
+ // stale local copy and let go. (The write-inert guards in
2454
+ // agent/projection are the belt; this is the braces that also
2455
+ // cleans up.)
2456
+ watchForRetirement(canvas.slug, provider.document);
1934
2457
 
1935
2458
  // Count local edits (agent-origin doc updates) toward queuedOps while
1936
2459
  // the hub is unreachable — the banner's "N edits queued" figure. Under
@@ -2056,9 +2579,10 @@ export function createSyncRuntime(
2056
2579
  // DDR-217 (fix 6) — mirror local assets up AFTER the handshakes settle
2057
2580
  // (they carry the canvases; assets ride behind, never in front). Not
2058
2581
  // under pairing: a cell's assets are already on the cell. Fire-and-forget
2059
- // — a miss is retried on the next boot for free.
2582
+ // — a miss is retried on the next boot for free. Journal-less hubs only:
2583
+ // on a Sync v2 hub the plane's first pass carries the same moment.
2060
2584
  if (!cellPairing && !stopped) {
2061
- startAssetSweep(linkedHub.url);
2585
+ scheduleLegacyPush(linkedHub.url);
2062
2586
  }
2063
2587
  });
2064
2588
 
@@ -2230,6 +2754,7 @@ export function createSyncRuntime(
2230
2754
  // immediately pulled back — the resurrection this lane exists to end,
2231
2755
  // reintroduced inside one tick.
2232
2756
  await applyTombstones(listing.tombstones);
2757
+ noteHubListing(listing.documents);
2233
2758
  const diff = diffRemoteDocs(
2234
2759
  [...descriptors.keys()].map((slug) => docNameFor(slug)),
2235
2760
  listing.documents
@@ -2252,11 +2777,27 @@ export function createSyncRuntime(
2252
2777
  // A canvas the project deleted is not a canvas to fetch, even while the
2253
2778
  // hub is still listing it — see `tombstoned`.
2254
2779
  .filter((t) => !tombstoned.has(t.slug))
2255
- .filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
2780
+ .filter((t) => {
2781
+ // The reason is gone ⇒ so is the memo. See `refusedPulls`.
2782
+ const blocker = refusedPulls.get(t.slug);
2783
+ if (blocker === undefined) return true;
2784
+ if (existsSync(blocker)) return false;
2785
+ refusedPulls.delete(t.slug);
2786
+ return true;
2787
+ })
2788
+ .filter((t) => {
2789
+ if (admitPullTarget(ctx, t.slug, t.bodyAbs)) return true;
2790
+ refusedPulls.set(t.slug, t.bodyAbs);
2791
+ nudgeRescanFor(t.slug);
2792
+ return false;
2793
+ })
2256
2794
  .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
- );
2795
+ const fresh = admitted
2796
+ .filter((t) => !agents.has(t.slug) && !projections.has(t.slug) && !providers.has(t.slug))
2797
+ // A document this runtime has seen retired is not a canvas to fetch —
2798
+ // the hub keeps listing it, and re-pulling it every poll was the
2799
+ // connect/release churn described at `retiredDocs`.
2800
+ .filter((t) => !retiredDocs.has(t.slug));
2260
2801
  if (fresh.length === 0) return;
2261
2802
  // VOLUME IS A SECURITY PROPERTY HERE, NOT A PERFORMANCE ONE.
2262
2803
  //
@@ -2296,26 +2837,6 @@ export function createSyncRuntime(
2296
2837
  notePulledAll();
2297
2838
  markUntrusted();
2298
2839
  };
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
2840
  // Cumulative per boot — the Sync panel's one line. `synced` is the last
2320
2841
  // pass's converged count; `pulled`/`conflicts` accumulate.
2321
2842
  const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
@@ -2325,6 +2846,69 @@ export function createSyncRuntime(
2325
2846
  fileTotals.conflicts += result.conflicts.length;
2326
2847
  statusStore?.updateFiles?.({ ...fileTotals });
2327
2848
  };
2849
+ /**
2850
+ * The v2 pass's counts, PLUS the doručenka.
2851
+ *
2852
+ * The counts alone are what the old lane reported, and they are exactly
2853
+ * what could not answer "where is file X" — the question three days of
2854
+ * dogfood kept asking. The per-path states ride beside them so the panel
2855
+ * can point at one file instead of a total, and the raw counters stay so a
2856
+ * lying panel is cross-checkable against them (DDR-214).
2857
+ */
2858
+ const noteFilePlane = (result: import('./file-plane.ts').FilePlaneResult): void => {
2859
+ fileTotals.synced = result.synced + result.pulled.length;
2860
+ fileTotals.pulled += result.pulled.length;
2861
+ fileTotals.conflicts += result.conflicts.length;
2862
+ filePushed += result.pushed.length;
2863
+ // EVERY HOLD REACHES THE PANEL. Without this the breakers were a
2864
+ // `console.warn` in a process log, and DDR-177's premise is that the
2865
+ // target user never opens a terminal — so a control whose only output
2866
+ // is a log line is a control nobody can act on.
2867
+ const held: NonNullable<import('./status.ts').FilePlaneStatus['held']> = [];
2868
+ if (result.deleteHeld) {
2869
+ held.push({
2870
+ kind: result.deleteHeld.direction === 'out' ? 'delete-out' : 'delete-in',
2871
+ count: result.deleteHeld.count,
2872
+ paths: result.deleteHeld.paths,
2873
+ detail:
2874
+ result.deleteHeld.direction === 'out'
2875
+ ? `${result.deleteHeld.count} files are gone from this machine — more than sync will remove from the project without you saying so. Nothing was deleted anywhere else. Set linkedHub.propagateDeletes: false to stop asking, or delete them again once you have confirmed this was deliberate.`
2876
+ : `The project wants to remove ${result.deleteHeld.count} files here — more than sync will delete without you saying so. Nothing was removed. They stay until you accept or the project puts them back.`,
2877
+ });
2878
+ }
2879
+ if (result.firstAnchorHeld) {
2880
+ held.push({
2881
+ kind: 'first-anchor',
2882
+ count: result.firstAnchorHeld.count,
2883
+ paths: result.firstAnchorHeld.paths,
2884
+ detail: `${result.firstAnchorHeld.count} files differ between this machine and the project, and neither copy has been reconciled here yet. Set linkedHub.resolveFirstAnchor to "keep-local" or "keep-cloud" to settle the whole set at once.`,
2885
+ });
2886
+ }
2887
+ if (result.reanchorHeld) {
2888
+ held.push({
2889
+ kind: 'reanchor',
2890
+ count: 0,
2891
+ paths: [],
2892
+ detail:
2893
+ 'The project has asked to start over repeatedly, which is what a broken or hostile hub looks like. Nothing was overwritten. Sync retries by itself; if this persists, the hub needs looking at.',
2894
+ });
2895
+ }
2896
+ statusStore?.updateFiles?.({
2897
+ ...fileTotals,
2898
+ pushed: filePushed,
2899
+ ...(filePlane ? { delivery: filePlane.doruceka() } : {}),
2900
+ ...(held.length > 0 ? { held } : {}),
2901
+ });
2902
+ for (const f of result.failed) {
2903
+ console.warn(`[sync/files] ${f.rel}: ${f.reason}`);
2904
+ }
2905
+ // A pass that landed bytes widened the hub-written set, so the
2906
+ // untrusted-context markers have to describe it. Cheap and idempotent —
2907
+ // the writer rebuilds the whole set each call — but only when something
2908
+ // actually arrived, so a converged pass costs nothing.
2909
+ if (result.pulled.length > 0 || result.conflicts.length > 0) markUntrusted();
2910
+ };
2911
+ planeResultSink = noteFilePlane;
2328
2912
  /**
2329
2913
  * Plane B's downward pass — after the doc poll and the asset pull, so a
2330
2914
  * canvas that arrived this tick has its design system resolved in the
@@ -2337,6 +2921,16 @@ export function createSyncRuntime(
2337
2921
  */
2338
2922
  const pullFilesOnce = async (): Promise<void> => {
2339
2923
  if (stopped || !syncFilesOn) return;
2924
+ // Sync v2 (DDR-226) — when the hub carries a journal, the file plane is
2925
+ // the ONE lane: a cursor read, one decision per path, and both
2926
+ // directions from the same pass. The v1 manifest pull stays for
2927
+ // journal-less hubs, which the compat matrix keeps working through the
2928
+ // burn-down window.
2929
+ if (filePlane) {
2930
+ const result = await filePlane.reconcile();
2931
+ noteFilePlane(result);
2932
+ return;
2933
+ }
2340
2934
  const result = await pullFiles({
2341
2935
  designRoot: ctx.paths.designRoot,
2342
2936
  hubUrl: linkedHub.url,
@@ -2348,13 +2942,11 @@ export function createSyncRuntime(
2348
2942
  };
2349
2943
  const pollRemote = (): void => {
2350
2944
  void pullRemoteOnce()
2351
- .then(() => pullAssetsOnce())
2352
2945
  .then(() => pullFilesOnce())
2353
2946
  .catch((err) => console.error('[sync] remote poll failed:', err));
2354
2947
  };
2355
2948
  remotePull = async () => {
2356
2949
  await pullRemoteOnce();
2357
- await pullAssetsOnce();
2358
2950
  await pullFilesOnce();
2359
2951
  };
2360
2952
  remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
@@ -2362,6 +2954,106 @@ export function createSyncRuntime(
2362
2954
  // dev server to refuse to exit.
2363
2955
  remotePollTimer.unref?.();
2364
2956
 
2957
+ // ── Sync v2 Increment 2 — the poke, desktop side (DDR-226 §4) ──────────
2958
+ //
2959
+ // CAPABILITY-GATED, and the gate is the compat matrix (§10, BINDING): a
2960
+ // journal-less self-hosted hub must see exactly today's client. So we ask
2961
+ // `/health` first and attach nothing unless it says `ledger`.
2962
+ //
2963
+ // THE POLL STAYS AT 20 s. The poke is additive this release — it makes the
2964
+ // common case fast, and the honesty counter below is what earns the right
2965
+ // to relax the poll later. Relaxing it now would trade a measured cadence
2966
+ // for an unmeasured one.
2967
+ //
2968
+ // Not started in a cell: there the CHILD holds this channel (ws.ts), and
2969
+ // its job is healing the UI rather than triggering pulls.
2970
+ if (!cellPairing && ctx.cfg.linkedHub?.fileEvents !== false) {
2971
+ fileEventsProbe = new AbortController();
2972
+ void hubCapabilities({ hubUrl: linkedHub.url, signal: fileEventsProbe.signal })
2973
+ .then((caps) => {
2974
+ if (stopped) return;
2975
+ if (!hasLedger(caps)) {
2976
+ // No journal on this hub ⇒ the legacy client carries the upward
2977
+ // lane, exactly as the pre-v2 desktop did (Open decision 4).
2978
+ decidePushLane(true);
2979
+ return;
2980
+ }
2981
+ // The hub carries a journal, so the file plane becomes the ONE lane
2982
+ // for this project. Built here rather than at start(): a client must
2983
+ // never send a journal request to a hub that would not understand
2984
+ // it (compat matrix §10 — BINDING).
2985
+ if (syncFilesOn && !filePlane) {
2986
+ fileLedger = createFileLedger({
2987
+ designRoot: ctx.paths.designRoot,
2988
+ hubUrl: linkedHub.url,
2989
+ });
2990
+ filePlane = createFilePlane({
2991
+ designRoot: ctx.paths.designRoot,
2992
+ hubUrl: linkedHub.url,
2993
+ token: () => token,
2994
+ ledger: fileLedger,
2995
+ canvasGroups: ctx.cfg.canvasGroups,
2996
+ allowCodeModules,
2997
+ // Increment 6, DEFAULT ON: a hub-owned mirror that ignores
2998
+ // deletes contradicts the model it is selling — you delete a
2999
+ // file and it comes back. `linkedHub.propagateDeletes: false`
3000
+ // is the per-project opt-out; the breakers hold either way.
3001
+ propagateDeletes: linkedHub.propagateDeletes !== false,
3002
+ // The answer to a first-anchor hold. A config key rather than a
3003
+ // prompt because the hold outlives the pass that raised it, and
3004
+ // DDR-177's user has no terminal to answer in.
3005
+ ...(linkedHub.resolveFirstAnchor === 'keep-local' ||
3006
+ linkedHub.resolveFirstAnchor === 'keep-cloud'
3007
+ ? { resolveFirstAnchor: linkedHub.resolveFirstAnchor }
3008
+ : {}),
3009
+ // Same exposure class as `syncMeta.by`, and the same reasoning:
3010
+ // a conflict copy nobody can attribute is a conflict copy nobody
3011
+ // resolves.
3012
+ label: hostname().slice(0, 32),
3013
+ });
3014
+ console.log(
3015
+ '[sync/files] journal file plane active — one lane, both directions, per-file delivery state in the Sync panel.'
3016
+ );
3017
+ // Anything the local disk already differs on goes now, rather than
3018
+ // at the first 20 s tick.
3019
+ schedulePlanePass();
3020
+ }
3021
+ // A ledger hub with the plane ON owns pushes; with the file-plane
3022
+ // flag OFF the legacy client still carries the DDR-217 assets lane.
3023
+ decidePushLane(filePlane === null);
3024
+ fileEventsCtl = createCtlProvider({
3025
+ url: linkedHub.url,
3026
+ token,
3027
+ onPoke: () => {
3028
+ // Reuses `pollRemoteSoon` rather than calling the file lanes
3029
+ // directly, for two reasons: it already coalesces a burst into
3030
+ // one pass (a fresh link appends hundreds of rows), and it is
3031
+ // the exact path a reconnect takes — one behaviour to reason
3032
+ // about instead of two that can drift.
3033
+ //
3034
+ // The PULL itself is unchanged: missing-only, idempotent, and
3035
+ // re-validating everything it accepts. So a poke can at worst
3036
+ // cost one early pass, and the scheduled poll remains the
3037
+ // reconciler underneath it.
3038
+ pokesSeen += 1;
3039
+ pollRemoteSoon({ cooled: true });
3040
+ },
3041
+ });
3042
+ console.log(
3043
+ '[sync/ctl] file-event channel attached — cloud changes now arrive in seconds instead of on the 20 s tick.'
3044
+ );
3045
+ })
3046
+ .catch(() => {
3047
+ // No capability probe ⇒ no channel ⇒ exactly today's behaviour —
3048
+ // which, for pushes, is the legacy client.
3049
+ decidePushLane(true);
3050
+ });
3051
+ } else if (!cellPairing) {
3052
+ // The probe is opted out (`linkedHub.fileEvents: false`), so no verdict
3053
+ // will ever arrive — the legacy client is the lane, as it always was.
3054
+ decidePushLane(true);
3055
+ }
3056
+
2365
3057
  // Arm the pre-expiry renewal from the credential that just booted. Placed
2366
3058
  // last — the timer needs nothing from boot, and boot needs nothing from it
2367
3059
  // (a credential that dies mid-boot lands in the invalid-token path, which
@@ -2372,6 +3064,26 @@ export function createSyncRuntime(
2372
3064
  async function stop(): Promise<void> {
2373
3065
  if (stopped) return;
2374
3066
  stopped = true;
3067
+ fileEventsProbe?.abort();
3068
+ fileEventsProbe = null;
3069
+ if (filePassTimer !== null) clearTimeout(filePassTimer);
3070
+ filePassTimer = null;
3071
+ planeResultSink = null;
3072
+ // Persist the ledger on the way out. Losing it is safe (a re-anchor, never
3073
+ // a loss) but paying for one on every restart would be needless noise.
3074
+ fileLedger?.stop();
3075
+ fileLedger = null;
3076
+ filePlane = null;
3077
+ // The control channel is a doorbell into this runtime; it goes with it.
3078
+ // Reported on the way out so the poke-miss question is answerable from a
3079
+ // session's log rather than from a hunch (DDR-226 §10).
3080
+ if (fileEventsCtl) {
3081
+ console.log(
3082
+ `[sync/ctl] file-event channel closing — ${pokesSeen} poke(s) received, ${fileEventsCtl.malformed()} refused.`
3083
+ );
3084
+ fileEventsCtl.stop();
3085
+ fileEventsCtl = null;
3086
+ }
2375
3087
  // Nothing may be adopted into a runtime that is going away.
2376
3088
  attachOne = null;
2377
3089
  discoveryUnsub?.();
@@ -2387,26 +3099,17 @@ export function createSyncRuntime(
2387
3099
  if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
2388
3100
  remotePollSoonTimer = null;
2389
3101
  remotePull = null;
2390
- // A sweep that outlives its runtime keeps uploading a project the person
2391
- // just closed — and `restart()` (the Resync button) calls stop() on every
2392
- // press, so without this each press would leave another sweep running.
2393
- if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
2394
- assetSweepTimer = null;
2395
- assetSweepAgain = false;
2396
- assetSweep?.cancel();
2397
- assetSweep = null;
2398
- // Commit whatever is still inside the quiescence window BEFORE tearing
2399
- // anything down. Shutting down mid-window would leave the last edits on
2400
- // disk but out of history — the one state this whole mechanism exists to
2401
- // make impossible.
2402
- if (autoCommit) {
2403
- try {
2404
- await autoCommit.flush();
2405
- } catch (err) {
2406
- console.error('[sync] final autocommit failed:', err);
2407
- }
2408
- autoCommit.stop();
2409
- }
3102
+ // A push pass that outlives its runtime keeps uploading a project the
3103
+ // person just closed — and `restart()` (the Resync button) calls stop() on
3104
+ // every press, so without this each press would leave another one running.
3105
+ if (legacyPushTimer !== null) clearTimeout(legacyPushTimer);
3106
+ legacyPushTimer = null;
3107
+ legacyPushAgain = false;
3108
+ legacyPushCancel = true;
3109
+ legacyBootPush = null;
3110
+ // (No autocommit flush here — this runtime constructs no committer; the
3111
+ // hub's own `afterStoreDocument` engine owns the SIGTERM-ordered flush.
3112
+ // See the note at the top of createSyncRuntime. DDR-226 Increment 0.)
2410
3113
  for (const slug of [...awarenessDetaches.keys()]) runDetaches(awarenessDetaches, slug);
2411
3114
  awarenessDetaches.clear();
2412
3115
  // Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
@@ -2523,10 +3226,11 @@ export function createSyncRuntime(
2523
3226
  agentFor: (slug) => agents.get(slug),
2524
3227
  status: () => statusStore?.get() ?? null,
2525
3228
  cancelAssetSweep: () => {
2526
- if (!assetSweep) return false;
2527
- assetSweep.cancel();
3229
+ if (!legacyPushRunning) return false;
3230
+ legacyPushCancel = true;
2528
3231
  return true;
2529
3232
  },
3233
+ retireForMove: (fromSlug, toRel) => serializeMembership(() => retireForMove(fromSlug, toRel)),
2530
3234
  };
2531
3235
  }
2532
3236
 
@@ -2625,12 +3329,13 @@ export function admitCanvases(
2625
3329
  * canvas boot would train an operator to skip the line that matters.
2626
3330
  */
2627
3331
  let sharedDocNoticeShown = false;
3332
+ function sharedDocNoticeText(url: string): string {
3333
+ return `shared-doc is ON for ${url} — your live editing buffer for each canvas is now the same object that syncs to the hub, not a copy reconciled through disk (DDR-064). Link only hubs you operate or trust.`;
3334
+ }
2628
3335
  function noticeSharedDocOnce(url: string, cellPairing: boolean): void {
2629
3336
  if (cellPairing || sharedDocNoticeShown) return;
2630
3337
  sharedDocNoticeShown = true;
2631
- console.warn(
2632
- `[sync] shared-doc is ON for ${url} — your live editing buffer for each canvas is now the same object that syncs to the hub, not a copy reconciled through disk (DDR-064). Link only hubs you operate or trust.`
2633
- );
3338
+ console.warn(`[sync] ${sharedDocNoticeText(url)}`);
2634
3339
  }
2635
3340
 
2636
3341
  /* ---------------------------------------------------------------- discovery */