@1agh/maude 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -26,15 +26,17 @@ import * as Y from 'yjs';
26
26
 
27
27
  import { Y_TYPES } from '../collab/persistence.ts';
28
28
  import type { Context } from '../context.ts';
29
+ import { createHistory } from '../history.ts';
29
30
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
30
31
  import {
31
32
  type ConnectionMonitor,
32
33
  createConnectionMonitor,
33
34
  type ProviderStatus,
34
35
  } from './connection-state.ts';
35
- import { createEchoGuard, type EchoGuard } from './echo-guard.ts';
36
+ import { createEchoGuard } from './echo-guard.ts';
36
37
  import { createFsReader, type FsReader } from './fs-mirror.ts';
37
38
  import { getHubToken } from './hubs-config.ts';
39
+ import { loadJournal, type SyncJournal } from './journal.ts';
38
40
  import { migrateSeed } from './migrate-seed.ts';
39
41
  import { createDocProjection, type DocProjection } from './projection.ts';
40
42
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
@@ -58,9 +60,48 @@ export interface SyncProvider {
58
60
  * without status events just isn't monitored (treated as always-online).
59
61
  */
60
62
  onStatus?(cb: (status: ProviderStatus) => void): () => void;
63
+ /**
64
+ * DDR-102 — subscribe to hub auth rejections for this document. Returns an
65
+ * unsubscribe fn. Optional — a stub without it just isn't classified.
66
+ */
67
+ onAuthFailed?(cb: (info: { reason: string }) => void): () => void;
61
68
  destroy(): void;
62
69
  }
63
70
 
71
+ /* ------------------------------------------------- auth-failure classification */
72
+
73
+ /** DDR-102 — rejection classes the runtime distinguishes. `rate-limit` and
74
+ * `generic` are transient (provider backoff keeps retrying); `not-authorized`
75
+ * and `invalid-token` are permanent (retrying spams the hub bucket — destroy
76
+ * the provider and re-probe on a slow timer instead). */
77
+ export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token' | 'generic';
78
+
79
+ /** Map a raw hub rejection reason to a class. New hubs send distinct reasons
80
+ * (DDR-102 hub fix); old hubs send the Hocuspocus default `permission-denied`
81
+ * → `generic` (interop-safe degradation). */
82
+ export function classifyAuthFailure(raw: string): AuthFailureClass {
83
+ const s = raw.toLowerCase();
84
+ if (s.includes('rate limit')) return 'rate-limit';
85
+ if (s.includes('not authorized')) return 'not-authorized';
86
+ if (s.includes('invalid token')) return 'invalid-token';
87
+ return 'generic';
88
+ }
89
+
90
+ export const AUTH_WARN_DEBOUNCE_MS = 2_000;
91
+ export const AUTH_REPROBE_MS = 5 * 60 * 1000;
92
+ export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
93
+
94
+ const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
95
+ 'rate-limit':
96
+ 'boot burst hit the hub rate limit — sync settles as providers back off; if persistent, raise HUB_CONN_RATE_LIMIT on the hub (DDR-102 hubs default to 600/min for valid tokens).',
97
+ 'not-authorized':
98
+ "the token's scope does not cover these canvases — mint a hub-wide token (`maude hub token generate --scope '*'` or an admin-UI invite) and re-link. Retries stopped; re-probing in 5 min.",
99
+ 'invalid-token':
100
+ 'the stored token was rejected — re-run `maude design link <url> --token …` on this machine. Retries stopped; re-probing in 5 min.',
101
+ generic:
102
+ 'the hub refused auth without a specific reason (older hub?) — check `maude design status` and the hub logs.',
103
+ };
104
+
64
105
  /**
65
106
  * Structural surface of the collab registry the runtime needs for Task 5.
66
107
  * Defined here (rather than imported from collab/) to avoid a dev-server
@@ -133,6 +174,20 @@ export interface CreateSyncRuntimeOptions {
133
174
  connectionMonitor?: ConnectionMonitor;
134
175
  /** Override the status store (Task 8 test injection). */
135
176
  statusStore?: SyncStatusStore;
177
+ /**
178
+ * DDR-102 — auth-failure + boot-settle knobs (test injection, mirrors the
179
+ * connection-state injectable-timer pattern).
180
+ */
181
+ auth?: {
182
+ /** Debounce for the ONE aggregated rejection warn. Default 2 s. */
183
+ warnDebounceMs?: number;
184
+ /** Re-probe interval for permanently-rejected docs. Default 5 min. */
185
+ reprobeMs?: number;
186
+ /** Boot summary settle ceiling. Default 15 s. */
187
+ settleTimeoutMs?: number;
188
+ setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
189
+ clearTimer?: (h: ReturnType<typeof setTimeout>) => void;
190
+ };
136
191
  }
137
192
 
138
193
  /**
@@ -195,7 +250,11 @@ export function createSyncRuntime(
195
250
  }
196
251
  const token: string = resolvedToken;
197
252
 
198
- const providerFactory = opts.providerFactory ?? defaultProviderFactory;
253
+ // DDR-102 the default factory multiplexes every provider over ONE shared
254
+ // WebSocket per hub URL; the runtime owns its disposal (stop(), after the
255
+ // providers detach). An injected test factory has no shared socket.
256
+ const ownedFactory = opts.providerFactory ? null : createDefaultProviderFactory();
257
+ const providerFactory = opts.providerFactory ?? (ownedFactory as ProviderFactory);
199
258
  const echoGuard = createEchoGuard();
200
259
  const agents = new Map<string, CanvasSyncAgent>();
201
260
  // Phase 9.2 (DDR-064) — under sharedDoc the disk handler is a loop-free
@@ -224,6 +283,28 @@ export function createSyncRuntime(
224
283
  // online/offline/escalated and feeds every change to the store.
225
284
  let statusStore: SyncStatusStore | null = null;
226
285
  let monitor: ConnectionMonitor | null = null;
286
+ // DDR-102 — the per-machine sync journal (divergence detector). Created in
287
+ // start() (after the hub URL is known), flushed + stopped in stop().
288
+ let journal: SyncJournal | null = null;
289
+
290
+ // DDR-102 — auth-failure intelligence state (timers cleared in stop()).
291
+ type TimerHandle = ReturnType<typeof setTimeout>;
292
+ const authSetTimer = opts.auth?.setTimer ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
293
+ const authClearTimer = opts.auth?.clearTimer ?? ((h: TimerHandle) => clearTimeout(h));
294
+ const warnDebounceMs = opts.auth?.warnDebounceMs ?? AUTH_WARN_DEBOUNCE_MS;
295
+ const reprobeMs = opts.auth?.reprobeMs ?? AUTH_REPROBE_MS;
296
+ const settleTimeoutMs = opts.auth?.settleTimeoutMs ?? BOOT_SETTLE_TIMEOUT_MS;
297
+ const pendingAuthWarn = new Map<AuthFailureClass, Set<string>>();
298
+ /** Latest rejection class per slug — feeds the boot summary detail. */
299
+ const rejectedReasons = new Map<string, AuthFailureClass>();
300
+ /** Permanently-rejected docs awaiting a slow re-probe (provider destroyed). */
301
+ const rejectedPermanent = new Map<
302
+ string,
303
+ { canvas: CanvasDescriptor; canvasPaths: import('./agent.ts').CanvasSyncPaths; doc: Y.Doc }
304
+ >();
305
+ let authWarnTimer: TimerHandle | null = null;
306
+ let reprobeTimer: TimerHandle | null = null;
307
+ const settleTimers = new Set<TimerHandle>();
227
308
 
228
309
  async function start(): Promise<void> {
229
310
  if (started || stopped) return;
@@ -312,6 +393,216 @@ export function createSyncRuntime(
312
393
  let adoptReconciled = 0;
313
394
  const adoptTarget = canvases.length;
314
395
 
396
+ // DDR-102 — journal + snapshot wiring for the conflict protocol. The
397
+ // journal is per-hub (relink to a different hub wipes it); snapshots land
398
+ // in `_history/<slug>/` via history.ts so /design:rollback recovers them.
399
+ journal = loadJournal(ctx.paths.designRoot);
400
+ journal.invalidateIfHubChanged(linkedHub.url);
401
+ const history = createHistory(ctx);
402
+
403
+ // ---- DDR-102 helpers: auth aggregation, re-probe, settle bookkeeping ----
404
+
405
+ const flushAuthWarn = (): void => {
406
+ authWarnTimer = null;
407
+ if (stopped || pendingAuthWarn.size === 0) return;
408
+ const lines: string[] = [];
409
+ for (const [cls, slugs] of pendingAuthWarn) {
410
+ const list = [...slugs];
411
+ const shown = list.slice(0, 10).join(', ');
412
+ const more = list.length > 10 ? ` (+${list.length - 10} more)` : '';
413
+ lines.push(
414
+ ` ${list.length} canvas(es) [${cls}]: ${shown}${more}\n → ${AUTH_CLASS_HINT[cls]}`
415
+ );
416
+ }
417
+ pendingAuthWarn.clear();
418
+ console.warn(`[sync] hub auth rejections (${linkedHub.url}):\n${lines.join('\n')}`);
419
+ };
420
+
421
+ const scheduleReprobe = (): void => {
422
+ if (reprobeTimer !== null || stopped) return;
423
+ reprobeTimer = authSetTimer(() => {
424
+ reprobeTimer = null;
425
+ if (stopped) return;
426
+ const entries = [...rejectedPermanent.values()];
427
+ rejectedPermanent.clear();
428
+ for (const entry of entries) {
429
+ mon.noteDocState(entry.canvas.slug, 'pending');
430
+ void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
431
+ console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
432
+ });
433
+ }
434
+ }, reprobeMs);
435
+ };
436
+
437
+ const handleAuthFailure = (
438
+ canvas: CanvasDescriptor,
439
+ canvasPaths: import('./agent.ts').CanvasSyncPaths,
440
+ provider: SyncProvider,
441
+ rawReason: string
442
+ ): void => {
443
+ if (stopped) return;
444
+ const reasonClass = classifyAuthFailure(rawReason);
445
+ mon.noteDocState(canvas.slug, 'auth-rejected');
446
+ rejectedReasons.set(canvas.slug, reasonClass);
447
+ // Aggregate console output: ONE debounced warn for the whole burst.
448
+ if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
449
+ pendingAuthWarn.get(reasonClass)?.add(canvas.slug);
450
+ if (authWarnTimer === null) authWarnTimer = authSetTimer(flushAuthWarn, warnDebounceMs);
451
+ // Permanent classes: retrying only spams the hub (and its rate bucket) —
452
+ // destroy the provider and re-probe on a slow timer. Transient classes
453
+ // (rate-limit / generic) keep the provider's built-in backoff.
454
+ if (reasonClass === 'not-authorized' || reasonClass === 'invalid-token') {
455
+ if (!rejectedPermanent.has(canvas.slug)) {
456
+ rejectedPermanent.set(canvas.slug, { canvas, canvasPaths, doc: provider.document });
457
+ providers.delete(canvas.slug);
458
+ try {
459
+ provider.destroy();
460
+ } catch {
461
+ /* best-effort */
462
+ }
463
+ scheduleReprobe();
464
+ }
465
+ }
466
+ };
467
+
468
+ /** Post-handshake reconcile — shared by first connect and re-probe. */
469
+ const handleSynced = async (
470
+ canvas: CanvasDescriptor,
471
+ canvasPaths: import('./agent.ts').CanvasSyncPaths,
472
+ provider: SyncProvider
473
+ ): Promise<void> => {
474
+ if (stopped) return;
475
+ const projection = projections.get(canvas.slug);
476
+ const agent = agents.get(canvas.slug);
477
+ if (projection) {
478
+ // Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
479
+ // materializing: escapes the duplication trap by picking ONE source
480
+ // inside a MIGRATION transaction. DDR-102: body divergence now takes
481
+ // the journal-gated conflict path (dual snapshot + newest-wins)
482
+ // instead of blind hub-wins. The room file-seed is disabled for this
483
+ // pinned slug (createCollab shouldSeed).
484
+ const relBody = path.relative(ctx.paths.repoRoot, canvas.html);
485
+ const result = await migrateSeed({
486
+ slug: canvas.slug,
487
+ doc: provider.document,
488
+ paths: canvasPaths,
489
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
490
+ journal: journal ?? undefined,
491
+ snapshot: async (content, reason) => {
492
+ try {
493
+ const snap = await history.writeSnapshot(relBody, content, reason);
494
+ return snap.ts;
495
+ } catch {
496
+ return null;
497
+ }
498
+ },
499
+ onConflict: (info) => store.addConflict(info),
500
+ });
501
+ if (result === 'local-adopt') {
502
+ console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
503
+ } else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
504
+ console.warn(
505
+ `[sync/${canvas.slug}] shared-doc: diverged — kept the ${
506
+ result === 'conflict-local-wins' ? 'local' : 'hub'
507
+ } version (newest-wins); the other is in _history/${canvas.slug}/ — recover via /design:rollback.`
508
+ );
509
+ }
510
+ // Then materialize the converged doc to disk (safe — never clobbers
511
+ // non-empty local with an empty doc value).
512
+ projection.reconcile();
513
+ } else if (agent) {
514
+ await agent.reconcile();
515
+ if (adoptOnce) {
516
+ adoptReconciled++;
517
+ if (adoptReconciled === adoptTarget) {
518
+ // All canvases adopted — clear the flag from .design/config.json
519
+ // so re-running serve doesn't re-trigger. DDR-054 §2i.
520
+ clearAdoptFlag(ctx);
521
+ }
522
+ }
523
+ }
524
+ // DDR-102 — honest status: the handshake + reconcile completed.
525
+ mon.noteDocState(canvas.slug, 'connected');
526
+ mon.noteSyncActivity(canvas.slug);
527
+ rejectedReasons.delete(canvas.slug);
528
+ };
529
+
530
+ /** onceSynced() with the boot-settle ceiling — never hangs the summary on
531
+ * an auth-rejected provider (whose handshake never completes). */
532
+ const settleWait = (p: Promise<void>): Promise<void> =>
533
+ new Promise<void>((resolve) => {
534
+ const h = authSetTimer(() => {
535
+ settleTimers.delete(h);
536
+ resolve();
537
+ }, settleTimeoutMs);
538
+ settleTimers.add(h);
539
+ p.then(
540
+ () => {
541
+ settleTimers.delete(h);
542
+ authClearTimer(h);
543
+ resolve();
544
+ },
545
+ () => {
546
+ settleTimers.delete(h);
547
+ authClearTimer(h);
548
+ resolve();
549
+ }
550
+ );
551
+ });
552
+ const bootWaits: Promise<void>[] = [];
553
+
554
+ /**
555
+ * Create + wire a provider for a canvas. Used by the boot loop and by the
556
+ * permanent-rejection re-probe (which passes the EXISTING doc so the
557
+ * agent/projection wiring — doc-scoped — survives the provider swap).
558
+ */
559
+ const connectCanvas = async (
560
+ canvas: CanvasDescriptor,
561
+ canvasPaths: import('./agent.ts').CanvasSyncPaths,
562
+ document?: Y.Doc,
563
+ setup?: (provider: SyncProvider) => void
564
+ ): Promise<SyncProvider> => {
565
+ const provider = await providerFactory({
566
+ url: linkedHub.url,
567
+ token,
568
+ documentName: canvas.slug,
569
+ document,
570
+ });
571
+ providers.set(canvas.slug, provider);
572
+ // First-connect setup (agent/projection creation + doc-scoped wiring)
573
+ // MUST run before the onceSynced chain below — handleSynced resolves the
574
+ // agent/projection from the maps, and a test stub's onceSynced can
575
+ // settle on the very next microtask.
576
+ setup?.(provider);
577
+
578
+ // Task 8 — feed this provider's WS status into the offline monitor.
579
+ if (provider.onStatus) {
580
+ statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
581
+ } else {
582
+ // No status events (test stub) — treat as connected so the monitor
583
+ // doesn't sit in the boot 'connecting' state forever.
584
+ mon.noteProviderStatus(canvas.slug, 'connected');
585
+ }
586
+ // DDR-102 — classify + aggregate hub auth rejections.
587
+ if (provider.onAuthFailed) {
588
+ statusDetaches.push(
589
+ provider.onAuthFailed(({ reason }) =>
590
+ handleAuthFailure(canvas, canvasPaths, provider, reason)
591
+ )
592
+ );
593
+ }
594
+ // Task 5 — bridge the provider's hub-synced Awareness to the Room so
595
+ // browser cursors relay cross-machine. No-op when the provider exposes
596
+ // no awareness or no registry was passed (file-sync-only tests).
597
+ if (opts.registry && provider.awareness) {
598
+ awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
599
+ }
600
+ // Cold-start reconcile fires once the provider has hub state.
601
+ const synced = provider.onceSynced().then(() => handleSynced(canvas, canvasPaths, provider));
602
+ bootWaits.push(settleWait(synced));
603
+ return provider;
604
+ };
605
+
315
606
  for (const canvas of canvases) {
316
607
  try {
317
608
  // Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
@@ -327,13 +618,6 @@ export function createSyncRuntime(
327
618
  opts.registry?.pin?.(canvas.slug);
328
619
  pinnedSlugs.add(canvas.slug);
329
620
  }
330
- const provider = await providerFactory({
331
- url: linkedHub.url,
332
- token,
333
- documentName: canvas.slug,
334
- document: sharedYDoc,
335
- });
336
- providers.set(canvas.slug, provider);
337
621
  const canvasPaths = {
338
622
  html: canvas.html,
339
623
  comments: canvas.comments,
@@ -341,139 +625,104 @@ export function createSyncRuntime(
341
625
  meta: canvas.meta,
342
626
  css: canvas.css,
343
627
  };
344
- // Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
345
- // loop-free projection (html/css/meta doc→file + all-types file→doc;
346
- // the collab room keeps comments/annotations doc→file, so no
347
- // double-write). Flag-OFF keeps the proven two-doc agent.
348
- let agent: CanvasSyncAgent | undefined;
349
- let projection: DocProjection | undefined;
350
- if (useSharedDoc && sharedYDoc) {
351
- projection = createDocProjection({
352
- slug: canvas.slug,
353
- doc: provider.document,
354
- paths: canvasPaths,
355
- echoGuard,
356
- });
357
- projection.start();
358
- projections.set(canvas.slug, projection);
359
- } else {
360
- agent = createCanvasSyncAgent({
361
- slug: canvas.slug,
362
- doc: provider.document,
363
- paths: canvasPaths,
364
- echoGuard,
365
- adopt: adoptOnce,
366
- onConflict: (info) => store.addConflict(info),
367
- });
368
- agent.start();
369
- agents.set(canvas.slug, agent);
370
- }
371
-
372
- // Task 8 — feed this provider's WS status into the offline monitor.
373
- if (provider.onStatus) {
374
- statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
375
- } else {
376
- // No status events (test stub) — treat as connected so the monitor
377
- // doesn't sit in the boot 'connecting' state forever.
378
- mon.noteProviderStatus(canvas.slug, 'connected');
379
- }
380
- // Count local edits (agent-origin doc updates) toward queuedOps while
381
- // the hub is unreachable — the banner's "N edits queued" figure. Under
382
- // sharedDoc there is no agent origin to key off (browser edits carry a
383
- // RoomConn origin); queued-edit counting in that mode is a known gap
384
- // (offline-banner accuracy only, not data) deferred past Phase C.
385
- if (agent) {
386
- const agentOrigin = agent.origin;
387
- const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
388
- if (origin === agentOrigin) mon.noteLocalEdit();
389
- };
390
- provider.document.on('update', onLocalUpdate);
391
- statusDetaches.push(() => provider.document.off('update', onLocalUpdate));
392
- }
393
-
394
- // Task 5 — bridge the provider's hub-synced Awareness to the Room so
395
- // browser cursors relay cross-machine. No-op when the provider exposes
396
- // no awareness or no registry was passed (file-sync-only tests).
397
- if (opts.registry && provider.awareness) {
398
- awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
399
- }
400
-
401
- // Relay hub-pushed comment/annotation changes straight into the live
402
- // room — IN-PROCESS + synchronous, so the room's in-memory doc is
403
- // updated BEFORE its 800ms persist timer can flush stale pre-sync state
404
- // back over the file (the disk-mediated re-seed in createCollab loses
405
- // that race under an actively-edited peer; this is the tight path that
406
- // actually closes the "comment reverts" clobber). Wholesale-replace via
407
- // syncRoomFrom* → no duplication. Skip agent-origin updates (our own
408
- // disk→doc apply — the file is authoritative there; a local design:edit
409
- // reaches the room via createCollab's fs hook instead).
410
- //
411
- // CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
412
- // whole-doc update. A whole-doc relay re-applies BOTH types on every
413
- // change, so a comment sync would re-push the (stale) annotation and
414
- // clobber an annotation the peer just drew but hasn't synced yet — and
415
- // vice versa. Per-type observers keep the two lanes independent.
416
- //
417
- // Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
418
- // room's doc, so there is no second doc to relay into — the room already
419
- // has every change. Skipping the relay is what RETIRES the
420
- // wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
421
- // CRDT merge handles concurrency, no last-writer-wins blob copy.
422
- const reg = opts.registry;
423
- if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
424
- const agentOrigin = agent.origin;
425
- const slug = canvas.slug;
426
- const provComments = provider.document.getArray(Y_TYPES.comments);
427
- const provAnn = provider.document.getMap(Y_TYPES.annotations);
428
- const onComments = (_e: unknown, tx: { origin: unknown }) => {
429
- if (tx.origin === agentOrigin) return;
430
- reg.syncRoomFromComments?.(slug, provComments.toArray());
431
- };
432
- const onAnn = (_e: unknown, tx: { origin: unknown }) => {
433
- if (tx.origin === agentOrigin) return;
434
- const svg = provAnn.get('svg');
435
- if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
436
- };
437
- provComments.observe(onComments);
438
- provAnn.observe(onAnn);
439
- statusDetaches.push(() => {
440
- provComments.unobserve(onComments);
441
- provAnn.unobserve(onAnn);
442
- });
443
- }
444
-
445
- // Cold-start reconcile fires once the provider has hub state.
446
- void provider.onceSynced().then(async () => {
447
- if (projection) {
448
- // Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
449
- // materializing: escapes the duplication trap by picking ONE source
450
- // (hub-wins if the synced doc holds state; adopt local files only
451
- // when the hub was empty), inside a MIGRATION transaction. The room
452
- // file-seed is disabled for this pinned slug (createCollab
453
- // shouldSeed), so no duplicate items can be introduced afterward.
454
- const result = migrateSeed({
628
+ mon.noteDocState(canvas.slug, 'pending');
629
+ await connectCanvas(canvas, canvasPaths, sharedYDoc, (provider) => {
630
+ // Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
631
+ // loop-free projection (html/css/meta doc→file + all-types file→doc;
632
+ // the collab room keeps comments/annotations doc→file, so no
633
+ // double-write). Flag-OFF keeps the proven two-doc agent. Created
634
+ // ONCE here (first connect) — a DDR-102 re-probe swaps only the
635
+ // provider; everything below is doc-scoped and survives.
636
+ let agent: CanvasSyncAgent | undefined;
637
+ if (useSharedDoc && sharedYDoc) {
638
+ const projection = createDocProjection({
455
639
  slug: canvas.slug,
456
640
  doc: provider.document,
457
641
  paths: canvasPaths,
458
- historyDir: path.join(ctx.paths.historyDir, canvas.slug),
642
+ echoGuard,
643
+ journal: journal ?? undefined,
459
644
  });
460
- if (result === 'local-adopt') {
461
- console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
462
- }
463
- // Then materialize the converged doc to disk (safe — never clobbers
464
- // non-empty local with an empty doc value).
465
- projection.reconcile();
466
- return;
645
+ projection.start();
646
+ projections.set(canvas.slug, projection);
647
+ } else {
648
+ const relBody = path.relative(ctx.paths.repoRoot, canvas.html);
649
+ agent = createCanvasSyncAgent({
650
+ slug: canvas.slug,
651
+ doc: provider.document,
652
+ paths: canvasPaths,
653
+ echoGuard,
654
+ adopt: adoptOnce,
655
+ journal: journal ?? undefined,
656
+ snapshot: async (content, reason) => {
657
+ try {
658
+ const snap = await history.writeSnapshot(relBody, content, reason);
659
+ return snap.ts;
660
+ } catch {
661
+ return null; // best-effort — resolution proceeds without refs
662
+ }
663
+ },
664
+ onConflict: (info) => store.addConflict(info),
665
+ });
666
+ agent.start();
667
+ agents.set(canvas.slug, agent);
467
668
  }
468
- if (!agent) return;
469
- await agent.reconcile();
470
- if (adoptOnce) {
471
- adoptReconciled++;
472
- if (adoptReconciled === adoptTarget) {
473
- // All canvases adopted clear the flag from .design/config.json
474
- // so re-running serve doesn't re-trigger. DDR-054 §2i.
475
- clearAdoptFlag(ctx);
476
- }
669
+
670
+ // Count local edits (agent-origin doc updates) toward queuedOps while
671
+ // the hub is unreachable — the banner's "N edits queued" figure. Under
672
+ // sharedDoc there is no agent origin to key off (browser edits carry a
673
+ // RoomConn origin); queued-edit counting in that mode is a known gap
674
+ // (offline-banner accuracy only, not data) deferred past Phase C.
675
+ if (agent) {
676
+ const agentOrigin = agent.origin;
677
+ const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
678
+ if (origin === agentOrigin) mon.noteLocalEdit();
679
+ };
680
+ provider.document.on('update', onLocalUpdate);
681
+ statusDetaches.push(() => provider.document.off('update', onLocalUpdate));
682
+ }
683
+
684
+ // Relay hub-pushed comment/annotation changes straight into the live
685
+ // room — IN-PROCESS + synchronous, so the room's in-memory doc is
686
+ // updated BEFORE its 800ms persist timer can flush stale pre-sync state
687
+ // back over the file (the disk-mediated re-seed in createCollab loses
688
+ // that race under an actively-edited peer; this is the tight path that
689
+ // actually closes the "comment reverts" clobber). Wholesale-replace via
690
+ // syncRoomFrom* → no duplication. Skip agent-origin updates (our own
691
+ // disk→doc apply — the file is authoritative there; a local design:edit
692
+ // reaches the room via createCollab's fs hook instead).
693
+ //
694
+ // CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
695
+ // whole-doc update. A whole-doc relay re-applies BOTH types on every
696
+ // change, so a comment sync would re-push the (stale) annotation and
697
+ // clobber an annotation the peer just drew but hasn't synced yet — and
698
+ // vice versa. Per-type observers keep the two lanes independent.
699
+ //
700
+ // Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
701
+ // room's doc, so there is no second doc to relay into — the room already
702
+ // has every change. Skipping the relay is what RETIRES the
703
+ // wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
704
+ // CRDT merge handles concurrency, no last-writer-wins blob copy.
705
+ const reg = opts.registry;
706
+ if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
707
+ const agentOrigin = agent.origin;
708
+ const slug = canvas.slug;
709
+ const provComments = provider.document.getArray(Y_TYPES.comments);
710
+ const provAnn = provider.document.getMap(Y_TYPES.annotations);
711
+ const onComments = (_e: unknown, tx: { origin: unknown }) => {
712
+ if (tx.origin === agentOrigin) return;
713
+ reg.syncRoomFromComments?.(slug, provComments.toArray());
714
+ };
715
+ const onAnn = (_e: unknown, tx: { origin: unknown }) => {
716
+ if (tx.origin === agentOrigin) return;
717
+ const svg = provAnn.get('svg');
718
+ if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
719
+ };
720
+ provComments.observe(onComments);
721
+ provAnn.observe(onAnn);
722
+ statusDetaches.push(() => {
723
+ provComments.unobserve(onComments);
724
+ provAnn.unobserve(onAnn);
725
+ });
477
726
  }
478
727
  });
479
728
  } catch (err) {
@@ -490,9 +739,32 @@ export function createSyncRuntime(
490
739
  // running" while sync is in fact healthy. This was the observed bug.
491
740
  store.update(mon.snapshot());
492
741
 
742
+ // DDR-102 — honest boot output. The old single line printed
743
+ // "83/83 canvas(es) syncing" BEFORE any handshake completed; per-canvas
744
+ // auth rejections were invisible. Print a short linking line now and the
745
+ // real summary once the handshakes settle (or the 15 s ceiling passes —
746
+ // auth-rejected providers never resolve onceSynced, so the ceiling keeps
747
+ // boot from hanging). Late canvases just update `_sync.json`.
493
748
  console.log(
494
- `[sync] linked to ${linkedHub.url} ${agents.size + projections.size}/${canvases.length} canvas(es) syncing${useSharedDoc ? ' (shared-doc)' : ''}${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
749
+ `[sync] linking to ${linkedHub.url} (${canvases.length} canvases)…${useSharedDoc ? ' (shared-doc)' : ''}${adoptOnce ? ' (adopt mode — pushing local up)' : ''}`
495
750
  );
751
+ void Promise.allSettled(bootWaits).then(() => {
752
+ if (stopped) return;
753
+ const snap = mon.snapshot();
754
+ const docs = snap.docs ?? { synced: 0, pending: 0, rejected: 0 };
755
+ const parts = [`${docs.synced}/${canvases.length} synced`];
756
+ if (docs.rejected > 0) {
757
+ const sample = (snap.rejectedSlugs ?? []).slice(0, 3).join(', ');
758
+ const classes = [...new Set(rejectedReasons.values())].join('/') || 'unknown';
759
+ parts.push(
760
+ `${docs.rejected} auth-rejected (${sample}${docs.rejected > 3 ? ', …' : ''} — ${classes})`
761
+ );
762
+ }
763
+ if (docs.pending > 0) parts.push(`${docs.pending} pending`);
764
+ console.log(
765
+ `[sync] ${linkedHub.url}: ${parts.join(' · ')} · shared-doc:${useSharedDoc ? 'on' : 'off'}`
766
+ );
767
+ });
496
768
  }
497
769
 
498
770
  async function stop(): Promise<void> {
@@ -525,6 +797,20 @@ export function createSyncRuntime(
525
797
  }
526
798
  statusDetaches.length = 0;
527
799
  monitor?.stop();
800
+ journal?.stop(); // flushes the pending debounce
801
+ journal = null;
802
+ // DDR-102 — auth/settle timers.
803
+ if (authWarnTimer !== null) {
804
+ authClearTimer(authWarnTimer);
805
+ authWarnTimer = null;
806
+ }
807
+ if (reprobeTimer !== null) {
808
+ authClearTimer(reprobeTimer);
809
+ reprobeTimer = null;
810
+ }
811
+ for (const h of settleTimers) authClearTimer(h);
812
+ settleTimers.clear();
813
+ rejectedPermanent.clear();
528
814
  busUnsub?.();
529
815
  busUnsub = null;
530
816
  fsReader?.stop();
@@ -559,6 +845,8 @@ export function createSyncRuntime(
559
845
  }
560
846
  }
561
847
  providers.clear();
848
+ // DDR-102 — destroy the shared WebSocket(s) AFTER the providers detached.
849
+ ownedFactory?.dispose();
562
850
  }
563
851
 
564
852
  return {
@@ -809,93 +1097,147 @@ function slugFor(absPath: string, designRoot: string, designRel: string): string
809
1097
  /* ---------------------------------------------------------------- default provider */
810
1098
 
811
1099
  /**
812
- * The production provider factory instantiates a real HocuspocusProvider.
813
- * Imported dynamically so tests / unlinked projects don't pay the load cost.
1100
+ * A ProviderFactory whose shared resources (the per-hub WebSocket) the runtime
1101
+ * can dispose on stop(). The call signature stays the plain ProviderFactory
1102
+ * shape so injected test stubs are unaffected.
814
1103
  */
815
- async function defaultProviderFactory(args: {
816
- url: string;
817
- token: string;
818
- documentName: string;
819
- document?: Y.Doc;
820
- }): Promise<SyncProvider> {
1104
+ export interface DisposableProviderFactory extends ProviderFactory {
1105
+ /** Destroy the shared HocuspocusProviderWebsocket(s). Call AFTER provider
1106
+ * destroys — a provider detach sends a Close message over the socket. */
1107
+ dispose(): void;
1108
+ }
1109
+
1110
+ /**
1111
+ * The production provider factory — DDR-102: ONE shared
1112
+ * `HocuspocusProviderWebsocket` per hub URL, with every canvas's
1113
+ * `HocuspocusProvider` attached to it, instead of one socket per canvas.
1114
+ * An 83-canvas project used to open 83 WebSockets at boot — the auth burst
1115
+ * tripped the hub's per-token rate limit (100/min) the moment two peers
1116
+ * booted together, and the per-socket retry storm then pinned the bucket
1117
+ * forever (the 2026-06-11 incident). NB: the hub still authenticates once per
1118
+ * DOCUMENT (each provider sends its own Auth message on socket open), so the
1119
+ * hub-side valid-token bucket resize is the companion fix — the multiplexing
1120
+ * kills the SOCKET burst and collapses the retry storm to one reconnect loop.
1121
+ *
1122
+ * Module + socket creation are lazy so tests / unlinked projects don't pay
1123
+ * the load cost. `loadModule` is injectable for unit tests.
1124
+ */
1125
+ export function createDefaultProviderFactory(
821
1126
  // biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional dep.
822
- let mod: any;
823
- try {
824
- mod = await import('@hocuspocus/provider');
825
- } catch (err) {
826
- throw new Error(
827
- `@hocuspocus/provider unavailable install it under apps/studio/. (${err instanceof Error ? err.message : String(err)})`
828
- );
829
- }
830
- // Hocuspocus accepts ws:// or wss://; the linked URL is http(s)://, so swap
831
- // the scheme. The provider also accepts http(s):// and upgrades internally
832
- // in newer versions, but ws:// is explicit + portable.
833
- const wsUrl = toWsUrl(args.url);
834
- // Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
835
- // injected one; otherwise own a fresh doc (the legacy two-doc path).
836
- const document = args.document ?? new Y.Doc();
837
- // biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
838
- const provider: any = new mod.HocuspocusProvider({
839
- url: wsUrl,
840
- name: args.documentName,
841
- token: args.token,
842
- document,
843
- connect: true,
844
- // Make a rejected handshake LOUD instead of an invisible reconnect loop.
845
- // The most common cause is a scope-bound token (DDR-053 default scope =
846
- // label) that can't authorize this canvas's flat-slug documentName that
847
- // failure mode previously looked identical to "hub unreachable / offline".
848
- onAuthenticationFailed: (data: { reason?: string }) => {
849
- const reason = (data?.reason ?? 'unknown reason').replace(/[\r\n]/g, ' ').slice(0, 200);
850
- console.warn(
851
- `[sync] hub REJECTED auth for documentName="${args.documentName}": ${reason}.
852
- If this is "token not authorized for this documentName", the token's scope
853
- does not cover this canvas — mint a hub-wide token (\`maude hub token generate
854
- --scope '*'\` or an admin-UI invite) and re-link. The peer keeps retrying until then.`
855
- );
856
- },
857
- });
858
- return {
859
- document,
860
- // HocuspocusProvider creates a hub-synced Awareness by default; expose it
861
- // so the runtime can bridge it to the collab Room (Task 5).
862
- awareness: provider.awareness as Awareness | undefined,
863
- onStatus(cb: (status: ProviderStatus) => void): () => void {
864
- // HocuspocusProvider emits 'status' with { status: 'connected' |
865
- // 'connecting' | 'disconnected' } on every WS transition (Task 8).
866
- const handler = (evt: { status?: string }) => {
867
- const s = evt?.status;
868
- if (s === 'connected' || s === 'connecting' || s === 'disconnected') cb(s);
869
- };
870
- provider.on('status', handler);
871
- // Seed the subscriber with the provider's CURRENT status immediately. On
872
- // localhost the WS can reach 'connected'/synced inside the constructor —
873
- // before this listener attaches so waiting for the *next* transition
874
- // would leave the connection monitor un-seeded and `_sync.json` never
875
- // written (status reads "idle / sync agent not running" while sync is in
876
- // fact healthy). Reading provider.status closes that race; it always
877
- // reflects the true current state, so a missed event can't strand us.
878
- const cur = provider.status;
879
- if (cur === 'connected' || cur === 'connecting' || cur === 'disconnected') cb(cur);
880
- return () => provider.off('status', handler);
881
- },
882
- onceSynced(): Promise<void> {
883
- return new Promise<void>((resolve) => {
884
- if (provider.synced) {
885
- resolve();
886
- return;
887
- }
888
- const handler = () => {
889
- provider.off('synced', handler);
890
- resolve();
1127
+ loadModule?: () => Promise<any>
1128
+ ): DisposableProviderFactory {
1129
+ // biome-ignore lint/suspicious/noExplicitAny: provider runtime typed at call site.
1130
+ let mod: any = null;
1131
+ // wsUrl → shared HocuspocusProviderWebsocket (one per hub URL; in practice a
1132
+ // runtime only ever talks to one hub, but the map keeps the contract exact).
1133
+ // biome-ignore lint/suspicious/noExplicitAny: provider runtime typed at call site.
1134
+ const sockets = new Map<string, any>();
1135
+
1136
+ const factory = async (args: {
1137
+ url: string;
1138
+ token: string;
1139
+ documentName: string;
1140
+ document?: Y.Doc;
1141
+ }): Promise<SyncProvider> => {
1142
+ if (!mod) {
1143
+ try {
1144
+ mod = await (loadModule ? loadModule() : import('@hocuspocus/provider'));
1145
+ } catch (err) {
1146
+ throw new Error(
1147
+ `@hocuspocus/provider unavailable — install it under apps/studio/. (${err instanceof Error ? err.message : String(err)})`
1148
+ );
1149
+ }
1150
+ }
1151
+ // Hocuspocus accepts ws:// or wss://; the linked URL is http(s)://, so swap
1152
+ // the scheme. The provider also accepts http(s):// and upgrades internally
1153
+ // in newer versions, but ws:// is explicit + portable.
1154
+ const wsUrl = toWsUrl(args.url);
1155
+ let socket = sockets.get(wsUrl);
1156
+ if (!socket) {
1157
+ socket = new mod.HocuspocusProviderWebsocket({ url: wsUrl });
1158
+ sockets.set(wsUrl, socket);
1159
+ }
1160
+ // Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
1161
+ // injected one; otherwise own a fresh doc (the legacy two-doc path).
1162
+ const document = args.document ?? new Y.Doc();
1163
+ // DDR-102 — rejections fan out to the runtime's aggregator (ONE debounced
1164
+ // warn with a reason-correct hint) instead of one 5-line warn per document
1165
+ // per retry. The reason is sanitized here, classified by the runtime.
1166
+ const authFailedCbs = new Set<(info: { reason: string }) => void>();
1167
+ // biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
1168
+ const provider: any = new mod.HocuspocusProvider({
1169
+ websocketProvider: socket,
1170
+ name: args.documentName,
1171
+ token: args.token,
1172
+ document,
1173
+ onAuthenticationFailed: (data: { reason?: string }) => {
1174
+ const reason = (data?.reason ?? 'permission-denied').replace(/[\r\n]/g, ' ').slice(0, 200);
1175
+ for (const cb of authFailedCbs) cb({ reason });
1176
+ },
1177
+ });
1178
+ // With an injected websocketProvider the provider does NOT auto-attach
1179
+ // (manageSocket=false in @hocuspocus/provider 4.x) attach explicitly.
1180
+ provider.attach();
1181
+ return {
1182
+ document,
1183
+ // HocuspocusProvider creates a hub-synced Awareness by default; expose it
1184
+ // so the runtime can bridge it to the collab Room (Task 5).
1185
+ awareness: provider.awareness as Awareness | undefined,
1186
+ onStatus(cb: (status: ProviderStatus) => void): () => void {
1187
+ // The shared socket emits 'status' on every WS transition and every
1188
+ // ATTACHED provider re-emits it (forwardStatus), so per-provider
1189
+ // subscription keeps working under multiplexing.
1190
+ const handler = (evt: { status?: string }) => {
1191
+ const s = evt?.status;
1192
+ if (s === 'connected' || s === 'connecting' || s === 'disconnected') cb(s);
891
1193
  };
892
- provider.on('synced', handler);
893
- });
894
- },
895
- destroy() {
896
- provider.destroy();
897
- },
1194
+ provider.on('status', handler);
1195
+ // Seed the subscriber with the CURRENT status immediately. On
1196
+ // localhost the WS can reach 'connected'/synced before this listener
1197
+ // attaches — waiting for the *next* transition would leave the
1198
+ // connection monitor un-seeded and `_sync.json` never written. The
1199
+ // socket (not the provider) owns the live status under multiplexing.
1200
+ const cur = socket.status;
1201
+ if (cur === 'connected' || cur === 'connecting' || cur === 'disconnected') cb(cur);
1202
+ return () => provider.off('status', handler);
1203
+ },
1204
+ onAuthFailed(cb: (info: { reason: string }) => void): () => void {
1205
+ authFailedCbs.add(cb);
1206
+ return () => authFailedCbs.delete(cb);
1207
+ },
1208
+ onceSynced(): Promise<void> {
1209
+ return new Promise<void>((resolve) => {
1210
+ if (provider.synced) {
1211
+ resolve();
1212
+ return;
1213
+ }
1214
+ const handler = () => {
1215
+ provider.off('synced', handler);
1216
+ resolve();
1217
+ };
1218
+ provider.on('synced', handler);
1219
+ });
1220
+ },
1221
+ destroy() {
1222
+ // Detaches from the shared socket (sends a per-document Close); the
1223
+ // socket itself is destroyed by dispose() after all providers.
1224
+ provider.destroy();
1225
+ },
1226
+ };
898
1227
  };
1228
+
1229
+ return Object.assign(factory, {
1230
+ dispose(): void {
1231
+ for (const socket of sockets.values()) {
1232
+ try {
1233
+ socket.destroy();
1234
+ } catch {
1235
+ /* best-effort */
1236
+ }
1237
+ }
1238
+ sockets.clear();
1239
+ },
1240
+ });
899
1241
  }
900
1242
 
901
1243
  /** Convert an http(s):// URL to ws(s):// for the HocuspocusProvider. */