@1agh/maude 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -26,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,17 +46,31 @@ import {
|
|
|
39
46
|
applyCssToDoc,
|
|
40
47
|
applyHtmlToDoc,
|
|
41
48
|
applyMetaToDoc,
|
|
49
|
+
bodyEditAtFromDoc,
|
|
42
50
|
commentsFromDoc,
|
|
43
51
|
cssFromDoc,
|
|
44
52
|
htmlFromDoc,
|
|
53
|
+
markSeeded,
|
|
45
54
|
mergeSharedMetaIntoLocal,
|
|
46
55
|
metaFromDoc,
|
|
56
|
+
seededByFromDoc,
|
|
57
|
+
stampBodyEdit,
|
|
47
58
|
Y_SYNC_TYPES,
|
|
48
59
|
} from './codec.ts';
|
|
60
|
+
import { decideColdStart, isExactRepeat, unionCommentsById } from './cold-start.ts';
|
|
49
61
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
62
|
+
import type { SyncJournal } from './journal.ts';
|
|
50
63
|
|
|
51
64
|
export const DOC_FLUSH_MS = 800;
|
|
52
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Window after a `seed-local-up` during which this agent will repair a
|
|
68
|
+
* concurrent-seed duplication (F1). Bounded so the repair only ever fires for
|
|
69
|
+
* the cold-start race — a genuine peer edit that arrives minutes later is NEVER
|
|
70
|
+
* collapsed back to our original seed. Generous relative to the hub sync RTT.
|
|
71
|
+
*/
|
|
72
|
+
export const SEED_REPAIR_WINDOW_MS = 10_000;
|
|
73
|
+
|
|
53
74
|
export interface CanvasSyncPaths {
|
|
54
75
|
/** Absolute path to <designRoot>/<canvas>.html. */
|
|
55
76
|
html: string;
|
|
@@ -80,13 +101,37 @@ export interface CanvasSyncAgentOptions {
|
|
|
80
101
|
/** Injected for tests — defaults to atomicWrite. */
|
|
81
102
|
writer?: (path: string, bytes: string | Uint8Array) => void;
|
|
82
103
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
104
|
+
* Per-machine sync journal (DDR-102) — the divergence detector. Every
|
|
105
|
+
* successful disk↔doc traversal checkpoints the content hash here; the
|
|
106
|
+
* cold-start decision allows a hub-wins overwrite ONLY when local matches
|
|
107
|
+
* the journal (clean fast-forward). Optional — older test constructions
|
|
108
|
+
* without it simply degrade to the conservative conflict path.
|
|
109
|
+
*/
|
|
110
|
+
journal?: SyncJournal;
|
|
111
|
+
/**
|
|
112
|
+
* Snapshot writer (DDR-102 conflict protocol) — persists a body version to
|
|
113
|
+
* `_history/<slug>/` and resolves with the snapshot's ISO ts (null on
|
|
114
|
+
* failure). The runtime wires this to history.ts `writeSnapshot`. Optional —
|
|
115
|
+
* without it the conflict path still resolves newest-wins, just without the
|
|
116
|
+
* recovery snapshots (test-only constructions).
|
|
117
|
+
*/
|
|
118
|
+
snapshot?: (content: string, reason: 'pre-sync-local' | 'pre-sync-hub') => Promise<string | null>;
|
|
119
|
+
/**
|
|
120
|
+
* Called when a non-adopt reconcile (cold-start / post-git-pull) found
|
|
121
|
+
* divergent non-empty content on both sides (DDR-102). `winner` is the side
|
|
122
|
+
* newest-wins kept; `snapshots` carries the `_history/` ISO timestamps of
|
|
123
|
+
* the pre-resolution versions (when the snapshot writer is wired). The
|
|
124
|
+
* legacy `cold-start-hub-wins` kind stays in the union for old readers.
|
|
88
125
|
*/
|
|
89
|
-
onConflict?: (info: {
|
|
126
|
+
onConflict?: (info: {
|
|
127
|
+
slug: string;
|
|
128
|
+
kind: 'cold-start-hub-wins' | 'cold-start-diverged';
|
|
129
|
+
winner?: 'local' | 'hub';
|
|
130
|
+
snapshots?: { local?: string; hub?: string };
|
|
131
|
+
/** DDR-102 fail-closed (F1): the local snapshot didn't land, so the
|
|
132
|
+
* hub-wins overwrite was refused and local kept instead. */
|
|
133
|
+
snapshotFailed?: boolean;
|
|
134
|
+
}) => void;
|
|
90
135
|
}
|
|
91
136
|
|
|
92
137
|
export interface CanvasSyncAgent {
|
|
@@ -129,10 +174,21 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
129
174
|
let lastMeta: string | null = null;
|
|
130
175
|
let lastCss: string | null = null;
|
|
131
176
|
|
|
177
|
+
// F1 — the body this agent pushed on `seed-local-up`, plus the deadline past
|
|
178
|
+
// which the de-dup repair stops firing. Set on seed; nulled only when the
|
|
179
|
+
// window lapses (after convergence the repair is a cheap no-op — the
|
|
180
|
+
// `body === seedInfo.body` guard short-circuits, so we don't bother clearing).
|
|
181
|
+
// Null = this agent never seeded (so it never repairs).
|
|
182
|
+
let seedInfo: { body: string; until: number } | null = null;
|
|
183
|
+
|
|
132
184
|
function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
|
|
133
185
|
if (stopped) return;
|
|
134
186
|
// Self-applied (we just synced from disk) — disk is already current.
|
|
135
187
|
if (updateOrigin === origin) return;
|
|
188
|
+
// F1 — a remote update during the seed window may have concatenated a
|
|
189
|
+
// concurrent peer's identical seed onto ours. Collapse it (single elected
|
|
190
|
+
// writer) BEFORE the flush below can mirror a doubled body to disk.
|
|
191
|
+
maybeRepairSeedDuplication();
|
|
136
192
|
scheduleFlush();
|
|
137
193
|
}
|
|
138
194
|
|
|
@@ -153,6 +209,45 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
153
209
|
}, flushMs);
|
|
154
210
|
}
|
|
155
211
|
|
|
212
|
+
/**
|
|
213
|
+
* F1 — collapse a concurrent cold-seed duplication. When two peers
|
|
214
|
+
* `seed-local-up` the SAME body into an empty hub at the same instant, the
|
|
215
|
+
* merged Y.Text holds both insertions (`BODY` × N — un-buildable). This runs
|
|
216
|
+
* on every remote update inside the seed window and, on the single peer the
|
|
217
|
+
* `seededBy` Y.Map LWW elected, re-applies the canonical body so
|
|
218
|
+
* `applyHtmlToDoc`'s diff deletes the trailing duplicate(s). Only the elected
|
|
219
|
+
* owner repairs → the collapse op is emitted once and converges across peers
|
|
220
|
+
* (a non-owner just receives the delete). Restricted to an EXACT repeat of our
|
|
221
|
+
* seed: a divergent edit carries genuine bytes, so we DON'T touch it here —
|
|
222
|
+
* silently collapsing it would discard content. NOTE: that leaves the rare
|
|
223
|
+
* "two DIFFERENT bodies seeded into one empty hub at the same instant" case
|
|
224
|
+
* un-buildable until the NEXT boot, when `decideColdStart` routes it through
|
|
225
|
+
* the conflict path (dual snapshot + newest-wins). This live repair only
|
|
226
|
+
* covers the identical-seed race (the F1 RCA's scenario); the divergent
|
|
227
|
+
* variant is an accepted follow-up, not handled in-flight.
|
|
228
|
+
*/
|
|
229
|
+
function maybeRepairSeedDuplication(): void {
|
|
230
|
+
if (!seedInfo) return;
|
|
231
|
+
if (Date.now() > seedInfo.until) {
|
|
232
|
+
seedInfo = null;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const owner = seededByFromDoc(doc);
|
|
236
|
+
if (owner === null || owner !== doc.clientID) return; // not the elected writer
|
|
237
|
+
const body = htmlFromDoc(doc);
|
|
238
|
+
if (body === seedInfo.body) return; // already a single copy
|
|
239
|
+
if (!isExactRepeat(body, seedInfo.body)) return; // divergent edit → conflict path owns it
|
|
240
|
+
const canonical = seedInfo.body;
|
|
241
|
+
doc.transact(() => {
|
|
242
|
+
if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
|
|
243
|
+
}, origin);
|
|
244
|
+
lastHtml = canonical;
|
|
245
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
|
|
246
|
+
console.warn(
|
|
247
|
+
`[sync/${slug}] concurrent cold-seed duplication detected — collapsed to one copy (F1).`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
156
251
|
async function flush(): Promise<void> {
|
|
157
252
|
if (!dirty || stopped) return;
|
|
158
253
|
dirty = false;
|
|
@@ -179,6 +274,9 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
179
274
|
echoGuard.record(paths.html, hash);
|
|
180
275
|
writer(paths.html, next);
|
|
181
276
|
lastHtml = next;
|
|
277
|
+
// DDR-102 — a successful doc→disk body flush is a journal checkpoint:
|
|
278
|
+
// disk now holds exactly what the hub holds.
|
|
279
|
+
opts.journal?.record(slug, { bodyHash: hash });
|
|
182
280
|
}
|
|
183
281
|
|
|
184
282
|
function writeCommentsIfChanged(): void {
|
|
@@ -234,6 +332,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
234
332
|
const hash = hashBytes(next);
|
|
235
333
|
echoGuard.record(paths.css, hash);
|
|
236
334
|
writer(paths.css, next);
|
|
335
|
+
opts.journal?.record(slug, { cssHash: hash }); // DDR-102 checkpoint
|
|
237
336
|
}
|
|
238
337
|
|
|
239
338
|
function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
|
|
@@ -243,8 +342,18 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
243
342
|
|
|
244
343
|
const str = bytesToString(evt.bytes);
|
|
245
344
|
if (evt.path === paths.html) {
|
|
246
|
-
|
|
247
|
-
|
|
345
|
+
// Body apply + syncMeta stamp in ONE transaction (same origin) so peers
|
|
346
|
+
// receive a single update and the newest-wins stamp rides the body edit.
|
|
347
|
+
let changed = false;
|
|
348
|
+
doc.transact(() => {
|
|
349
|
+
changed = applyHtmlToDoc(doc, str, origin);
|
|
350
|
+
if (changed) stampBodyEdit(doc, origin);
|
|
351
|
+
}, origin);
|
|
352
|
+
if (changed) {
|
|
353
|
+
lastHtml = htmlFromDoc(doc);
|
|
354
|
+
// DDR-102 — a successful disk→doc body apply is a journal checkpoint.
|
|
355
|
+
opts.journal?.record(slug, { bodyHash: evt.hash });
|
|
356
|
+
}
|
|
248
357
|
return changed;
|
|
249
358
|
}
|
|
250
359
|
if (evt.path === paths.comments) {
|
|
@@ -268,7 +377,10 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
268
377
|
}
|
|
269
378
|
if (paths.css && evt.path === paths.css) {
|
|
270
379
|
const changed = applyCssToDoc(doc, str, origin);
|
|
271
|
-
if (changed)
|
|
380
|
+
if (changed) {
|
|
381
|
+
lastCss = str;
|
|
382
|
+
opts.journal?.record(slug, { cssHash: evt.hash }); // DDR-102 checkpoint
|
|
383
|
+
}
|
|
272
384
|
return changed;
|
|
273
385
|
}
|
|
274
386
|
return false;
|
|
@@ -293,7 +405,16 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
293
405
|
if (adopt) {
|
|
294
406
|
// Push local up: doc takes its values from disk. Hub becomes our
|
|
295
407
|
// canonical view of this canvas. One-shot.
|
|
296
|
-
if (localHtml !== null)
|
|
408
|
+
if (localHtml !== null) {
|
|
409
|
+
doc.transact(() => {
|
|
410
|
+
if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
|
|
411
|
+
// F1 — adopt is also a "seed local up" (first link / fresh hub); claim
|
|
412
|
+
// it + arm the repair window so two peers adopting the same draft into
|
|
413
|
+
// one hub at once self-heal the same way the decision-table seed does.
|
|
414
|
+
markSeeded(doc, origin);
|
|
415
|
+
}, origin);
|
|
416
|
+
seedInfo = { body: localHtml, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
417
|
+
}
|
|
297
418
|
if (localComments !== null) {
|
|
298
419
|
const parsed = tryParseJsonArray(localComments);
|
|
299
420
|
if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
|
|
@@ -307,55 +428,185 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
307
428
|
lastMeta = metaFromDoc(doc);
|
|
308
429
|
lastCss = cssFromDoc(doc);
|
|
309
430
|
adopt = false;
|
|
431
|
+
// DDR-102 — adopt is a disk→doc traversal: checkpoint it so the next
|
|
432
|
+
// boot fast-forwards instead of re-entering the conflict path.
|
|
433
|
+
if (localHtml !== null) {
|
|
434
|
+
opts.journal?.record(slug, {
|
|
435
|
+
bodyHash: hashBytes(localHtml),
|
|
436
|
+
...(localCss !== null ? { cssHash: hashBytes(localCss) } : {}),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
310
439
|
return;
|
|
311
440
|
}
|
|
312
441
|
|
|
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
|
-
|
|
442
|
+
// ---- body: cold-start decision table (DDR-102; replaces v1.1 hub-wins) --
|
|
443
|
+
const decision = decideColdStart({
|
|
444
|
+
localBody: localHtml,
|
|
445
|
+
docBody: docHtml,
|
|
446
|
+
journalHash: opts.journal?.get(slug)?.bodyHash ?? null,
|
|
447
|
+
localMtimeMs: localMtimeMs(paths.html),
|
|
448
|
+
docBodyEditAtMs: bodyEditAtFromDoc(doc),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Which side owns the visually-coupled lanes (annotations/css) below.
|
|
452
|
+
let bodyWinner: 'local' | 'hub' = 'hub';
|
|
453
|
+
|
|
454
|
+
const writeBodyFromDoc = (): void => {
|
|
455
|
+
const hash = hashBytes(docHtml);
|
|
456
|
+
echoGuard.record(paths.html, hash);
|
|
457
|
+
writer(paths.html, docHtml);
|
|
458
|
+
lastHtml = docHtml;
|
|
459
|
+
opts.journal?.record(slug, { bodyHash: hash });
|
|
460
|
+
};
|
|
461
|
+
const seedBodyUp = (body: string): void => {
|
|
462
|
+
doc.transact(() => {
|
|
463
|
+
if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
|
|
464
|
+
// F1 — claim the seed (clientID marker) in the SAME update so a
|
|
465
|
+
// concurrent second seeder's merge resolves a single elected writer.
|
|
466
|
+
markSeeded(doc, origin);
|
|
467
|
+
}, origin);
|
|
468
|
+
lastHtml = body;
|
|
469
|
+
// Arm the de-dup repair window: if another peer seeded the same body at the
|
|
470
|
+
// same instant, the merged Y.Text will double — maybeRepairSeedDuplication
|
|
471
|
+
// (on the elected owner) collapses it back during this window.
|
|
472
|
+
seedInfo = { body, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
473
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(body) });
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
switch (decision.action) {
|
|
477
|
+
case 'noop':
|
|
478
|
+
lastHtml = docHtml;
|
|
479
|
+
// Identical non-empty sides: checkpoint so the next boot fast-forwards.
|
|
480
|
+
if (localHtml !== null && localHtml === docHtml && docHtml !== '') {
|
|
481
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(docHtml) });
|
|
339
482
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
483
|
+
break;
|
|
484
|
+
case 'materialize-hub':
|
|
485
|
+
case 'fast-forward-hub':
|
|
486
|
+
writeBodyFromDoc();
|
|
487
|
+
break;
|
|
488
|
+
case 'seed-local-up':
|
|
489
|
+
// The DDR-064 empty-hub guard as a named decision row: an empty hub doc
|
|
490
|
+
// means the hub holds no body for this slug yet — NOT an authoritative
|
|
491
|
+
// "this canvas is blank". Seed the doc FROM local so the body survives
|
|
492
|
+
// AND the hub gets our content.
|
|
493
|
+
seedBodyUp(localHtml as string);
|
|
494
|
+
bodyWinner = 'local';
|
|
495
|
+
break;
|
|
496
|
+
case 'recover-seed-dup':
|
|
497
|
+
// F1 — booting against a hub whose body is our local body repeated (a
|
|
498
|
+
// concurrent cold-seed that already duplicated). Re-apply local so the
|
|
499
|
+
// diff deletes the extra copy/copies; the content equals local, so the
|
|
500
|
+
// visually-coupled lanes follow local too. Idempotent across peers.
|
|
501
|
+
seedBodyUp(localHtml as string);
|
|
502
|
+
bodyWinner = 'local';
|
|
503
|
+
break;
|
|
504
|
+
case 'conflict': {
|
|
505
|
+
// Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
|
|
506
|
+
// write, then apply the newest-wins winner. Even a wrong pick costs
|
|
507
|
+
// one /design:rollback (the incident's class of loss is closed).
|
|
508
|
+
const snapshots: { local?: string; hub?: string } = {};
|
|
509
|
+
let snapshotAttempted = false;
|
|
510
|
+
if (opts.snapshot) {
|
|
511
|
+
snapshotAttempted = true;
|
|
512
|
+
try {
|
|
513
|
+
const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
|
|
514
|
+
if (localTs) snapshots.local = localTs;
|
|
515
|
+
const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
|
|
516
|
+
if (hubTs) snapshots.hub = hubTs;
|
|
517
|
+
} catch {
|
|
518
|
+
/* swallowed below — the missing snapshot ref drives the fail-closed guard */
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// DDR-102 fail-closed (security F1): the whole guarantee is "the loser is
|
|
522
|
+
// recoverable from _history". A hub-wins resolution OVERWRITES local — so
|
|
523
|
+
// if we asked for a snapshot but the local one did NOT land (full disk,
|
|
524
|
+
// read-only `_history/`, a Bun.write error), refuse the destructive
|
|
525
|
+
// overwrite. Keep local on disk and seed it UP instead, so nothing is
|
|
526
|
+
// lost on either side (local survives; the hub still gets our content).
|
|
527
|
+
// `snapshotAttempted` gates this to production wiring — a test/standalone
|
|
528
|
+
// agent with no snapshot fn keeps the plain newest-wins behavior.
|
|
529
|
+
const localSnapshotMissing = snapshotAttempted && !snapshots.local;
|
|
530
|
+
let winner = decision.winner;
|
|
531
|
+
if (winner === 'hub' && localSnapshotMissing) {
|
|
532
|
+
winner = 'local';
|
|
533
|
+
console.error(
|
|
534
|
+
`[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.`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (winner === 'local') {
|
|
538
|
+
seedBodyUp(localHtml as string);
|
|
539
|
+
bodyWinner = 'local';
|
|
540
|
+
} else {
|
|
541
|
+
writeBodyFromDoc();
|
|
542
|
+
}
|
|
543
|
+
console.warn(`[sync/${slug}] cold-start divergence — ${decision.reason}`);
|
|
544
|
+
opts.onConflict?.({
|
|
545
|
+
slug,
|
|
546
|
+
kind: 'cold-start-diverged',
|
|
547
|
+
winner,
|
|
548
|
+
...(snapshots.local || snapshots.hub ? { snapshots } : {}),
|
|
549
|
+
...(localSnapshotMissing ? { snapshotFailed: true } : {}),
|
|
550
|
+
});
|
|
551
|
+
break;
|
|
343
552
|
}
|
|
344
553
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
554
|
+
|
|
555
|
+
// ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
|
|
556
|
+
const localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
|
|
557
|
+
if (localParsedComments !== null && localParsedComments.length > 0) {
|
|
558
|
+
const merged = unionCommentsById(docComments, localParsedComments);
|
|
559
|
+
const mergedStr = merged.length > 0 ? `${JSON.stringify(merged, null, 2)}\n` : '';
|
|
560
|
+
applyCommentsToDoc(doc, merged, origin); // no-ops when equal
|
|
561
|
+
if (mergedStr !== '' && mergedStr !== localComments) {
|
|
562
|
+
const hash = hashBytes(mergedStr);
|
|
563
|
+
echoGuard.record(paths.comments, hash);
|
|
564
|
+
writer(paths.comments, mergedStr);
|
|
565
|
+
}
|
|
566
|
+
lastComments = mergedStr;
|
|
567
|
+
} else {
|
|
568
|
+
// No (parseable) local comments — hub state materializes as before.
|
|
569
|
+
lastComments = docCommentsStr;
|
|
570
|
+
if (docCommentsStr !== '' && localComments !== docCommentsStr) {
|
|
571
|
+
const hash = hashBytes(docCommentsStr);
|
|
572
|
+
echoGuard.record(paths.comments, hash);
|
|
573
|
+
writer(paths.comments, docCommentsStr);
|
|
574
|
+
}
|
|
349
575
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
576
|
+
|
|
577
|
+
// ---- annotations + css: follow the body winner (visually coupled) ------
|
|
578
|
+
if (bodyWinner === 'local') {
|
|
579
|
+
if (localAnnotations !== null && localAnnotations !== docAnnotations) {
|
|
580
|
+
applyAnnotationsToDoc(doc, localAnnotations, origin);
|
|
581
|
+
lastAnnotations = localAnnotations;
|
|
582
|
+
} else {
|
|
583
|
+
lastAnnotations = docAnnotations;
|
|
584
|
+
}
|
|
585
|
+
if (paths.css && localCss !== null && localCss !== docCss) {
|
|
586
|
+
applyCssToDoc(doc, localCss, origin);
|
|
587
|
+
lastCss = localCss;
|
|
588
|
+
opts.journal?.record(slug, { cssHash: hashBytes(localCss) });
|
|
589
|
+
} else {
|
|
590
|
+
lastCss = docCss;
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
lastAnnotations = docAnnotations;
|
|
594
|
+
if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
|
|
595
|
+
const hash = hashBytes(docAnnotations);
|
|
596
|
+
echoGuard.record(paths.annotations, hash);
|
|
597
|
+
writer(paths.annotations, docAnnotations);
|
|
598
|
+
}
|
|
599
|
+
lastCss = docCss;
|
|
600
|
+
if (paths.css && docCss !== null && localCss !== docCss) {
|
|
601
|
+
const hash = hashBytes(docCss);
|
|
602
|
+
echoGuard.record(paths.css, hash);
|
|
603
|
+
writer(paths.css, docCss);
|
|
604
|
+
opts.journal?.record(slug, { cssHash: hash });
|
|
605
|
+
}
|
|
354
606
|
}
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
// artboard layout the hub carries that local lacks).
|
|
607
|
+
|
|
608
|
+
// ---- meta: shared-subset merge, unchanged in all cases -----------------
|
|
609
|
+
lastMeta = docMeta;
|
|
359
610
|
if (paths.meta && docMeta !== null) {
|
|
360
611
|
const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
|
|
361
612
|
if (merged !== null && merged !== localMeta) {
|
|
@@ -364,11 +615,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
364
615
|
writer(paths.meta, merged);
|
|
365
616
|
}
|
|
366
617
|
}
|
|
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
618
|
}
|
|
373
619
|
|
|
374
620
|
return {
|
|
@@ -395,6 +641,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
395
641
|
|
|
396
642
|
/* ---------------------------------------------------------------- helpers */
|
|
397
643
|
|
|
644
|
+
function localMtimeMs(p: string): number | null {
|
|
645
|
+
try {
|
|
646
|
+
return statSync(p).mtimeMs;
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
398
652
|
function readLocal(p: string): string | null {
|
|
399
653
|
if (!existsSync(p)) return null;
|
|
400
654
|
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,66 @@ 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
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
|
|
343
|
+
* in the SAME transaction + origin as a `seed-local-up` body apply. The value is
|
|
344
|
+
* a Yjs clientID, so when two peers seed the same empty hub simultaneously and
|
|
345
|
+
* their maps merge, Y.Map's deterministic last-writer-wins resolution converges
|
|
346
|
+
* `seededBy` to ONE clientID on EVERY peer — that elected peer is the
|
|
347
|
+
* single-writer that performs the de-duplication repair, so the collapse op is
|
|
348
|
+
* never emitted twice. Informational/bookkeeping only; never materialized to
|
|
349
|
+
* disk (syncMeta is a sync-internal lane).
|
|
350
|
+
*/
|
|
351
|
+
export function markSeeded(doc: Y.Doc, origin?: unknown): void {
|
|
352
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
353
|
+
doc.transact(() => {
|
|
354
|
+
map.set('seededBy', doc.clientID);
|
|
355
|
+
}, origin);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The elected seeder's clientID (`syncMeta.seededBy`), or null when no peer
|
|
359
|
+
* ever seeded through the marker path (older peers / non-seed canvases). */
|
|
360
|
+
export function seededByFromDoc(doc: Y.Doc): number | null {
|
|
361
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('seededBy');
|
|
362
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
296
365
|
/* ---------------------------------------------------------------- css */
|
|
297
366
|
|
|
298
367
|
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|