@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.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
642
|
+
echoGuard,
|
|
643
|
+
journal: journal ?? undefined,
|
|
459
644
|
});
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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]
|
|
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
|
-
*
|
|
813
|
-
*
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
document
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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('
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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. */
|