@1agh/maude 0.29.0 → 0.31.0

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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -26,8 +26,15 @@
26
26
  // → echoGuard.consume returns true → event dropped (no infinite loop)
27
27
  //
28
28
  // 800ms quiescence matches the existing Phase 8 collab room flush (DDR-051).
29
+ //
30
+ // Cold-start resolution (DDR-102, supersedes the v1.1 "always hub-wins"):
31
+ // reconcile() feeds the journal-aware decision table in cold-start.ts. A
32
+ // hub-wins overwrite of local disk happens ONLY on a clean fast-forward
33
+ // (local hash == journal hash); genuine divergence snapshots BOTH versions
34
+ // to `_history/<slug>/` and resolves newest-wins (doc syncMeta.bodyEditAt vs
35
+ // local file mtime; unknown/tie → hub). Comments union-merge by id.
29
36
 
30
- import { existsSync, readFileSync } from 'node:fs';
37
+ import { existsSync, readFileSync, statSync } from 'node:fs';
31
38
 
32
39
  import type * as Y from 'yjs';
33
40
 
@@ -39,17 +46,31 @@ import {
39
46
  applyCssToDoc,
40
47
  applyHtmlToDoc,
41
48
  applyMetaToDoc,
49
+ bodyEditAtFromDoc,
42
50
  commentsFromDoc,
43
51
  cssFromDoc,
44
52
  htmlFromDoc,
53
+ markSeeded,
45
54
  mergeSharedMetaIntoLocal,
46
55
  metaFromDoc,
56
+ seededByFromDoc,
57
+ stampBodyEdit,
47
58
  Y_SYNC_TYPES,
48
59
  } from './codec.ts';
60
+ import { decideColdStart, isExactRepeat, unionCommentsById } from './cold-start.ts';
49
61
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
62
+ import type { SyncJournal } from './journal.ts';
50
63
 
51
64
  export const DOC_FLUSH_MS = 800;
52
65
 
66
+ /**
67
+ * Window after a `seed-local-up` during which this agent will repair a
68
+ * concurrent-seed duplication (F1). Bounded so the repair only ever fires for
69
+ * the cold-start race — a genuine peer edit that arrives minutes later is NEVER
70
+ * collapsed back to our original seed. Generous relative to the hub sync RTT.
71
+ */
72
+ export const SEED_REPAIR_WINDOW_MS = 10_000;
73
+
53
74
  export interface CanvasSyncPaths {
54
75
  /** Absolute path to <designRoot>/<canvas>.html. */
55
76
  html: string;
@@ -80,13 +101,37 @@ export interface CanvasSyncAgentOptions {
80
101
  /** Injected for tests — defaults to atomicWrite. */
81
102
  writer?: (path: string, bytes: string | Uint8Array) => void;
82
103
  /**
83
- * Called when a non-adopt reconcile (cold-start / post-git-pull) overwrites
84
- * differing, non-empty local content with hub state i.e. the local peer
85
- * had divergent work that hub-wins discarded. Lets the runtime surface a
86
- * "hub overwrote your local changes" notification (Phase 9 Task 8). v1.1
87
- * resolution is always hub-wins; the interactive 3-way prompt is deferred.
104
+ * Per-machine sync journal (DDR-102) the divergence detector. Every
105
+ * successful disk↔doc traversal checkpoints the content hash here; the
106
+ * cold-start decision allows a hub-wins overwrite ONLY when local matches
107
+ * the journal (clean fast-forward). Optional older test constructions
108
+ * without it simply degrade to the conservative conflict path.
109
+ */
110
+ journal?: SyncJournal;
111
+ /**
112
+ * Snapshot writer (DDR-102 conflict protocol) — persists a body version to
113
+ * `_history/<slug>/` and resolves with the snapshot's ISO ts (null on
114
+ * failure). The runtime wires this to history.ts `writeSnapshot`. Optional —
115
+ * without it the conflict path still resolves newest-wins, just without the
116
+ * recovery snapshots (test-only constructions).
117
+ */
118
+ snapshot?: (content: string, reason: 'pre-sync-local' | 'pre-sync-hub') => Promise<string | null>;
119
+ /**
120
+ * Called when a non-adopt reconcile (cold-start / post-git-pull) found
121
+ * divergent non-empty content on both sides (DDR-102). `winner` is the side
122
+ * newest-wins kept; `snapshots` carries the `_history/` ISO timestamps of
123
+ * the pre-resolution versions (when the snapshot writer is wired). The
124
+ * legacy `cold-start-hub-wins` kind stays in the union for old readers.
88
125
  */
89
- onConflict?: (info: { slug: string; kind: 'cold-start-hub-wins' }) => void;
126
+ onConflict?: (info: {
127
+ slug: string;
128
+ kind: 'cold-start-hub-wins' | 'cold-start-diverged';
129
+ winner?: 'local' | 'hub';
130
+ snapshots?: { local?: string; hub?: string };
131
+ /** DDR-102 fail-closed (F1): the local snapshot didn't land, so the
132
+ * hub-wins overwrite was refused and local kept instead. */
133
+ snapshotFailed?: boolean;
134
+ }) => void;
90
135
  }
91
136
 
92
137
  export interface CanvasSyncAgent {
@@ -129,10 +174,21 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
129
174
  let lastMeta: string | null = null;
130
175
  let lastCss: string | null = null;
131
176
 
177
+ // F1 — the body this agent pushed on `seed-local-up`, plus the deadline past
178
+ // which the de-dup repair stops firing. Set on seed; nulled only when the
179
+ // window lapses (after convergence the repair is a cheap no-op — the
180
+ // `body === seedInfo.body` guard short-circuits, so we don't bother clearing).
181
+ // Null = this agent never seeded (so it never repairs).
182
+ let seedInfo: { body: string; until: number } | null = null;
183
+
132
184
  function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
133
185
  if (stopped) return;
134
186
  // Self-applied (we just synced from disk) — disk is already current.
135
187
  if (updateOrigin === origin) return;
188
+ // F1 — a remote update during the seed window may have concatenated a
189
+ // concurrent peer's identical seed onto ours. Collapse it (single elected
190
+ // writer) BEFORE the flush below can mirror a doubled body to disk.
191
+ maybeRepairSeedDuplication();
136
192
  scheduleFlush();
137
193
  }
138
194
 
@@ -153,6 +209,45 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
153
209
  }, flushMs);
154
210
  }
155
211
 
212
+ /**
213
+ * F1 — collapse a concurrent cold-seed duplication. When two peers
214
+ * `seed-local-up` the SAME body into an empty hub at the same instant, the
215
+ * merged Y.Text holds both insertions (`BODY` × N — un-buildable). This runs
216
+ * on every remote update inside the seed window and, on the single peer the
217
+ * `seededBy` Y.Map LWW elected, re-applies the canonical body so
218
+ * `applyHtmlToDoc`'s diff deletes the trailing duplicate(s). Only the elected
219
+ * owner repairs → the collapse op is emitted once and converges across peers
220
+ * (a non-owner just receives the delete). Restricted to an EXACT repeat of our
221
+ * seed: a divergent edit carries genuine bytes, so we DON'T touch it here —
222
+ * silently collapsing it would discard content. NOTE: that leaves the rare
223
+ * "two DIFFERENT bodies seeded into one empty hub at the same instant" case
224
+ * un-buildable until the NEXT boot, when `decideColdStart` routes it through
225
+ * the conflict path (dual snapshot + newest-wins). This live repair only
226
+ * covers the identical-seed race (the F1 RCA's scenario); the divergent
227
+ * variant is an accepted follow-up, not handled in-flight.
228
+ */
229
+ function maybeRepairSeedDuplication(): void {
230
+ if (!seedInfo) return;
231
+ if (Date.now() > seedInfo.until) {
232
+ seedInfo = null;
233
+ return;
234
+ }
235
+ const owner = seededByFromDoc(doc);
236
+ if (owner === null || owner !== doc.clientID) return; // not the elected writer
237
+ const body = htmlFromDoc(doc);
238
+ if (body === seedInfo.body) return; // already a single copy
239
+ if (!isExactRepeat(body, seedInfo.body)) return; // divergent edit → conflict path owns it
240
+ const canonical = seedInfo.body;
241
+ doc.transact(() => {
242
+ if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
243
+ }, origin);
244
+ lastHtml = canonical;
245
+ opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
246
+ console.warn(
247
+ `[sync/${slug}] concurrent cold-seed duplication detected — collapsed to one copy (F1).`
248
+ );
249
+ }
250
+
156
251
  async function flush(): Promise<void> {
157
252
  if (!dirty || stopped) return;
158
253
  dirty = false;
@@ -179,6 +274,9 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
179
274
  echoGuard.record(paths.html, hash);
180
275
  writer(paths.html, next);
181
276
  lastHtml = next;
277
+ // DDR-102 — a successful doc→disk body flush is a journal checkpoint:
278
+ // disk now holds exactly what the hub holds.
279
+ opts.journal?.record(slug, { bodyHash: hash });
182
280
  }
183
281
 
184
282
  function writeCommentsIfChanged(): void {
@@ -234,6 +332,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
234
332
  const hash = hashBytes(next);
235
333
  echoGuard.record(paths.css, hash);
236
334
  writer(paths.css, next);
335
+ opts.journal?.record(slug, { cssHash: hash }); // DDR-102 checkpoint
237
336
  }
238
337
 
239
338
  function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
@@ -243,8 +342,18 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
243
342
 
244
343
  const str = bytesToString(evt.bytes);
245
344
  if (evt.path === paths.html) {
246
- const changed = applyHtmlToDoc(doc, str, origin);
247
- if (changed) lastHtml = htmlFromDoc(doc);
345
+ // Body apply + syncMeta stamp in ONE transaction (same origin) so peers
346
+ // receive a single update and the newest-wins stamp rides the body edit.
347
+ let changed = false;
348
+ doc.transact(() => {
349
+ changed = applyHtmlToDoc(doc, str, origin);
350
+ if (changed) stampBodyEdit(doc, origin);
351
+ }, origin);
352
+ if (changed) {
353
+ lastHtml = htmlFromDoc(doc);
354
+ // DDR-102 — a successful disk→doc body apply is a journal checkpoint.
355
+ opts.journal?.record(slug, { bodyHash: evt.hash });
356
+ }
248
357
  return changed;
249
358
  }
250
359
  if (evt.path === paths.comments) {
@@ -268,7 +377,10 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
268
377
  }
269
378
  if (paths.css && evt.path === paths.css) {
270
379
  const changed = applyCssToDoc(doc, str, origin);
271
- if (changed) lastCss = str;
380
+ if (changed) {
381
+ lastCss = str;
382
+ opts.journal?.record(slug, { cssHash: evt.hash }); // DDR-102 checkpoint
383
+ }
272
384
  return changed;
273
385
  }
274
386
  return false;
@@ -293,7 +405,16 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
293
405
  if (adopt) {
294
406
  // Push local up: doc takes its values from disk. Hub becomes our
295
407
  // canonical view of this canvas. One-shot.
296
- if (localHtml !== null) applyHtmlToDoc(doc, localHtml, origin);
408
+ if (localHtml !== null) {
409
+ doc.transact(() => {
410
+ if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
411
+ // F1 — adopt is also a "seed local up" (first link / fresh hub); claim
412
+ // it + arm the repair window so two peers adopting the same draft into
413
+ // one hub at once self-heal the same way the decision-table seed does.
414
+ markSeeded(doc, origin);
415
+ }, origin);
416
+ seedInfo = { body: localHtml, until: Date.now() + SEED_REPAIR_WINDOW_MS };
417
+ }
297
418
  if (localComments !== null) {
298
419
  const parsed = tryParseJsonArray(localComments);
299
420
  if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
@@ -307,55 +428,185 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
307
428
  lastMeta = metaFromDoc(doc);
308
429
  lastCss = cssFromDoc(doc);
309
430
  adopt = false;
431
+ // DDR-102 — adopt is a disk→doc traversal: checkpoint it so the next
432
+ // boot fast-forwards instead of re-entering the conflict path.
433
+ if (localHtml !== null) {
434
+ opts.journal?.record(slug, {
435
+ bodyHash: hashBytes(localHtml),
436
+ ...(localCss !== null ? { cssHash: hashBytes(localCss) } : {}),
437
+ });
438
+ }
310
439
  return;
311
440
  }
312
441
 
313
- // Hub-wins (default): overwrite disk from doc when they differ.
314
- lastHtml = docHtml;
315
- lastComments = docCommentsStr;
316
- lastAnnotations = docAnnotations;
317
- lastMeta = docMeta;
318
- lastCss = docCss;
319
- if (localHtml !== docHtml) {
320
- // DATA-LOSS GUARD: an EMPTY hub doc at cold-start means the hub holds no
321
- // body for this slug yet (fresh / never-seeded hub) — NOT an authoritative
322
- // "this canvas is blank". Overwriting a non-empty local body with it would
323
- // silently destroy the canvas (observed in the wild: a fresh hub emptied
324
- // every local .tsx on first connect). The comments/annotations/meta/css
325
- // branches below already skip empty-doc writes, and the shared-doc
326
- // projection path documents the same "never clobber non-empty local with
327
- // an empty doc value" rule — the HTML body was the one branch missing it.
328
- // Resolution: seed the doc FROM local instead, so the body survives AND
329
- // the hub gets our content (local→doc, exactly like a first-sync seed).
330
- if (docHtml === '' && localHtml !== null && localHtml.trim() !== '') {
331
- applyHtmlToDoc(doc, localHtml, origin);
332
- lastHtml = localHtml;
333
- } else {
334
- // Local had divergent, non-empty content that hub-wins is discarding —
335
- // notify so the user knows their local edits were overwritten (Task 8).
336
- // An absent/empty local file is a clean first-sync, not a conflict.
337
- if (localHtml !== null && localHtml.trim() !== '') {
338
- opts.onConflict?.({ slug, kind: 'cold-start-hub-wins' });
442
+ // ---- body: cold-start decision table (DDR-102; replaces v1.1 hub-wins) --
443
+ const decision = decideColdStart({
444
+ localBody: localHtml,
445
+ docBody: docHtml,
446
+ journalHash: opts.journal?.get(slug)?.bodyHash ?? null,
447
+ localMtimeMs: localMtimeMs(paths.html),
448
+ docBodyEditAtMs: bodyEditAtFromDoc(doc),
449
+ });
450
+
451
+ // Which side owns the visually-coupled lanes (annotations/css) below.
452
+ let bodyWinner: 'local' | 'hub' = 'hub';
453
+
454
+ const writeBodyFromDoc = (): void => {
455
+ const hash = hashBytes(docHtml);
456
+ echoGuard.record(paths.html, hash);
457
+ writer(paths.html, docHtml);
458
+ lastHtml = docHtml;
459
+ opts.journal?.record(slug, { bodyHash: hash });
460
+ };
461
+ const seedBodyUp = (body: string): void => {
462
+ doc.transact(() => {
463
+ if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
464
+ // F1 claim the seed (clientID marker) in the SAME update so a
465
+ // concurrent second seeder's merge resolves a single elected writer.
466
+ markSeeded(doc, origin);
467
+ }, origin);
468
+ lastHtml = body;
469
+ // Arm the de-dup repair window: if another peer seeded the same body at the
470
+ // same instant, the merged Y.Text will double — maybeRepairSeedDuplication
471
+ // (on the elected owner) collapses it back during this window.
472
+ seedInfo = { body, until: Date.now() + SEED_REPAIR_WINDOW_MS };
473
+ opts.journal?.record(slug, { bodyHash: hashBytes(body) });
474
+ };
475
+
476
+ switch (decision.action) {
477
+ case 'noop':
478
+ lastHtml = docHtml;
479
+ // Identical non-empty sides: checkpoint so the next boot fast-forwards.
480
+ if (localHtml !== null && localHtml === docHtml && docHtml !== '') {
481
+ opts.journal?.record(slug, { bodyHash: hashBytes(docHtml) });
339
482
  }
340
- const hash = hashBytes(docHtml);
341
- echoGuard.record(paths.html, hash);
342
- writer(paths.html, docHtml);
483
+ break;
484
+ case 'materialize-hub':
485
+ case 'fast-forward-hub':
486
+ writeBodyFromDoc();
487
+ break;
488
+ case 'seed-local-up':
489
+ // The DDR-064 empty-hub guard as a named decision row: an empty hub doc
490
+ // means the hub holds no body for this slug yet — NOT an authoritative
491
+ // "this canvas is blank". Seed the doc FROM local so the body survives
492
+ // AND the hub gets our content.
493
+ seedBodyUp(localHtml as string);
494
+ bodyWinner = 'local';
495
+ break;
496
+ case 'recover-seed-dup':
497
+ // F1 — booting against a hub whose body is our local body repeated (a
498
+ // concurrent cold-seed that already duplicated). Re-apply local so the
499
+ // diff deletes the extra copy/copies; the content equals local, so the
500
+ // visually-coupled lanes follow local too. Idempotent across peers.
501
+ seedBodyUp(localHtml as string);
502
+ bodyWinner = 'local';
503
+ break;
504
+ case 'conflict': {
505
+ // Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
506
+ // write, then apply the newest-wins winner. Even a wrong pick costs
507
+ // one /design:rollback (the incident's class of loss is closed).
508
+ const snapshots: { local?: string; hub?: string } = {};
509
+ let snapshotAttempted = false;
510
+ if (opts.snapshot) {
511
+ snapshotAttempted = true;
512
+ try {
513
+ const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
514
+ if (localTs) snapshots.local = localTs;
515
+ const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
516
+ if (hubTs) snapshots.hub = hubTs;
517
+ } catch {
518
+ /* swallowed below — the missing snapshot ref drives the fail-closed guard */
519
+ }
520
+ }
521
+ // DDR-102 fail-closed (security F1): the whole guarantee is "the loser is
522
+ // recoverable from _history". A hub-wins resolution OVERWRITES local — so
523
+ // if we asked for a snapshot but the local one did NOT land (full disk,
524
+ // read-only `_history/`, a Bun.write error), refuse the destructive
525
+ // overwrite. Keep local on disk and seed it UP instead, so nothing is
526
+ // lost on either side (local survives; the hub still gets our content).
527
+ // `snapshotAttempted` gates this to production wiring — a test/standalone
528
+ // agent with no snapshot fn keeps the plain newest-wins behavior.
529
+ const localSnapshotMissing = snapshotAttempted && !snapshots.local;
530
+ let winner = decision.winner;
531
+ if (winner === 'hub' && localSnapshotMissing) {
532
+ winner = 'local';
533
+ console.error(
534
+ `[sync/${slug}] cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Keeping local + pushing it up; resolve the _history/ write failure (disk full / read-only?) to restore newest-wins.`
535
+ );
536
+ }
537
+ if (winner === 'local') {
538
+ seedBodyUp(localHtml as string);
539
+ bodyWinner = 'local';
540
+ } else {
541
+ writeBodyFromDoc();
542
+ }
543
+ console.warn(`[sync/${slug}] cold-start divergence — ${decision.reason}`);
544
+ opts.onConflict?.({
545
+ slug,
546
+ kind: 'cold-start-diverged',
547
+ winner,
548
+ ...(snapshots.local || snapshots.hub ? { snapshots } : {}),
549
+ ...(localSnapshotMissing ? { snapshotFailed: true } : {}),
550
+ });
551
+ break;
343
552
  }
344
553
  }
345
- if (docCommentsStr !== '' && localComments !== docCommentsStr) {
346
- const hash = hashBytes(docCommentsStr);
347
- echoGuard.record(paths.comments, hash);
348
- writer(paths.comments, docCommentsStr);
554
+
555
+ // ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
556
+ const localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
557
+ if (localParsedComments !== null && localParsedComments.length > 0) {
558
+ const merged = unionCommentsById(docComments, localParsedComments);
559
+ const mergedStr = merged.length > 0 ? `${JSON.stringify(merged, null, 2)}\n` : '';
560
+ applyCommentsToDoc(doc, merged, origin); // no-ops when equal
561
+ if (mergedStr !== '' && mergedStr !== localComments) {
562
+ const hash = hashBytes(mergedStr);
563
+ echoGuard.record(paths.comments, hash);
564
+ writer(paths.comments, mergedStr);
565
+ }
566
+ lastComments = mergedStr;
567
+ } else {
568
+ // No (parseable) local comments — hub state materializes as before.
569
+ lastComments = docCommentsStr;
570
+ if (docCommentsStr !== '' && localComments !== docCommentsStr) {
571
+ const hash = hashBytes(docCommentsStr);
572
+ echoGuard.record(paths.comments, hash);
573
+ writer(paths.comments, docCommentsStr);
574
+ }
349
575
  }
350
- if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
351
- const hash = hashBytes(docAnnotations);
352
- echoGuard.record(paths.annotations, hash);
353
- writer(paths.annotations, docAnnotations);
576
+
577
+ // ---- annotations + css: follow the body winner (visually coupled) ------
578
+ if (bodyWinner === 'local') {
579
+ if (localAnnotations !== null && localAnnotations !== docAnnotations) {
580
+ applyAnnotationsToDoc(doc, localAnnotations, origin);
581
+ lastAnnotations = localAnnotations;
582
+ } else {
583
+ lastAnnotations = docAnnotations;
584
+ }
585
+ if (paths.css && localCss !== null && localCss !== docCss) {
586
+ applyCssToDoc(doc, localCss, origin);
587
+ lastCss = localCss;
588
+ opts.journal?.record(slug, { cssHash: hashBytes(localCss) });
589
+ } else {
590
+ lastCss = docCss;
591
+ }
592
+ } else {
593
+ lastAnnotations = docAnnotations;
594
+ if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
595
+ const hash = hashBytes(docAnnotations);
596
+ echoGuard.record(paths.annotations, hash);
597
+ writer(paths.annotations, docAnnotations);
598
+ }
599
+ lastCss = docCss;
600
+ if (paths.css && docCss !== null && localCss !== docCss) {
601
+ const hash = hashBytes(docCss);
602
+ echoGuard.record(paths.css, hash);
603
+ writer(paths.css, docCss);
604
+ opts.journal?.record(slug, { cssHash: hash });
605
+ }
354
606
  }
355
- // Meta: merge the doc's shared subset (layout/artboards) into local,
356
- // preserving this machine's viewport + syncable. Only writes when the merge
357
- // actually changes the file (a fresh peer with no local viewport, or an
358
- // artboard layout the hub carries that local lacks).
607
+
608
+ // ---- meta: shared-subset merge, unchanged in all cases -----------------
609
+ lastMeta = docMeta;
359
610
  if (paths.meta && docMeta !== null) {
360
611
  const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
361
612
  if (merged !== null && merged !== localMeta) {
@@ -364,11 +615,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
364
615
  writer(paths.meta, merged);
365
616
  }
366
617
  }
367
- if (paths.css && docCss !== null && localCss !== docCss) {
368
- const hash = hashBytes(docCss);
369
- echoGuard.record(paths.css, hash);
370
- writer(paths.css, docCss);
371
- }
372
618
  }
373
619
 
374
620
  return {
@@ -395,6 +641,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
395
641
 
396
642
  /* ---------------------------------------------------------------- helpers */
397
643
 
644
+ function localMtimeMs(p: string): number | null {
645
+ try {
646
+ return statSync(p).mtimeMs;
647
+ } catch {
648
+ return null;
649
+ }
650
+ }
651
+
398
652
  function readLocal(p: string): string | null {
399
653
  if (!existsSync(p)) return null;
400
654
  try {
@@ -21,6 +21,8 @@
21
21
  // insert-all`, and crucially preserves cursor positions other peers may have
22
22
  // in the unchanged regions.
23
23
 
24
+ import { hostname } from 'node:os';
25
+
24
26
  import type * as Y from 'yjs';
25
27
 
26
28
  import { Y_TYPES } from '../collab/persistence.ts';
@@ -43,6 +45,13 @@ export const Y_SYNC_TYPES = {
43
45
  * security opt-in (syncable) are stripped before sync — see META_LOCAL_KEYS
44
46
  * + sharedMetaCanonical. Phase 9.1 Gap 2. */
45
47
  meta: 'meta',
48
+ /** Doc-side sync bookkeeping (Y.Map, DDR-102): `bodyEditAt` ms-epoch stamp
49
+ * written by every peer that applies a LOCAL body into the doc, + `by` (a
50
+ * short peer label). The cold-start newest-wins decision compares it to the
51
+ * local file mtime. Deliberately a dedicated lane: `.meta.json`'s
52
+ * `last_modified` is in META_LOCAL_KEYS (per-machine, never syncs), so the
53
+ * meta codec CANNOT carry this. Never materialized to disk. */
54
+ syncMeta: 'syncMeta',
46
55
  } as const;
47
56
 
48
57
  /**
@@ -293,6 +302,66 @@ export function mergeSharedMetaIntoLocal(
293
302
  return `${JSON.stringify(merged, null, 2)}\n`;
294
303
  }
295
304
 
305
+ /* ---------------------------------------------------------------- syncMeta */
306
+
307
+ /** Short peer label for `syncMeta.by` — os.hostname() truncated. Informational
308
+ * only (status surfaces); never trusted for decisions. */
309
+ const PEER_LABEL_MAX = 32;
310
+ function peerLabel(): string {
311
+ try {
312
+ return hostname().slice(0, PEER_LABEL_MAX);
313
+ } catch {
314
+ return 'unknown';
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Stamp `syncMeta.bodyEditAt` (+ `by`) on the doc. Call this in the SAME
320
+ * transaction + origin as every local→doc body apply (agent applyFromFs html
321
+ * branch, reconcile seed-up, migrate-seed adopt, projection file→doc body
322
+ * import) so peers receive ONE update and origin-filtering stays intact.
323
+ * yjs nests transactions — wrapping `applyHtmlToDoc` + `stampBodyEdit` in an
324
+ * outer `doc.transact(fn, origin)` produces a single update.
325
+ */
326
+ export function stampBodyEdit(doc: Y.Doc, origin?: unknown, nowMs?: number): void {
327
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
328
+ doc.transact(() => {
329
+ map.set('bodyEditAt', nowMs ?? Date.now());
330
+ map.set('by', peerLabel());
331
+ }, origin);
332
+ }
333
+
334
+ /** The doc-side body-edit stamp, or null when no peer ever stamped (older
335
+ * peers don't write syncMeta → callers fall back to hub-wins, interop-safe). */
336
+ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
337
+ const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('bodyEditAt');
338
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
339
+ }
340
+
341
+ /**
342
+ * Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
343
+ * in the SAME transaction + origin as a `seed-local-up` body apply. The value is
344
+ * a Yjs clientID, so when two peers seed the same empty hub simultaneously and
345
+ * their maps merge, Y.Map's deterministic last-writer-wins resolution converges
346
+ * `seededBy` to ONE clientID on EVERY peer — that elected peer is the
347
+ * single-writer that performs the de-duplication repair, so the collapse op is
348
+ * never emitted twice. Informational/bookkeeping only; never materialized to
349
+ * disk (syncMeta is a sync-internal lane).
350
+ */
351
+ export function markSeeded(doc: Y.Doc, origin?: unknown): void {
352
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
353
+ doc.transact(() => {
354
+ map.set('seededBy', doc.clientID);
355
+ }, origin);
356
+ }
357
+
358
+ /** The elected seeder's clientID (`syncMeta.seededBy`), or null when no peer
359
+ * ever seeded through the marker path (older peers / non-seed canvases). */
360
+ export function seededByFromDoc(doc: Y.Doc): number | null {
361
+ const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('seededBy');
362
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
363
+ }
364
+
296
365
  /* ---------------------------------------------------------------- css */
297
366
 
298
367
  /** The synced canvas CSS string held in the doc, or null when unset/empty. */