@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
@@ -12,7 +12,8 @@ import * as Y from 'yjs';
12
12
 
13
13
  import { createPersistence, Y_TYPES } from '../collab/persistence.ts';
14
14
  import type { Context } from '../context.ts';
15
- import { applyCommentsToDoc, applyHtmlToDoc } from '../sync/codec.ts';
15
+ import { applyCommentsToDoc, applyCssToDoc, applyHtmlToDoc } from '../sync/codec.ts';
16
+ import { hashBytes } from '../sync/echo-guard.ts';
16
17
  import { docIsEmpty, migrateSeed } from '../sync/migrate-seed.ts';
17
18
 
18
19
  let dir: string;
@@ -43,28 +44,28 @@ describe('docIsEmpty', () => {
43
44
  });
44
45
 
45
46
  describe('migrateSeed — adopt (hub was empty)', () => {
46
- test('seeds an empty doc from local files and reports local-adopt', () => {
47
+ test('seeds an empty doc from local files and reports local-adopt', async () => {
47
48
  const p = paths();
48
49
  writeFileSync(p.html, '<main>local body</main>');
49
50
  writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'local' }]));
50
51
 
51
52
  const doc = new Y.Doc();
52
- const result = migrateSeed({ slug: 's', doc, paths: p });
53
+ const result = await migrateSeed({ slug: 's', doc, paths: p });
53
54
 
54
55
  expect(result).toBe('local-adopt');
55
56
  expect(doc.getText('html').toString()).toBe('<main>local body</main>');
56
57
  expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'local' }]);
57
58
  });
58
59
 
59
- test('empty doc + no local files → "empty" (nothing to seed)', () => {
60
+ test('empty doc + no local files → "empty" (nothing to seed)', async () => {
60
61
  const doc = new Y.Doc();
61
- expect(migrateSeed({ slug: 's', doc, paths: paths() })).toBe('empty');
62
+ expect(await migrateSeed({ slug: 's', doc, paths: paths() })).toBe('empty');
62
63
  expect(docIsEmpty(doc)).toBe(true);
63
64
  });
64
65
  });
65
66
 
66
67
  describe('migrateSeed — idempotent (no duplication on re-run)', () => {
67
- test('adopting twice leaves comments un-duplicated', () => {
68
+ test('adopting twice leaves comments un-duplicated', async () => {
68
69
  const p = paths();
69
70
  writeFileSync(
70
71
  p.comments,
@@ -75,12 +76,12 @@ describe('migrateSeed — idempotent (no duplication on re-run)', () => {
75
76
  );
76
77
 
77
78
  const doc = new Y.Doc();
78
- expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('local-adopt');
79
+ expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('local-adopt');
79
80
  expect(doc.getArray(Y_TYPES.comments).length).toBe(2);
80
81
 
81
82
  // Second run (e.g. server restart): the doc is now non-empty → hub-wins
82
83
  // branch → no re-adopt → still exactly 2 comments, byte-identical.
83
- expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
84
+ expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
84
85
  expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([
85
86
  { id: 'c1', text: 'x' },
86
87
  { id: 'c2', text: 'y' },
@@ -123,7 +124,7 @@ describe('room file-seed disable (the other half of the Risk-1 fix)', () => {
123
124
  });
124
125
 
125
126
  describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
126
- test('a doc already holding hub comments is NOT merged with the local file → no dup', () => {
127
+ test('a doc already holding hub comments is NOT merged with the local file → no dup', async () => {
127
128
  const p = paths();
128
129
  // Local file (e.g. copied via git) holds the SAME logical comment id…
129
130
  writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'from disk' }]));
@@ -132,14 +133,15 @@ describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
132
133
  const doc = new Y.Doc();
133
134
  applyCommentsToDoc(doc, [{ id: 'c1', text: 'from hub' }], 'hub');
134
135
 
135
- const result = migrateSeed({ slug: 's', doc, paths: p });
136
+ const result = await migrateSeed({ slug: 's', doc, paths: p });
136
137
 
137
- // Hub wins; the local file is NOT pushed as a second item — exactly ONE c1.
138
+ // Hub wins; the local file is NOT pushed as a second item — exactly ONE c1
139
+ // (DDR-102 id-union keeps the doc's version for a shared id).
138
140
  expect(result).toBe('hub-wins');
139
141
  expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'from hub' }]);
140
142
  });
141
143
 
142
- test('snapshots divergent local files to historyDir before the hub-wins overwrite', () => {
144
+ test('snapshots divergent local files to historyDir before the hub-wins overwrite', async () => {
143
145
  const p = paths();
144
146
  writeFileSync(p.html, '<main>LOCAL divergent</main>');
145
147
  writeFileSync(p.comments, JSON.stringify([{ id: 'local' }]));
@@ -148,12 +150,220 @@ describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
148
150
  applyHtmlToDoc(doc, '<main>HUB</main>', 'hub'); // doc holds hub state
149
151
 
150
152
  const historyDir = join(dir, '_history', 's');
151
- const result = migrateSeed({ slug: 's', doc, paths: p, historyDir });
153
+ // DDR-102: this divergence (no journal, no doc stamp) resolves
154
+ // conflict-hub-wins — the legacy whole-set safety copy still happens.
155
+ const result = await migrateSeed({ slug: 's', doc, paths: p, historyDir });
152
156
 
153
- expect(result).toBe('hub-wins');
157
+ expect(result).toBe('conflict-hub-wins');
154
158
  // The pre-migration snapshot captured the local files for rollback.
155
159
  const snapped = readdirSync(join(historyDir, 'pre-shared-doc-migration'));
156
160
  expect(snapped).toContain('screen.html');
157
161
  expect(snapped).toContain('screen.comments.json');
158
162
  });
159
163
  });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // DDR-102 — the shared-doc path consumes the same cold-start decision table:
167
+ // journal-gated fast-forward, dual snapshot + newest-wins on divergence,
168
+ // comments id-union.
169
+ describe('migrateSeed — DDR-102 divergence matrix', () => {
170
+ function makeJournal(initial: Record<string, { bodyHash: string }> = {}) {
171
+ const entries = new Map(Object.entries(initial));
172
+ return {
173
+ get: (slug: string) => entries.get(slug) ?? null,
174
+ record: (slug: string, hashes: { bodyHash?: string; cssHash?: string }) => {
175
+ const prev = entries.get(slug);
176
+ entries.set(slug, { bodyHash: hashes.bodyHash ?? prev?.bodyHash ?? '' });
177
+ },
178
+ invalidateIfHubChanged: () => {},
179
+ flush: () => {},
180
+ stop: () => {},
181
+ size: () => entries.size,
182
+ };
183
+ }
184
+
185
+ test('journal match → fast-forward (hub keeps), no snapshot, no conflict', async () => {
186
+ const p = paths();
187
+ writeFileSync(p.html, '<main>synced-v1</main>');
188
+ const doc = new Y.Doc();
189
+ applyHtmlToDoc(doc, '<main>hub-v2</main>', 'hub');
190
+
191
+ const journal = makeJournal({ s: { bodyHash: hashBytes('<main>synced-v1</main>') } });
192
+ const snapshots: string[] = [];
193
+ const conflicts: unknown[] = [];
194
+ const result = await migrateSeed({
195
+ slug: 's',
196
+ doc,
197
+ paths: p,
198
+ journal,
199
+ snapshot: async (_c, reason) => {
200
+ snapshots.push(reason);
201
+ return 'ts';
202
+ },
203
+ onConflict: (info) => {
204
+ conflicts.push(info);
205
+ },
206
+ });
207
+
208
+ expect(result).toBe('hub-wins');
209
+ expect(doc.getText('html').toString()).toBe('<main>hub-v2</main>'); // doc untouched
210
+ expect(snapshots).toHaveLength(0);
211
+ expect(conflicts).toHaveLength(0);
212
+ });
213
+
214
+ test('diverged + local newer → conflict-local-wins: doc REBUILT from local in one MIGRATION tx', async () => {
215
+ const p = paths();
216
+ writeFileSync(p.html, '<main>local-newer</main>');
217
+ writeFileSync(p.css, '.local{}');
218
+ const doc = new Y.Doc();
219
+ doc.transact(() => {
220
+ applyHtmlToDoc(doc, '<main>hub-older</main>', 'hub');
221
+ applyCssToDoc(doc, '.hub{}', 'hub');
222
+ doc.getMap('syncMeta').set('bodyEditAt', Date.now() - 60_000);
223
+ });
224
+
225
+ const snapshots: Array<{ content: string; reason: string }> = [];
226
+ const conflicts: Array<{ winner?: string; snapshots?: { local?: string; hub?: string } }> = [];
227
+ const result = await migrateSeed({
228
+ slug: 's',
229
+ doc,
230
+ paths: p,
231
+ journal: makeJournal(),
232
+ snapshot: async (content, reason) => {
233
+ snapshots.push({ content, reason });
234
+ return `ts-${snapshots.length}`;
235
+ },
236
+ onConflict: (info) => {
237
+ conflicts.push(info);
238
+ },
239
+ });
240
+
241
+ expect(result).toBe('conflict-local-wins');
242
+ expect(doc.getText('html').toString()).toBe('<main>local-newer</main>');
243
+ // css follows the body winner.
244
+ expect(doc.getText('css').toString()).toBe('.local{}');
245
+ // Dual snapshot captured BOTH pre-resolution versions.
246
+ expect(snapshots.map((s) => s.reason)).toEqual(['pre-sync-local', 'pre-sync-hub']);
247
+ expect(snapshots[0].content).toBe('<main>local-newer</main>');
248
+ expect(snapshots[1].content).toBe('<main>hub-older</main>');
249
+ expect(conflicts[0].winner).toBe('local');
250
+ expect(conflicts[0].snapshots).toEqual({ local: 'ts-1', hub: 'ts-2' });
251
+ });
252
+
253
+ test('diverged + hub newer (stamp) → conflict-hub-wins: doc untouched', async () => {
254
+ const p = paths();
255
+ writeFileSync(p.html, '<main>local-older</main>');
256
+ const doc = new Y.Doc();
257
+ doc.transact(() => {
258
+ applyHtmlToDoc(doc, '<main>hub-newer</main>', 'hub');
259
+ doc.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
260
+ });
261
+
262
+ const conflicts: Array<{ winner?: string }> = [];
263
+ const result = await migrateSeed({
264
+ slug: 's',
265
+ doc,
266
+ paths: p,
267
+ journal: makeJournal(),
268
+ onConflict: (info) => {
269
+ conflicts.push(info);
270
+ },
271
+ });
272
+
273
+ expect(result).toBe('conflict-hub-wins');
274
+ expect(doc.getText('html').toString()).toBe('<main>hub-newer</main>');
275
+ expect(conflicts[0].winner).toBe('hub');
276
+ });
277
+
278
+ test('doc holds comments but NO body + local body → body-seed-up (gap closed)', async () => {
279
+ const p = paths();
280
+ writeFileSync(p.html, '<main>only-local-body</main>');
281
+ const doc = new Y.Doc();
282
+ doc.getArray(Y_TYPES.comments).push([{ id: 'h1' }]); // doc non-empty, body empty
283
+
284
+ const result = await migrateSeed({ slug: 's', doc, paths: p, journal: makeJournal() });
285
+
286
+ expect(result).toBe('body-seed-up');
287
+ expect(doc.getText('html').toString()).toBe('<main>only-local-body</main>');
288
+ // Hub comments survive untouched.
289
+ expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'h1' }]);
290
+ });
291
+
292
+ test('comments id-union: local-only comments survive a hub-wins cutover', async () => {
293
+ const p = paths();
294
+ writeFileSync(p.html, '<main>same</main>');
295
+ writeFileSync(
296
+ p.comments,
297
+ JSON.stringify([
298
+ { id: 'c1', text: 'shared' },
299
+ { id: 'c3', text: 'local-only' },
300
+ ])
301
+ );
302
+ const doc = new Y.Doc();
303
+ doc.transact(() => {
304
+ applyHtmlToDoc(doc, '<main>same</main>', 'hub');
305
+ applyCommentsToDoc(
306
+ doc,
307
+ [
308
+ { id: 'c1', text: 'shared (hub copy wins)' },
309
+ { id: 'c2', text: 'hub-only' },
310
+ ],
311
+ 'hub'
312
+ );
313
+ });
314
+
315
+ const result = await migrateSeed({ slug: 's', doc, paths: p });
316
+
317
+ expect(result).toBe('hub-wins');
318
+ expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([
319
+ { id: 'c1', text: 'shared (hub copy wins)' },
320
+ { id: 'c2', text: 'hub-only' },
321
+ { id: 'c3', text: 'local-only' },
322
+ ]);
323
+ });
324
+ });
325
+
326
+ // DDR-102 fail-closed (security review F1) — the shared-doc path must also
327
+ // refuse a hub-wins cutover when the local snapshot didn't land.
328
+ describe('migrateSeed — fail-closed on snapshot failure (DDR-102 F1)', () => {
329
+ function makeJournal() {
330
+ const entries = new Map<string, { bodyHash: string }>();
331
+ return {
332
+ get: (slug: string) => entries.get(slug) ?? null,
333
+ record: (slug: string, h: { bodyHash?: string; cssHash?: string }) =>
334
+ entries.set(slug, { bodyHash: h.bodyHash ?? entries.get(slug)?.bodyHash ?? '' }),
335
+ invalidateIfHubChanged: () => {},
336
+ flush: () => {},
337
+ stop: () => {},
338
+ size: () => entries.size,
339
+ };
340
+ }
341
+
342
+ test('hub newer BUT local snapshot null → rebuild doc from local (no overwrite), conflict-local-wins', async () => {
343
+ const p = paths();
344
+ writeFileSync(p.html, '<main>local-work</main>');
345
+ const doc = new Y.Doc();
346
+ doc.transact(() => {
347
+ applyHtmlToDoc(doc, '<main>hub-stale</main>', 'hub');
348
+ doc.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000); // hub would win
349
+ });
350
+
351
+ const conflicts: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
352
+ const result = await migrateSeed({
353
+ slug: 's',
354
+ doc,
355
+ paths: p,
356
+ journal: makeJournal(),
357
+ snapshot: async () => null, // _history write fails
358
+ onConflict: (info) => {
359
+ conflicts.push(info);
360
+ },
361
+ });
362
+
363
+ // Doc rebuilt from local → projection would write local back (no loss).
364
+ expect(result).toBe('conflict-local-wins');
365
+ expect(doc.getText('html').toString()).toBe('<main>local-work</main>');
366
+ expect(conflicts[0].winner).toBe('local');
367
+ expect(conflicts[0].snapshotFailed).toBe(true);
368
+ });
369
+ });
@@ -11,13 +11,13 @@
11
11
  // 100-event stress scenario from the plan without booting a real hub.
12
12
 
13
13
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import * as Y from 'yjs';
18
18
 
19
19
  import { type CanvasSyncAgent, createCanvasSyncAgent } from '../sync/agent.ts';
20
- import { applyHtmlToDoc, htmlFromDoc, Y_SYNC_TYPES } from '../sync/codec.ts';
20
+ import { applyHtmlToDoc, htmlFromDoc } from '../sync/codec.ts';
21
21
  import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
22
22
  import { createFsReader } from '../sync/fs-mirror.ts';
23
23
 
@@ -303,3 +303,273 @@ describe('CanvasSyncAgent — stress: 100 rapid disk writes converge with no ech
303
303
  expect(docUpdates).toBeGreaterThan(0);
304
304
  });
305
305
  });
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // DDR-102 — cold-start conflict protocol (journal-gated fast-forward, dual
309
+ // snapshot, newest-wins, comments id-union).
310
+ describe('CanvasSyncAgent — DDR-102 cold-start conflict protocol', () => {
311
+ interface SnapCall {
312
+ content: string;
313
+ reason: string;
314
+ }
315
+
316
+ function makeJournal(initial: Record<string, { bodyHash: string }> = {}) {
317
+ const entries = new Map(Object.entries(initial));
318
+ const records: Array<{ slug: string; hashes: Record<string, string | undefined> }> = [];
319
+ return {
320
+ journal: {
321
+ get: (slug: string) => entries.get(slug) ?? null,
322
+ record: (slug: string, hashes: { bodyHash?: string; cssHash?: string }) => {
323
+ records.push({ slug, hashes });
324
+ const prev = entries.get(slug);
325
+ entries.set(slug, { bodyHash: hashes.bodyHash ?? prev?.bodyHash ?? '' });
326
+ },
327
+ invalidateIfHubChanged: () => {},
328
+ flush: () => {},
329
+ stop: () => {},
330
+ size: () => entries.size,
331
+ },
332
+ records,
333
+ };
334
+ }
335
+
336
+ function protocolAgent(extra: {
337
+ journal?: ReturnType<typeof makeJournal>['journal'];
338
+ snapshots?: SnapCall[];
339
+ conflicts?: unknown[];
340
+ }) {
341
+ const a = createCanvasSyncAgent({
342
+ slug: 'screen',
343
+ doc: docB,
344
+ paths: paths(),
345
+ echoGuard: createEchoGuard(),
346
+ flushMs: 0,
347
+ journal: extra.journal,
348
+ snapshot: extra.snapshots
349
+ ? async (content, reason) => {
350
+ extra.snapshots?.push({ content, reason });
351
+ return `ts-${extra.snapshots?.length}`;
352
+ }
353
+ : undefined,
354
+ onConflict: (info) => {
355
+ extra.conflicts?.push(info);
356
+ },
357
+ });
358
+ a.start();
359
+ return a;
360
+ }
361
+
362
+ test('journal match → silent fast-forward: hub overwrites disk, NO snapshot, NO conflict', async () => {
363
+ const { setHtml } = await import('node:fs').then(() => ({ setHtml: writeFileSync }));
364
+ setHtml(paths().html, '<button>synced-v1</button>');
365
+ applyHtmlToDoc(docA, '<button>hub-v2</button>');
366
+
367
+ const { journal } = makeJournal({
368
+ screen: { bodyHash: hashBytes('<button>synced-v1</button>') },
369
+ });
370
+ const snapshots: SnapCall[] = [];
371
+ const conflicts: unknown[] = [];
372
+ agent = protocolAgent({ journal, snapshots, conflicts });
373
+ await agent.reconcile();
374
+
375
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>hub-v2</button>');
376
+ expect(snapshots).toHaveLength(0);
377
+ expect(conflicts).toHaveLength(0);
378
+ });
379
+
380
+ test('diverged + local newer → local wins, BOTH sides snapshotted, conflict recorded', async () => {
381
+ writeFileSync(paths().html, '<button>local-newer</button>');
382
+ // Hub doc carries different body with an OLDER bodyEditAt stamp.
383
+ docA.transact(() => {
384
+ applyHtmlToDoc(docA, '<button>hub-older</button>');
385
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() - 60_000);
386
+ });
387
+
388
+ const { journal } = makeJournal(); // no entry → divergence
389
+ const snapshots: SnapCall[] = [];
390
+ const conflicts: Array<{ kind: string; winner?: string; snapshots?: object }> = [];
391
+ agent = protocolAgent({ journal, snapshots, conflicts });
392
+ await agent.reconcile();
393
+
394
+ // Local body survives on disk AND is pushed up to the hub.
395
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>local-newer</button>');
396
+ expect(htmlFromDoc(docA)).toBe('<button>local-newer</button>');
397
+ // Dual snapshot: local first, hub second.
398
+ expect(snapshots.map((s) => s.reason)).toEqual(['pre-sync-local', 'pre-sync-hub']);
399
+ expect(snapshots[0].content).toBe('<button>local-newer</button>');
400
+ expect(snapshots[1].content).toBe('<button>hub-older</button>');
401
+ // Conflict entry carries winner + snapshot refs.
402
+ expect(conflicts).toHaveLength(1);
403
+ expect(conflicts[0].kind).toBe('cold-start-diverged');
404
+ expect(conflicts[0].winner).toBe('local');
405
+ expect(conflicts[0].snapshots).toBeDefined();
406
+ });
407
+
408
+ test('diverged + hub newer → hub wins, both sides snapshotted', async () => {
409
+ writeFileSync(paths().html, '<button>local-older</button>');
410
+ docA.transact(() => {
411
+ applyHtmlToDoc(docA, '<button>hub-newer</button>');
412
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
413
+ });
414
+
415
+ const { journal } = makeJournal();
416
+ const snapshots: SnapCall[] = [];
417
+ const conflicts: Array<{ winner?: string }> = [];
418
+ agent = protocolAgent({ journal, snapshots, conflicts });
419
+ await agent.reconcile();
420
+
421
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>hub-newer</button>');
422
+ expect(snapshots.map((s) => s.reason)).toEqual(['pre-sync-local', 'pre-sync-hub']);
423
+ expect(conflicts[0].winner).toBe('hub');
424
+ });
425
+
426
+ test('DDR-064 empty-hub guard is bit-identical: seed-local-up, no snapshot, no conflict', async () => {
427
+ writeFileSync(paths().html, '<button>real-canvas</button>');
428
+ // docA/docB empty — fresh hub.
429
+ const { journal } = makeJournal();
430
+ const snapshots: SnapCall[] = [];
431
+ const conflicts: unknown[] = [];
432
+ agent = protocolAgent({ journal, snapshots, conflicts });
433
+ await agent.reconcile();
434
+
435
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>real-canvas</button>');
436
+ expect(htmlFromDoc(docA)).toBe('<button>real-canvas</button>');
437
+ expect(snapshots).toHaveLength(0);
438
+ expect(conflicts).toHaveLength(0);
439
+ });
440
+
441
+ test('journal checkpoints: doc→disk flush and disk→doc apply both record', async () => {
442
+ const { journal, records } = makeJournal();
443
+ agent = createCanvasSyncAgent({
444
+ slug: 'screen',
445
+ doc: docB,
446
+ paths: paths(),
447
+ echoGuard: createEchoGuard(),
448
+ flushMs: 0,
449
+ journal,
450
+ });
451
+ agent.start();
452
+
453
+ // Hub edit → disk write → checkpoint.
454
+ applyHtmlToDoc(docA, '<button>from-hub</button>');
455
+ await agent.flush();
456
+ expect(records.some((r) => r.hashes.bodyHash === hashBytes('<button>from-hub</button>'))).toBe(
457
+ true
458
+ );
459
+
460
+ // Local edit → doc apply → checkpoint.
461
+ const bytes = new TextEncoder().encode('<button>from-disk</button>');
462
+ agent.applyFromFs({ path: paths().html, bytes, hash: hashBytes(bytes) });
463
+ expect(records.some((r) => r.hashes.bodyHash === hashBytes(bytes))).toBe(true);
464
+ });
465
+
466
+ test('comments id-union: doc order first, local-only appended, both sides converge', async () => {
467
+ // Local file has c1 + c3; hub doc has c1 + c2.
468
+ const localComments = [
469
+ { id: 'c1', body: 'shared' },
470
+ { id: 'c3', body: 'local-only' },
471
+ ];
472
+ mkdirSync(join(dir, '_comments'), { recursive: true });
473
+ writeFileSync(paths().comments, `${JSON.stringify(localComments, null, 2)}\n`);
474
+ docA.transact(() => {
475
+ docA.getArray('comments').push([
476
+ { id: 'c1', body: 'shared' },
477
+ { id: 'c2', body: 'hub-only' },
478
+ ]);
479
+ });
480
+
481
+ agent = protocolAgent({});
482
+ await agent.reconcile();
483
+
484
+ const expectUnion = [
485
+ { id: 'c1', body: 'shared' },
486
+ { id: 'c2', body: 'hub-only' },
487
+ { id: 'c3', body: 'local-only' },
488
+ ];
489
+ // Doc (and the peer) hold the union…
490
+ expect(docA.getArray('comments').toArray()).toEqual(expectUnion);
491
+ // …and the disk file was rewritten to the union too.
492
+ expect(JSON.parse(readFileSync(paths().comments, 'utf8'))).toEqual(expectUnion);
493
+ });
494
+
495
+ test('comments union with empty hub doc keeps + pushes local comments', async () => {
496
+ const localComments = [{ id: 'c9', body: 'only-local' }];
497
+ mkdirSync(join(dir, '_comments'), { recursive: true });
498
+ writeFileSync(paths().comments, `${JSON.stringify(localComments, null, 2)}\n`);
499
+
500
+ agent = protocolAgent({});
501
+ await agent.reconcile();
502
+
503
+ expect(docA.getArray('comments').toArray()).toEqual(localComments);
504
+ expect(JSON.parse(readFileSync(paths().comments, 'utf8'))).toEqual(localComments);
505
+ });
506
+ });
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // DDR-102 fail-closed (security review F1): a hub-wins resolution must NEVER
510
+ // overwrite local when the local snapshot didn't land — keep local + push up.
511
+ describe('CanvasSyncAgent — fail-closed on snapshot failure (DDR-102 F1)', () => {
512
+ function failClosedAgent(
513
+ snapshotImpl: (content: string, reason: string) => Promise<string | null>,
514
+ conflicts: unknown[]
515
+ ) {
516
+ const a = createCanvasSyncAgent({
517
+ slug: 'screen',
518
+ doc: docB,
519
+ paths: paths(),
520
+ echoGuard: createEchoGuard(),
521
+ flushMs: 0,
522
+ // No journal → every diff is divergence (conflict path).
523
+ snapshot: snapshotImpl,
524
+ onConflict: (info) => {
525
+ conflicts.push(info);
526
+ },
527
+ });
528
+ a.start();
529
+ return a;
530
+ }
531
+
532
+ test('hub newer BUT local snapshot returns null → REFUSE overwrite, keep local, push up', async () => {
533
+ writeFileSync(paths().html, '<button>local-work</button>');
534
+ // Hub doc carries a NEWER stamp so newest-wins would pick hub.
535
+ docA.transact(() => {
536
+ applyHtmlToDoc(docA, '<button>hub-stale</button>');
537
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
538
+ });
539
+
540
+ const conflicts: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
541
+ // Snapshot writer that FAILS (disk full / read-only _history): returns null.
542
+ agent = failClosedAgent(async () => null, conflicts);
543
+ await agent.reconcile();
544
+
545
+ // Local work survives on disk (NOT overwritten by hub-stale) …
546
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>local-work</button>');
547
+ // … and is pushed UP to the hub (nothing lost on either side).
548
+ expect(htmlFromDoc(docA)).toBe('<button>local-work</button>');
549
+ // The conflict is recorded with the degraded-snapshot flag + flipped winner.
550
+ expect(conflicts).toHaveLength(1);
551
+ expect(conflicts[0].winner).toBe('local');
552
+ expect(conflicts[0].snapshotFailed).toBe(true);
553
+ });
554
+
555
+ test('hub newer + local snapshot SUCCEEDS → normal hub-wins (control)', async () => {
556
+ writeFileSync(paths().html, '<button>local-work</button>');
557
+ docA.transact(() => {
558
+ applyHtmlToDoc(docA, '<button>hub-newer</button>');
559
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
560
+ });
561
+
562
+ const conflicts: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
563
+ // Only the local snapshot succeeds; hub snapshot can fail without changing
564
+ // the outcome (local is the loser when hub wins).
565
+ agent = failClosedAgent(
566
+ async (_c, reason) => (reason === 'pre-sync-local' ? 'ts-local' : null),
567
+ conflicts
568
+ );
569
+ await agent.reconcile();
570
+
571
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>hub-newer</button>');
572
+ expect(conflicts[0].winner).toBe('hub');
573
+ expect(conflicts[0].snapshotFailed).toBeUndefined();
574
+ });
575
+ });
@@ -12,8 +12,10 @@ import {
12
12
  applyAnnotationsToDoc,
13
13
  applyCommentsToDoc,
14
14
  applyHtmlToDoc,
15
+ bodyEditAtFromDoc,
15
16
  commentsFromDoc,
16
17
  htmlFromDoc,
18
+ stampBodyEdit,
17
19
  Y_SYNC_TYPES,
18
20
  } from '../sync/codec.ts';
19
21
 
@@ -163,3 +165,66 @@ describe('Annotations codec', () => {
163
165
  expect(doc.getMap<string>(Y_TYPES.annotations).get('svg')).toBe('<svg>x</svg>');
164
166
  });
165
167
  });
168
+
169
+ // syncMeta lane — DDR-102. The doc-side body-edit stamp the cold-start
170
+ // newest-wins decision reads. Dedicated Y.Map lane because meta's
171
+ // `last_modified` is in META_LOCAL_KEYS (per-machine, never syncs).
172
+ describe('syncMeta codec (DDR-102)', () => {
173
+ test('bodyEditAtFromDoc returns null on a fresh doc (older-peer interop)', () => {
174
+ const doc = new Y.Doc();
175
+ expect(bodyEditAtFromDoc(doc)).toBeNull();
176
+ });
177
+
178
+ test('stampBodyEdit → bodyEditAtFromDoc round-trip with explicit nowMs', () => {
179
+ const doc = new Y.Doc();
180
+ stampBodyEdit(doc, undefined, 1_700_000_000_000);
181
+ expect(bodyEditAtFromDoc(doc)).toBe(1_700_000_000_000);
182
+ const by = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('by');
183
+ expect(typeof by).toBe('string');
184
+ expect((by as string).length).toBeGreaterThan(0);
185
+ expect((by as string).length).toBeLessThanOrEqual(32);
186
+ });
187
+
188
+ test('stampBodyEdit defaults to Date.now()', () => {
189
+ const doc = new Y.Doc();
190
+ const before = Date.now();
191
+ stampBodyEdit(doc);
192
+ const after = Date.now();
193
+ const at = bodyEditAtFromDoc(doc);
194
+ expect(at).not.toBeNull();
195
+ expect(at as number).toBeGreaterThanOrEqual(before);
196
+ expect(at as number).toBeLessThanOrEqual(after);
197
+ });
198
+
199
+ test('a hostile/garbage bodyEditAt value reads as null', () => {
200
+ const doc = new Y.Doc();
201
+ doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).set('bodyEditAt', 'not-a-number');
202
+ expect(bodyEditAtFromDoc(doc)).toBeNull();
203
+ doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).set('bodyEditAt', Number.POSITIVE_INFINITY);
204
+ expect(bodyEditAtFromDoc(doc)).toBeNull();
205
+ });
206
+
207
+ test('body apply + stamp wrapped in one transaction emits ONE update', () => {
208
+ const doc = new Y.Doc();
209
+ let updates = 0;
210
+ doc.on('update', () => {
211
+ updates++;
212
+ });
213
+ const origin = { agent: 'test' };
214
+ doc.transact(() => {
215
+ applyHtmlToDoc(doc, '<div>v1</div>', origin);
216
+ stampBodyEdit(doc, origin, 123);
217
+ }, origin);
218
+ expect(updates).toBe(1);
219
+ expect(htmlFromDoc(doc)).toBe('<div>v1</div>');
220
+ expect(bodyEditAtFromDoc(doc)).toBe(123);
221
+ });
222
+
223
+ test('the stamp syncs to a peer doc via update exchange', () => {
224
+ const a = new Y.Doc();
225
+ const b = new Y.Doc();
226
+ a.on('update', (u: Uint8Array) => Y.applyUpdate(b, u));
227
+ stampBodyEdit(a, undefined, 456);
228
+ expect(bodyEditAtFromDoc(b)).toBe(456);
229
+ });
230
+ });