@1agh/maude 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -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 +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-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-connection-state.test.ts +146 -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-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -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,324 @@
|
|
|
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
|
+
/** The canvas's sibling `.css` (e.g. `Kanban App.css`), as opaque Y.Text.
|
|
37
|
+
* Edited via files (design:edit), not a live browser surface, so it mirrors
|
|
38
|
+
* wholesale like the body — no per-user keys, no room-clobber. Phase 9.1
|
|
39
|
+
* Gap 3. */
|
|
40
|
+
css: 'css',
|
|
41
|
+
/** The canvas `.meta.json` SHARED subset (layout/artboards/structure) as an
|
|
42
|
+
* opaque canonical-JSON Y.Text. Per-user keys (viewport pan/zoom) + the
|
|
43
|
+
* security opt-in (syncable) are stripped before sync — see META_LOCAL_KEYS
|
|
44
|
+
* + sharedMetaCanonical. Phase 9.1 Gap 2. */
|
|
45
|
+
meta: 'meta',
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hard caps on hub-pushed content (DDR-054 §2d — closes attacker F7). yjs
|
|
50
|
+
* has no upstream-enforced size cap; the codec is the consumer's guard.
|
|
51
|
+
* Mirrors the existing /_api/annotations 1 MB cap (api.ts) so the sync path
|
|
52
|
+
* doesn't bypass the HTTP-layer guard.
|
|
53
|
+
*/
|
|
54
|
+
export const MAX_HTML_BYTES = 4 * 1024 * 1024;
|
|
55
|
+
export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
|
|
56
|
+
export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
|
|
57
|
+
export const MAX_META_BYTES = 1 * 1024 * 1024;
|
|
58
|
+
export const MAX_CSS_BYTES = 4 * 1024 * 1024;
|
|
59
|
+
|
|
60
|
+
function byteLengthUtf8(s: string): number {
|
|
61
|
+
return Buffer.byteLength(s, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// DDR-054 §2g — strip dangerous keys at parse time so a hostile hub-pushed (or
|
|
65
|
+
// planted-commit) payload can't seed `__proto__` / `constructor` / `prototype`
|
|
66
|
+
// own-properties that yjs then serializes to other peers / writes to disk that
|
|
67
|
+
// Claude reads. Mirrors the agent's comments reviver; applied to the `.meta.json`
|
|
68
|
+
// parse paths (Phase D security re-audit finding A3 — the reviver had been
|
|
69
|
+
// applied only to the comments lane, not the symmetric meta lane).
|
|
70
|
+
function parseJsonSafe(s: string): unknown {
|
|
71
|
+
return JSON.parse(s, (key, value) => {
|
|
72
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
|
|
73
|
+
return value;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---------------------------------------------------------------- HTML */
|
|
78
|
+
|
|
79
|
+
export function htmlFromDoc(doc: Y.Doc): string {
|
|
80
|
+
return doc.getText(Y_SYNC_TYPES.html).toString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply `next` to the Y.Text inside `doc`. Uses a minimal common-prefix /
|
|
85
|
+
* common-suffix replace so peers see a small op rather than a full replace.
|
|
86
|
+
*
|
|
87
|
+
* Pass `origin` as the transaction origin so a downstream observer can
|
|
88
|
+
* distinguish self-originated updates from peer/remote ones.
|
|
89
|
+
*/
|
|
90
|
+
export function applyHtmlToDoc(doc: Y.Doc, next: string, origin?: unknown): boolean {
|
|
91
|
+
if (byteLengthUtf8(next) > MAX_HTML_BYTES) {
|
|
92
|
+
console.warn(
|
|
93
|
+
`[sync/codec] refusing HTML apply > ${MAX_HTML_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
|
|
94
|
+
);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const yText = doc.getText(Y_SYNC_TYPES.html);
|
|
98
|
+
const current = yText.toString();
|
|
99
|
+
if (current === next) return false;
|
|
100
|
+
|
|
101
|
+
// Find longest common prefix.
|
|
102
|
+
let prefix = 0;
|
|
103
|
+
const maxPrefix = Math.min(current.length, next.length);
|
|
104
|
+
while (prefix < maxPrefix && current.charCodeAt(prefix) === next.charCodeAt(prefix)) {
|
|
105
|
+
prefix++;
|
|
106
|
+
}
|
|
107
|
+
// Find longest common suffix that doesn't overlap the prefix.
|
|
108
|
+
let suffix = 0;
|
|
109
|
+
const maxSuffix = Math.min(current.length - prefix, next.length - prefix);
|
|
110
|
+
while (
|
|
111
|
+
suffix < maxSuffix &&
|
|
112
|
+
current.charCodeAt(current.length - 1 - suffix) === next.charCodeAt(next.length - 1 - suffix)
|
|
113
|
+
) {
|
|
114
|
+
suffix++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const deleteLen = current.length - prefix - suffix;
|
|
118
|
+
const insertStr = next.slice(prefix, next.length - suffix);
|
|
119
|
+
|
|
120
|
+
doc.transact(() => {
|
|
121
|
+
if (deleteLen > 0) yText.delete(prefix, deleteLen);
|
|
122
|
+
if (insertStr.length > 0) yText.insert(prefix, insertStr);
|
|
123
|
+
}, origin);
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ---------------------------------------------------------------- comments */
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Comments JSON payload — opaque to the codec, just the array of objects the
|
|
132
|
+
* Y.Array holds.
|
|
133
|
+
*/
|
|
134
|
+
export type CommentsSnapshot = unknown[];
|
|
135
|
+
|
|
136
|
+
export function commentsFromDoc(doc: Y.Doc): CommentsSnapshot {
|
|
137
|
+
const arr = doc.getArray(Y_TYPES.comments);
|
|
138
|
+
return arr.toArray();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function applyCommentsToDoc(doc: Y.Doc, next: CommentsSnapshot, origin?: unknown): boolean {
|
|
142
|
+
const arr = doc.getArray(Y_TYPES.comments);
|
|
143
|
+
// Comments are LWW on the JSON file (the snapshot is the source of truth);
|
|
144
|
+
// collapse Y.Array to the new state. For v1.1 we just replace wholesale —
|
|
145
|
+
// structural comment-level merge is deferred along with structured HTML.
|
|
146
|
+
// Check whether anything actually changed to avoid no-op transactions
|
|
147
|
+
// (transactions still fire `update` events, which would re-enter the loop).
|
|
148
|
+
const before = JSON.stringify(arr.toArray());
|
|
149
|
+
const after = JSON.stringify(next);
|
|
150
|
+
if (byteLengthUtf8(after) > MAX_COMMENTS_BYTES) {
|
|
151
|
+
console.warn(
|
|
152
|
+
`[sync/codec] refusing comments apply > ${MAX_COMMENTS_BYTES} bytes (got ${byteLengthUtf8(after)}). DDR-054 §2d.`
|
|
153
|
+
);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (before === after) return false;
|
|
157
|
+
|
|
158
|
+
doc.transact(() => {
|
|
159
|
+
if (arr.length > 0) arr.delete(0, arr.length);
|
|
160
|
+
if (next.length > 0) arr.push(next);
|
|
161
|
+
}, origin);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ---------------------------------------------------------------- annotations */
|
|
166
|
+
|
|
167
|
+
/** Returns the annotations SVG string, or null if unset. */
|
|
168
|
+
export function annotationsFromDoc(doc: Y.Doc): string | null {
|
|
169
|
+
const map = doc.getMap<unknown>(Y_TYPES.annotations);
|
|
170
|
+
const svg = map.get('svg');
|
|
171
|
+
return typeof svg === 'string' ? svg : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
|
|
175
|
+
if (next !== null && byteLengthUtf8(next) > MAX_ANNOTATIONS_BYTES) {
|
|
176
|
+
console.warn(
|
|
177
|
+
`[sync/codec] refusing annotations apply > ${MAX_ANNOTATIONS_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
|
|
178
|
+
);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const map = doc.getMap<unknown>(Y_TYPES.annotations);
|
|
182
|
+
const current = map.get('svg');
|
|
183
|
+
const currentStr = typeof current === 'string' ? current : null;
|
|
184
|
+
if (currentStr === next) return false;
|
|
185
|
+
|
|
186
|
+
doc.transact(() => {
|
|
187
|
+
if (next === null || next === '') {
|
|
188
|
+
map.delete('svg');
|
|
189
|
+
} else {
|
|
190
|
+
map.set('svg', next);
|
|
191
|
+
}
|
|
192
|
+
}, origin);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ---------------------------------------------------------------- meta */
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Keys of a canvas `.meta.json` that are PER-MACHINE and MUST NOT cross the hub:
|
|
200
|
+
* - `viewport` — this user's pan/zoom; syncing it would yank a
|
|
201
|
+
* collaborator's camera around on every pan.
|
|
202
|
+
* - `last_modified` — a local write timestamp; syncing it churns with no signal.
|
|
203
|
+
* - `syncable` — the security opt-in (DDR-054). A peer/hub must NEVER be
|
|
204
|
+
* able to flip another repo's sync gate, so it stays local
|
|
205
|
+
* and human-edited — same rationale that keeps it out of
|
|
206
|
+
* the /_api/canvas-meta PATCH whitelist.
|
|
207
|
+
* Everything else (title, sections/artboards, `layout` rects, css_mode, …) is
|
|
208
|
+
* shared canvas structure and DOES sync — that's how an artboard move on one
|
|
209
|
+
* machine reaches the other's disk.
|
|
210
|
+
*/
|
|
211
|
+
export const META_LOCAL_KEYS = ['viewport', 'last_modified', 'syncable'] as const;
|
|
212
|
+
|
|
213
|
+
/** Canonical JSON of the SHARED subset of a parsed meta object: local keys
|
|
214
|
+
* dropped, remaining keys sorted so equal content always serializes byte-equal
|
|
215
|
+
* (stable → the no-op guards stay quiet and sync doesn't churn). */
|
|
216
|
+
function sharedMetaCanonical(meta: Record<string, unknown>): string {
|
|
217
|
+
const out: Record<string, unknown> = {};
|
|
218
|
+
for (const k of Object.keys(meta).sort()) {
|
|
219
|
+
if ((META_LOCAL_KEYS as readonly string[]).includes(k)) continue;
|
|
220
|
+
out[k] = meta[k];
|
|
221
|
+
}
|
|
222
|
+
return JSON.stringify(out);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The synced shared-meta JSON string held in the doc, or null when unset. */
|
|
226
|
+
export function metaFromDoc(doc: Y.Doc): string | null {
|
|
227
|
+
const s = doc.getText(Y_SYNC_TYPES.meta).toString();
|
|
228
|
+
return s.length > 0 ? s : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply a FULL `.meta.json` string to the doc as its shared subset: parse, strip
|
|
233
|
+
* per-user/security keys, store the canonical shared JSON in a Y.Text. Returns
|
|
234
|
+
* false on parse error / over-cap / no change.
|
|
235
|
+
*/
|
|
236
|
+
export function applyMetaToDoc(doc: Y.Doc, fullMetaJson: string, origin?: unknown): boolean {
|
|
237
|
+
let obj: unknown;
|
|
238
|
+
try {
|
|
239
|
+
obj = parseJsonSafe(fullMetaJson);
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
|
|
244
|
+
const shared = sharedMetaCanonical(obj as Record<string, unknown>);
|
|
245
|
+
if (byteLengthUtf8(shared) > MAX_META_BYTES) {
|
|
246
|
+
console.warn(
|
|
247
|
+
`[sync/codec] refusing meta apply > ${MAX_META_BYTES} bytes (got ${byteLengthUtf8(shared)}). DDR-054 §2d.`
|
|
248
|
+
);
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
const t = doc.getText(Y_SYNC_TYPES.meta);
|
|
252
|
+
if (t.toString() === shared) return false;
|
|
253
|
+
doc.transact(() => {
|
|
254
|
+
if (t.length > 0) t.delete(0, t.length);
|
|
255
|
+
t.insert(0, shared);
|
|
256
|
+
}, origin);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Merge the synced shared-meta into a local `.meta.json` string, PRESERVING the
|
|
262
|
+
* local per-user/security keys (META_LOCAL_KEYS). Shared keys become exactly
|
|
263
|
+
* what the doc holds (so a deletion on the source propagates); the local keys
|
|
264
|
+
* are layered back on top. Returns the merged JSON (2-space, trailing newline)
|
|
265
|
+
* ready to write, or null when the shared payload is unparseable.
|
|
266
|
+
*/
|
|
267
|
+
export function mergeSharedMetaIntoLocal(
|
|
268
|
+
localMetaJson: string | null,
|
|
269
|
+
sharedJson: string
|
|
270
|
+
): string | null {
|
|
271
|
+
let shared: unknown;
|
|
272
|
+
try {
|
|
273
|
+
shared = parseJsonSafe(sharedJson);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (!shared || typeof shared !== 'object' || Array.isArray(shared)) return null;
|
|
278
|
+
let local: Record<string, unknown> = {};
|
|
279
|
+
if (localMetaJson) {
|
|
280
|
+
try {
|
|
281
|
+
const parsed = parseJsonSafe(localMetaJson);
|
|
282
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
283
|
+
local = parsed as Record<string, unknown>;
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
/* unparseable local meta — treat as empty; the shared subset becomes the base */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const merged: Record<string, unknown> = { ...(shared as Record<string, unknown>) };
|
|
290
|
+
for (const k of META_LOCAL_KEYS) {
|
|
291
|
+
if (k in local) merged[k] = local[k];
|
|
292
|
+
}
|
|
293
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ---------------------------------------------------------------- css */
|
|
297
|
+
|
|
298
|
+
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|
|
299
|
+
export function cssFromDoc(doc: Y.Doc): string | null {
|
|
300
|
+
const s = doc.getText(Y_SYNC_TYPES.css).toString();
|
|
301
|
+
return s.length > 0 ? s : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Apply the canvas's `.css` to the doc as opaque Y.Text (wholesale replace).
|
|
306
|
+
* CSS round-trips byte-identically (we write exactly the doc string back), so a
|
|
307
|
+
* wholesale delete+insert can't churn; the equality short-circuit keeps it quiet
|
|
308
|
+
* when unchanged. Returns false on over-cap / no change.
|
|
309
|
+
*/
|
|
310
|
+
export function applyCssToDoc(doc: Y.Doc, next: string, origin?: unknown): boolean {
|
|
311
|
+
if (byteLengthUtf8(next) > MAX_CSS_BYTES) {
|
|
312
|
+
console.warn(
|
|
313
|
+
`[sync/codec] refusing CSS apply > ${MAX_CSS_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
|
|
314
|
+
);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
const t = doc.getText(Y_SYNC_TYPES.css);
|
|
318
|
+
if (t.toString() === next) return false;
|
|
319
|
+
doc.transact(() => {
|
|
320
|
+
if (t.length > 0) t.delete(0, t.length);
|
|
321
|
+
t.insert(0, next);
|
|
322
|
+
}, origin);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Hub connection state machine — Phase 9 Task 8 (hub-down offline mode).
|
|
2
|
+
//
|
|
3
|
+
// The sync runtime owns one monitor for the whole linked session. Each
|
|
4
|
+
// HocuspocusProvider reports its WS status ('connected' | 'connecting' |
|
|
5
|
+
// 'disconnected'); the monitor aggregates them into a single hub-reachability
|
|
6
|
+
// view and drives the user-facing offline UX:
|
|
7
|
+
//
|
|
8
|
+
// online — hub reachable, edits flow live.
|
|
9
|
+
// connecting — WS dropped, inside the reconnect grace window. No banner yet
|
|
10
|
+
// (transient blips shouldn't flash UI).
|
|
11
|
+
// offline — still not reconnected after graceMs (default 30s). Local
|
|
12
|
+
// edits keep working + queue; yellow banner.
|
|
13
|
+
// offline-long — offline > escalateMs (default 24h). Red banner: "consider
|
|
14
|
+
// git commit && push as backup".
|
|
15
|
+
//
|
|
16
|
+
// On reconnect from any offline state the monitor emits a transient `flash:
|
|
17
|
+
// 'synced'` (green, flashMs default 3s) then settles back to online with
|
|
18
|
+
// queuedOps reset (the provider replays the buffered ops to the hub).
|
|
19
|
+
//
|
|
20
|
+
// Timers + clock are injectable so the state machine is fully unit-testable
|
|
21
|
+
// without real wall-clock waits.
|
|
22
|
+
|
|
23
|
+
export type ProviderStatus = 'connected' | 'connecting' | 'disconnected';
|
|
24
|
+
export type SyncState = 'online' | 'connecting' | 'offline' | 'offline-long';
|
|
25
|
+
|
|
26
|
+
export interface SyncStatusSnapshot {
|
|
27
|
+
state: SyncState;
|
|
28
|
+
/** Local edits made since the hub went unreachable (replayed on reconnect). */
|
|
29
|
+
queuedOps: number;
|
|
30
|
+
/** ms epoch of the last successful hub sync, or null if never synced. */
|
|
31
|
+
lastSyncAt: number | null;
|
|
32
|
+
/** ms epoch the current offline streak began, or null when online. */
|
|
33
|
+
offlineSince: number | null;
|
|
34
|
+
/** Transient 'synced' signal (green flash) right after a reconnect. */
|
|
35
|
+
flash: 'synced' | null;
|
|
36
|
+
/** ms epoch this snapshot was produced. */
|
|
37
|
+
updatedAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
41
|
+
|
|
42
|
+
export interface ConnectionMonitorOptions {
|
|
43
|
+
/** ms disconnected before declaring offline. Default 30_000. */
|
|
44
|
+
graceMs?: number;
|
|
45
|
+
/** ms offline before escalating to offline-long. Default 24h. */
|
|
46
|
+
escalateMs?: number;
|
|
47
|
+
/** ms the green "synced" flash stays up after reconnect. Default 3_000. */
|
|
48
|
+
flashMs?: number;
|
|
49
|
+
now?: () => number;
|
|
50
|
+
setTimer?: (cb: () => void, ms: number) => TimerHandle;
|
|
51
|
+
clearTimer?: (h: TimerHandle) => void;
|
|
52
|
+
/** Called on every observable state change (including flash set/clear). */
|
|
53
|
+
onChange?: (snapshot: SyncStatusSnapshot) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ConnectionMonitor {
|
|
57
|
+
/** Aggregate a single provider's status into the session view. */
|
|
58
|
+
noteProviderStatus(providerId: string, status: ProviderStatus): void;
|
|
59
|
+
/** A local edit happened — counts toward queuedOps while not online. */
|
|
60
|
+
noteLocalEdit(): void;
|
|
61
|
+
/** Current snapshot (defensive copy). */
|
|
62
|
+
snapshot(): SyncStatusSnapshot;
|
|
63
|
+
/** Tear down timers. */
|
|
64
|
+
stop(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_GRACE_MS = 30_000;
|
|
68
|
+
const DEFAULT_ESCALATE_MS = 24 * 60 * 60 * 1000;
|
|
69
|
+
const DEFAULT_FLASH_MS = 3_000;
|
|
70
|
+
|
|
71
|
+
export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): ConnectionMonitor {
|
|
72
|
+
const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
|
|
73
|
+
const escalateMs = opts.escalateMs ?? DEFAULT_ESCALATE_MS;
|
|
74
|
+
const flashMs = opts.flashMs ?? DEFAULT_FLASH_MS;
|
|
75
|
+
const now = opts.now ?? Date.now;
|
|
76
|
+
const setTimer = opts.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
77
|
+
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
|
|
78
|
+
const onChange = opts.onChange;
|
|
79
|
+
|
|
80
|
+
const providerStatuses = new Map<string, ProviderStatus>();
|
|
81
|
+
|
|
82
|
+
let state: SyncState = 'online';
|
|
83
|
+
let queuedOps = 0;
|
|
84
|
+
let lastSyncAt: number | null = null;
|
|
85
|
+
let offlineSince: number | null = null;
|
|
86
|
+
let flash: 'synced' | null = null;
|
|
87
|
+
|
|
88
|
+
let graceTimer: TimerHandle | null = null;
|
|
89
|
+
let escalateTimer: TimerHandle | null = null;
|
|
90
|
+
let flashTimer: TimerHandle | null = null;
|
|
91
|
+
let stopped = false;
|
|
92
|
+
|
|
93
|
+
function snapshot(): SyncStatusSnapshot {
|
|
94
|
+
return { state, queuedOps, lastSyncAt, offlineSince, flash, updatedAt: now() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function emit(): void {
|
|
98
|
+
onChange?.(snapshot());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function clearAllTimers(): void {
|
|
102
|
+
if (graceTimer !== null) {
|
|
103
|
+
clearTimer(graceTimer);
|
|
104
|
+
graceTimer = null;
|
|
105
|
+
}
|
|
106
|
+
if (escalateTimer !== null) {
|
|
107
|
+
clearTimer(escalateTimer);
|
|
108
|
+
escalateTimer = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Aggregate provider statuses: connected if ANY is connected, else
|
|
113
|
+
* connecting if ANY is connecting, else disconnected. An empty map (no
|
|
114
|
+
* providers yet) is treated as connecting (boot, pre-handshake). */
|
|
115
|
+
function aggregate(): ProviderStatus {
|
|
116
|
+
if (providerStatuses.size === 0) return 'connecting';
|
|
117
|
+
let anyConnecting = false;
|
|
118
|
+
for (const s of providerStatuses.values()) {
|
|
119
|
+
if (s === 'connected') return 'connected';
|
|
120
|
+
if (s === 'connecting') anyConnecting = true;
|
|
121
|
+
}
|
|
122
|
+
return anyConnecting ? 'connecting' : 'disconnected';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function goOnline(): void {
|
|
126
|
+
const wasOffline = state === 'offline' || state === 'offline-long';
|
|
127
|
+
clearAllTimers();
|
|
128
|
+
state = 'online';
|
|
129
|
+
lastSyncAt = now();
|
|
130
|
+
offlineSince = null;
|
|
131
|
+
queuedOps = 0;
|
|
132
|
+
if (wasOffline) {
|
|
133
|
+
flash = 'synced';
|
|
134
|
+
if (flashTimer !== null) clearTimer(flashTimer);
|
|
135
|
+
flashTimer = setTimer(() => {
|
|
136
|
+
flashTimer = null;
|
|
137
|
+
flash = null;
|
|
138
|
+
if (!stopped) emit();
|
|
139
|
+
}, flashMs);
|
|
140
|
+
}
|
|
141
|
+
emit();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function enterGrace(): void {
|
|
145
|
+
// Already counting down or already offline — don't restart the clock.
|
|
146
|
+
if (state !== 'online') return;
|
|
147
|
+
state = 'connecting';
|
|
148
|
+
if (graceTimer !== null) clearTimer(graceTimer);
|
|
149
|
+
graceTimer = setTimer(() => {
|
|
150
|
+
graceTimer = null;
|
|
151
|
+
goOffline();
|
|
152
|
+
}, graceMs);
|
|
153
|
+
emit();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function goOffline(): void {
|
|
157
|
+
state = 'offline';
|
|
158
|
+
offlineSince = now();
|
|
159
|
+
if (escalateTimer !== null) clearTimer(escalateTimer);
|
|
160
|
+
escalateTimer = setTimer(() => {
|
|
161
|
+
escalateTimer = null;
|
|
162
|
+
state = 'offline-long';
|
|
163
|
+
emit();
|
|
164
|
+
}, escalateMs);
|
|
165
|
+
emit();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
noteProviderStatus(providerId, status) {
|
|
170
|
+
if (stopped) return;
|
|
171
|
+
providerStatuses.set(providerId, status);
|
|
172
|
+
const agg = aggregate();
|
|
173
|
+
if (agg === 'connected') {
|
|
174
|
+
if (state !== 'online') goOnline();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Aggregate is connecting or disconnected → start (or continue) the
|
|
178
|
+
// grace countdown. Once offline/offline-long we stay there until a
|
|
179
|
+
// provider reports 'connected' again.
|
|
180
|
+
if (state === 'online') enterGrace();
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
noteLocalEdit() {
|
|
184
|
+
if (stopped) return;
|
|
185
|
+
// Only count edits made while the hub is unreachable — those are the
|
|
186
|
+
// ones queued for replay. Edits while online flush immediately.
|
|
187
|
+
if (state === 'online') return;
|
|
188
|
+
queuedOps += 1;
|
|
189
|
+
emit();
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
snapshot,
|
|
193
|
+
|
|
194
|
+
stop() {
|
|
195
|
+
stopped = true;
|
|
196
|
+
clearAllTimers();
|
|
197
|
+
if (flashTimer !== null) {
|
|
198
|
+
clearTimer(flashTimer);
|
|
199
|
+
flashTimer = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|