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