@1agh/maude 0.22.2 → 0.24.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 (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -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
@@ -35,6 +36,14 @@ await bootSelfHeal();
35
36
 
36
37
  const ctx = createContext();
37
38
 
39
+ // Phase 9.2 (DDR-064) — `MAUDE_SHARED_DOC` feature flag. OPT-IN (default OFF),
40
+ // the inverse of MAUDE_CANVAS_ORIGIN_SPLIT's opt-out parsing: only an explicit
41
+ // truthy value enables the single-shared-doc path. OFF = the proven two-doc +
42
+ // disk-reconcile path = byte-for-byte current behavior. The flag is threaded
43
+ // onto ctx here (before createCollab / createSyncRuntime read it) so every
44
+ // downstream consumer sees one source of truth.
45
+ ctx.sharedDoc = /^(1|true|on|yes)$/i.test(process.env.MAUDE_SHARED_DOC ?? '');
46
+
38
47
  // Forward-declared so the api.commentsAdd/patch/delete/addReply callback can
39
48
  // reach into the collab registry (Phase 8 Task 3 bridge). collab is initialized
40
49
  // synchronously below; the callback only fires at runtime, by which point the
@@ -141,6 +150,87 @@ function startServer(port: number): BunServer {
141
150
  });
142
151
  }
143
152
 
153
+ // T2 (9.1-A) — the segregated canvas-content origin. A second Bun.serve on its
154
+ // own (OS-assigned) port, sharing this process's ctx / api / inspect / collab /
155
+ // ws. Canvas iframes load from here; hub-pushed JSX that executes in a canvas
156
+ // can therefore only reach THIS origin (locked further by the CSP on the shell
157
+ // + an iframe sandbox), never the main origin's /_api/export, /_config,
158
+ // /_sync-status, /_comments, or arbitrary repo files. Routes are a hard
159
+ // allowlist: Bun matches `routes` before `fetch`, so we expose ONLY the two
160
+ // gated API endpoints the runtime needs here and 403 everything else in fetch.
161
+ function startCanvasServer(port: number): BunServer {
162
+ return Bun.serve<WsData, never>({
163
+ port,
164
+ hostname: '127.0.0.1',
165
+ development: process.env.NODE_ENV !== 'production',
166
+ // Hard allowlist of route-table endpoints (Bun matches `routes` before
167
+ // `fetch`). Only the collab/display-data endpoints the canvas runtime needs
168
+ // — see http.isCanvasSafeRoute for the trust rationale. The dynamic
169
+ // /_api/comments/<id>/reply POST is fetch-handled + gated there.
170
+ routes: {
171
+ '/_health': http.routes['/_health'],
172
+ '/_api/git-user': http.routes['/_api/git-user'],
173
+ '/_api/canvas-meta': http.routes['/_api/canvas-meta'],
174
+ '/_api/annotations': http.routes['/_api/annotations'],
175
+ '/_api/git-committers': http.routes['/_api/git-committers'],
176
+ '/_api/ai': http.routes['/_api/ai'],
177
+ '/_comments': http.routes['/_comments'],
178
+ },
179
+ async fetch(req, srv) {
180
+ const pathname = new URL(req.url).pathname;
181
+
182
+ // Collab WS — shared registry, loopback-only (same gate as the main origin).
183
+ const collabSlug = parseCollabSlug(pathname);
184
+ if (collabSlug !== null) {
185
+ if (!isLoopbackHost(req.headers.get('host'))) {
186
+ return new Response('cross-machine collab requires Phase 9 hub deploy', { status: 403 });
187
+ }
188
+ const ok = srv.upgrade(req, {
189
+ data: {
190
+ id: crypto.randomUUID(),
191
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
192
+ kind: 'collab',
193
+ slug: collabSlug,
194
+ },
195
+ });
196
+ if (ok) return undefined as unknown as Response;
197
+ return new Response('Upgrade failed', { status: 400 });
198
+ }
199
+
200
+ // HMR-only socket — canvas iframes listen for `canvas-hmr` here. Carries
201
+ // NO privileged inspector feed and ignores inbound messages (ws.ts).
202
+ if (pathname.startsWith('/_ws')) {
203
+ const ok = srv.upgrade(req, {
204
+ data: {
205
+ id: crypto.randomUUID(),
206
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
207
+ kind: 'canvas-hmr',
208
+ },
209
+ });
210
+ if (ok) return undefined as unknown as Response;
211
+ return new Response('Upgrade failed', { status: 400 });
212
+ }
213
+
214
+ // Canvas mount harness with the strict CSP ALWAYS on (F1 gate).
215
+ if (pathname === '/_canvas-shell.html' || pathname === '/_canvas-shell') {
216
+ return http.serveCanvasShell(true);
217
+ }
218
+
219
+ // Allowlist gate — runtime bundles, comment-mount, transpiled .tsx + CSS/
220
+ // assets under designRoot. Everything else is refused at the door.
221
+ if (!http.isCanvasSafeRoute(pathname)) {
222
+ return new Response('Forbidden (canvas origin)', { status: 403 });
223
+ }
224
+ return http.fetch(req);
225
+ },
226
+ websocket: ws.handler,
227
+ error(e) {
228
+ console.error('[bun.serve canvas-origin error]', e);
229
+ return new Response('Server error', { status: 500 });
230
+ },
231
+ });
232
+ }
233
+
144
234
  function isAddrInUse(e: unknown): boolean {
145
235
  if (!e || typeof e !== 'object') return false;
146
236
  const err = e as { code?: string; errno?: number };
@@ -180,6 +270,30 @@ let server: BunServer;
180
270
  server = bound;
181
271
  }
182
272
 
273
+ // T2 (9.1-A) — advertise the main origin so the canvas origin's CSP can
274
+ // allowlist it in `frame-ancestors` (the legit embedder). Must be set before
275
+ // the canvas listener serves its first shell.
276
+ ctx.mainOrigin = `http://localhost:${server.port}`;
277
+
278
+ // T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
279
+ // phase-9.1: a second listener binds an OS-assigned free port, advertised as
280
+ // `canvasOrigin`, and the client loads canvas iframes cross-origin under the
281
+ // strict CSP + sandbox + route-allowlist (the F1 containment). This is purely
282
+ // protective — for a solo user it sandboxes their OWN canvas code (no untrusted
283
+ // content, no exfil concern), and interactive-feature parity (selection,
284
+ // comments, presence, motion) is verified. It does NOT by itself enable
285
+ // untrusted `.tsx` sync — that still requires the per-canvas `syncable` opt-in
286
+ // (sync/index.ts), so the WebRTC/self-nav exfil residual only applies to a
287
+ // canvas explicitly opted into syncing (DDR-060 + the F1 re-audit report).
288
+ // Set `MAUDE_CANVAS_ORIGIN_SPLIT=0` (or false/off/no) to fall back to the legacy
289
+ // same-origin path.
290
+ const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
291
+ process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
292
+ );
293
+ const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
294
+ const canvasOrigin = canvasServer ? `http://localhost:${canvasServer.port}` : undefined;
295
+ if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
296
+
183
297
  await Bun.write(
184
298
  ctx.paths.serverInfoFile,
185
299
  JSON.stringify(
@@ -187,6 +301,7 @@ await Bun.write(
187
301
  pid: process.pid,
188
302
  port: server.port,
189
303
  url: `http://localhost:${server.port}`,
304
+ ...(canvasOrigin ? { canvasOrigin } : {}),
190
305
  started: new Date().toISOString(),
191
306
  project: ctx.cfg.name,
192
307
  config_source: ctx.cfg._source,
@@ -199,6 +314,18 @@ await Bun.write(
199
314
  fsWatch.start();
200
315
  startHeapWatch();
201
316
 
317
+ // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
318
+ // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
319
+ // off after fsWatch so the agent's bus subscription receives every fs event.
320
+ const syncRuntime = createSyncRuntime(ctx, collab ? { registry: collab.registry } : {});
321
+ if (syncRuntime) {
322
+ try {
323
+ await syncRuntime.start();
324
+ } catch (err) {
325
+ console.error('[sync] startup failed — continuing in solo mode:', err);
326
+ }
327
+ }
328
+
202
329
  const url = `http://localhost:${server.port}`;
203
330
  console.log(`\n ${ctx.projectLabel} — local browser`);
204
331
  console.log(' ─────────────────────────────');
@@ -220,7 +347,15 @@ async function shutdown() {
220
347
  console.log('\n Stopping…');
221
348
  fsWatch.stop();
222
349
  try {
223
- if (collab) await collab.registry.destroyAll();
350
+ if (syncRuntime) await syncRuntime.stop();
351
+ } catch {
352
+ /* best-effort — provider sockets will be closed by process exit anyway */
353
+ }
354
+ try {
355
+ if (collab) {
356
+ collab.dispose();
357
+ await collab.registry.destroyAll();
358
+ }
224
359
  } catch {
225
360
  /* best-effort flush; the JSON snapshot is the ground truth anyway */
226
361
  }
@@ -243,6 +378,11 @@ async function shutdown() {
243
378
  /* ignore */
244
379
  }
245
380
  server.stop();
381
+ try {
382
+ canvasServer?.stop();
383
+ } catch {
384
+ /* best-effort */
385
+ }
246
386
  process.exit(0);
247
387
  }
248
388
 
@@ -0,0 +1,418 @@
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
+ applyCssToDoc,
41
+ applyHtmlToDoc,
42
+ applyMetaToDoc,
43
+ commentsFromDoc,
44
+ cssFromDoc,
45
+ htmlFromDoc,
46
+ mergeSharedMetaIntoLocal,
47
+ metaFromDoc,
48
+ } from './codec.ts';
49
+ import { type EchoGuard, hashBytes } from './echo-guard.ts';
50
+
51
+ export const DOC_FLUSH_MS = 800;
52
+
53
+ export interface CanvasSyncPaths {
54
+ /** Absolute path to <designRoot>/<canvas>.html. */
55
+ html: string;
56
+ /** Absolute path to <designRoot>/_comments/<slug>.json. */
57
+ comments: string;
58
+ /** Absolute path to <designRoot>/<slug>.annotations.svg. */
59
+ annotations: string;
60
+ /** Absolute path to the canvas `.meta.json` (sibling of the body). Optional:
61
+ * when set (always, in production wiring), shared meta keys (layout/artboards)
62
+ * sync while per-user viewport stays local (Phase 9.1 Gap 2). Omitted in
63
+ * older test constructions → meta sync is simply inert. */
64
+ meta?: string;
65
+ /** Absolute path to the canvas's sibling `.css` (Phase 9.1 Gap 3). Optional —
66
+ * inline-CSS canvases have none; omitted/absent → css sync is inert. */
67
+ css?: string;
68
+ }
69
+
70
+ export interface CanvasSyncAgentOptions {
71
+ slug: string;
72
+ doc: Y.Doc;
73
+ paths: CanvasSyncPaths;
74
+ echoGuard: EchoGuard;
75
+ /** When true, the first reconcile() pushes local disk state up to the doc
76
+ * instead of overwriting disk with the doc state. Cleared after first run. */
77
+ adopt?: boolean;
78
+ /** Override the 800ms flush. Tests use 0 to flush synchronously. */
79
+ flushMs?: number;
80
+ /** Injected for tests — defaults to atomicWrite. */
81
+ writer?: (path: string, bytes: string | Uint8Array) => void;
82
+ /**
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.
88
+ */
89
+ onConflict?: (info: { slug: string; kind: 'cold-start-hub-wins' }) => void;
90
+ }
91
+
92
+ export interface CanvasSyncAgent {
93
+ readonly slug: string;
94
+ /** Set up the doc.on('update') listener. Idempotent. */
95
+ start(): void;
96
+ /**
97
+ * Reconcile disk ↔ doc once. In adopt mode, disk wins; otherwise doc
98
+ * (= hub) wins. Call this AFTER the provider's `synced` event.
99
+ */
100
+ reconcile(): Promise<void>;
101
+ /** Apply an fs event (from fs-mirror) to the doc, honoring echo guard. */
102
+ applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean;
103
+ /** Force the pending flush timer immediately. */
104
+ flush(): Promise<void>;
105
+ /** Stop all timers + listeners. */
106
+ stop(): void;
107
+ /** Test/inspection: the origin tag used on agent-applied transactions. */
108
+ readonly origin: object;
109
+ }
110
+
111
+ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncAgent {
112
+ const { slug, doc, paths, echoGuard } = opts;
113
+ const flushMs = opts.flushMs ?? DOC_FLUSH_MS;
114
+ const writer = opts.writer ?? atomicWrite;
115
+ let adopt = !!opts.adopt;
116
+
117
+ const origin = Object.freeze({ agent: 'sync', slug });
118
+
119
+ let started = false;
120
+ let stopped = false;
121
+ let dirty = false;
122
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
123
+
124
+ // Tracks the last-written contents for each path so we don't issue redundant
125
+ // disk writes when a flush fires but the projection hasn't changed.
126
+ let lastHtml: string | null = null;
127
+ let lastComments: string | null = null;
128
+ let lastAnnotations: string | null = null;
129
+ let lastMeta: string | null = null;
130
+ let lastCss: string | null = null;
131
+
132
+ function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
133
+ if (stopped) return;
134
+ // Self-applied (we just synced from disk) — disk is already current.
135
+ if (updateOrigin === origin) return;
136
+ scheduleFlush();
137
+ }
138
+
139
+ function scheduleFlush(): void {
140
+ dirty = true;
141
+ if (flushMs === 0) {
142
+ // Synchronous mode for tests — fire on next microtask so the doc
143
+ // observer has fully run.
144
+ queueMicrotask(() => {
145
+ void flush();
146
+ });
147
+ return;
148
+ }
149
+ if (flushTimer) clearTimeout(flushTimer);
150
+ flushTimer = setTimeout(() => {
151
+ flushTimer = null;
152
+ void flush();
153
+ }, flushMs);
154
+ }
155
+
156
+ async function flush(): Promise<void> {
157
+ if (!dirty || stopped) return;
158
+ dirty = false;
159
+ if (flushTimer) {
160
+ clearTimeout(flushTimer);
161
+ flushTimer = null;
162
+ }
163
+ try {
164
+ writeHtmlIfChanged();
165
+ writeCommentsIfChanged();
166
+ writeAnnotationsIfChanged();
167
+ writeMetaIfChanged();
168
+ writeCssIfChanged();
169
+ } catch (err) {
170
+ dirty = true;
171
+ console.error(`[sync/${slug}] flush failed:`, err);
172
+ }
173
+ }
174
+
175
+ function writeHtmlIfChanged(): void {
176
+ const next = htmlFromDoc(doc);
177
+ if (next === lastHtml) return;
178
+ const hash = hashBytes(next);
179
+ echoGuard.record(paths.html, hash);
180
+ writer(paths.html, next);
181
+ lastHtml = next;
182
+ }
183
+
184
+ function writeCommentsIfChanged(): void {
185
+ const next = commentsFromDoc(doc);
186
+ const serialized = next.length > 0 ? `${JSON.stringify(next, null, 2)}\n` : '';
187
+ if (serialized === lastComments) return;
188
+ if (serialized === '') {
189
+ // Empty comments — don't create an empty file; just remember the state.
190
+ lastComments = serialized;
191
+ return;
192
+ }
193
+ const hash = hashBytes(serialized);
194
+ echoGuard.record(paths.comments, hash);
195
+ writer(paths.comments, serialized);
196
+ lastComments = serialized;
197
+ }
198
+
199
+ function writeAnnotationsIfChanged(): void {
200
+ const next = annotationsFromDoc(doc);
201
+ const value = next ?? '';
202
+ if (value === lastAnnotations) return;
203
+ if (value === '') {
204
+ // Empty annotations — same handling as comments.
205
+ lastAnnotations = value;
206
+ return;
207
+ }
208
+ const hash = hashBytes(value);
209
+ echoGuard.record(paths.annotations, hash);
210
+ writer(paths.annotations, value);
211
+ lastAnnotations = value;
212
+ }
213
+
214
+ function writeMetaIfChanged(): void {
215
+ if (!paths.meta) return;
216
+ const shared = metaFromDoc(doc);
217
+ if (shared === lastMeta) return;
218
+ lastMeta = shared;
219
+ if (shared === null) return; // doc carries no shared meta yet — nothing to merge down
220
+ const local = readLocal(paths.meta);
221
+ const merged = mergeSharedMetaIntoLocal(local, shared);
222
+ if (merged === null || merged === local) return; // unparseable, or disk already matches
223
+ const hash = hashBytes(merged);
224
+ echoGuard.record(paths.meta, hash);
225
+ writer(paths.meta, merged);
226
+ }
227
+
228
+ function writeCssIfChanged(): void {
229
+ if (!paths.css) return;
230
+ const next = cssFromDoc(doc);
231
+ if (next === lastCss) return;
232
+ lastCss = next;
233
+ if (next === null) return; // doc carries no css yet — nothing to write
234
+ const hash = hashBytes(next);
235
+ echoGuard.record(paths.css, hash);
236
+ writer(paths.css, next);
237
+ }
238
+
239
+ function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
240
+ if (stopped) return false;
241
+ // Echo of our own atomicWrite — drop.
242
+ if (echoGuard.consume(evt.path, evt.hash)) return false;
243
+
244
+ const str = bytesToString(evt.bytes);
245
+ if (evt.path === paths.html) {
246
+ const changed = applyHtmlToDoc(doc, str, origin);
247
+ if (changed) lastHtml = htmlFromDoc(doc);
248
+ return changed;
249
+ }
250
+ if (evt.path === paths.comments) {
251
+ const parsed = tryParseJsonArray(str);
252
+ if (parsed === null) return false;
253
+ const changed = applyCommentsToDoc(doc, parsed, origin);
254
+ if (changed) lastComments = str;
255
+ return changed;
256
+ }
257
+ if (evt.path === paths.annotations) {
258
+ const changed = applyAnnotationsToDoc(doc, str, origin);
259
+ if (changed) lastAnnotations = str;
260
+ return changed;
261
+ }
262
+ if (paths.meta && evt.path === paths.meta) {
263
+ // Local meta changed (canvas-meta PATCH / design:edit) → push its SHARED
264
+ // subset (layout/artboards, minus per-user viewport) into the doc.
265
+ const changed = applyMetaToDoc(doc, str, origin);
266
+ if (changed) lastMeta = metaFromDoc(doc);
267
+ return changed;
268
+ }
269
+ if (paths.css && evt.path === paths.css) {
270
+ const changed = applyCssToDoc(doc, str, origin);
271
+ if (changed) lastCss = str;
272
+ return changed;
273
+ }
274
+ return false;
275
+ }
276
+
277
+ async function reconcile(): Promise<void> {
278
+ if (stopped) return;
279
+ const localHtml = readLocal(paths.html);
280
+ const localComments = readLocal(paths.comments);
281
+ const localAnnotations = readLocal(paths.annotations);
282
+ const localMeta = paths.meta ? readLocal(paths.meta) : null;
283
+ const localCss = paths.css ? readLocal(paths.css) : null;
284
+
285
+ const docHtml = htmlFromDoc(doc);
286
+ const docComments = commentsFromDoc(doc);
287
+ const docCommentsStr =
288
+ docComments.length > 0 ? `${JSON.stringify(docComments, null, 2)}\n` : '';
289
+ const docAnnotations = annotationsFromDoc(doc) ?? '';
290
+ const docMeta = metaFromDoc(doc);
291
+ const docCss = cssFromDoc(doc);
292
+
293
+ if (adopt) {
294
+ // Push local up: doc takes its values from disk. Hub becomes our
295
+ // canonical view of this canvas. One-shot.
296
+ if (localHtml !== null) applyHtmlToDoc(doc, localHtml, origin);
297
+ if (localComments !== null) {
298
+ const parsed = tryParseJsonArray(localComments);
299
+ if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
300
+ }
301
+ if (localAnnotations !== null) applyAnnotationsToDoc(doc, localAnnotations, origin);
302
+ if (paths.meta && localMeta !== null) applyMetaToDoc(doc, localMeta, origin);
303
+ if (paths.css && localCss !== null) applyCssToDoc(doc, localCss, origin);
304
+ lastHtml = localHtml ?? '';
305
+ lastComments = localComments ?? '';
306
+ lastAnnotations = localAnnotations ?? '';
307
+ lastMeta = metaFromDoc(doc);
308
+ lastCss = cssFromDoc(doc);
309
+ adopt = false;
310
+ return;
311
+ }
312
+
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
+ // Local had divergent, non-empty content that hub-wins is discarding —
321
+ // notify so the user knows their local edits were overwritten (Task 8).
322
+ // An absent/empty local file is a clean first-sync, not a conflict.
323
+ if (localHtml !== null && localHtml.trim() !== '') {
324
+ opts.onConflict?.({ slug, kind: 'cold-start-hub-wins' });
325
+ }
326
+ const hash = hashBytes(docHtml);
327
+ echoGuard.record(paths.html, hash);
328
+ writer(paths.html, docHtml);
329
+ }
330
+ if (docCommentsStr !== '' && localComments !== docCommentsStr) {
331
+ const hash = hashBytes(docCommentsStr);
332
+ echoGuard.record(paths.comments, hash);
333
+ writer(paths.comments, docCommentsStr);
334
+ }
335
+ if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
336
+ const hash = hashBytes(docAnnotations);
337
+ echoGuard.record(paths.annotations, hash);
338
+ writer(paths.annotations, docAnnotations);
339
+ }
340
+ // Meta: merge the doc's shared subset (layout/artboards) into local,
341
+ // preserving this machine's viewport + syncable. Only writes when the merge
342
+ // actually changes the file (a fresh peer with no local viewport, or an
343
+ // artboard layout the hub carries that local lacks).
344
+ if (paths.meta && docMeta !== null) {
345
+ const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
346
+ if (merged !== null && merged !== localMeta) {
347
+ const hash = hashBytes(merged);
348
+ echoGuard.record(paths.meta, hash);
349
+ writer(paths.meta, merged);
350
+ }
351
+ }
352
+ if (paths.css && docCss !== null && localCss !== docCss) {
353
+ const hash = hashBytes(docCss);
354
+ echoGuard.record(paths.css, hash);
355
+ writer(paths.css, docCss);
356
+ }
357
+ }
358
+
359
+ return {
360
+ slug,
361
+ origin,
362
+ start() {
363
+ if (started) return;
364
+ doc.on('update', onDocUpdate);
365
+ started = true;
366
+ },
367
+ reconcile,
368
+ applyFromFs,
369
+ flush,
370
+ stop() {
371
+ stopped = true;
372
+ doc.off('update', onDocUpdate);
373
+ if (flushTimer) {
374
+ clearTimeout(flushTimer);
375
+ flushTimer = null;
376
+ }
377
+ },
378
+ };
379
+ }
380
+
381
+ /* ---------------------------------------------------------------- helpers */
382
+
383
+ function readLocal(p: string): string | null {
384
+ if (!existsSync(p)) return null;
385
+ try {
386
+ return readFileSync(p, 'utf8');
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+
392
+ function bytesToString(bytes: Uint8Array): string {
393
+ return new TextDecoder().decode(bytes);
394
+ }
395
+
396
+ // Reviver strips dangerous keys at parse time so a malicious hub-pushed
397
+ // payload (or a planted commit) can't seed `__proto__` / `constructor` /
398
+ // `prototype` own-properties into the comment objects yjs subsequently
399
+ // serializes to other peers. Modern V8/Bun block direct Object.prototype
400
+ // pollution at parse, but the reviver also closes the cross-machine
401
+ // propagation surface where an unsafe `for…in` on a peer would re-pollute.
402
+ // DDR-054 §2g (defender M2).
403
+ function tryParseJsonArray(s: string): unknown[] | null {
404
+ try {
405
+ const parsed = JSON.parse(s, (key, value) => {
406
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
407
+ return undefined;
408
+ }
409
+ return value;
410
+ });
411
+ return Array.isArray(parsed) ? parsed : null;
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ // Re-export for the wiring layer to know which shared type holds HTML.
418
+ export { Y_SYNC_TYPES };