@1agh/maude 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +333 -19
- package/plugins/design/dev-server/client/app.jsx +155 -7
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +126 -13
- package/plugins/design/dev-server/dist/comment-mount.js +78 -11
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +26 -3
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +49 -1
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -24,11 +24,21 @@ import path from 'node:path';
|
|
|
24
24
|
import type { Awareness } from 'y-protocols/awareness';
|
|
25
25
|
import * as Y from 'yjs';
|
|
26
26
|
|
|
27
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
27
28
|
import type { Context } from '../context.ts';
|
|
28
29
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
30
|
+
import {
|
|
31
|
+
type ConnectionMonitor,
|
|
32
|
+
type ProviderStatus,
|
|
33
|
+
createConnectionMonitor,
|
|
34
|
+
} from './connection-state.ts';
|
|
29
35
|
import { type EchoGuard, createEchoGuard } from './echo-guard.ts';
|
|
30
36
|
import { type FsReader, createFsReader } from './fs-mirror.ts';
|
|
31
37
|
import { getHubToken } from './hubs-config.ts';
|
|
38
|
+
import { migrateSeed } from './migrate-seed.ts';
|
|
39
|
+
import { type DocProjection, createDocProjection } from './projection.ts';
|
|
40
|
+
import { type SyncStatusStore, createSyncStatusStore } from './status.ts';
|
|
41
|
+
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
32
42
|
|
|
33
43
|
/** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
|
|
34
44
|
export interface SyncProvider {
|
|
@@ -42,6 +52,12 @@ export interface SyncProvider {
|
|
|
42
52
|
readonly awareness?: Awareness;
|
|
43
53
|
/** Resolves when the first hub sync handshake completes. */
|
|
44
54
|
onceSynced(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
|
|
57
|
+
* mode). Returns an unsubscribe fn. Optional — a test stub or a provider
|
|
58
|
+
* without status events just isn't monitored (treated as always-online).
|
|
59
|
+
*/
|
|
60
|
+
onStatus?(cb: (status: ProviderStatus) => void): () => void;
|
|
45
61
|
destroy(): void;
|
|
46
62
|
}
|
|
47
63
|
|
|
@@ -52,6 +68,23 @@ export interface SyncProvider {
|
|
|
52
68
|
*/
|
|
53
69
|
export interface AwarenessRegistry {
|
|
54
70
|
attachHubAwareness(slug: string, awareness: Awareness): () => void;
|
|
71
|
+
/** Phase 9.1 — relay a hub-pushed comment/annotation snapshot into a live
|
|
72
|
+
* room (wholesale, in-process) so the peer's canvas reflects it immediately
|
|
73
|
+
* and the room's own debounced persist can't clobber the synced state back.
|
|
74
|
+
* Optional so file-sync-only tests can pass a minimal registry. */
|
|
75
|
+
syncRoomFromComments?(slug: string, comments: readonly unknown[]): void;
|
|
76
|
+
syncRoomFromAnnotations?(slug: string, svg: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Phase 9.2 (DDR-064) — the single cached `Y.Doc` for a slug. When present
|
|
79
|
+
* AND `ctx.sharedDoc` is set, the runtime attaches the HocuspocusProvider to
|
|
80
|
+
* THIS doc instead of a fresh one (the convergence: one doc, both providers).
|
|
81
|
+
* Optional so file-sync-only tests can pass a minimal registry.
|
|
82
|
+
*/
|
|
83
|
+
getDoc?(slug: string): Y.Doc;
|
|
84
|
+
/** Keep a shared-doc room alive while its provider is attached (drop guard). */
|
|
85
|
+
pin?(slug: string): void;
|
|
86
|
+
/** Release the shared-doc pin on runtime stop. */
|
|
87
|
+
unpin?(slug: string): void;
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
/** Factory the runtime calls per discovered canvas. Default uses Hocuspocus. */
|
|
@@ -59,6 +92,13 @@ export type ProviderFactory = (args: {
|
|
|
59
92
|
url: string;
|
|
60
93
|
token: string;
|
|
61
94
|
documentName: string;
|
|
95
|
+
/**
|
|
96
|
+
* Phase 9.2 (DDR-064) — when set, the provider MUST attach to this existing
|
|
97
|
+
* `Y.Doc` (the shared room doc) instead of creating its own. The runtime
|
|
98
|
+
* passes it only when `ctx.sharedDoc` is on and the registry exposes
|
|
99
|
+
* `getDoc`. Default factory: `args.document ?? new Y.Doc()`.
|
|
100
|
+
*/
|
|
101
|
+
document?: Y.Doc;
|
|
62
102
|
}) => SyncProvider | Promise<SyncProvider>;
|
|
63
103
|
|
|
64
104
|
export interface SyncRuntime {
|
|
@@ -68,6 +108,8 @@ export interface SyncRuntime {
|
|
|
68
108
|
size(): number;
|
|
69
109
|
/** Test inspection — get the agent for a slug if one was created. */
|
|
70
110
|
agentFor(slug: string): CanvasSyncAgent | undefined;
|
|
111
|
+
/** Current offline/sync status payload (Task 8), or null when unlinked. */
|
|
112
|
+
status(): import('./status.ts').SyncStatusPayload | null;
|
|
71
113
|
}
|
|
72
114
|
|
|
73
115
|
export interface CreateSyncRuntimeOptions {
|
|
@@ -83,6 +125,14 @@ export interface CreateSyncRuntimeOptions {
|
|
|
83
125
|
* unit tests that only exercise the file-sync path.
|
|
84
126
|
*/
|
|
85
127
|
registry?: AwarenessRegistry;
|
|
128
|
+
/**
|
|
129
|
+
* Override the offline-mode connection monitor (Task 8 test injection —
|
|
130
|
+
* lets tests pass injectable timers/clock). Defaults to a real monitor that
|
|
131
|
+
* writes `_sync.json` + broadcasts 'sync:status' on the bus.
|
|
132
|
+
*/
|
|
133
|
+
connectionMonitor?: ConnectionMonitor;
|
|
134
|
+
/** Override the status store (Task 8 test injection). */
|
|
135
|
+
statusStore?: SyncStatusStore;
|
|
86
136
|
}
|
|
87
137
|
|
|
88
138
|
/**
|
|
@@ -95,6 +145,11 @@ export interface CanvasDescriptor {
|
|
|
95
145
|
html: string;
|
|
96
146
|
comments: string;
|
|
97
147
|
annotations: string;
|
|
148
|
+
/** The canvas `.meta.json` sidecar (sibling of the body). Phase 9.1 Gap 2 —
|
|
149
|
+
* shared keys (layout/artboards) sync; per-user viewport stays local. */
|
|
150
|
+
meta: string;
|
|
151
|
+
/** The canvas's sibling `.css` (Phase 9.1 Gap 3), synced as opaque text. */
|
|
152
|
+
css: string;
|
|
98
153
|
}
|
|
99
154
|
|
|
100
155
|
/**
|
|
@@ -143,36 +198,93 @@ export function createSyncRuntime(
|
|
|
143
198
|
const providerFactory = opts.providerFactory ?? defaultProviderFactory;
|
|
144
199
|
const echoGuard = createEchoGuard();
|
|
145
200
|
const agents = new Map<string, CanvasSyncAgent>();
|
|
201
|
+
// Phase 9.2 (DDR-064) — under sharedDoc the disk handler is a loop-free
|
|
202
|
+
// projection (sole owner of html/css/meta doc→file + all-types file→doc),
|
|
203
|
+
// created INSTEAD of an agent. Exactly one of agents/projections is populated
|
|
204
|
+
// per run (chosen by useSharedDoc).
|
|
205
|
+
const projections = new Map<string, DocProjection>();
|
|
146
206
|
const providers = new Map<string, SyncProvider>();
|
|
147
207
|
const awarenessDetaches: Array<() => void> = [];
|
|
208
|
+
const statusDetaches: Array<() => void> = [];
|
|
209
|
+
// Phase 9.2 (DDR-064) — slugs pinned in the registry because a provider is
|
|
210
|
+
// attached to their shared doc; released on stop(). Empty unless sharedDoc.
|
|
211
|
+
const pinnedSlugs = new Set<string>();
|
|
212
|
+
// The shared-doc convergence path is active only when the flag is ON AND the
|
|
213
|
+
// registry can hand us the canvas's single doc. Flag OFF / no registry / a
|
|
214
|
+
// minimal test registry without getDoc → the proven two-doc path, unchanged.
|
|
215
|
+
const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
|
|
148
216
|
let fsReader: FsReader | null = null;
|
|
149
217
|
let busUnsub: (() => void) | null = null;
|
|
150
218
|
let started = false;
|
|
151
219
|
let stopped = false;
|
|
152
220
|
|
|
221
|
+
// Task 8 — offline-mode status surface, initialized in start() once the
|
|
222
|
+
// canvas count is known. The store writes `_sync.json` + broadcasts
|
|
223
|
+
// 'sync:status' on the bus; the monitor aggregates provider WS status into
|
|
224
|
+
// online/offline/escalated and feeds every change to the store.
|
|
225
|
+
let statusStore: SyncStatusStore | null = null;
|
|
226
|
+
let monitor: ConnectionMonitor | null = null;
|
|
227
|
+
|
|
153
228
|
async function start(): Promise<void> {
|
|
154
229
|
if (started || stopped) return;
|
|
155
230
|
started = true;
|
|
156
231
|
|
|
157
|
-
const
|
|
232
|
+
const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
|
|
233
|
+
const canvases = scan.canvases;
|
|
234
|
+
// T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
|
|
235
|
+
// content, so the whole set is untrusted Claude-context. Mark it (writes
|
|
236
|
+
// `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
|
|
237
|
+
// when the set is empty). Best-effort — never throws into boot.
|
|
238
|
+
writeUntrustedMarkers(ctx, canvases, linkedHub.url);
|
|
158
239
|
if (canvases.length === 0) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
)
|
|
240
|
+
// DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
|
|
241
|
+
// while syncing nothing (TSX-only projects: discovery admits .html only,
|
|
242
|
+
// .tsx needs the opt-in + sandbox gate that 9.1-A/B ship). Surface the gap
|
|
243
|
+
// loudly: a warn, a `_sync.json` the CLI + browser banner read, and a bus
|
|
244
|
+
// broadcast so open tabs render it immediately.
|
|
245
|
+
surfaceNoSyncable(ctx, linkedHub.url, scan.tsxCount);
|
|
162
246
|
return;
|
|
163
247
|
}
|
|
164
248
|
|
|
249
|
+
statusStore =
|
|
250
|
+
opts.statusStore ??
|
|
251
|
+
createSyncStatusStore({
|
|
252
|
+
url: linkedHub.url,
|
|
253
|
+
canvases: canvases.length,
|
|
254
|
+
sharedDoc: useSharedDoc,
|
|
255
|
+
write: (payload) => {
|
|
256
|
+
const file = path.join(ctx.paths.designRoot, '_sync.json');
|
|
257
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
258
|
+
},
|
|
259
|
+
broadcast: (payload) => ctx.bus.emit('sync:status', payload),
|
|
260
|
+
});
|
|
261
|
+
const store = statusStore;
|
|
262
|
+
monitor =
|
|
263
|
+
opts.connectionMonitor ?? createConnectionMonitor({ onChange: (snap) => store.update(snap) });
|
|
264
|
+
const mon = monitor;
|
|
265
|
+
|
|
165
266
|
const reader = createFsReader({
|
|
166
267
|
rootDir: ctx.paths.designRoot,
|
|
167
268
|
accept: (rel) => {
|
|
168
269
|
const ext = path.extname(rel).toLowerCase();
|
|
169
|
-
|
|
270
|
+
// `.tsx` added in T3 (9.1-B) so local edits to an opted-in syncable
|
|
271
|
+
// `.tsx` body propagate. Non-syncable `.tsx` changes still notify but
|
|
272
|
+
// match no agent in onRead (descriptor-scoped) → harmless no-op.
|
|
273
|
+
// `.css` added in Gap 3 so the canvas's sibling stylesheet syncs.
|
|
274
|
+
return (
|
|
275
|
+
ext === '.html' || ext === '.tsx' || ext === '.json' || ext === '.svg' || ext === '.css'
|
|
276
|
+
);
|
|
170
277
|
},
|
|
171
278
|
onRead: (evt) => {
|
|
279
|
+
const abs = path.join(ctx.paths.designRoot, evt.path);
|
|
280
|
+
// Dispatch to whichever disk handler owns this path. Only one of
|
|
281
|
+
// agents/projections is populated (useSharedDoc decides); the projector
|
|
282
|
+
// exposes the same applyFromFs(evt) shape as the agent.
|
|
172
283
|
for (const agent of agents.values()) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
284
|
+
if (agent.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
|
|
285
|
+
}
|
|
286
|
+
for (const proj of projections.values()) {
|
|
287
|
+
if (proj.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
|
|
176
288
|
}
|
|
177
289
|
},
|
|
178
290
|
});
|
|
@@ -188,25 +300,82 @@ export function createSyncRuntime(
|
|
|
188
300
|
|
|
189
301
|
for (const canvas of canvases) {
|
|
190
302
|
try {
|
|
303
|
+
// Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
|
|
304
|
+
// the collab room's single Y.Doc (registry.getDoc) instead of a fresh
|
|
305
|
+
// one, so browser edits flow straight into the doc that syncs to the
|
|
306
|
+
// hub — no disk hop, no relay, no clobber. Pin the room so the
|
|
307
|
+
// last-browser-leaves drop can't destroy the doc out from under the
|
|
308
|
+
// provider. NB: cold-start seeding of a divergent local+hub doc is the
|
|
309
|
+
// duplication trap (Risk 1) — made safe by Phase E (migrate-seed); the
|
|
310
|
+
// flag stays OFF until then.
|
|
311
|
+
const sharedYDoc = useSharedDoc ? opts.registry?.getDoc?.(canvas.slug) : undefined;
|
|
312
|
+
if (useSharedDoc && sharedYDoc) {
|
|
313
|
+
opts.registry?.pin?.(canvas.slug);
|
|
314
|
+
pinnedSlugs.add(canvas.slug);
|
|
315
|
+
}
|
|
191
316
|
const provider = await providerFactory({
|
|
192
317
|
url: linkedHub.url,
|
|
193
318
|
token,
|
|
194
319
|
documentName: canvas.slug,
|
|
320
|
+
document: sharedYDoc,
|
|
195
321
|
});
|
|
196
322
|
providers.set(canvas.slug, provider);
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
agent
|
|
209
|
-
|
|
323
|
+
const canvasPaths = {
|
|
324
|
+
html: canvas.html,
|
|
325
|
+
comments: canvas.comments,
|
|
326
|
+
annotations: canvas.annotations,
|
|
327
|
+
meta: canvas.meta,
|
|
328
|
+
css: canvas.css,
|
|
329
|
+
};
|
|
330
|
+
// Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
|
|
331
|
+
// loop-free projection (html/css/meta doc→file + all-types file→doc;
|
|
332
|
+
// the collab room keeps comments/annotations doc→file, so no
|
|
333
|
+
// double-write). Flag-OFF keeps the proven two-doc agent.
|
|
334
|
+
let agent: CanvasSyncAgent | undefined;
|
|
335
|
+
let projection: DocProjection | undefined;
|
|
336
|
+
if (useSharedDoc && sharedYDoc) {
|
|
337
|
+
projection = createDocProjection({
|
|
338
|
+
slug: canvas.slug,
|
|
339
|
+
doc: provider.document,
|
|
340
|
+
paths: canvasPaths,
|
|
341
|
+
echoGuard,
|
|
342
|
+
});
|
|
343
|
+
projection.start();
|
|
344
|
+
projections.set(canvas.slug, projection);
|
|
345
|
+
} else {
|
|
346
|
+
agent = createCanvasSyncAgent({
|
|
347
|
+
slug: canvas.slug,
|
|
348
|
+
doc: provider.document,
|
|
349
|
+
paths: canvasPaths,
|
|
350
|
+
echoGuard,
|
|
351
|
+
adopt: adoptOnce,
|
|
352
|
+
onConflict: (info) => store.addConflict(info),
|
|
353
|
+
});
|
|
354
|
+
agent.start();
|
|
355
|
+
agents.set(canvas.slug, agent);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Task 8 — feed this provider's WS status into the offline monitor.
|
|
359
|
+
if (provider.onStatus) {
|
|
360
|
+
statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
|
|
361
|
+
} else {
|
|
362
|
+
// No status events (test stub) — treat as connected so the monitor
|
|
363
|
+
// doesn't sit in the boot 'connecting' state forever.
|
|
364
|
+
mon.noteProviderStatus(canvas.slug, 'connected');
|
|
365
|
+
}
|
|
366
|
+
// Count local edits (agent-origin doc updates) toward queuedOps while
|
|
367
|
+
// the hub is unreachable — the banner's "N edits queued" figure. Under
|
|
368
|
+
// sharedDoc there is no agent origin to key off (browser edits carry a
|
|
369
|
+
// RoomConn origin); queued-edit counting in that mode is a known gap
|
|
370
|
+
// (offline-banner accuracy only, not data) deferred past Phase C.
|
|
371
|
+
if (agent) {
|
|
372
|
+
const agentOrigin = agent.origin;
|
|
373
|
+
const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
|
|
374
|
+
if (origin === agentOrigin) mon.noteLocalEdit();
|
|
375
|
+
};
|
|
376
|
+
provider.document.on('update', onLocalUpdate);
|
|
377
|
+
statusDetaches.push(() => provider.document.off('update', onLocalUpdate));
|
|
378
|
+
}
|
|
210
379
|
|
|
211
380
|
// Task 5 — bridge the provider's hub-synced Awareness to the Room so
|
|
212
381
|
// browser cursors relay cross-machine. No-op when the provider exposes
|
|
@@ -215,8 +384,74 @@ export function createSyncRuntime(
|
|
|
215
384
|
awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
|
|
216
385
|
}
|
|
217
386
|
|
|
387
|
+
// Relay hub-pushed comment/annotation changes straight into the live
|
|
388
|
+
// room — IN-PROCESS + synchronous, so the room's in-memory doc is
|
|
389
|
+
// updated BEFORE its 800ms persist timer can flush stale pre-sync state
|
|
390
|
+
// back over the file (the disk-mediated re-seed in createCollab loses
|
|
391
|
+
// that race under an actively-edited peer; this is the tight path that
|
|
392
|
+
// actually closes the "comment reverts" clobber). Wholesale-replace via
|
|
393
|
+
// syncRoomFrom* → no duplication. Skip agent-origin updates (our own
|
|
394
|
+
// disk→doc apply — the file is authoritative there; a local design:edit
|
|
395
|
+
// reaches the room via createCollab's fs hook instead).
|
|
396
|
+
//
|
|
397
|
+
// CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
|
|
398
|
+
// whole-doc update. A whole-doc relay re-applies BOTH types on every
|
|
399
|
+
// change, so a comment sync would re-push the (stale) annotation and
|
|
400
|
+
// clobber an annotation the peer just drew but hasn't synced yet — and
|
|
401
|
+
// vice versa. Per-type observers keep the two lanes independent.
|
|
402
|
+
//
|
|
403
|
+
// Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
|
|
404
|
+
// room's doc, so there is no second doc to relay into — the room already
|
|
405
|
+
// has every change. Skipping the relay is what RETIRES the
|
|
406
|
+
// wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
|
|
407
|
+
// CRDT merge handles concurrency, no last-writer-wins blob copy.
|
|
408
|
+
const reg = opts.registry;
|
|
409
|
+
if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
|
|
410
|
+
const agentOrigin = agent.origin;
|
|
411
|
+
const slug = canvas.slug;
|
|
412
|
+
const provComments = provider.document.getArray(Y_TYPES.comments);
|
|
413
|
+
const provAnn = provider.document.getMap(Y_TYPES.annotations);
|
|
414
|
+
const onComments = (_e: unknown, tx: { origin: unknown }) => {
|
|
415
|
+
if (tx.origin === agentOrigin) return;
|
|
416
|
+
reg.syncRoomFromComments?.(slug, provComments.toArray());
|
|
417
|
+
};
|
|
418
|
+
const onAnn = (_e: unknown, tx: { origin: unknown }) => {
|
|
419
|
+
if (tx.origin === agentOrigin) return;
|
|
420
|
+
const svg = provAnn.get('svg');
|
|
421
|
+
if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
|
|
422
|
+
};
|
|
423
|
+
provComments.observe(onComments);
|
|
424
|
+
provAnn.observe(onAnn);
|
|
425
|
+
statusDetaches.push(() => {
|
|
426
|
+
provComments.unobserve(onComments);
|
|
427
|
+
provAnn.unobserve(onAnn);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
218
431
|
// Cold-start reconcile fires once the provider has hub state.
|
|
219
432
|
void provider.onceSynced().then(async () => {
|
|
433
|
+
if (projection) {
|
|
434
|
+
// Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
|
|
435
|
+
// materializing: escapes the duplication trap by picking ONE source
|
|
436
|
+
// (hub-wins if the synced doc holds state; adopt local files only
|
|
437
|
+
// when the hub was empty), inside a MIGRATION transaction. The room
|
|
438
|
+
// file-seed is disabled for this pinned slug (createCollab
|
|
439
|
+
// shouldSeed), so no duplicate items can be introduced afterward.
|
|
440
|
+
const result = migrateSeed({
|
|
441
|
+
slug: canvas.slug,
|
|
442
|
+
doc: provider.document,
|
|
443
|
+
paths: canvasPaths,
|
|
444
|
+
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
445
|
+
});
|
|
446
|
+
if (result === 'local-adopt') {
|
|
447
|
+
console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
|
|
448
|
+
}
|
|
449
|
+
// Then materialize the converged doc to disk (safe — never clobbers
|
|
450
|
+
// non-empty local with an empty doc value).
|
|
451
|
+
projection.reconcile();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (!agent) return;
|
|
220
455
|
await agent.reconcile();
|
|
221
456
|
if (adoptOnce) {
|
|
222
457
|
adoptReconciled++;
|
|
@@ -232,8 +467,17 @@ export function createSyncRuntime(
|
|
|
232
467
|
}
|
|
233
468
|
}
|
|
234
469
|
|
|
470
|
+
// Persist an initial status snapshot so `_sync.json` exists — and `maude
|
|
471
|
+
// design status` + the browser banner report "agent running" — from the
|
|
472
|
+
// moment serve boots. The ConnectionMonitor only emits on *transitions* and
|
|
473
|
+
// starts in 'online', so on a clean fast localhost connect (provider
|
|
474
|
+
// reaches 'connected' at/before subscribe → no transition fires) nothing
|
|
475
|
+
// would otherwise be written, and status would read "idle / sync agent not
|
|
476
|
+
// running" while sync is in fact healthy. This was the observed bug.
|
|
477
|
+
store.update(mon.snapshot());
|
|
478
|
+
|
|
235
479
|
console.log(
|
|
236
|
-
`[sync] linked to ${linkedHub.url} — ${agents.size}/${canvases.length} canvas(es) syncing${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
|
|
480
|
+
`[sync] linked to ${linkedHub.url} — ${agents.size + projections.size}/${canvases.length} canvas(es) syncing${useSharedDoc ? ' (shared-doc)' : ''}${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
|
|
237
481
|
);
|
|
238
482
|
}
|
|
239
483
|
|
|
@@ -248,6 +492,25 @@ export function createSyncRuntime(
|
|
|
248
492
|
}
|
|
249
493
|
}
|
|
250
494
|
awarenessDetaches.length = 0;
|
|
495
|
+
// Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
|
|
496
|
+
// / destroyed normally on shutdown. Empty unless sharedDoc was active.
|
|
497
|
+
for (const slug of pinnedSlugs) {
|
|
498
|
+
try {
|
|
499
|
+
opts.registry?.unpin?.(slug);
|
|
500
|
+
} catch {
|
|
501
|
+
/* best-effort */
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
pinnedSlugs.clear();
|
|
505
|
+
for (const detach of statusDetaches) {
|
|
506
|
+
try {
|
|
507
|
+
detach();
|
|
508
|
+
} catch {
|
|
509
|
+
/* best-effort */
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
statusDetaches.length = 0;
|
|
513
|
+
monitor?.stop();
|
|
251
514
|
busUnsub?.();
|
|
252
515
|
busUnsub = null;
|
|
253
516
|
fsReader?.stop();
|
|
@@ -261,6 +524,19 @@ export function createSyncRuntime(
|
|
|
261
524
|
}
|
|
262
525
|
}
|
|
263
526
|
agents.clear();
|
|
527
|
+
// Phase 9.2 (DDR-064) — final doc→file flush + stop the projectors BEFORE
|
|
528
|
+
// tearing down providers (so the converged doc lands on disk). The shared
|
|
529
|
+
// doc itself is owned by the collab room and destroyed by registry teardown,
|
|
530
|
+
// not here.
|
|
531
|
+
for (const proj of projections.values()) {
|
|
532
|
+
try {
|
|
533
|
+
await proj.flush();
|
|
534
|
+
proj.stop();
|
|
535
|
+
} catch {
|
|
536
|
+
/* best-effort */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
projections.clear();
|
|
264
540
|
for (const provider of providers.values()) {
|
|
265
541
|
try {
|
|
266
542
|
provider.destroy();
|
|
@@ -274,8 +550,11 @@ export function createSyncRuntime(
|
|
|
274
550
|
return {
|
|
275
551
|
start,
|
|
276
552
|
stop,
|
|
277
|
-
|
|
553
|
+
// Under sharedDoc the per-canvas handler is a projection, not an agent;
|
|
554
|
+
// count both so size() reflects the synced-canvas count in either mode.
|
|
555
|
+
size: () => agents.size + projections.size,
|
|
278
556
|
agentFor: (slug) => agents.get(slug),
|
|
557
|
+
status: () => statusStore?.get() ?? null,
|
|
279
558
|
};
|
|
280
559
|
}
|
|
281
560
|
|
|
@@ -287,21 +566,70 @@ export function createSyncRuntime(
|
|
|
287
566
|
* specialised for the sync runtime (we only need the three paths per canvas,
|
|
288
567
|
* not the full metadata).
|
|
289
568
|
*
|
|
290
|
-
* DDR-054 §2b — `.tsx` canvases are deliberately EXCLUDED from sync.
|
|
291
|
-
* dev-server transpiles `.tsx` to JavaScript and serves it as
|
|
569
|
+
* DDR-054 §2b / DDR-060 — `.tsx` canvases are deliberately EXCLUDED from sync.
|
|
570
|
+
* The dev-server transpiles `.tsx` to JavaScript and serves it as
|
|
292
571
|
* `application/javascript` in iframe same-origin; a hostile hub pushing
|
|
293
|
-
* arbitrary TypeScript source would result in RCE
|
|
294
|
-
*
|
|
295
|
-
*
|
|
572
|
+
* arbitrary TypeScript source would result in RCE (the audit's CRITICAL F1).
|
|
573
|
+
* Since Phase 3.6 made `.tsx` the ONLY canvas format, this means real projects
|
|
574
|
+
* discover zero syncable canvases — surfaced loudly by `surfaceNoSyncable`
|
|
575
|
+
* (9.1-D) rather than silently. The per-canvas opt-in (`.meta.json.syncable:
|
|
576
|
+
* true`) + CSP/sandbox gate that make `.tsx` syncable land in 9.1-A/B.
|
|
296
577
|
*/
|
|
297
578
|
export async function discoverCanvases(ctx: Context): Promise<CanvasDescriptor[]> {
|
|
579
|
+
return (await scanCanvases(ctx)).canvases;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Result of a canvas-group scan: syncable descriptors + a tally of the .tsx
|
|
583
|
+
* canvases that exist but are NOT yet syncable (DDR-060 — they need the
|
|
584
|
+
* per-canvas opt-in + sandbox gate). The tsx count feeds 9.1-D's loud
|
|
585
|
+
* zero-syncable surface so the message can say *why* nothing syncs. */
|
|
586
|
+
export interface CanvasScan {
|
|
587
|
+
canvases: CanvasDescriptor[];
|
|
588
|
+
tsxCount: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
|
|
298
592
|
const out: CanvasDescriptor[] = [];
|
|
593
|
+
const counter = { tsx: 0 };
|
|
594
|
+
// T3 (9.1-B) — LOAD-BEARING coupling (the plan's "two locks flip together"
|
|
595
|
+
// invariant): a `.tsx` body is admitted to sync ONLY when the cross-origin
|
|
596
|
+
// CSP/sandbox containment is active (`ctx.canvasOrigin` is set — Lock 2). The
|
|
597
|
+
// sandbox is now ON BY DEFAULT, but a user can opt out with
|
|
598
|
+
// MAUDE_CANVAS_ORIGIN_SPLIT=0; if they do, `canvasOrigin` is undefined and NO
|
|
599
|
+
// `.tsx` syncs — the per-canvas opt-in is inert without the sandbox, and
|
|
600
|
+
// decoupling them would re-open the CRITICAL F1 RCE (DDR-060, DDR-054 §F1).
|
|
601
|
+
const splitActive = !!ctx.canvasOrigin;
|
|
299
602
|
for (const group of ctx.cfg.canvasGroups) {
|
|
300
603
|
const groupAbs = path.join(ctx.paths.designRoot, group.path);
|
|
301
604
|
if (!existsSync(groupAbs)) continue;
|
|
302
|
-
await walk(
|
|
605
|
+
await walk(
|
|
606
|
+
groupAbs,
|
|
607
|
+
ctx.paths.designRoot,
|
|
608
|
+
ctx.paths.commentsDir,
|
|
609
|
+
ctx.paths.designRel,
|
|
610
|
+
out,
|
|
611
|
+
counter,
|
|
612
|
+
splitActive
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
return { canvases: out, tsxCount: counter.tsx };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* T3 (9.1-B) — per-canvas sync opt-in. A `.tsx` canvas is syncable only if its
|
|
620
|
+
* sibling `<name>.meta.json` declares `"syncable": true`. The flag is set by a
|
|
621
|
+
* human editing the sidecar; it is deliberately NOT in the untrusted
|
|
622
|
+
* `/_api/canvas-meta` PATCH whitelist (api.ts), so a hostile canvas/hub cannot
|
|
623
|
+
* flip its own body into the sync set. Missing sidecar / parse error → false.
|
|
624
|
+
*/
|
|
625
|
+
function readSyncableFlag(bodyAbs: string): boolean {
|
|
626
|
+
const metaAbs = bodyAbs.replace(/\.(tsx|html)$/i, '.meta.json');
|
|
627
|
+
try {
|
|
628
|
+
const obj = JSON.parse(readFileSync(metaAbs, 'utf8'));
|
|
629
|
+
return obj && typeof obj === 'object' && obj.syncable === true;
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
303
632
|
}
|
|
304
|
-
return out;
|
|
305
633
|
}
|
|
306
634
|
|
|
307
635
|
async function walk(
|
|
@@ -309,7 +637,9 @@ async function walk(
|
|
|
309
637
|
designRoot: string,
|
|
310
638
|
commentsDir: string,
|
|
311
639
|
designRel: string,
|
|
312
|
-
acc: CanvasDescriptor[]
|
|
640
|
+
acc: CanvasDescriptor[],
|
|
641
|
+
counter: { tsx: number },
|
|
642
|
+
splitActive: boolean
|
|
313
643
|
): Promise<void> {
|
|
314
644
|
let entries: import('node:fs').Dirent[];
|
|
315
645
|
try {
|
|
@@ -322,22 +652,101 @@ async function walk(
|
|
|
322
652
|
if (entry.isDirectory()) {
|
|
323
653
|
// Skip plugin runtime dirs.
|
|
324
654
|
if (entry.name.startsWith('_')) continue;
|
|
325
|
-
await walk(abs, designRoot, commentsDir, designRel, acc);
|
|
655
|
+
await walk(abs, designRoot, commentsDir, designRel, acc, counter, splitActive);
|
|
326
656
|
continue;
|
|
327
657
|
}
|
|
328
658
|
const ext = path.extname(entry.name).toLowerCase();
|
|
329
|
-
//
|
|
330
|
-
|
|
659
|
+
// T3 (9.1-B) — a `.tsx` syncs ONLY when the sandbox is active AND the
|
|
660
|
+
// sidecar opts in (see readSyncableFlag + the splitActive coupling above).
|
|
661
|
+
// Otherwise tally it for 9.1-D's loud zero-syncable surface so the message
|
|
662
|
+
// can explain *why* it isn't syncing.
|
|
663
|
+
if (ext === '.tsx') {
|
|
664
|
+
if (!(splitActive && readSyncableFlag(abs))) {
|
|
665
|
+
counter.tsx += 1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
// falls through to descriptor push (body = the .tsx file)
|
|
669
|
+
} else if (ext !== '.html') {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
331
672
|
const slug = slugFor(abs, designRoot, designRel);
|
|
332
673
|
acc.push({
|
|
674
|
+
// `html` is the canvas BODY path — `.html` or an opted-in `.tsx`. The
|
|
675
|
+
// sync codec treats the body as opaque Y.Text, so the field name is
|
|
676
|
+
// historical; both formats round-trip identically (DDR-060).
|
|
333
677
|
slug,
|
|
334
678
|
html: abs,
|
|
335
679
|
comments: path.join(commentsDir, `${slug}.json`),
|
|
336
680
|
annotations: path.join(designRoot, `${slug}.annotations.svg`),
|
|
681
|
+
// The `.meta.json` sidecar sits next to the body: `Foo.tsx` → `Foo.meta.json`.
|
|
682
|
+
meta: abs.replace(/\.(tsx|html)$/i, '.meta.json'),
|
|
683
|
+
// The `.css` sibling: `Foo.tsx` → `Foo.css` (absent for inline-CSS canvases).
|
|
684
|
+
css: abs.replace(/\.(tsx|html)$/i, '.css'),
|
|
337
685
|
});
|
|
338
686
|
}
|
|
339
687
|
}
|
|
340
688
|
|
|
689
|
+
/**
|
|
690
|
+
* The shape written to `<designRoot>/_sync.json` (and broadcast on the
|
|
691
|
+
* 'sync:status' bus) when the project is linked but has zero syncable
|
|
692
|
+
* canvases. DDR-060 / 9.1-D. Distinct from the live SyncStatusPayload — the
|
|
693
|
+
* `notSyncable` discriminator lets the CLI status line and the browser banner
|
|
694
|
+
* render the "linked but nothing syncs" state instead of a healthy one.
|
|
695
|
+
*/
|
|
696
|
+
export interface NoSyncablePayload {
|
|
697
|
+
linked: true;
|
|
698
|
+
notSyncable: true;
|
|
699
|
+
url: string;
|
|
700
|
+
reason: string;
|
|
701
|
+
/** Count of .tsx canvases present (syncable once 9.1-A/B land). */
|
|
702
|
+
tsxCount: number;
|
|
703
|
+
canvases: 0;
|
|
704
|
+
updatedAt: number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Build the zero-syncable payload (exported for the CLI + tests). */
|
|
708
|
+
export function buildNoSyncablePayload(
|
|
709
|
+
url: string,
|
|
710
|
+
tsxCount: number,
|
|
711
|
+
designRoot: string
|
|
712
|
+
): NoSyncablePayload {
|
|
713
|
+
const reason =
|
|
714
|
+
tsxCount > 0
|
|
715
|
+
? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs only when the canvas opts in (.meta.json "syncable": true). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
|
|
716
|
+
: `no canvases found under ${designRoot}.`;
|
|
717
|
+
return {
|
|
718
|
+
linked: true,
|
|
719
|
+
notSyncable: true,
|
|
720
|
+
url,
|
|
721
|
+
reason,
|
|
722
|
+
tsxCount,
|
|
723
|
+
canvases: 0,
|
|
724
|
+
updatedAt: Date.now(),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* 9.1-D — replace the silent zero-canvas early-return with a loud surface:
|
|
730
|
+
* a warn line, a `_sync.json` the CLI + browser read, and a bus broadcast.
|
|
731
|
+
* Best-effort on the write/broadcast — a failure there must never throw into
|
|
732
|
+
* the boot path (solo mode for unlinked projects is unaffected either way).
|
|
733
|
+
*/
|
|
734
|
+
function surfaceNoSyncable(ctx: Context, url: string, tsxCount: number): void {
|
|
735
|
+
const payload = buildNoSyncablePayload(url, tsxCount, ctx.paths.designRoot);
|
|
736
|
+
console.warn(`[sync] linked to ${url} but 0 syncable canvases — ${payload.reason}`);
|
|
737
|
+
try {
|
|
738
|
+
const file = path.join(ctx.paths.designRoot, '_sync.json');
|
|
739
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
740
|
+
} catch {
|
|
741
|
+
/* best-effort — never throw into boot */
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
ctx.bus.emit('sync:status', payload);
|
|
745
|
+
} catch {
|
|
746
|
+
/* best-effort */
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
341
750
|
function slugFor(absPath: string, designRoot: string, designRel: string): string {
|
|
342
751
|
let rel = path.relative(designRoot, absPath);
|
|
343
752
|
rel = rel.replace(/\\/g, '/');
|
|
@@ -362,6 +771,7 @@ async function defaultProviderFactory(args: {
|
|
|
362
771
|
url: string;
|
|
363
772
|
token: string;
|
|
364
773
|
documentName: string;
|
|
774
|
+
document?: Y.Doc;
|
|
365
775
|
}): Promise<SyncProvider> {
|
|
366
776
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional dep.
|
|
367
777
|
let mod: any;
|
|
@@ -376,7 +786,9 @@ async function defaultProviderFactory(args: {
|
|
|
376
786
|
// the scheme. The provider also accepts http(s):// and upgrades internally
|
|
377
787
|
// in newer versions, but ws:// is explicit + portable.
|
|
378
788
|
const wsUrl = toWsUrl(args.url);
|
|
379
|
-
|
|
789
|
+
// Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
|
|
790
|
+
// injected one; otherwise own a fresh doc (the legacy two-doc path).
|
|
791
|
+
const document = args.document ?? new Y.Doc();
|
|
380
792
|
// biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
|
|
381
793
|
const provider: any = new mod.HocuspocusProvider({
|
|
382
794
|
url: wsUrl,
|
|
@@ -384,12 +796,44 @@ async function defaultProviderFactory(args: {
|
|
|
384
796
|
token: args.token,
|
|
385
797
|
document,
|
|
386
798
|
connect: true,
|
|
799
|
+
// Make a rejected handshake LOUD instead of an invisible reconnect loop.
|
|
800
|
+
// The most common cause is a scope-bound token (DDR-053 default scope =
|
|
801
|
+
// label) that can't authorize this canvas's flat-slug documentName — that
|
|
802
|
+
// failure mode previously looked identical to "hub unreachable / offline".
|
|
803
|
+
onAuthenticationFailed: (data: { reason?: string }) => {
|
|
804
|
+
const reason = (data?.reason ?? 'unknown reason').replace(/[\r\n]/g, ' ').slice(0, 200);
|
|
805
|
+
console.warn(
|
|
806
|
+
`[sync] hub REJECTED auth for documentName="${args.documentName}": ${reason}.
|
|
807
|
+
If this is "token not authorized for this documentName", the token's scope
|
|
808
|
+
does not cover this canvas — mint a hub-wide token (\`maude hub token generate
|
|
809
|
+
--scope '*'\` or an admin-UI invite) and re-link. The peer keeps retrying until then.`
|
|
810
|
+
);
|
|
811
|
+
},
|
|
387
812
|
});
|
|
388
813
|
return {
|
|
389
814
|
document,
|
|
390
815
|
// HocuspocusProvider creates a hub-synced Awareness by default; expose it
|
|
391
816
|
// so the runtime can bridge it to the collab Room (Task 5).
|
|
392
817
|
awareness: provider.awareness as Awareness | undefined,
|
|
818
|
+
onStatus(cb: (status: ProviderStatus) => void): () => void {
|
|
819
|
+
// HocuspocusProvider emits 'status' with { status: 'connected' |
|
|
820
|
+
// 'connecting' | 'disconnected' } on every WS transition (Task 8).
|
|
821
|
+
const handler = (evt: { status?: string }) => {
|
|
822
|
+
const s = evt?.status;
|
|
823
|
+
if (s === 'connected' || s === 'connecting' || s === 'disconnected') cb(s);
|
|
824
|
+
};
|
|
825
|
+
provider.on('status', handler);
|
|
826
|
+
// Seed the subscriber with the provider's CURRENT status immediately. On
|
|
827
|
+
// localhost the WS can reach 'connected'/synced inside the constructor —
|
|
828
|
+
// before this listener attaches — so waiting for the *next* transition
|
|
829
|
+
// would leave the connection monitor un-seeded and `_sync.json` never
|
|
830
|
+
// written (status reads "idle / sync agent not running" while sync is in
|
|
831
|
+
// fact healthy). Reading provider.status closes that race; it always
|
|
832
|
+
// reflects the true current state, so a missed event can't strand us.
|
|
833
|
+
const cur = provider.status;
|
|
834
|
+
if (cur === 'connected' || cur === 'connecting' || cur === 'disconnected') cb(cur);
|
|
835
|
+
return () => provider.off('status', handler);
|
|
836
|
+
},
|
|
393
837
|
onceSynced(): Promise<void> {
|
|
394
838
|
return new Promise<void>((resolve) => {
|
|
395
839
|
if (provider.synced) {
|