@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
@@ -65,11 +65,25 @@ export interface CellPairingVerdict {
65
65
  detail?: string;
66
66
  }
67
67
 
68
- /** Env truthiness, matching `server.ts`'s MAUDE_SHARED_DOC parsing exactly. */
68
+ /** Env truthiness for the opt-in interlocks below (explicit `1`-style value). */
69
69
  function on(value: string | undefined): boolean {
70
70
  return /^(1|true|on|yes)$/i.test(value ?? '');
71
71
  }
72
72
 
73
+ /**
74
+ * Is the single-shared-doc model enabled? — DEFAULT ON (the DDR-064 cutover,
75
+ * Sync v2 Increment 7). Only an explicit falsy value opts a machine back onto
76
+ * the proven two-doc path, and that opt-out IS the rollback for this release:
77
+ * both paths coexist, nothing is deleted, `MAUDE_SHARED_DOC=0` flips back.
78
+ *
79
+ * ONE parser, exported — `server.ts` and the pairing gate used to carry
80
+ * byte-identical mirrors of the opt-in regex, and a default flip is exactly
81
+ * the change that would have let them drift.
82
+ */
83
+ export function sharedDocEnabled(env: Record<string, string | undefined> = process.env): boolean {
84
+ return !/^(0|false|off|no)$/i.test(env.MAUDE_SHARED_DOC ?? '');
85
+ }
86
+
73
87
  /**
74
88
  * Strip control characters and cap length before a value reaches a log line.
75
89
  *
@@ -159,14 +173,15 @@ export function resolveCellPairing(
159
173
  };
160
174
  }
161
175
 
162
- if (!on(env.MAUDE_SHARED_DOC)) {
176
+ if (!sharedDocEnabled(env)) {
163
177
  return {
164
178
  pairing: null,
165
179
  refusal: 'shared-doc-off',
166
180
  detail:
167
- 'refusing to pair without MAUDE_SHARED_DOC=1 — pairing IS the single-shared-doc ' +
168
- 'model (DDR-064). Without it the loopback provider would open a second Y.Doc per ' +
169
- 'canvas beside the browser room, which is the two-doc world pairing exists to end.',
181
+ 'refusing to pair with MAUDE_SHARED_DOC explicitly off — pairing IS the ' +
182
+ 'single-shared-doc model (DDR-064). Without it the loopback provider would open a ' +
183
+ 'second Y.Doc per canvas beside the browser room, which is the two-doc world ' +
184
+ 'pairing exists to end.',
170
185
  };
171
186
  }
172
187
 
@@ -0,0 +1,244 @@
1
+ // The doorbell's other button — the cell child telling its hub it wrote.
2
+ //
3
+ // Increment 3 shipped `POST /api/journal/report` and nothing that calls it. The
4
+ // asymmetry that left is the one a user actually feels:
5
+ //
6
+ // desktop → cloud a peer PUTs through the write door, the door hooks the
7
+ // journal synchronously, the hub pokes, peers pull. Seconds.
8
+ // cloud → desktop the cell's OWN studio child writes straight to the shared
9
+ // checkout. No door, no hook, no row — so the hub does not
10
+ // know, cannot poke, and the change waits for the 15-minute
11
+ // walk-import belt.
12
+ //
13
+ // Both halves "work"; only one is prompt, and that reads as "sync is broken"
14
+ // because a person drops an image in the cloud and it is not on their laptop
15
+ // ten minutes later. Observed live: `assets/2e32c88c.png` written at 14:45,
16
+ // journalled at 14:55 with `source: walk-import`.
17
+ //
18
+ // ── Why a nudge and not a report ───────────────────────────────────────────
19
+ //
20
+ // This carries PATHS ONLY. It cannot state a hash, a size, a class, or a
21
+ // deletion, because the hub re-stats and re-hashes its own disk for every path
22
+ // it is handed (`journal.recordWrite`). That is what makes it safe to accept
23
+ // from a process the hub supervises but does not trust to speak about content —
24
+ // and it is why being INCOMPLETE here is a latency bug and never a correctness
25
+ // one. `walk-import` remains the backstop it always was.
26
+ //
27
+ // ── Why `fs:any` is the right input ────────────────────────────────────────
28
+ //
29
+ // A cell's recursive `fs.watch` does not fire for atomic tmp+rename writes, so
30
+ // there is no watcher to subscribe to. But the studio already had to solve
31
+ // exactly this for hot-reload, and the answer it landed on is a single bus
32
+ // event: `createContainerWriteBridge` synthesises `fs:any` from the
33
+ // `activity:suppress` every API write path arms, and `announceWrite` does the
34
+ // same for the doc→file projector, which arms nothing. Between them they are
35
+ // the complete set of writes this process makes — which is the same
36
+ // completeness argument the HMR path already stakes itself on. Subscribing to
37
+ // their common output means this module needs no new instrumentation at any
38
+ // write site, and a future write path that remembers to hot-reload gets a
39
+ // journal row for free.
40
+ //
41
+ // ── The one thing it must not do ───────────────────────────────────────────
42
+ //
43
+ // `createCtlHealer` emits `fs:any` too, for paths the HUB just told us about.
44
+ // Nudging those back is harmless — same bytes, same hash, `recordWrite` is a
45
+ // no-op, no row, no poke, so it cannot loop — but it is a request per healed
46
+ // path for a fact the hub stated in the first place. `mute()` drops that echo
47
+ // at the source instead of paying for it.
48
+
49
+ import { isProjectFileShape, isRuntimeStateRel } from './file-membership.ts';
50
+
51
+ /** Collect a burst of writes into one request. */
52
+ const COALESCE_MS = 250;
53
+
54
+ /**
55
+ * How long a healed path stays muted. Long enough to cover the healer's own
56
+ * `fs:any` (immediate) and the coalesce window behind it; short enough that a
57
+ * genuine local edit to a file we just pulled still nudges.
58
+ */
59
+ const MUTE_MS = 1_500;
60
+
61
+ /** The route's own ceiling (`MAX_NUDGE_PATHS`). Batches split at it. */
62
+ const MAX_PATHS_PER_NUDGE = 64;
63
+
64
+ export interface CellWriteNudgeOptions {
65
+ hubUrl: string;
66
+ token: string;
67
+ fetchImpl?: typeof fetch;
68
+ log?: Pick<Console, 'log' | 'warn' | 'error'>;
69
+ coalesceMs?: number;
70
+ muteMs?: number;
71
+ setTimeoutImpl?: typeof setTimeout;
72
+ clearTimeoutImpl?: typeof clearTimeout;
73
+ /** Injected in tests. Defaults to `Date.now`. */
74
+ now?: () => number;
75
+ }
76
+
77
+ export interface CellWriteNudge {
78
+ /** A path this process wrote. Filtered, coalesced, then named in a nudge. */
79
+ note(rel: string): void;
80
+ /** This path came FROM the hub — do not tell the hub about it. */
81
+ mute(rel: string): void;
82
+ /** Send whatever is pending now (boot, tests, shutdown). */
83
+ flush(): Promise<void>;
84
+ stop(): void;
85
+ /** Requests that got a 2xx — the sender half of the honesty counters. */
86
+ sent(): number;
87
+ /** Paths named across all requests. */
88
+ named(): number;
89
+ /** Paths dropped as hub echoes. */
90
+ muted(): number;
91
+ /** Consecutive failed requests. Non-zero and climbing means the hub is gone. */
92
+ failures(): number;
93
+ }
94
+
95
+ /**
96
+ * Build the nudge sender.
97
+ *
98
+ * Never throws and never rejects: a hub that refuses the nudge costs the
99
+ * freshness this module exists to buy, and nothing else. The child keeps
100
+ * serving, the checkout keeps its bytes, and the reconciler still finds the
101
+ * drift on its own belt.
102
+ */
103
+ export function createCellWriteNudge(opts: CellWriteNudgeOptions): CellWriteNudge {
104
+ const log = opts.log ?? console;
105
+ const coalesceMs = opts.coalesceMs ?? COALESCE_MS;
106
+ const muteMs = opts.muteMs ?? MUTE_MS;
107
+ const setTimeoutImpl = opts.setTimeoutImpl ?? setTimeout;
108
+ const clearTimeoutImpl = opts.clearTimeoutImpl ?? clearTimeout;
109
+ const now = opts.now ?? Date.now;
110
+ const doFetch = opts.fetchImpl ?? fetch;
111
+ const url = `${opts.hubUrl.replace(/\/+$/, '')}/api/journal/report`;
112
+
113
+ const pending = new Set<string>();
114
+ const mutedUntil = new Map<string, number>();
115
+ let timer: ReturnType<typeof setTimeout> | null = null;
116
+ let inFlight: Promise<void> | null = null;
117
+ let stopped = false;
118
+ let sent = 0;
119
+ let named = 0;
120
+ let muted = 0;
121
+ let failures = 0;
122
+ /** Log the first failure at error, the rest at debug — one hub outage is one line. */
123
+ let warnedFailure = false;
124
+
125
+ function sweepMutes(at: number): void {
126
+ for (const [rel, until] of mutedUntil) {
127
+ if (until <= at) mutedUntil.delete(rel);
128
+ }
129
+ }
130
+
131
+ async function postOnce(paths: string[]): Promise<boolean> {
132
+ try {
133
+ const res = await doFetch(url, {
134
+ method: 'POST',
135
+ headers: {
136
+ authorization: `Bearer ${opts.token}`,
137
+ 'content-type': 'application/json',
138
+ },
139
+ body: JSON.stringify({ paths }),
140
+ });
141
+ // The answer is `{ noted }` and deliberately says nothing about what
142
+ // landed (it would be an existence oracle over the checkout). So the ONLY
143
+ // thing worth reading here is the status.
144
+ return res.ok;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ async function drain(): Promise<void> {
151
+ if (stopped || pending.size === 0) return;
152
+ const batch = [...pending];
153
+ pending.clear();
154
+ for (let i = 0; i < batch.length; i += MAX_PATHS_PER_NUDGE) {
155
+ const slice = batch.slice(i, i + MAX_PATHS_PER_NUDGE);
156
+ const ok = await postOnce(slice);
157
+ if (ok) {
158
+ sent += 1;
159
+ named += slice.length;
160
+ failures = 0;
161
+ warnedFailure = false;
162
+ continue;
163
+ }
164
+ failures += 1;
165
+ if (!warnedFailure) {
166
+ warnedFailure = true;
167
+ log.warn?.(
168
+ `[sync/nudge] could not tell the hub about ${slice.length} write(s) — it will find them on the walk-import belt instead. Changes made here reach peers late until this recovers.`
169
+ );
170
+ }
171
+ // A dropped nudge is latency, not loss: `walk-import` still finds the
172
+ // drift. Re-queueing a failed batch forever would turn a hub restart into
173
+ // an unbounded retry storm against a route that is rate-limited, so the
174
+ // paths go and the backstop takes it from here.
175
+ }
176
+ }
177
+
178
+ function schedule(): void {
179
+ if (stopped || timer !== null) return;
180
+ timer = setTimeoutImpl(() => {
181
+ timer = null;
182
+ void flush();
183
+ }, coalesceMs);
184
+ timer.unref?.();
185
+ }
186
+
187
+ async function flush(): Promise<void> {
188
+ if (inFlight) return inFlight;
189
+ inFlight = drain().finally(() => {
190
+ inFlight = null;
191
+ });
192
+ return inFlight;
193
+ }
194
+
195
+ return {
196
+ note(rel: string): void {
197
+ if (stopped || typeof rel !== 'string' || rel.length === 0) return;
198
+ // Separators are NOT normalised here. Every `fs:any` producer already
199
+ // emits forward slashes (`fs-watch`, both halves of `hmr-broadcast`,
200
+ // `announceWrite`, the healer's journal rows), so there is nothing left to
201
+ // fix — and rewriting anyway would be actively wrong on POSIX, where
202
+ // `a\b.css` is one legal filename and `a/b.css` is a different file. Let
203
+ // the classifier judge what arrives, exactly as the hub will.
204
+ //
205
+ // Cheap and pure. The hub's classifier is still the authority on
206
+ // membership; this only keeps the obvious noise off the wire, and
207
+ // `_state/` alone would otherwise be most of the traffic on a busy canvas.
208
+ if (!isProjectFileShape(rel) || isRuntimeStateRel(rel)) return;
209
+ const at = now();
210
+ const until = mutedUntil.get(rel);
211
+ if (until !== undefined && until > at) {
212
+ mutedUntil.delete(rel);
213
+ muted += 1;
214
+ return;
215
+ }
216
+ pending.add(rel);
217
+ schedule();
218
+ },
219
+
220
+ mute(rel: string): void {
221
+ if (stopped || typeof rel !== 'string' || rel.length === 0) return;
222
+ const at = now();
223
+ sweepMutes(at);
224
+ mutedUntil.set(rel, at + muteMs);
225
+ },
226
+
227
+ flush,
228
+
229
+ stop(): void {
230
+ stopped = true;
231
+ if (timer !== null) {
232
+ clearTimeoutImpl(timer);
233
+ timer = null;
234
+ }
235
+ pending.clear();
236
+ mutedUntil.clear();
237
+ },
238
+
239
+ sent: () => sent,
240
+ named: () => named,
241
+ muted: () => muted,
242
+ failures: () => failures,
243
+ };
244
+ }
@@ -194,8 +194,8 @@ export function annotationsFromDoc(doc: Y.Doc): string | null {
194
194
  * This distinction is load-bearing for cold start (the 2026-08-14 annotations
195
195
  * eraser): the wrapper is a non-empty STRING, so every `!== ''` emptiness
196
196
  * guard let a stale hub wrapper overwrite a peer's real strokes — and with the
197
- * strokes went the `assets/<sha8>` references `asset-pull` scans, so freshly
198
- * dropped images never crossed machines. Live delete-all still materializes
197
+ * strokes went the `assets/<sha8>` references the asset lane pulled by, so
198
+ * freshly dropped images never crossed machines. Live delete-all still materializes
199
199
  * the wrapper through `writeAnnotationsIfChanged` (deletes must propagate);
200
200
  * only COLD-START decisions treat it as emptiness.
201
201
  */
@@ -256,8 +256,58 @@ function sharedMetaCanonical(meta: Record<string, unknown>): string {
256
256
  return JSON.stringify(out);
257
257
  }
258
258
 
259
+ /**
260
+ * THE META LANE CAN DUPLICATE ITSELF, SO IT HAS TO BE ABLE TO HEAL.
261
+ *
262
+ * Meta is a WHOLE VALUE carried in a Y.Text, written by delete-all +
263
+ * insert-all. That is duplication-prone by construction, and the case is not
264
+ * exotic — it is the normal one: every peer holding the file tries to publish
265
+ * it, and two peers inserting the same string into an empty lane produce two
266
+ * inserts Yjs has no reason to merge. The result is
267
+ * `{"title":"Home"}{"title":"Home"}`: not a value, not empty, and — because
268
+ * every consumer runs it through `JSON.parse` and bails — indistinguishable
269
+ * from "this canvas has no meta".
270
+ *
271
+ * Observed live: nine canvases created on a laptop reached the hub with correct
272
+ * bodies and doubled meta, so a third machine syncing the project got their
273
+ * titles, kinds and design-system bindings dropped on the floor. Silently.
274
+ *
275
+ * Preventing every interleaving is not on offer while the lane is a Y.Text. So
276
+ * the lane repairs instead: a stack of IDENTICAL copies is recognised for what
277
+ * it is — one value, published twice — and collapses back to one. Anything else
278
+ * unparseable stays `null`, which routes to the existing "the doc has no
279
+ * opinion" path rather than to a guess.
280
+ */
281
+ export function normalizeSharedMeta(raw: string | null): string | null {
282
+ if (raw === null || raw.length === 0) return null;
283
+ if (parsesAsObject(raw)) return raw;
284
+ // Split only at a `}{` seam — the exact shape concurrent whole-value inserts
285
+ // produce. A JSON string containing "}{" cannot create a false seam here,
286
+ // because each resulting segment must itself parse as a complete object.
287
+ const parts = raw.split(/(?<=\})(?=\{)/);
288
+ if (parts.length < 2) return null;
289
+ const first = parts[0] ?? '';
290
+ if (!parts.every((p) => p === first) || !parsesAsObject(first)) return null;
291
+ return first;
292
+ }
293
+
294
+ function parsesAsObject(s: string): boolean {
295
+ try {
296
+ const v = parseJsonSafe(s);
297
+ return !!v && typeof v === 'object' && !Array.isArray(v);
298
+ } catch {
299
+ return false;
300
+ }
301
+ }
302
+
259
303
  /** The synced shared-meta JSON string held in the doc, or null when unset. */
260
304
  export function metaFromDoc(doc: Y.Doc): string | null {
305
+ return normalizeSharedMeta(doc.getText(Y_SYNC_TYPES.meta).toString());
306
+ }
307
+
308
+ /** What the doc LITERALLY holds — for the repair path, which needs to know
309
+ * that the stored text differs from the value it normalises to. */
310
+ export function rawMetaFromDoc(doc: Y.Doc): string | null {
261
311
  const s = doc.getText(Y_SYNC_TYPES.meta).toString();
262
312
  return s.length > 0 ? s : null;
263
313
  }
@@ -283,7 +333,10 @@ export function applyMetaToDoc(doc: Y.Doc, fullMetaJson: string, origin?: unknow
283
333
  return false;
284
334
  }
285
335
  const t = doc.getText(Y_SYNC_TYPES.meta);
286
- if (t.toString() === shared) return false;
336
+ // Compare against the NORMALISED value, not the literal text: a lane holding
337
+ // two identical copies already carries this exact meta, and treating it as a
338
+ // difference would rewrite it on every pass forever.
339
+ if (normalizeSharedMeta(t.toString()) === shared) return false;
287
340
  doc.transact(() => {
288
341
  if (t.length > 0) t.delete(0, t.length);
289
342
  t.insert(0, shared);
@@ -291,6 +344,30 @@ export function applyMetaToDoc(doc: Y.Doc, fullMetaJson: string, origin?: unknow
291
344
  return true;
292
345
  }
293
346
 
347
+ /**
348
+ * Collapse a duplicated meta lane back to one copy.
349
+ *
350
+ * Repairing on APPLY alone is not enough — a canvas nobody edits again would
351
+ * keep its doubled meta forever, and a machine that syncs the project
352
+ * afterwards would keep dropping it. This runs on cold start, where the doc has
353
+ * synced and "what it holds" is a fact rather than ignorance.
354
+ *
355
+ * Returns true when it changed something. A lane that is empty, already single,
356
+ * or unparseable-in-some-other-way is left exactly as it is: this repairs the
357
+ * one shape it can prove, and guesses at nothing.
358
+ */
359
+ export function repairSharedMeta(doc: Y.Doc, origin?: unknown): boolean {
360
+ const t = doc.getText(Y_SYNC_TYPES.meta);
361
+ const raw = t.toString();
362
+ const single = normalizeSharedMeta(raw);
363
+ if (single === null || single === raw) return false;
364
+ doc.transact(() => {
365
+ t.delete(0, t.length);
366
+ t.insert(0, single);
367
+ }, origin);
368
+ return true;
369
+ }
370
+
294
371
  /**
295
372
  * Merge the synced shared-meta into a local `.meta.json` string, PRESERVING the
296
373
  * local per-user/security keys (META_LOCAL_KEYS). Shared keys become exactly
@@ -457,6 +534,81 @@ export function canvasPathFromDoc(doc: Y.Doc): string | null {
457
534
  return typeof v === 'string' && v.length > 0 ? v : null;
458
535
  }
459
536
 
537
+ /* ------------------------------------------------------------- retirement */
538
+
539
+ /**
540
+ * Mark this document RETIRED BY A MOVE: its canvas now lives at `toRel`, in a
541
+ * DIFFERENT document (the slug is derived from the path, so a moved canvas is
542
+ * a new document by construction — nothing can rename a Hocuspocus doc).
543
+ *
544
+ * Before this stamp existed, the pre-move document simply lived on: the hub
545
+ * kept materialising it at the old path, every peer's cold start saw "doc has
546
+ * a body, disk has no file" and resurrected it, and a moved canvas came back
547
+ * as a duplicate on every machine that ever synced it. Observed live: moving
548
+ * `shoj` into a folder on the desktop left BOTH `ui/dbucket/shoj.tsx` and a
549
+ * re-materialised `ui/shoj.tsx` on both machines, plus two documents on the
550
+ * hub.
551
+ *
552
+ * The stamp is a STATEMENT, deliberately not an empty body: emptiness is
553
+ * ambiguous (a crash mid-write, an unseeded doc), and DDR-223 spent a whole
554
+ * arc making emptiness weak. `movedTo` is unambiguous, carries WHERE the
555
+ * content went, and — because the content provably lives at the new path in
556
+ * the new document — quarantining the old file against it is safe in a way
557
+ * that acting on a bare deletion never is (that one stays Increment 6).
558
+ *
559
+ * Consumers:
560
+ * - every materialise path treats a retired doc as WRITE-INERT — it never
561
+ * lands another byte on disk and never accepts another local edit;
562
+ * - the sync runtime, on SEEING the stamp arrive, releases the canvas and
563
+ * quarantines the stale local copy into `_trash/` (recoverable, DDR-102's
564
+ * spine) — except on the machine performing the move, which renames the
565
+ * file itself;
566
+ * - the hub's workspace agent quarantines the checkout copy and commits the
567
+ * deletion, so the cloud tree stops listing the ghost.
568
+ */
569
+ export function stampMovedTo(doc: Y.Doc, toRel: string, origin?: unknown): boolean {
570
+ const next = String(toRel ?? '').replace(/\\/g, '/');
571
+ if (!next) return false;
572
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
573
+ if (map.get('movedTo') === next) return false;
574
+ doc.transact(() => {
575
+ map.set('movedTo', next);
576
+ map.set('movedAt', Date.now());
577
+ map.set('movedBy', peerLabel());
578
+ }, origin);
579
+ return true;
580
+ }
581
+
582
+ /**
583
+ * Un-retire a document that says it moved to where it already is.
584
+ *
585
+ * A move renames the canvas's artifacts onto the new slug, and the retirement
586
+ * stamp lives INSIDE the document — so any copy of the old document that reaches
587
+ * the new slug arrives pre-stamped "I have moved away", and every peer that
588
+ * opens it releases the canvas instead of syncing it. Clearing the stamp is the
589
+ * correction, and because it is a doc edit it travels to every peer that already
590
+ * believed the lie. The caller decides the "already is" part; this only writes.
591
+ */
592
+ export function clearMovedTo(doc: Y.Doc, origin?: unknown): boolean {
593
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
594
+ if (map.get('movedTo') === undefined) return false;
595
+ doc.transact(() => {
596
+ map.delete('movedTo');
597
+ map.delete('movedAt');
598
+ map.delete('movedBy');
599
+ }, origin);
600
+ return true;
601
+ }
602
+
603
+ /** Where a retired document says its canvas went, or null for a live one.
604
+ * UNTRUSTED — a consumer that turns this into a path must validate it the
605
+ * same way it validates `syncMeta.path`. Most consumers only need the
606
+ * null/non-null fact. */
607
+ export function movedToFromDoc(doc: Y.Doc): string | null {
608
+ const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('movedTo');
609
+ return typeof v === 'string' && v.length > 0 ? v : null;
610
+ }
611
+
460
612
  /* ---------------------------------------------------------------- css */
461
613
 
462
614
  /** The synced canvas CSS string held in the doc, or null when unset/empty. */