@1agh/maude 0.29.0 → 0.30.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 +1 -1
- 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 +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- 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/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-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- 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 +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- 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/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-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -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/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -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/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-start.test.ts +244 -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/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 +12 -2
- 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-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
|
@@ -26,8 +26,15 @@
|
|
|
26
26
|
// → echoGuard.consume returns true → event dropped (no infinite loop)
|
|
27
27
|
//
|
|
28
28
|
// 800ms quiescence matches the existing Phase 8 collab room flush (DDR-051).
|
|
29
|
+
//
|
|
30
|
+
// Cold-start resolution (DDR-102, supersedes the v1.1 "always hub-wins"):
|
|
31
|
+
// reconcile() feeds the journal-aware decision table in cold-start.ts. A
|
|
32
|
+
// hub-wins overwrite of local disk happens ONLY on a clean fast-forward
|
|
33
|
+
// (local hash == journal hash); genuine divergence snapshots BOTH versions
|
|
34
|
+
// to `_history/<slug>/` and resolves newest-wins (doc syncMeta.bodyEditAt vs
|
|
35
|
+
// local file mtime; unknown/tie → hub). Comments union-merge by id.
|
|
29
36
|
|
|
30
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
31
38
|
|
|
32
39
|
import type * as Y from 'yjs';
|
|
33
40
|
|
|
@@ -39,14 +46,18 @@ import {
|
|
|
39
46
|
applyCssToDoc,
|
|
40
47
|
applyHtmlToDoc,
|
|
41
48
|
applyMetaToDoc,
|
|
49
|
+
bodyEditAtFromDoc,
|
|
42
50
|
commentsFromDoc,
|
|
43
51
|
cssFromDoc,
|
|
44
52
|
htmlFromDoc,
|
|
45
53
|
mergeSharedMetaIntoLocal,
|
|
46
54
|
metaFromDoc,
|
|
55
|
+
stampBodyEdit,
|
|
47
56
|
Y_SYNC_TYPES,
|
|
48
57
|
} from './codec.ts';
|
|
58
|
+
import { decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
49
59
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
60
|
+
import type { SyncJournal } from './journal.ts';
|
|
50
61
|
|
|
51
62
|
export const DOC_FLUSH_MS = 800;
|
|
52
63
|
|
|
@@ -80,13 +91,37 @@ export interface CanvasSyncAgentOptions {
|
|
|
80
91
|
/** Injected for tests — defaults to atomicWrite. */
|
|
81
92
|
writer?: (path: string, bytes: string | Uint8Array) => void;
|
|
82
93
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
94
|
+
* Per-machine sync journal (DDR-102) — the divergence detector. Every
|
|
95
|
+
* successful disk↔doc traversal checkpoints the content hash here; the
|
|
96
|
+
* cold-start decision allows a hub-wins overwrite ONLY when local matches
|
|
97
|
+
* the journal (clean fast-forward). Optional — older test constructions
|
|
98
|
+
* without it simply degrade to the conservative conflict path.
|
|
99
|
+
*/
|
|
100
|
+
journal?: SyncJournal;
|
|
101
|
+
/**
|
|
102
|
+
* Snapshot writer (DDR-102 conflict protocol) — persists a body version to
|
|
103
|
+
* `_history/<slug>/` and resolves with the snapshot's ISO ts (null on
|
|
104
|
+
* failure). The runtime wires this to history.ts `writeSnapshot`. Optional —
|
|
105
|
+
* without it the conflict path still resolves newest-wins, just without the
|
|
106
|
+
* recovery snapshots (test-only constructions).
|
|
107
|
+
*/
|
|
108
|
+
snapshot?: (content: string, reason: 'pre-sync-local' | 'pre-sync-hub') => Promise<string | null>;
|
|
109
|
+
/**
|
|
110
|
+
* Called when a non-adopt reconcile (cold-start / post-git-pull) found
|
|
111
|
+
* divergent non-empty content on both sides (DDR-102). `winner` is the side
|
|
112
|
+
* newest-wins kept; `snapshots` carries the `_history/` ISO timestamps of
|
|
113
|
+
* the pre-resolution versions (when the snapshot writer is wired). The
|
|
114
|
+
* legacy `cold-start-hub-wins` kind stays in the union for old readers.
|
|
88
115
|
*/
|
|
89
|
-
onConflict?: (info: {
|
|
116
|
+
onConflict?: (info: {
|
|
117
|
+
slug: string;
|
|
118
|
+
kind: 'cold-start-hub-wins' | 'cold-start-diverged';
|
|
119
|
+
winner?: 'local' | 'hub';
|
|
120
|
+
snapshots?: { local?: string; hub?: string };
|
|
121
|
+
/** DDR-102 fail-closed (F1): the local snapshot didn't land, so the
|
|
122
|
+
* hub-wins overwrite was refused and local kept instead. */
|
|
123
|
+
snapshotFailed?: boolean;
|
|
124
|
+
}) => void;
|
|
90
125
|
}
|
|
91
126
|
|
|
92
127
|
export interface CanvasSyncAgent {
|
|
@@ -179,6 +214,9 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
179
214
|
echoGuard.record(paths.html, hash);
|
|
180
215
|
writer(paths.html, next);
|
|
181
216
|
lastHtml = next;
|
|
217
|
+
// DDR-102 — a successful doc→disk body flush is a journal checkpoint:
|
|
218
|
+
// disk now holds exactly what the hub holds.
|
|
219
|
+
opts.journal?.record(slug, { bodyHash: hash });
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
function writeCommentsIfChanged(): void {
|
|
@@ -234,6 +272,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
234
272
|
const hash = hashBytes(next);
|
|
235
273
|
echoGuard.record(paths.css, hash);
|
|
236
274
|
writer(paths.css, next);
|
|
275
|
+
opts.journal?.record(slug, { cssHash: hash }); // DDR-102 checkpoint
|
|
237
276
|
}
|
|
238
277
|
|
|
239
278
|
function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
|
|
@@ -243,8 +282,18 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
243
282
|
|
|
244
283
|
const str = bytesToString(evt.bytes);
|
|
245
284
|
if (evt.path === paths.html) {
|
|
246
|
-
|
|
247
|
-
|
|
285
|
+
// Body apply + syncMeta stamp in ONE transaction (same origin) so peers
|
|
286
|
+
// receive a single update and the newest-wins stamp rides the body edit.
|
|
287
|
+
let changed = false;
|
|
288
|
+
doc.transact(() => {
|
|
289
|
+
changed = applyHtmlToDoc(doc, str, origin);
|
|
290
|
+
if (changed) stampBodyEdit(doc, origin);
|
|
291
|
+
}, origin);
|
|
292
|
+
if (changed) {
|
|
293
|
+
lastHtml = htmlFromDoc(doc);
|
|
294
|
+
// DDR-102 — a successful disk→doc body apply is a journal checkpoint.
|
|
295
|
+
opts.journal?.record(slug, { bodyHash: evt.hash });
|
|
296
|
+
}
|
|
248
297
|
return changed;
|
|
249
298
|
}
|
|
250
299
|
if (evt.path === paths.comments) {
|
|
@@ -268,7 +317,10 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
268
317
|
}
|
|
269
318
|
if (paths.css && evt.path === paths.css) {
|
|
270
319
|
const changed = applyCssToDoc(doc, str, origin);
|
|
271
|
-
if (changed)
|
|
320
|
+
if (changed) {
|
|
321
|
+
lastCss = str;
|
|
322
|
+
opts.journal?.record(slug, { cssHash: evt.hash }); // DDR-102 checkpoint
|
|
323
|
+
}
|
|
272
324
|
return changed;
|
|
273
325
|
}
|
|
274
326
|
return false;
|
|
@@ -293,7 +345,11 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
293
345
|
if (adopt) {
|
|
294
346
|
// Push local up: doc takes its values from disk. Hub becomes our
|
|
295
347
|
// canonical view of this canvas. One-shot.
|
|
296
|
-
if (localHtml !== null)
|
|
348
|
+
if (localHtml !== null) {
|
|
349
|
+
doc.transact(() => {
|
|
350
|
+
if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
|
|
351
|
+
}, origin);
|
|
352
|
+
}
|
|
297
353
|
if (localComments !== null) {
|
|
298
354
|
const parsed = tryParseJsonArray(localComments);
|
|
299
355
|
if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
|
|
@@ -307,55 +363,170 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
307
363
|
lastMeta = metaFromDoc(doc);
|
|
308
364
|
lastCss = cssFromDoc(doc);
|
|
309
365
|
adopt = false;
|
|
366
|
+
// DDR-102 — adopt is a disk→doc traversal: checkpoint it so the next
|
|
367
|
+
// boot fast-forwards instead of re-entering the conflict path.
|
|
368
|
+
if (localHtml !== null) {
|
|
369
|
+
opts.journal?.record(slug, {
|
|
370
|
+
bodyHash: hashBytes(localHtml),
|
|
371
|
+
...(localCss !== null ? { cssHash: hashBytes(localCss) } : {}),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
310
374
|
return;
|
|
311
375
|
}
|
|
312
376
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
377
|
+
// ---- body: cold-start decision table (DDR-102; replaces v1.1 hub-wins) --
|
|
378
|
+
const decision = decideColdStart({
|
|
379
|
+
localBody: localHtml,
|
|
380
|
+
docBody: docHtml,
|
|
381
|
+
journalHash: opts.journal?.get(slug)?.bodyHash ?? null,
|
|
382
|
+
localMtimeMs: localMtimeMs(paths.html),
|
|
383
|
+
docBodyEditAtMs: bodyEditAtFromDoc(doc),
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Which side owns the visually-coupled lanes (annotations/css) below.
|
|
387
|
+
let bodyWinner: 'local' | 'hub' = 'hub';
|
|
388
|
+
|
|
389
|
+
const writeBodyFromDoc = (): void => {
|
|
390
|
+
const hash = hashBytes(docHtml);
|
|
391
|
+
echoGuard.record(paths.html, hash);
|
|
392
|
+
writer(paths.html, docHtml);
|
|
393
|
+
lastHtml = docHtml;
|
|
394
|
+
opts.journal?.record(slug, { bodyHash: hash });
|
|
395
|
+
};
|
|
396
|
+
const seedBodyUp = (body: string): void => {
|
|
397
|
+
doc.transact(() => {
|
|
398
|
+
if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
|
|
399
|
+
}, origin);
|
|
400
|
+
lastHtml = body;
|
|
401
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(body) });
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
switch (decision.action) {
|
|
405
|
+
case 'noop':
|
|
406
|
+
lastHtml = docHtml;
|
|
407
|
+
// Identical non-empty sides: checkpoint so the next boot fast-forwards.
|
|
408
|
+
if (localHtml !== null && localHtml === docHtml && docHtml !== '') {
|
|
409
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(docHtml) });
|
|
339
410
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
411
|
+
break;
|
|
412
|
+
case 'materialize-hub':
|
|
413
|
+
case 'fast-forward-hub':
|
|
414
|
+
writeBodyFromDoc();
|
|
415
|
+
break;
|
|
416
|
+
case 'seed-local-up':
|
|
417
|
+
// The DDR-064 empty-hub guard as a named decision row: an empty hub doc
|
|
418
|
+
// means the hub holds no body for this slug yet — NOT an authoritative
|
|
419
|
+
// "this canvas is blank". Seed the doc FROM local so the body survives
|
|
420
|
+
// AND the hub gets our content.
|
|
421
|
+
seedBodyUp(localHtml as string);
|
|
422
|
+
bodyWinner = 'local';
|
|
423
|
+
break;
|
|
424
|
+
case 'conflict': {
|
|
425
|
+
// Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
|
|
426
|
+
// write, then apply the newest-wins winner. Even a wrong pick costs
|
|
427
|
+
// one /design:rollback (the incident's class of loss is closed).
|
|
428
|
+
const snapshots: { local?: string; hub?: string } = {};
|
|
429
|
+
let snapshotAttempted = false;
|
|
430
|
+
if (opts.snapshot) {
|
|
431
|
+
snapshotAttempted = true;
|
|
432
|
+
try {
|
|
433
|
+
const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
|
|
434
|
+
if (localTs) snapshots.local = localTs;
|
|
435
|
+
const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
|
|
436
|
+
if (hubTs) snapshots.hub = hubTs;
|
|
437
|
+
} catch {
|
|
438
|
+
/* swallowed below — the missing snapshot ref drives the fail-closed guard */
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// DDR-102 fail-closed (security F1): the whole guarantee is "the loser is
|
|
442
|
+
// recoverable from _history". A hub-wins resolution OVERWRITES local — so
|
|
443
|
+
// if we asked for a snapshot but the local one did NOT land (full disk,
|
|
444
|
+
// read-only `_history/`, a Bun.write error), refuse the destructive
|
|
445
|
+
// overwrite. Keep local on disk and seed it UP instead, so nothing is
|
|
446
|
+
// lost on either side (local survives; the hub still gets our content).
|
|
447
|
+
// `snapshotAttempted` gates this to production wiring — a test/standalone
|
|
448
|
+
// agent with no snapshot fn keeps the plain newest-wins behavior.
|
|
449
|
+
const localSnapshotMissing = snapshotAttempted && !snapshots.local;
|
|
450
|
+
let winner = decision.winner;
|
|
451
|
+
if (winner === 'hub' && localSnapshotMissing) {
|
|
452
|
+
winner = 'local';
|
|
453
|
+
console.error(
|
|
454
|
+
`[sync/${slug}] cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Keeping local + pushing it up; resolve the _history/ write failure (disk full / read-only?) to restore newest-wins.`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (winner === 'local') {
|
|
458
|
+
seedBodyUp(localHtml as string);
|
|
459
|
+
bodyWinner = 'local';
|
|
460
|
+
} else {
|
|
461
|
+
writeBodyFromDoc();
|
|
462
|
+
}
|
|
463
|
+
console.warn(`[sync/${slug}] cold-start divergence — ${decision.reason}`);
|
|
464
|
+
opts.onConflict?.({
|
|
465
|
+
slug,
|
|
466
|
+
kind: 'cold-start-diverged',
|
|
467
|
+
winner,
|
|
468
|
+
...(snapshots.local || snapshots.hub ? { snapshots } : {}),
|
|
469
|
+
...(localSnapshotMissing ? { snapshotFailed: true } : {}),
|
|
470
|
+
});
|
|
471
|
+
break;
|
|
343
472
|
}
|
|
344
473
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
474
|
+
|
|
475
|
+
// ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
|
|
476
|
+
const localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
|
|
477
|
+
if (localParsedComments !== null && localParsedComments.length > 0) {
|
|
478
|
+
const merged = unionCommentsById(docComments, localParsedComments);
|
|
479
|
+
const mergedStr = merged.length > 0 ? `${JSON.stringify(merged, null, 2)}\n` : '';
|
|
480
|
+
applyCommentsToDoc(doc, merged, origin); // no-ops when equal
|
|
481
|
+
if (mergedStr !== '' && mergedStr !== localComments) {
|
|
482
|
+
const hash = hashBytes(mergedStr);
|
|
483
|
+
echoGuard.record(paths.comments, hash);
|
|
484
|
+
writer(paths.comments, mergedStr);
|
|
485
|
+
}
|
|
486
|
+
lastComments = mergedStr;
|
|
487
|
+
} else {
|
|
488
|
+
// No (parseable) local comments — hub state materializes as before.
|
|
489
|
+
lastComments = docCommentsStr;
|
|
490
|
+
if (docCommentsStr !== '' && localComments !== docCommentsStr) {
|
|
491
|
+
const hash = hashBytes(docCommentsStr);
|
|
492
|
+
echoGuard.record(paths.comments, hash);
|
|
493
|
+
writer(paths.comments, docCommentsStr);
|
|
494
|
+
}
|
|
349
495
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
496
|
+
|
|
497
|
+
// ---- annotations + css: follow the body winner (visually coupled) ------
|
|
498
|
+
if (bodyWinner === 'local') {
|
|
499
|
+
if (localAnnotations !== null && localAnnotations !== docAnnotations) {
|
|
500
|
+
applyAnnotationsToDoc(doc, localAnnotations, origin);
|
|
501
|
+
lastAnnotations = localAnnotations;
|
|
502
|
+
} else {
|
|
503
|
+
lastAnnotations = docAnnotations;
|
|
504
|
+
}
|
|
505
|
+
if (paths.css && localCss !== null && localCss !== docCss) {
|
|
506
|
+
applyCssToDoc(doc, localCss, origin);
|
|
507
|
+
lastCss = localCss;
|
|
508
|
+
opts.journal?.record(slug, { cssHash: hashBytes(localCss) });
|
|
509
|
+
} else {
|
|
510
|
+
lastCss = docCss;
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
lastAnnotations = docAnnotations;
|
|
514
|
+
if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
|
|
515
|
+
const hash = hashBytes(docAnnotations);
|
|
516
|
+
echoGuard.record(paths.annotations, hash);
|
|
517
|
+
writer(paths.annotations, docAnnotations);
|
|
518
|
+
}
|
|
519
|
+
lastCss = docCss;
|
|
520
|
+
if (paths.css && docCss !== null && localCss !== docCss) {
|
|
521
|
+
const hash = hashBytes(docCss);
|
|
522
|
+
echoGuard.record(paths.css, hash);
|
|
523
|
+
writer(paths.css, docCss);
|
|
524
|
+
opts.journal?.record(slug, { cssHash: hash });
|
|
525
|
+
}
|
|
354
526
|
}
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
// artboard layout the hub carries that local lacks).
|
|
527
|
+
|
|
528
|
+
// ---- meta: shared-subset merge, unchanged in all cases -----------------
|
|
529
|
+
lastMeta = docMeta;
|
|
359
530
|
if (paths.meta && docMeta !== null) {
|
|
360
531
|
const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
|
|
361
532
|
if (merged !== null && merged !== localMeta) {
|
|
@@ -364,11 +535,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
364
535
|
writer(paths.meta, merged);
|
|
365
536
|
}
|
|
366
537
|
}
|
|
367
|
-
if (paths.css && docCss !== null && localCss !== docCss) {
|
|
368
|
-
const hash = hashBytes(docCss);
|
|
369
|
-
echoGuard.record(paths.css, hash);
|
|
370
|
-
writer(paths.css, docCss);
|
|
371
|
-
}
|
|
372
538
|
}
|
|
373
539
|
|
|
374
540
|
return {
|
|
@@ -395,6 +561,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
395
561
|
|
|
396
562
|
/* ---------------------------------------------------------------- helpers */
|
|
397
563
|
|
|
564
|
+
function localMtimeMs(p: string): number | null {
|
|
565
|
+
try {
|
|
566
|
+
return statSync(p).mtimeMs;
|
|
567
|
+
} catch {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
398
572
|
function readLocal(p: string): string | null {
|
|
399
573
|
if (!existsSync(p)) return null;
|
|
400
574
|
try {
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// insert-all`, and crucially preserves cursor positions other peers may have
|
|
22
22
|
// in the unchanged regions.
|
|
23
23
|
|
|
24
|
+
import { hostname } from 'node:os';
|
|
25
|
+
|
|
24
26
|
import type * as Y from 'yjs';
|
|
25
27
|
|
|
26
28
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
@@ -43,6 +45,13 @@ export const Y_SYNC_TYPES = {
|
|
|
43
45
|
* security opt-in (syncable) are stripped before sync — see META_LOCAL_KEYS
|
|
44
46
|
* + sharedMetaCanonical. Phase 9.1 Gap 2. */
|
|
45
47
|
meta: 'meta',
|
|
48
|
+
/** Doc-side sync bookkeeping (Y.Map, DDR-102): `bodyEditAt` ms-epoch stamp
|
|
49
|
+
* written by every peer that applies a LOCAL body into the doc, + `by` (a
|
|
50
|
+
* short peer label). The cold-start newest-wins decision compares it to the
|
|
51
|
+
* local file mtime. Deliberately a dedicated lane: `.meta.json`'s
|
|
52
|
+
* `last_modified` is in META_LOCAL_KEYS (per-machine, never syncs), so the
|
|
53
|
+
* meta codec CANNOT carry this. Never materialized to disk. */
|
|
54
|
+
syncMeta: 'syncMeta',
|
|
46
55
|
} as const;
|
|
47
56
|
|
|
48
57
|
/**
|
|
@@ -293,6 +302,42 @@ export function mergeSharedMetaIntoLocal(
|
|
|
293
302
|
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
294
303
|
}
|
|
295
304
|
|
|
305
|
+
/* ---------------------------------------------------------------- syncMeta */
|
|
306
|
+
|
|
307
|
+
/** Short peer label for `syncMeta.by` — os.hostname() truncated. Informational
|
|
308
|
+
* only (status surfaces); never trusted for decisions. */
|
|
309
|
+
const PEER_LABEL_MAX = 32;
|
|
310
|
+
function peerLabel(): string {
|
|
311
|
+
try {
|
|
312
|
+
return hostname().slice(0, PEER_LABEL_MAX);
|
|
313
|
+
} catch {
|
|
314
|
+
return 'unknown';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Stamp `syncMeta.bodyEditAt` (+ `by`) on the doc. Call this in the SAME
|
|
320
|
+
* transaction + origin as every local→doc body apply (agent applyFromFs html
|
|
321
|
+
* branch, reconcile seed-up, migrate-seed adopt, projection file→doc body
|
|
322
|
+
* import) so peers receive ONE update and origin-filtering stays intact.
|
|
323
|
+
* yjs nests transactions — wrapping `applyHtmlToDoc` + `stampBodyEdit` in an
|
|
324
|
+
* outer `doc.transact(fn, origin)` produces a single update.
|
|
325
|
+
*/
|
|
326
|
+
export function stampBodyEdit(doc: Y.Doc, origin?: unknown, nowMs?: number): void {
|
|
327
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
328
|
+
doc.transact(() => {
|
|
329
|
+
map.set('bodyEditAt', nowMs ?? Date.now());
|
|
330
|
+
map.set('by', peerLabel());
|
|
331
|
+
}, origin);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The doc-side body-edit stamp, or null when no peer ever stamped (older
|
|
335
|
+
* peers don't write syncMeta → callers fall back to hub-wins, interop-safe). */
|
|
336
|
+
export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
|
|
337
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('bodyEditAt');
|
|
338
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
296
341
|
/* ---------------------------------------------------------------- css */
|
|
297
342
|
|
|
298
343
|
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Cold-start body resolution — the pure decision core of the conflict
|
|
2
|
+
// protocol (DDR-102; supersedes the v1.1 "hub-wins always" scoping).
|
|
3
|
+
//
|
|
4
|
+
// Both sync paths (agent.ts reconcile, migrate-seed.ts for the shared-doc
|
|
5
|
+
// cutover) feed their observed state into `decideColdStart` and execute the
|
|
6
|
+
// returned action. Keeping the table pure makes the full matrix unit-testable
|
|
7
|
+
// without Y.Docs or disk (mirrors codec.ts's pure-function style).
|
|
8
|
+
//
|
|
9
|
+
// The journal (journal.ts) is what separates a CLEAN catch-up from divergence:
|
|
10
|
+
// journalHash == hash(local) means every local byte was already reconciled
|
|
11
|
+
// through this machine — the hub being different just means it moved ahead, so
|
|
12
|
+
// overwriting disk is a fast-forward, not data loss. Any other combination of
|
|
13
|
+
// "both sides non-empty and different" is a conflict: the caller snapshots
|
|
14
|
+
// BOTH versions to `_history/<slug>/` and applies the newest-wins winner.
|
|
15
|
+
//
|
|
16
|
+
// Newest-wins compares the doc-side `syncMeta.bodyEditAt` stamp (codec.ts —
|
|
17
|
+
// written by every peer that applies a local body into the doc) against the
|
|
18
|
+
// local file mtime. Either side unknown (older peer never stamped; mtime
|
|
19
|
+
// unavailable) or equal → hub wins, exactly the v1.1 default — but now both
|
|
20
|
+
// sides are snapshotted first, so even a wrong pick costs one /design:rollback.
|
|
21
|
+
|
|
22
|
+
import { hashBytes } from './echo-guard.ts';
|
|
23
|
+
|
|
24
|
+
export type ColdStartAction =
|
|
25
|
+
| 'noop'
|
|
26
|
+
| 'materialize-hub'
|
|
27
|
+
| 'seed-local-up'
|
|
28
|
+
| 'fast-forward-hub'
|
|
29
|
+
| 'conflict';
|
|
30
|
+
|
|
31
|
+
export interface ColdStartInput {
|
|
32
|
+
/** Local body file content, or null when the file doesn't exist. */
|
|
33
|
+
localBody: string | null;
|
|
34
|
+
/** Body currently held by the doc (hub state after first sync). */
|
|
35
|
+
docBody: string;
|
|
36
|
+
/** Journal entry's bodyHash for this slug, or null when never checkpointed. */
|
|
37
|
+
journalHash: string | null;
|
|
38
|
+
/** Local body file mtime (ms epoch), or null when unavailable. */
|
|
39
|
+
localMtimeMs: number | null;
|
|
40
|
+
/** Doc-side syncMeta.bodyEditAt stamp (ms epoch), or null when no peer ever
|
|
41
|
+
* stamped (older peer interop) — falls back to hub-wins. */
|
|
42
|
+
docBodyEditAtMs: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ColdStartDecision {
|
|
46
|
+
action: ColdStartAction;
|
|
47
|
+
/** Set only for `conflict`. */
|
|
48
|
+
winner?: 'local' | 'hub';
|
|
49
|
+
/** Human-readable, logged + recorded in the conflict entry. */
|
|
50
|
+
reason: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Trim-only-whitespace bodies count as empty (mirrors the agent's
|
|
54
|
+
* `localHtml.trim() !== ''` guard). */
|
|
55
|
+
function isEmptyBody(body: string | null): boolean {
|
|
56
|
+
return body === null || body.trim() === '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function commentId(c: unknown): string | null {
|
|
60
|
+
if (c && typeof c === 'object' && typeof (c as { id?: unknown }).id === 'string') {
|
|
61
|
+
return (c as { id: string }).id;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Union-merge two comment snapshots by stable `id` (DDR-102): doc order first,
|
|
68
|
+
* local-only entries appended. Comments carry stable ids, so union loses
|
|
69
|
+
* nothing and needs no winner — same-id entries keep the doc's version.
|
|
70
|
+
* Id-less entries dedupe by JSON identity (conservative). Shared by both
|
|
71
|
+
* cold-start paths (agent reconcile + migrate-seed).
|
|
72
|
+
*/
|
|
73
|
+
export function unionCommentsById(docList: unknown[], localList: unknown[]): unknown[] {
|
|
74
|
+
const out = [...docList];
|
|
75
|
+
const seenIds = new Set<string>();
|
|
76
|
+
const seenJson = new Set<string>();
|
|
77
|
+
for (const c of docList) {
|
|
78
|
+
const id = commentId(c);
|
|
79
|
+
if (id !== null) seenIds.add(id);
|
|
80
|
+
else seenJson.add(JSON.stringify(c));
|
|
81
|
+
}
|
|
82
|
+
for (const c of localList) {
|
|
83
|
+
const id = commentId(c);
|
|
84
|
+
const dup = id !== null ? seenIds.has(id) : seenJson.has(JSON.stringify(c));
|
|
85
|
+
if (dup) continue;
|
|
86
|
+
out.push(c);
|
|
87
|
+
if (id !== null) seenIds.add(id);
|
|
88
|
+
else seenJson.add(JSON.stringify(c));
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function decideColdStart(input: ColdStartInput): ColdStartDecision {
|
|
94
|
+
const localEmpty = isEmptyBody(input.localBody);
|
|
95
|
+
const docEmpty = isEmptyBody(input.docBody);
|
|
96
|
+
|
|
97
|
+
if (localEmpty && docEmpty) {
|
|
98
|
+
return { action: 'noop', reason: 'both sides empty — nothing to reconcile' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (localEmpty) {
|
|
102
|
+
// Clean first sync: nothing local to lose.
|
|
103
|
+
return { action: 'materialize-hub', reason: 'no local body — materializing hub state' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (docEmpty) {
|
|
107
|
+
// DDR-064 empty-hub guard, now a named case: an empty hub doc means the hub
|
|
108
|
+
// holds no body for this slug yet — NOT an authoritative "blank canvas".
|
|
109
|
+
return {
|
|
110
|
+
action: 'seed-local-up',
|
|
111
|
+
reason: 'hub doc empty — seeding local body up (DDR-064 guard)',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (input.localBody === input.docBody) {
|
|
116
|
+
// Caller records the journal so the NEXT boot sees a clean checkpoint.
|
|
117
|
+
return { action: 'noop', reason: 'local and hub identical' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Hash via hashBytes ONLY — the journal recorded its hashes through the same
|
|
121
|
+
// fn (single source, echo-guard.ts), so this comparison is apples-to-apples.
|
|
122
|
+
if (
|
|
123
|
+
input.journalHash !== null &&
|
|
124
|
+
input.localBody !== null &&
|
|
125
|
+
input.journalHash === hashBytes(input.localBody)
|
|
126
|
+
) {
|
|
127
|
+
// Everything local was already reconciled through this machine; the hub is
|
|
128
|
+
// simply ahead. Overwrite WITHOUT snapshot/conflict — a clean fast-forward.
|
|
129
|
+
return {
|
|
130
|
+
action: 'fast-forward-hub',
|
|
131
|
+
reason: 'local matches last-synced journal hash — hub is ahead, fast-forwarding',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Divergence: both sides non-empty, different, and local carries bytes this
|
|
136
|
+
// machine never reconciled (journal stale or absent). Newest wins; unknown
|
|
137
|
+
// or tied timestamps fall back to hub (v1.1 default, now recoverable).
|
|
138
|
+
const local = input.localMtimeMs;
|
|
139
|
+
const doc = input.docBodyEditAtMs;
|
|
140
|
+
if (local !== null && doc !== null && local !== doc) {
|
|
141
|
+
const winner = local > doc ? 'local' : 'hub';
|
|
142
|
+
return {
|
|
143
|
+
action: 'conflict',
|
|
144
|
+
winner,
|
|
145
|
+
reason: `diverged — newest wins: local mtime ${new Date(local).toISOString()} ${
|
|
146
|
+
winner === 'local' ? '>' : '<'
|
|
147
|
+
} doc bodyEditAt ${new Date(doc).toISOString()}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
action: 'conflict',
|
|
152
|
+
winner: 'hub',
|
|
153
|
+
reason:
|
|
154
|
+
local === null || doc === null
|
|
155
|
+
? 'diverged — timestamp unknown on one side, falling back to hub-wins (recoverable: both sides snapshotted)'
|
|
156
|
+
: 'diverged — timestamps tied, falling back to hub-wins (recoverable: both sides snapshotted)',
|
|
157
|
+
};
|
|
158
|
+
}
|