@1agh/maude 0.22.0 → 0.23.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 (60) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +9 -9
  6. package/plugins/design/dev-server/bin/check-runtime-bundles.sh +125 -0
  7. package/plugins/design/dev-server/bin/runtime-health.sh +47 -6
  8. package/plugins/design/dev-server/build.ts +87 -7
  9. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  10. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  11. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  12. package/plugins/design/dev-server/client/app.jsx +37 -15
  13. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  14. package/plugins/design/dev-server/collab/registry.ts +51 -0
  15. package/plugins/design/dev-server/config.schema.json +20 -0
  16. package/plugins/design/dev-server/context.ts +7 -0
  17. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  18. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  19. package/plugins/design/dev-server/dist/runtime/.min-sizes.json +16 -0
  20. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +2 -6
  21. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +2 -6
  22. package/plugins/design/dev-server/dist/runtime/motion.js +4787 -8
  23. package/plugins/design/dev-server/dist/runtime/motion_react.js +9654 -7
  24. package/plugins/design/dev-server/dist/runtime/pixi-js.js +5865 -5469
  25. package/plugins/design/dev-server/dist/runtime/react-dom.js +4 -22
  26. package/plugins/design/dev-server/dist/runtime/react-dom_client.js +5 -23
  27. package/plugins/design/dev-server/dist/runtime/react.js +4 -22
  28. package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +4 -22
  29. package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +4 -22
  30. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +2 -6
  31. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +2 -6
  32. package/plugins/design/dev-server/dist/runtime/yjs.js +2 -6
  33. package/plugins/design/dev-server/dom-selection.ts +156 -0
  34. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  35. package/plugins/design/dev-server/input-router.tsx +99 -61
  36. package/plugins/design/dev-server/server.ts +18 -0
  37. package/plugins/design/dev-server/sync/agent.ts +323 -0
  38. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  39. package/plugins/design/dev-server/sync/codec.ts +169 -0
  40. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  41. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  42. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  43. package/plugins/design/dev-server/sync/index.ts +474 -0
  44. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  45. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  46. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  47. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  48. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  49. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  50. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  51. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  52. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  53. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  54. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  55. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  56. package/plugins/design/dev-server/use-collab.tsx +157 -13
  57. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  58. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  59. package/plugins/design/templates/_shell.html +15 -5
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -268,6 +268,16 @@ export interface UseInputRouterOptions {
268
268
  callbacks: RouterCallbacks;
269
269
  /** When false, listeners are not attached. Defaults to true. */
270
270
  enabled?: boolean;
271
+ /**
272
+ * Allowlist of action kinds this router is permitted to CLAIM (preventDefault
273
+ * + stopImmediatePropagation + dispatch). Any classified action outside the
274
+ * set is downgraded to `no-op` so it propagates untouched to other listeners.
275
+ * Omit to claim everything (the default — used by the full DesignCanvas
276
+ * router). The shell-owned comment mount layer passes a narrow set so it can
277
+ * coexist as an ANCESTOR capture-listener over a UI canvas's own router
278
+ * without swallowing select / context-menu / undo gestures it doesn't own.
279
+ */
280
+ claimableActions?: ReadonlySet<RouterAction['kind']>;
271
281
  }
272
282
 
273
283
  export function isEditableTarget(t: EventTarget | null): boolean {
@@ -295,13 +305,21 @@ export function isOverlayTarget(t: EventTarget | null): boolean {
295
305
  }
296
306
 
297
307
  export function useInputRouter(opts: UseInputRouterOptions): void {
298
- const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true } = opts;
308
+ const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
299
309
 
300
310
  useEffect(() => {
301
311
  if (!enabled) return;
302
312
  const host = hostRef.current;
303
313
  if (!host) return;
304
314
 
315
+ // Downgrade any action this router isn't permitted to claim to no-op so it
316
+ // propagates untouched (no preventDefault / no dispatch). Identity pass-
317
+ // through when no allowlist is configured.
318
+ const claim = (action: RouterAction): RouterAction =>
319
+ claimableActions && action.kind !== 'no-op' && !claimableActions.has(action.kind)
320
+ ? { kind: 'no-op' }
321
+ : action;
322
+
305
323
  const dispatch = (action: RouterAction): void => {
306
324
  switch (action.kind) {
307
325
  case 'hover':
@@ -334,18 +352,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
334
352
  };
335
353
 
336
354
  const onPointerMove = (e: PointerEvent): void => {
337
- const action = classify({
338
- type: 'pointermove',
339
- button: e.button,
340
- metaKey: e.metaKey,
341
- ctrlKey: e.ctrlKey,
342
- shiftKey: e.shiftKey,
343
- altKey: e.altKey,
344
- clientX: e.clientX,
345
- clientY: e.clientY,
346
- spaceHeld: isSpaceHeld?.() ?? false,
347
- activeTool: getActiveTool(),
348
- });
355
+ const action = claim(
356
+ classify({
357
+ type: 'pointermove',
358
+ button: e.button,
359
+ metaKey: e.metaKey,
360
+ ctrlKey: e.ctrlKey,
361
+ shiftKey: e.shiftKey,
362
+ altKey: e.altKey,
363
+ clientX: e.clientX,
364
+ clientY: e.clientY,
365
+ spaceHeld: isSpaceHeld?.() ?? false,
366
+ activeTool: getActiveTool(),
367
+ })
368
+ );
349
369
  dispatch(action);
350
370
  };
351
371
 
@@ -354,18 +374,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
354
374
  // their own clicks. The router is in capture phase, so we have to
355
375
  // bail HERE before classify can claim the event.
356
376
  if (isOverlayTarget(e.target)) return;
357
- const action = classify({
358
- type: 'pointerdown',
359
- button: e.button,
360
- metaKey: e.metaKey,
361
- ctrlKey: e.ctrlKey,
362
- shiftKey: e.shiftKey,
363
- altKey: e.altKey,
364
- clientX: e.clientX,
365
- clientY: e.clientY,
366
- spaceHeld: isSpaceHeld?.() ?? false,
367
- activeTool: getActiveTool(),
368
- });
377
+ const action = claim(
378
+ classify({
379
+ type: 'pointerdown',
380
+ button: e.button,
381
+ metaKey: e.metaKey,
382
+ ctrlKey: e.ctrlKey,
383
+ shiftKey: e.shiftKey,
384
+ altKey: e.altKey,
385
+ clientX: e.clientX,
386
+ clientY: e.clientY,
387
+ spaceHeld: isSpaceHeld?.() ?? false,
388
+ activeTool: getActiveTool(),
389
+ })
390
+ );
369
391
  if (action.kind !== 'no-op') {
370
392
  // Suppress native behavior on every event the router claims —
371
393
  // button presses don't fire, inputs don't focus, the canvas
@@ -386,18 +408,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
386
408
  */
387
409
  const onMouseDown = (e: MouseEvent): void => {
388
410
  if (isOverlayTarget(e.target)) return;
389
- const action = classify({
390
- type: 'pointerdown',
391
- button: e.button,
392
- metaKey: e.metaKey,
393
- ctrlKey: e.ctrlKey,
394
- shiftKey: e.shiftKey,
395
- altKey: e.altKey,
396
- clientX: e.clientX,
397
- clientY: e.clientY,
398
- spaceHeld: isSpaceHeld?.() ?? false,
399
- activeTool: getActiveTool(),
400
- });
411
+ const action = claim(
412
+ classify({
413
+ type: 'pointerdown',
414
+ button: e.button,
415
+ metaKey: e.metaKey,
416
+ ctrlKey: e.ctrlKey,
417
+ shiftKey: e.shiftKey,
418
+ altKey: e.altKey,
419
+ clientX: e.clientX,
420
+ clientY: e.clientY,
421
+ spaceHeld: isSpaceHeld?.() ?? false,
422
+ activeTool: getActiveTool(),
423
+ })
424
+ );
401
425
  if (action.kind !== 'no-op') {
402
426
  e.preventDefault();
403
427
  e.stopImmediatePropagation();
@@ -414,41 +438,55 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
414
438
  if (isOverlayTarget(e.target)) return;
415
439
  const tool = getActiveTool();
416
440
  const mod = e.metaKey || e.ctrlKey;
417
- const wouldRoute =
418
- tool === 'comment' || (tool === 'move' && mod && e.button === 0) || e.button === 2;
419
- if (wouldRoute) {
441
+ // Map the click to the action kind the matching pointerdown would have
442
+ // produced, then honor the claim allowlist so a scoped router (the
443
+ // comment mount layer) doesn't suppress clicks it never claimed.
444
+ const wouldRouteKind: RouterAction['kind'] | null =
445
+ tool === 'comment'
446
+ ? 'drop-comment'
447
+ : tool === 'move' && mod && e.button === 0
448
+ ? 'select'
449
+ : e.button === 2
450
+ ? 'context-menu'
451
+ : null;
452
+ if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
420
453
  e.preventDefault();
421
454
  e.stopImmediatePropagation();
422
455
  }
423
456
  };
424
457
 
425
458
  const onContextMenu = (e: MouseEvent): void => {
459
+ const action = claim(
460
+ classify({
461
+ type: 'contextmenu',
462
+ clientX: e.clientX,
463
+ clientY: e.clientY,
464
+ metaKey: e.metaKey,
465
+ ctrlKey: e.ctrlKey,
466
+ shiftKey: e.shiftKey,
467
+ altKey: e.altKey,
468
+ activeTool: getActiveTool(),
469
+ })
470
+ );
471
+ if (action.kind === 'no-op') return; // not ours to claim — let it bubble
426
472
  e.preventDefault();
427
473
  e.stopImmediatePropagation();
428
- const action = classify({
429
- type: 'contextmenu',
430
- clientX: e.clientX,
431
- clientY: e.clientY,
432
- metaKey: e.metaKey,
433
- ctrlKey: e.ctrlKey,
434
- shiftKey: e.shiftKey,
435
- altKey: e.altKey,
436
- activeTool: getActiveTool(),
437
- });
438
474
  dispatch(action);
439
475
  };
440
476
 
441
477
  const onKeyDown = (e: KeyboardEvent): void => {
442
- const action = classify({
443
- type: 'keydown',
444
- key: e.key,
445
- metaKey: e.metaKey,
446
- ctrlKey: e.ctrlKey,
447
- shiftKey: e.shiftKey,
448
- altKey: e.altKey,
449
- isEditable: isEditableTarget(e.target),
450
- activeTool: getActiveTool(),
451
- });
478
+ const action = claim(
479
+ classify({
480
+ type: 'keydown',
481
+ key: e.key,
482
+ metaKey: e.metaKey,
483
+ ctrlKey: e.ctrlKey,
484
+ shiftKey: e.shiftKey,
485
+ altKey: e.altKey,
486
+ isEditable: isEditableTarget(e.target),
487
+ activeTool: getActiveTool(),
488
+ })
489
+ );
452
490
  if (
453
491
  action.kind === 'tool' ||
454
492
  action.kind === 'escape' ||
@@ -486,7 +524,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
486
524
  } as EventListenerOptions);
487
525
  doc.removeEventListener('keydown', onKeyDown, true);
488
526
  };
489
- }, [enabled, hostRef, getActiveTool, isSpaceHeld, callbacks]);
527
+ }, [enabled, hostRef, getActiveTool, isSpaceHeld, callbacks, claimableActions]);
490
528
  }
491
529
 
492
530
  // ─────────────────────────────────────────────────────────────────────────────
@@ -26,6 +26,7 @@ import { createFsWatch } from './fs-watch.ts';
26
26
  import { createHttp } from './http.ts';
27
27
  import { createInspect } from './inspect.ts';
28
28
  import { startHeapWatch } from './mem.ts';
29
+ import { createSyncRuntime } from './sync/index.ts';
29
30
  import { type WsData, createWs, isLoopbackHost, parseCollabSlug } from './ws.ts';
30
31
 
31
32
  // Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
@@ -199,6 +200,18 @@ await Bun.write(
199
200
  fsWatch.start();
200
201
  startHeapWatch();
201
202
 
203
+ // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
204
+ // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
205
+ // off after fsWatch so the agent's bus subscription receives every fs event.
206
+ const syncRuntime = createSyncRuntime(ctx, collab ? { registry: collab.registry } : {});
207
+ if (syncRuntime) {
208
+ try {
209
+ await syncRuntime.start();
210
+ } catch (err) {
211
+ console.error('[sync] startup failed — continuing in solo mode:', err);
212
+ }
213
+ }
214
+
202
215
  const url = `http://localhost:${server.port}`;
203
216
  console.log(`\n ${ctx.projectLabel} — local browser`);
204
217
  console.log(' ─────────────────────────────');
@@ -219,6 +232,11 @@ if (!process.env.NO_OPEN) {
219
232
  async function shutdown() {
220
233
  console.log('\n Stopping…');
221
234
  fsWatch.stop();
235
+ try {
236
+ if (syncRuntime) await syncRuntime.stop();
237
+ } catch {
238
+ /* best-effort — provider sockets will be closed by process exit anyway */
239
+ }
222
240
  try {
223
241
  if (collab) await collab.registry.destroyAll();
224
242
  } catch {
@@ -0,0 +1,323 @@
1
+ // Per-canvas bidirectional sync agent — Phase 9 Task 4.
2
+ //
3
+ // Wires together the Y.Doc the HocuspocusProvider keeps in sync with the hub
4
+ // and the local on-disk files Claude Code reads + writes:
5
+ //
6
+ // `.design/<slug>.html` ←→ Y.Text (Y_SYNC_TYPES.html)
7
+ // `.design/_comments/<slug>.json` ←→ Y.Array (Y_TYPES.comments)
8
+ // `.design/<slug>.annotations.svg`←→ Y.Map.svg (Y_TYPES.annotations)
9
+ //
10
+ // Provider is INJECTED — the agent doesn't import @hocuspocus/provider. This
11
+ // keeps the orchestration testable with an in-memory pair of Y.Docs (no hub
12
+ // process required) and makes the wiring layer (sync/index.ts) responsible
13
+ // for the HocuspocusProvider lifecycle.
14
+ //
15
+ // Flow A — local edit (Claude `Write`, designer ⌘S) → hub:
16
+ // 1. fs.watch fires → fs-mirror debounces 250ms → onRead({bytes, hash})
17
+ // 2. echoGuard.consume(path, hash) returns false (genuine edit)
18
+ // 3. applyHtmlToDoc(doc, str, agentOrigin) — emits Y op
19
+ // 4. HocuspocusProvider broadcasts the op to hub → other peers
20
+ //
21
+ // Flow B — hub broadcasts other peer's edit → us:
22
+ // 1. Provider applies update to doc with NON-agent origin
23
+ // 2. doc.on('update') schedules a 800ms-debounced flush
24
+ // 3. On flush: htmlFromDoc(doc) → echoGuard.record(path, hash) → atomicWrite
25
+ // 4. fs.watch fires → fs-mirror reads → onRead matches the recorded echo
26
+ // → echoGuard.consume returns true → event dropped (no infinite loop)
27
+ //
28
+ // 800ms quiescence matches the existing Phase 8 collab room flush (DDR-051).
29
+
30
+ import { existsSync, readFileSync } from 'node:fs';
31
+
32
+ import type * as Y from 'yjs';
33
+
34
+ import { atomicWrite } from './atomic-write.ts';
35
+ import {
36
+ Y_SYNC_TYPES,
37
+ annotationsFromDoc,
38
+ applyAnnotationsToDoc,
39
+ applyCommentsToDoc,
40
+ applyHtmlToDoc,
41
+ commentsFromDoc,
42
+ htmlFromDoc,
43
+ } from './codec.ts';
44
+ import { type EchoGuard, hashBytes } from './echo-guard.ts';
45
+
46
+ export const DOC_FLUSH_MS = 800;
47
+
48
+ export interface CanvasSyncPaths {
49
+ /** Absolute path to <designRoot>/<canvas>.html. */
50
+ html: string;
51
+ /** Absolute path to <designRoot>/_comments/<slug>.json. */
52
+ comments: string;
53
+ /** Absolute path to <designRoot>/<slug>.annotations.svg. */
54
+ annotations: string;
55
+ }
56
+
57
+ export interface CanvasSyncAgentOptions {
58
+ slug: string;
59
+ doc: Y.Doc;
60
+ paths: CanvasSyncPaths;
61
+ echoGuard: EchoGuard;
62
+ /** When true, the first reconcile() pushes local disk state up to the doc
63
+ * instead of overwriting disk with the doc state. Cleared after first run. */
64
+ adopt?: boolean;
65
+ /** Override the 800ms flush. Tests use 0 to flush synchronously. */
66
+ flushMs?: number;
67
+ /** Injected for tests — defaults to atomicWrite. */
68
+ writer?: (path: string, bytes: string | Uint8Array) => void;
69
+ }
70
+
71
+ export interface CanvasSyncAgent {
72
+ readonly slug: string;
73
+ /** Set up the doc.on('update') listener. Idempotent. */
74
+ start(): void;
75
+ /**
76
+ * Reconcile disk ↔ doc once. In adopt mode, disk wins; otherwise doc
77
+ * (= hub) wins. Call this AFTER the provider's `synced` event.
78
+ */
79
+ reconcile(): Promise<void>;
80
+ /** Apply an fs event (from fs-mirror) to the doc, honoring echo guard. */
81
+ applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean;
82
+ /** Force the pending flush timer immediately. */
83
+ flush(): Promise<void>;
84
+ /** Stop all timers + listeners. */
85
+ stop(): void;
86
+ /** Test/inspection: the origin tag used on agent-applied transactions. */
87
+ readonly origin: object;
88
+ }
89
+
90
+ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncAgent {
91
+ const { slug, doc, paths, echoGuard } = opts;
92
+ const flushMs = opts.flushMs ?? DOC_FLUSH_MS;
93
+ const writer = opts.writer ?? atomicWrite;
94
+ let adopt = !!opts.adopt;
95
+
96
+ const origin = Object.freeze({ agent: 'sync', slug });
97
+
98
+ let started = false;
99
+ let stopped = false;
100
+ let dirty = false;
101
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
102
+
103
+ // Tracks the last-written contents for each path so we don't issue redundant
104
+ // disk writes when a flush fires but the projection hasn't changed.
105
+ let lastHtml: string | null = null;
106
+ let lastComments: string | null = null;
107
+ let lastAnnotations: string | null = null;
108
+
109
+ function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
110
+ if (stopped) return;
111
+ // Self-applied (we just synced from disk) — disk is already current.
112
+ if (updateOrigin === origin) return;
113
+ scheduleFlush();
114
+ }
115
+
116
+ function scheduleFlush(): void {
117
+ dirty = true;
118
+ if (flushMs === 0) {
119
+ // Synchronous mode for tests — fire on next microtask so the doc
120
+ // observer has fully run.
121
+ queueMicrotask(() => {
122
+ void flush();
123
+ });
124
+ return;
125
+ }
126
+ if (flushTimer) clearTimeout(flushTimer);
127
+ flushTimer = setTimeout(() => {
128
+ flushTimer = null;
129
+ void flush();
130
+ }, flushMs);
131
+ }
132
+
133
+ async function flush(): Promise<void> {
134
+ if (!dirty || stopped) return;
135
+ dirty = false;
136
+ if (flushTimer) {
137
+ clearTimeout(flushTimer);
138
+ flushTimer = null;
139
+ }
140
+ try {
141
+ writeHtmlIfChanged();
142
+ writeCommentsIfChanged();
143
+ writeAnnotationsIfChanged();
144
+ } catch (err) {
145
+ dirty = true;
146
+ console.error(`[sync/${slug}] flush failed:`, err);
147
+ }
148
+ }
149
+
150
+ function writeHtmlIfChanged(): void {
151
+ const next = htmlFromDoc(doc);
152
+ if (next === lastHtml) return;
153
+ const hash = hashBytes(next);
154
+ echoGuard.record(paths.html, hash);
155
+ writer(paths.html, next);
156
+ lastHtml = next;
157
+ }
158
+
159
+ function writeCommentsIfChanged(): void {
160
+ const next = commentsFromDoc(doc);
161
+ const serialized = next.length > 0 ? `${JSON.stringify(next, null, 2)}\n` : '';
162
+ if (serialized === lastComments) return;
163
+ if (serialized === '') {
164
+ // Empty comments — don't create an empty file; just remember the state.
165
+ lastComments = serialized;
166
+ return;
167
+ }
168
+ const hash = hashBytes(serialized);
169
+ echoGuard.record(paths.comments, hash);
170
+ writer(paths.comments, serialized);
171
+ lastComments = serialized;
172
+ }
173
+
174
+ function writeAnnotationsIfChanged(): void {
175
+ const next = annotationsFromDoc(doc);
176
+ const value = next ?? '';
177
+ if (value === lastAnnotations) return;
178
+ if (value === '') {
179
+ // Empty annotations — same handling as comments.
180
+ lastAnnotations = value;
181
+ return;
182
+ }
183
+ const hash = hashBytes(value);
184
+ echoGuard.record(paths.annotations, hash);
185
+ writer(paths.annotations, value);
186
+ lastAnnotations = value;
187
+ }
188
+
189
+ function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
190
+ if (stopped) return false;
191
+ // Echo of our own atomicWrite — drop.
192
+ if (echoGuard.consume(evt.path, evt.hash)) return false;
193
+
194
+ const str = bytesToString(evt.bytes);
195
+ if (evt.path === paths.html) {
196
+ const changed = applyHtmlToDoc(doc, str, origin);
197
+ if (changed) lastHtml = htmlFromDoc(doc);
198
+ return changed;
199
+ }
200
+ if (evt.path === paths.comments) {
201
+ const parsed = tryParseJsonArray(str);
202
+ if (parsed === null) return false;
203
+ const changed = applyCommentsToDoc(doc, parsed, origin);
204
+ if (changed) lastComments = str;
205
+ return changed;
206
+ }
207
+ if (evt.path === paths.annotations) {
208
+ const changed = applyAnnotationsToDoc(doc, str, origin);
209
+ if (changed) lastAnnotations = str;
210
+ return changed;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ async function reconcile(): Promise<void> {
216
+ if (stopped) return;
217
+ const localHtml = readLocal(paths.html);
218
+ const localComments = readLocal(paths.comments);
219
+ const localAnnotations = readLocal(paths.annotations);
220
+
221
+ const docHtml = htmlFromDoc(doc);
222
+ const docComments = commentsFromDoc(doc);
223
+ const docCommentsStr =
224
+ docComments.length > 0 ? `${JSON.stringify(docComments, null, 2)}\n` : '';
225
+ const docAnnotations = annotationsFromDoc(doc) ?? '';
226
+
227
+ if (adopt) {
228
+ // Push local up: doc takes its values from disk. Hub becomes our
229
+ // canonical view of this canvas. One-shot.
230
+ if (localHtml !== null) applyHtmlToDoc(doc, localHtml, origin);
231
+ if (localComments !== null) {
232
+ const parsed = tryParseJsonArray(localComments);
233
+ if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
234
+ }
235
+ if (localAnnotations !== null) applyAnnotationsToDoc(doc, localAnnotations, origin);
236
+ lastHtml = localHtml ?? '';
237
+ lastComments = localComments ?? '';
238
+ lastAnnotations = localAnnotations ?? '';
239
+ adopt = false;
240
+ return;
241
+ }
242
+
243
+ // Hub-wins (default): overwrite disk from doc when they differ.
244
+ lastHtml = docHtml;
245
+ lastComments = docCommentsStr;
246
+ lastAnnotations = docAnnotations;
247
+ if (localHtml !== docHtml) {
248
+ const hash = hashBytes(docHtml);
249
+ echoGuard.record(paths.html, hash);
250
+ writer(paths.html, docHtml);
251
+ }
252
+ if (docCommentsStr !== '' && localComments !== docCommentsStr) {
253
+ const hash = hashBytes(docCommentsStr);
254
+ echoGuard.record(paths.comments, hash);
255
+ writer(paths.comments, docCommentsStr);
256
+ }
257
+ if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
258
+ const hash = hashBytes(docAnnotations);
259
+ echoGuard.record(paths.annotations, hash);
260
+ writer(paths.annotations, docAnnotations);
261
+ }
262
+ }
263
+
264
+ return {
265
+ slug,
266
+ origin,
267
+ start() {
268
+ if (started) return;
269
+ doc.on('update', onDocUpdate);
270
+ started = true;
271
+ },
272
+ reconcile,
273
+ applyFromFs,
274
+ flush,
275
+ stop() {
276
+ stopped = true;
277
+ doc.off('update', onDocUpdate);
278
+ if (flushTimer) {
279
+ clearTimeout(flushTimer);
280
+ flushTimer = null;
281
+ }
282
+ },
283
+ };
284
+ }
285
+
286
+ /* ---------------------------------------------------------------- helpers */
287
+
288
+ function readLocal(p: string): string | null {
289
+ if (!existsSync(p)) return null;
290
+ try {
291
+ return readFileSync(p, 'utf8');
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ function bytesToString(bytes: Uint8Array): string {
298
+ return new TextDecoder().decode(bytes);
299
+ }
300
+
301
+ // Reviver strips dangerous keys at parse time so a malicious hub-pushed
302
+ // payload (or a planted commit) can't seed `__proto__` / `constructor` /
303
+ // `prototype` own-properties into the comment objects yjs subsequently
304
+ // serializes to other peers. Modern V8/Bun block direct Object.prototype
305
+ // pollution at parse, but the reviver also closes the cross-machine
306
+ // propagation surface where an unsafe `for…in` on a peer would re-pollute.
307
+ // DDR-054 §2g (defender M2).
308
+ function tryParseJsonArray(s: string): unknown[] | null {
309
+ try {
310
+ const parsed = JSON.parse(s, (key, value) => {
311
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
312
+ return undefined;
313
+ }
314
+ return value;
315
+ });
316
+ return Array.isArray(parsed) ? parsed : null;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ // Re-export for the wiring layer to know which shared type holds HTML.
323
+ export { Y_SYNC_TYPES };
@@ -0,0 +1,103 @@
1
+ // Atomic file writes for the bidirectional file sync agent (Phase 9 Task 4).
2
+ //
3
+ // fs.watch on macOS/Linux fires on rename, so a non-atomic write (open →
4
+ // truncate → write chunks → close) emits multiple watch events with partial
5
+ // content. The agent's echo guard hashes the final content, so a partial-write
6
+ // event would miss the hash match and incorrectly bubble the (truncated) state
7
+ // up to Y.Doc — corrupting the live state.
8
+ //
9
+ // Pattern: open `<path>.tmp.<random-128-bit>` with O_CREAT|O_EXCL ('wx' in
10
+ // Node), write bytes, close, `renameSync` to the final path. POSIX rename is
11
+ // atomic when source + destination are on the same filesystem; the watch sees
12
+ // the final-content-already-present rename event rather than a stream of
13
+ // in-flight chunks. O_EXCL + 128-bit suffix defeats pre-created symlinks on
14
+ // shared-tenant hosts (DDR-054 §2c — closes attacker Chain C + defender L1).
15
+ //
16
+ // File mode 0o600 on the staging file so other tenants on shared hosts can't
17
+ // read in-flight content before the rename.
18
+ //
19
+ // Windows note: rename of a file the watcher has open returns EBUSY. The
20
+ // plan's Task 4 step 4 acknowledges this as a known minor risk for v1.1;
21
+ // the agent treats EBUSY as a transient error and retries once after a 25ms
22
+ // jitter.
23
+
24
+ import { randomBytes } from 'node:crypto';
25
+ import { closeSync, mkdirSync, openSync, renameSync, unlinkSync, writeSync } from 'node:fs';
26
+ import { dirname } from 'node:path';
27
+
28
+ const RETRY_DELAY_MS = 25;
29
+ // 'wx' = O_CREAT | O_EXCL — fails if the target already exists, including as
30
+ // a dangling/live symlink. Plus O_TRUNC is implied; mode 0o600 sets owner-only.
31
+ const TMP_OPEN_FLAGS = 'wx' as const;
32
+ const TMP_OPEN_MODE = 0o600;
33
+
34
+ /**
35
+ * Write `bytes` to `path` atomically. Returns the absolute path written
36
+ * (same as input — convenience for chaining).
37
+ *
38
+ * Bytes are written via Node's synchronous `openSync` with `O_CREAT|O_EXCL`
39
+ * (fails on pre-existing tmp / symlink), then `renameSync` moves it into
40
+ * place. 128-bit random suffix; mode 0o600. On POSIX the rename is atomic;
41
+ * on Windows there is a small window where the watcher may see a brief gap,
42
+ * retried once on EBUSY.
43
+ *
44
+ * `bytes` accepts string (UTF-8 encoded) or Uint8Array (written verbatim).
45
+ */
46
+ export function atomicWrite(path: string, bytes: string | Uint8Array): string {
47
+ // Ensure the parent directory exists. Cheap (mkdirSync recursive is a no-op
48
+ // when present) and removes the per-canvas burden of creating _comments/
49
+ // before the agent's first flush.
50
+ mkdirSync(dirname(path), { recursive: true });
51
+ // 16 bytes = 32 hex chars = 128 bits. Unforgeable in practice against a
52
+ // local racer (vs. the prior 32-bit value which was brute-forceable in ms).
53
+ const suffix = randomBytes(16).toString('hex');
54
+ const tmp = `${path}.tmp.${suffix}`;
55
+ let fd: number | null = null;
56
+ try {
57
+ fd = openSync(tmp, TMP_OPEN_FLAGS, TMP_OPEN_MODE);
58
+ const buf = typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : Buffer.from(bytes);
59
+ writeSync(fd, buf, 0, buf.byteLength, 0);
60
+ closeSync(fd);
61
+ fd = null;
62
+ try {
63
+ renameSync(tmp, path);
64
+ } catch (err) {
65
+ if (isWindowsBusy(err)) {
66
+ // Brief retry — Windows watcher may hold a handle while reading the
67
+ // pre-rename target. 25ms is short enough to be invisible to the user
68
+ // and long enough to clear the typical fs.watch poll interval.
69
+ const start = Date.now();
70
+ while (Date.now() - start < RETRY_DELAY_MS) {
71
+ // Tight loop is OK for 25ms — keeps the call synchronous so callers
72
+ // can record the echo-guard hash before any fs event fires.
73
+ }
74
+ renameSync(tmp, path);
75
+ } else {
76
+ throw err;
77
+ }
78
+ }
79
+ } catch (err) {
80
+ // Best-effort cleanup of the .tmp + fd on failure — don't mask the
81
+ // original exception with a cleanup error.
82
+ if (fd !== null) {
83
+ try {
84
+ closeSync(fd);
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ try {
90
+ unlinkSync(tmp);
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ throw err;
95
+ }
96
+ return path;
97
+ }
98
+
99
+ function isWindowsBusy(err: unknown): boolean {
100
+ if (!err || typeof err !== 'object') return false;
101
+ const code = (err as { code?: string }).code;
102
+ return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES';
103
+ }