@1agh/maude 0.22.2 → 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 +8 -8
- package/plugins/design/dev-server/build.ts +69 -3
- 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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
|
|
2
|
+
//
|
|
3
|
+
// The agent shuttles three classes of files between disk and the Y.Doc the
|
|
4
|
+
// HocuspocusProvider holds:
|
|
5
|
+
//
|
|
6
|
+
// `.design/<canvas>.html` -> Y.Text (Y_SYNC_TYPES.html)
|
|
7
|
+
// `.design/_comments/<slug>.json` -> Y.Array (Y_TYPES.comments — Phase 6)
|
|
8
|
+
// `.design/<slug>.annotations.svg` -> Y.Map.svg (Y_TYPES.annotations — Phase 5)
|
|
9
|
+
//
|
|
10
|
+
// v1.1 design decision (plan §"Key insight"): HTML body is treated as opaque
|
|
11
|
+
// Y.Text rather than structured Y.XmlFragment. Round-trip drift would
|
|
12
|
+
// otherwise cause infinite sync churn — every time we serialize the structured
|
|
13
|
+
// CRDT back to HTML the formatting whitespace would shift and re-enter the
|
|
14
|
+
// system as a new mutation. Structured CRDT (true element-level co-editing) is
|
|
15
|
+
// Phase 10 / v1.2.
|
|
16
|
+
//
|
|
17
|
+
// The "diff-aware" applyHtmlToDoc replaces the Y.Text contents using a
|
|
18
|
+
// longest-common-prefix + suffix elimination so other peers see a minimal
|
|
19
|
+
// op (e.g. "user changed character 42 only"). This isn't a true textual diff
|
|
20
|
+
// (no LCS), but it's drastically cheaper for typical edits than `delete-all +
|
|
21
|
+
// insert-all`, and crucially preserves cursor positions other peers may have
|
|
22
|
+
// in the unchanged regions.
|
|
23
|
+
|
|
24
|
+
import type * as Y from 'yjs';
|
|
25
|
+
|
|
26
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Y.Doc shared-type names introduced by Task 4. Distinct namespace from
|
|
30
|
+
* Y_TYPES so existing comments / annotations stay untouched; new fields
|
|
31
|
+
* land here.
|
|
32
|
+
*/
|
|
33
|
+
export const Y_SYNC_TYPES = {
|
|
34
|
+
/** The canvas HTML body, as opaque Y.Text. */
|
|
35
|
+
html: 'html',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Hard caps on hub-pushed content (DDR-054 §2d — closes attacker F7). yjs
|
|
40
|
+
* has no upstream-enforced size cap; the codec is the consumer's guard.
|
|
41
|
+
* Mirrors the existing /_api/annotations 1 MB cap (api.ts) so the sync path
|
|
42
|
+
* doesn't bypass the HTTP-layer guard.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_HTML_BYTES = 4 * 1024 * 1024;
|
|
45
|
+
export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
|
|
46
|
+
export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
function byteLengthUtf8(s: string): number {
|
|
49
|
+
return Buffer.byteLength(s, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ---------------------------------------------------------------- HTML */
|
|
53
|
+
|
|
54
|
+
export function htmlFromDoc(doc: Y.Doc): string {
|
|
55
|
+
return doc.getText(Y_SYNC_TYPES.html).toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apply `next` to the Y.Text inside `doc`. Uses a minimal common-prefix /
|
|
60
|
+
* common-suffix replace so peers see a small op rather than a full replace.
|
|
61
|
+
*
|
|
62
|
+
* Pass `origin` as the transaction origin so a downstream observer can
|
|
63
|
+
* distinguish self-originated updates from peer/remote ones.
|
|
64
|
+
*/
|
|
65
|
+
export function applyHtmlToDoc(doc: Y.Doc, next: string, origin?: unknown): boolean {
|
|
66
|
+
if (byteLengthUtf8(next) > MAX_HTML_BYTES) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`[sync/codec] refusing HTML apply > ${MAX_HTML_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
|
|
69
|
+
);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const yText = doc.getText(Y_SYNC_TYPES.html);
|
|
73
|
+
const current = yText.toString();
|
|
74
|
+
if (current === next) return false;
|
|
75
|
+
|
|
76
|
+
// Find longest common prefix.
|
|
77
|
+
let prefix = 0;
|
|
78
|
+
const maxPrefix = Math.min(current.length, next.length);
|
|
79
|
+
while (prefix < maxPrefix && current.charCodeAt(prefix) === next.charCodeAt(prefix)) {
|
|
80
|
+
prefix++;
|
|
81
|
+
}
|
|
82
|
+
// Find longest common suffix that doesn't overlap the prefix.
|
|
83
|
+
let suffix = 0;
|
|
84
|
+
const maxSuffix = Math.min(current.length - prefix, next.length - prefix);
|
|
85
|
+
while (
|
|
86
|
+
suffix < maxSuffix &&
|
|
87
|
+
current.charCodeAt(current.length - 1 - suffix) === next.charCodeAt(next.length - 1 - suffix)
|
|
88
|
+
) {
|
|
89
|
+
suffix++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const deleteLen = current.length - prefix - suffix;
|
|
93
|
+
const insertStr = next.slice(prefix, next.length - suffix);
|
|
94
|
+
|
|
95
|
+
doc.transact(() => {
|
|
96
|
+
if (deleteLen > 0) yText.delete(prefix, deleteLen);
|
|
97
|
+
if (insertStr.length > 0) yText.insert(prefix, insertStr);
|
|
98
|
+
}, origin);
|
|
99
|
+
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ---------------------------------------------------------------- comments */
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Comments JSON payload — opaque to the codec, just the array of objects the
|
|
107
|
+
* Y.Array holds.
|
|
108
|
+
*/
|
|
109
|
+
export type CommentsSnapshot = unknown[];
|
|
110
|
+
|
|
111
|
+
export function commentsFromDoc(doc: Y.Doc): CommentsSnapshot {
|
|
112
|
+
const arr = doc.getArray(Y_TYPES.comments);
|
|
113
|
+
return arr.toArray();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function applyCommentsToDoc(doc: Y.Doc, next: CommentsSnapshot, origin?: unknown): boolean {
|
|
117
|
+
const arr = doc.getArray(Y_TYPES.comments);
|
|
118
|
+
// Comments are LWW on the JSON file (the snapshot is the source of truth);
|
|
119
|
+
// collapse Y.Array to the new state. For v1.1 we just replace wholesale —
|
|
120
|
+
// structural comment-level merge is deferred along with structured HTML.
|
|
121
|
+
// Check whether anything actually changed to avoid no-op transactions
|
|
122
|
+
// (transactions still fire `update` events, which would re-enter the loop).
|
|
123
|
+
const before = JSON.stringify(arr.toArray());
|
|
124
|
+
const after = JSON.stringify(next);
|
|
125
|
+
if (byteLengthUtf8(after) > MAX_COMMENTS_BYTES) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`[sync/codec] refusing comments apply > ${MAX_COMMENTS_BYTES} bytes (got ${byteLengthUtf8(after)}). DDR-054 §2d.`
|
|
128
|
+
);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (before === after) return false;
|
|
132
|
+
|
|
133
|
+
doc.transact(() => {
|
|
134
|
+
if (arr.length > 0) arr.delete(0, arr.length);
|
|
135
|
+
if (next.length > 0) arr.push(next);
|
|
136
|
+
}, origin);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ---------------------------------------------------------------- annotations */
|
|
141
|
+
|
|
142
|
+
/** Returns the annotations SVG string, or null if unset. */
|
|
143
|
+
export function annotationsFromDoc(doc: Y.Doc): string | null {
|
|
144
|
+
const map = doc.getMap<unknown>(Y_TYPES.annotations);
|
|
145
|
+
const svg = map.get('svg');
|
|
146
|
+
return typeof svg === 'string' ? svg : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
|
|
150
|
+
if (next !== null && byteLengthUtf8(next) > MAX_ANNOTATIONS_BYTES) {
|
|
151
|
+
console.warn(
|
|
152
|
+
`[sync/codec] refusing annotations apply > ${MAX_ANNOTATIONS_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
|
|
153
|
+
);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const map = doc.getMap<unknown>(Y_TYPES.annotations);
|
|
157
|
+
const current = map.get('svg');
|
|
158
|
+
const currentStr = typeof current === 'string' ? current : null;
|
|
159
|
+
if (currentStr === next) return false;
|
|
160
|
+
|
|
161
|
+
doc.transact(() => {
|
|
162
|
+
if (next === null || next === '') {
|
|
163
|
+
map.delete('svg');
|
|
164
|
+
} else {
|
|
165
|
+
map.set('svg', next);
|
|
166
|
+
}
|
|
167
|
+
}, origin);
|
|
168
|
+
return true;
|
|
169
|
+
}
|