@1agh/maude 0.23.0 → 0.25.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 +32 -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 +126 -2
- 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 +74 -10
- 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/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- 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 +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -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 +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- 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-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- 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/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -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-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- 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-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- 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 +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -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,62 @@
|
|
|
1
|
+
// Canonical doc materialization — Phase 9.2 (DDR-064 Task 10, shadow-compare).
|
|
2
|
+
//
|
|
3
|
+
// A deterministic, stable string view of a canvas doc's five synced types. Two
|
|
4
|
+
// docs that have CONVERGED (same comments/annotations/body/meta/css content)
|
|
5
|
+
// produce byte-identical output regardless of CRDT internal structure or apply
|
|
6
|
+
// order. This is the comparison primitive the cutover relies on:
|
|
7
|
+
//
|
|
8
|
+
// - shadow-compare: materialize the shared-doc path's doc and the legacy
|
|
9
|
+
// two-doc path's doc; equal strings ⇒ zero divergence ⇒ safe to cut over a
|
|
10
|
+
// canvas's reads to the shared doc. (The convergence test suite asserts this
|
|
11
|
+
// law deterministically; the live runtime "shadow mode" — running both paths
|
|
12
|
+
// simultaneously and logging divergences — is a cutover-time activity layered
|
|
13
|
+
// on this same primitive.)
|
|
14
|
+
// - round-trip laws: materialize∘import and import∘materialize for the
|
|
15
|
+
// projection (test/shared-doc-* suites).
|
|
16
|
+
//
|
|
17
|
+
// Comments are canonicalized via JSON.stringify of the array (the snapshot IS
|
|
18
|
+
// the source of truth — LWW per the codec); the SVG / body / css / meta are
|
|
19
|
+
// taken as their string values. The object is key-ordered so the output is
|
|
20
|
+
// stable.
|
|
21
|
+
|
|
22
|
+
import type * as Y from 'yjs';
|
|
23
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
24
|
+
import { Y_SYNC_TYPES, annotationsFromDoc, commentsFromDoc } from './codec.ts';
|
|
25
|
+
|
|
26
|
+
export interface CanonicalMaterialization {
|
|
27
|
+
html: string;
|
|
28
|
+
css: string;
|
|
29
|
+
meta: string;
|
|
30
|
+
comments: unknown[];
|
|
31
|
+
annotations: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Structured canonical view (for assertions that want to compare per-type). */
|
|
35
|
+
export function materialize(doc: Y.Doc): CanonicalMaterialization {
|
|
36
|
+
return {
|
|
37
|
+
html: doc.getText(Y_SYNC_TYPES.html).toString(),
|
|
38
|
+
css: doc.getText(Y_SYNC_TYPES.css).toString(),
|
|
39
|
+
meta: doc.getText(Y_SYNC_TYPES.meta).toString(),
|
|
40
|
+
comments: commentsFromDoc(doc),
|
|
41
|
+
annotations: annotationsFromDoc(doc) ?? '',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Stable canonical STRING of a doc's synced content. Byte-equal ⟺ converged.
|
|
47
|
+
* Use for shadow-compare diffing + the round-trip convergence laws.
|
|
48
|
+
*/
|
|
49
|
+
export function materializeCanonical(doc: Y.Doc): string {
|
|
50
|
+
const m = materialize(doc);
|
|
51
|
+
// Fixed key order; comments serialized as-is (the JSON snapshot is canonical).
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
html: m.html,
|
|
54
|
+
css: m.css,
|
|
55
|
+
meta: m.meta,
|
|
56
|
+
comments: m.comments,
|
|
57
|
+
annotations: m.annotations,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Re-export so a shadow-compare caller has the type names in one import.
|
|
62
|
+
export { Y_TYPES, Y_SYNC_TYPES };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// One-time authoritative seed for the shared-doc cutover — Phase 9.2 (DDR-064
|
|
2
|
+
// Task 9). The fix for Risk 1 (the duplication-on-merge trap).
|
|
3
|
+
//
|
|
4
|
+
// The trap (dmonad-confirmed, discuss.yjs.dev/t/.../2538): if a shared doc is
|
|
5
|
+
// populated from TWO independent sources — the local file-seed (room.seed
|
|
6
|
+
// pushing `_comments/<slug>.json` as fresh Y.Array items) AND the hub provider
|
|
7
|
+
// (syncing the hub's canonical items) — the two item-sets have different client
|
|
8
|
+
// IDs, so the CRDT merge CONCATENATES them: comment "c1" appears twice. You
|
|
9
|
+
// cannot fix this with `applyUpdate` of two docs; you must pick ONE authoritative
|
|
10
|
+
// source and build the doc from it.
|
|
11
|
+
//
|
|
12
|
+
// This module is that decision, run ONCE per canvas at cutover, AFTER the
|
|
13
|
+
// provider's first sync (so the doc already holds hub state if the hub had any):
|
|
14
|
+
//
|
|
15
|
+
// - hub HAD state (doc non-empty) → HUB WINS. Leave the doc; the projection
|
|
16
|
+
// materializes hub state to disk. Local divergent files are snapshotted to
|
|
17
|
+
// `_history/` first (rollback) then overwritten by the room persist.
|
|
18
|
+
// - hub was EMPTY (doc empty) → ADOPT. Clear+rebuild the doc from the
|
|
19
|
+
// local files inside `transact(fn, MIGRATION)` (the apply* codecs already
|
|
20
|
+
// delete-then-insert, so this is a true rebuild, not an append). The
|
|
21
|
+
// provider then pushes it up as the canonical first version.
|
|
22
|
+
//
|
|
23
|
+
// Idempotent: re-running with hub state present is a no-op (doc already holds
|
|
24
|
+
// it); re-running an adopt rebuilds from the same files → byte-identical, no
|
|
25
|
+
// duplicate items. The companion guard is the room's seed being disabled for
|
|
26
|
+
// pinned (provider-attached) slugs under sharedDoc (see collab/index.ts
|
|
27
|
+
// `shouldSeed`), so the file-seed can never re-introduce duplicate items after
|
|
28
|
+
// this runs.
|
|
29
|
+
|
|
30
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
import type * as Y from 'yjs';
|
|
34
|
+
|
|
35
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
36
|
+
import {
|
|
37
|
+
Y_SYNC_TYPES,
|
|
38
|
+
applyAnnotationsToDoc,
|
|
39
|
+
applyCommentsToDoc,
|
|
40
|
+
applyCssToDoc,
|
|
41
|
+
applyHtmlToDoc,
|
|
42
|
+
applyMetaToDoc,
|
|
43
|
+
} from './codec.ts';
|
|
44
|
+
import { ORIGINS } from './origins.ts';
|
|
45
|
+
|
|
46
|
+
export interface MigrateSeedPaths {
|
|
47
|
+
html: string;
|
|
48
|
+
comments: string;
|
|
49
|
+
annotations: string;
|
|
50
|
+
meta?: string;
|
|
51
|
+
css?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MigrateSeedOptions {
|
|
55
|
+
slug: string;
|
|
56
|
+
doc: Y.Doc;
|
|
57
|
+
paths: MigrateSeedPaths;
|
|
58
|
+
/** `_history/<slug>/` (or any dir) — when set, local files are snapshotted
|
|
59
|
+
* here before a hub-wins overwrite, for rollback. Best-effort. */
|
|
60
|
+
historyDir?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type MigrateSeedResult = 'hub-wins' | 'local-adopt' | 'empty';
|
|
64
|
+
|
|
65
|
+
/** True when the shared doc holds no synced content for any of the five types. */
|
|
66
|
+
export function docIsEmpty(doc: Y.Doc): boolean {
|
|
67
|
+
if (doc.getText(Y_SYNC_TYPES.html).length > 0) return false;
|
|
68
|
+
if (doc.getText(Y_SYNC_TYPES.css).length > 0) return false;
|
|
69
|
+
if (doc.getText(Y_SYNC_TYPES.meta).length > 0) return false;
|
|
70
|
+
if (doc.getArray(Y_TYPES.comments).length > 0) return false;
|
|
71
|
+
const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
|
|
72
|
+
if (typeof svg === 'string' && svg.length > 0) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run the one-time authoritative seed. Returns which source won so the caller
|
|
78
|
+
* can log / surface a conflict. Safe to call on every boot (idempotent).
|
|
79
|
+
*/
|
|
80
|
+
export function migrateSeed(opts: MigrateSeedOptions): MigrateSeedResult {
|
|
81
|
+
const { doc, paths } = opts;
|
|
82
|
+
|
|
83
|
+
// Hub had canonical state → it wins. The doc already holds it; the projection
|
|
84
|
+
// will materialize it to disk. Snapshot any divergent local files first so the
|
|
85
|
+
// overwrite is recoverable.
|
|
86
|
+
if (!docIsEmpty(doc)) {
|
|
87
|
+
snapshotLocal(opts);
|
|
88
|
+
return 'hub-wins';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Hub was empty → adopt local. Build the doc from the local files ONCE, inside
|
|
92
|
+
// a single MIGRATION transaction. The apply* codecs delete-then-insert, so
|
|
93
|
+
// this is a clear+rebuild (re-running is a no-op once content matches).
|
|
94
|
+
const localHtml = readLocal(paths.html);
|
|
95
|
+
const localComments = readLocal(paths.comments);
|
|
96
|
+
const localAnnotations = readLocal(paths.annotations);
|
|
97
|
+
const localMeta = paths.meta ? readLocal(paths.meta) : null;
|
|
98
|
+
const localCss = paths.css ? readLocal(paths.css) : null;
|
|
99
|
+
|
|
100
|
+
const hasLocal =
|
|
101
|
+
!!localHtml || !!localComments || !!localAnnotations || !!localMeta || !!localCss;
|
|
102
|
+
if (!hasLocal) return 'empty';
|
|
103
|
+
|
|
104
|
+
doc.transact(() => {
|
|
105
|
+
if (localHtml) applyHtmlToDoc(doc, localHtml, ORIGINS.MIGRATION);
|
|
106
|
+
if (localComments) {
|
|
107
|
+
const parsed = tryParseJsonArray(localComments);
|
|
108
|
+
if (parsed) applyCommentsToDoc(doc, parsed, ORIGINS.MIGRATION);
|
|
109
|
+
}
|
|
110
|
+
if (localAnnotations) applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
|
|
111
|
+
if (paths.meta && localMeta) applyMetaToDoc(doc, localMeta, ORIGINS.MIGRATION);
|
|
112
|
+
if (paths.css && localCss) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
113
|
+
}, ORIGINS.MIGRATION);
|
|
114
|
+
|
|
115
|
+
return 'local-adopt';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ---------------------------------------------------------------- helpers */
|
|
119
|
+
|
|
120
|
+
function snapshotLocal(opts: MigrateSeedOptions): void {
|
|
121
|
+
if (!opts.historyDir) return;
|
|
122
|
+
try {
|
|
123
|
+
const dir = path.join(opts.historyDir, 'pre-shared-doc-migration');
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
for (const p of [
|
|
126
|
+
opts.paths.html,
|
|
127
|
+
opts.paths.comments,
|
|
128
|
+
opts.paths.annotations,
|
|
129
|
+
opts.paths.meta,
|
|
130
|
+
opts.paths.css,
|
|
131
|
+
]) {
|
|
132
|
+
if (p && existsSync(p)) {
|
|
133
|
+
copyFileSync(p, path.join(dir, path.basename(p)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
/* best-effort — a snapshot failure must not block the cutover */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readLocal(p: string): string | null {
|
|
142
|
+
if (!existsSync(p)) return null;
|
|
143
|
+
try {
|
|
144
|
+
return readFileSync(p, 'utf8');
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Proto-pollution-safe (DDR-054 §2g), mirroring the agent + projection comments
|
|
151
|
+
// parse — a planted local `_comments/<slug>.json` must not seed dangerous keys
|
|
152
|
+
// during adopt.
|
|
153
|
+
function tryParseJsonArray(s: string): unknown[] | null {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(s, (key, value) => {
|
|
156
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
|
|
157
|
+
return value;
|
|
158
|
+
});
|
|
159
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Central transaction-origin sentinels for the shared-doc path — Phase 9.2
|
|
2
|
+
// (DDR-064 Task 5).
|
|
3
|
+
//
|
|
4
|
+
// Once a SINGLE Y.Doc per canvas has multiple writers attached (the browser
|
|
5
|
+
// collab WS, the hub HocuspocusProvider, the disk projector, and — at cutover —
|
|
6
|
+
// the one-time migration seed), every writer MUST tag its transactions with a
|
|
7
|
+
// distinct origin and every consumer MUST filter its own, or a write echoes
|
|
8
|
+
// back into the system and loops. This module is the one place those origins
|
|
9
|
+
// are defined so the discipline is auditable in one read.
|
|
10
|
+
//
|
|
11
|
+
// Two of the origins are inherently PER-INSTANCE and are NOT exported here:
|
|
12
|
+
//
|
|
13
|
+
// HUB — the HocuspocusProvider applies hub updates tagged with its own
|
|
14
|
+
// internal origin (the provider instance). Consumers detect "this
|
|
15
|
+
// came from the hub" as "origin is neither a RoomConn nor one of
|
|
16
|
+
// the sentinels below".
|
|
17
|
+
// LOCAL_WS — a browser edit arrives through the collab Room as a RoomConn
|
|
18
|
+
// object (it has an `id` field — see room.ts). That object IS the
|
|
19
|
+
// origin; the Room's own broadcaster already keys off it.
|
|
20
|
+
//
|
|
21
|
+
// The remaining origins are process-wide singletons (frozen so identity
|
|
22
|
+
// comparison `===` is stable and a consumer can't accidentally reconstruct an
|
|
23
|
+
// equal-but-different object):
|
|
24
|
+
|
|
25
|
+
export const ORIGINS = {
|
|
26
|
+
/**
|
|
27
|
+
* The disk projector materializing the doc to files (doc→file). The projector
|
|
28
|
+
* writes FILES, not the doc, so this is not normally applied as a Y
|
|
29
|
+
* transaction origin — it exists for symmetry + so any future doc-touching
|
|
30
|
+
* projection step (e.g. a normalization write-back) is filterable.
|
|
31
|
+
*/
|
|
32
|
+
DISK_PROJECTION: Object.freeze({ origin: 'disk-projection' as const }),
|
|
33
|
+
/**
|
|
34
|
+
* An external file edit (`/design:edit`, a human, a git pull) imported INTO
|
|
35
|
+
* the doc as a minimal diff (file→doc). The projector tags its import with
|
|
36
|
+
* this so its OWN doc.on('update') handler skips it — the file is already
|
|
37
|
+
* current, re-projecting it back would be a redundant write.
|
|
38
|
+
*/
|
|
39
|
+
FILE_IMPORT: Object.freeze({ origin: 'file-import' as const }),
|
|
40
|
+
/**
|
|
41
|
+
* The one-time authoritative clear-and-rebuild seed at per-canvas cutover
|
|
42
|
+
* (Phase E migrate-seed). Distinct so the projector can choose to debounce or
|
|
43
|
+
* suppress disk writes during the seed transaction (it's already on disk).
|
|
44
|
+
*/
|
|
45
|
+
MIGRATION: Object.freeze({ origin: 'migration' as const }),
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
export type OriginSentinel = (typeof ORIGINS)[keyof typeof ORIGINS];
|
|
49
|
+
|
|
50
|
+
/** True when `origin` is one of this module's projector/seed sentinels. */
|
|
51
|
+
export function isProjectorOrigin(origin: unknown): boolean {
|
|
52
|
+
return (
|
|
53
|
+
origin === ORIGINS.DISK_PROJECTION ||
|
|
54
|
+
origin === ORIGINS.FILE_IMPORT ||
|
|
55
|
+
origin === ORIGINS.MIGRATION
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Loop-free bidirectional disk projection for the shared-doc path — Phase 9.2
|
|
2
|
+
// (DDR-064 Tasks 6 + 7).
|
|
3
|
+
//
|
|
4
|
+
// In the two-doc world (flag OFF) the sync AGENT mirrored a SECOND doc to disk.
|
|
5
|
+
// Under MAUDE_SHARED_DOC there is ONE doc per canvas (the collab room's), so the
|
|
6
|
+
// file stops being a reconciliation medium and becomes a materialized
|
|
7
|
+
// PROJECTION of the doc (Zed's principle). This module owns that projection:
|
|
8
|
+
//
|
|
9
|
+
// doc → file (browser/hub edits converged): debounced, hash-gated, writes the
|
|
10
|
+
// human-readable files. The projector writes html/css/meta ONLY —
|
|
11
|
+
// the collab room already persists comments + annotations doc→file
|
|
12
|
+
// (persistence.ts), so projecting them here too would double-write.
|
|
13
|
+
// file → doc (external edit by /design:edit, a human, git): diff the file
|
|
14
|
+
// against the doc's current materialization and apply ONLY the
|
|
15
|
+
// delta as Yjs ops tagged ORIGINS.FILE_IMPORT — never a wholesale
|
|
16
|
+
// replace (that was the Phase 9.1 clobber). All 5 types import.
|
|
17
|
+
//
|
|
18
|
+
// Loop freedom rests on two guards, exactly as the agent's:
|
|
19
|
+
// 1. echo hash — every doc→file write records sha256(bytes); the fs event it
|
|
20
|
+
// triggers carries the same hash → consume() drops it.
|
|
21
|
+
// 2. origin — a file→doc import is tagged FILE_IMPORT; the projector's own
|
|
22
|
+
// doc.on('update') skips that origin so it doesn't re-project what it just
|
|
23
|
+
// imported.
|
|
24
|
+
// Plus a per-path circuit breaker: a file that fails to PARSE 3× in a row is
|
|
25
|
+
// quarantined until its checksum changes, so an unparseable file can't spin.
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
|
|
29
|
+
import type * as Y from 'yjs';
|
|
30
|
+
|
|
31
|
+
import { atomicWrite } from './atomic-write.ts';
|
|
32
|
+
import {
|
|
33
|
+
MAX_CSS_BYTES,
|
|
34
|
+
MAX_HTML_BYTES,
|
|
35
|
+
MAX_META_BYTES,
|
|
36
|
+
applyAnnotationsToDoc,
|
|
37
|
+
applyCommentsToDoc,
|
|
38
|
+
applyCssToDoc,
|
|
39
|
+
applyHtmlToDoc,
|
|
40
|
+
applyMetaToDoc,
|
|
41
|
+
cssFromDoc,
|
|
42
|
+
htmlFromDoc,
|
|
43
|
+
mergeSharedMetaIntoLocal,
|
|
44
|
+
metaFromDoc,
|
|
45
|
+
} from './codec.ts';
|
|
46
|
+
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
47
|
+
import { ORIGINS } from './origins.ts';
|
|
48
|
+
|
|
49
|
+
export const PROJECT_FLUSH_MS = 800;
|
|
50
|
+
export const CIRCUIT_MAX_STRIKES = 3;
|
|
51
|
+
|
|
52
|
+
export interface ProjectionPaths {
|
|
53
|
+
/** Absolute path to the canvas body (`.html` or opted-in `.tsx`). */
|
|
54
|
+
html: string;
|
|
55
|
+
/** Absolute path to `_comments/<slug>.json`. */
|
|
56
|
+
comments: string;
|
|
57
|
+
/** Absolute path to `<slug>.annotations.svg`. */
|
|
58
|
+
annotations: string;
|
|
59
|
+
/** Absolute path to the canvas `.meta.json` sibling (optional). */
|
|
60
|
+
meta?: string;
|
|
61
|
+
/** Absolute path to the canvas `.css` sibling (optional). */
|
|
62
|
+
css?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DocProjectionOptions {
|
|
66
|
+
slug: string;
|
|
67
|
+
doc: Y.Doc;
|
|
68
|
+
paths: ProjectionPaths;
|
|
69
|
+
/**
|
|
70
|
+
* Echo guard shared with the rest of the sync runtime so a doc→file write here
|
|
71
|
+
* is recognized as an echo when its fs event arrives. Optional — a standalone
|
|
72
|
+
* test can omit it (echo handling then relies on the hash-equality drop only).
|
|
73
|
+
*/
|
|
74
|
+
echoGuard?: EchoGuard;
|
|
75
|
+
/** Injected for tests — defaults to atomicWrite. */
|
|
76
|
+
writer?: (path: string, bytes: string | Uint8Array) => void;
|
|
77
|
+
/** Override the 800 ms debounce. Tests use 0 to flush on the next microtask. */
|
|
78
|
+
flushMs?: number;
|
|
79
|
+
/** Circuit-breaker threshold (consecutive parse failures per path). */
|
|
80
|
+
maxStrikes?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DocProjection {
|
|
84
|
+
readonly slug: string;
|
|
85
|
+
/** Subscribe to doc updates (doc→file). Idempotent. */
|
|
86
|
+
start(): void;
|
|
87
|
+
/**
|
|
88
|
+
* Apply an external file edit to the doc (file→doc). Returns true when the doc
|
|
89
|
+
* changed. Mirrors the agent's `applyFromFs` signature so the runtime's
|
|
90
|
+
* fs-reader can dispatch to either uniformly.
|
|
91
|
+
*/
|
|
92
|
+
applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean;
|
|
93
|
+
/** Project the doc's current html/css/meta to disk now (initial materialize).
|
|
94
|
+
* SAFE: never writes an empty doc value over non-empty local content — the
|
|
95
|
+
* authoritative push-local-up seed is Phase E (migrate-seed). */
|
|
96
|
+
reconcile(): void;
|
|
97
|
+
/** Force the pending doc→file flush immediately. */
|
|
98
|
+
flush(): Promise<void>;
|
|
99
|
+
/** Stop the doc listener + timers. */
|
|
100
|
+
stop(): void;
|
|
101
|
+
/** Test/inspection — the origin used on file→doc imports. */
|
|
102
|
+
readonly importOrigin: object;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
106
|
+
const { slug, doc, paths } = opts;
|
|
107
|
+
const flushMs = opts.flushMs ?? PROJECT_FLUSH_MS;
|
|
108
|
+
const writer = opts.writer ?? atomicWrite;
|
|
109
|
+
const maxStrikes = opts.maxStrikes ?? CIRCUIT_MAX_STRIKES;
|
|
110
|
+
const importOrigin = ORIGINS.FILE_IMPORT;
|
|
111
|
+
|
|
112
|
+
let started = false;
|
|
113
|
+
let stopped = false;
|
|
114
|
+
let dirty = false;
|
|
115
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
116
|
+
|
|
117
|
+
// doc→file last-written hashes (skip redundant writes).
|
|
118
|
+
let lastHtml: string | null = null;
|
|
119
|
+
let lastMeta: string | null = null;
|
|
120
|
+
let lastCss: string | null = null;
|
|
121
|
+
|
|
122
|
+
// Circuit breaker: per-path { hash, strikes }. A file that fails to PARSE
|
|
123
|
+
// maxStrikes times in a row is quarantined until its checksum changes.
|
|
124
|
+
const quarantine = new Map<string, { hash: string; strikes: number }>();
|
|
125
|
+
|
|
126
|
+
function onDocUpdate(_u: Uint8Array, origin: unknown): void {
|
|
127
|
+
if (stopped) return;
|
|
128
|
+
// Skip our own file→doc import — the file is already current, re-projecting
|
|
129
|
+
// would be a redundant write. (Migration seed is on disk already too.)
|
|
130
|
+
if (origin === ORIGINS.FILE_IMPORT || origin === ORIGINS.MIGRATION) return;
|
|
131
|
+
scheduleFlush();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function scheduleFlush(): void {
|
|
135
|
+
dirty = true;
|
|
136
|
+
if (flushMs === 0) {
|
|
137
|
+
queueMicrotask(() => void flush());
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
141
|
+
flushTimer = setTimeout(() => {
|
|
142
|
+
flushTimer = null;
|
|
143
|
+
void flush();
|
|
144
|
+
}, flushMs);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function recordEcho(path: string, value: string): void {
|
|
148
|
+
opts.echoGuard?.record(path, hashBytes(value));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Security re-audit (Phase D, finding A2 / DDR-054 §2d): the codec's
|
|
152
|
+
// MAX_*_BYTES caps guard the file→doc *import* lane, but hub-pushed content
|
|
153
|
+
// arrives as raw Yjs updates through the provider (NOT via applyXToDoc), so it
|
|
154
|
+
// bypasses those caps. This doc→file lane is the consumer's guard on that
|
|
155
|
+
// direction — refuse to materialize an oversized hub-pushed body to disk
|
|
156
|
+
// (disk-fill DoS). Returns true when the write is allowed.
|
|
157
|
+
function withinCap(path: string, value: string, max: number): boolean {
|
|
158
|
+
if (Buffer.byteLength(value, 'utf8') <= max) return true;
|
|
159
|
+
console.warn(
|
|
160
|
+
`[projection/${slug}] refusing doc→file write of ${path} > ${max} bytes (hub-pushed oversize). DDR-054 §2d.`
|
|
161
|
+
);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ----- doc → file (html / css / meta only; room owns comments/annotations)
|
|
166
|
+
|
|
167
|
+
function writeHtmlIfChanged(): void {
|
|
168
|
+
const next = htmlFromDoc(doc);
|
|
169
|
+
if (next === lastHtml) return;
|
|
170
|
+
// Don't clobber a non-empty local body with an empty doc (cold-start before
|
|
171
|
+
// the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
|
|
172
|
+
if (next === '') {
|
|
173
|
+
lastHtml = next;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return;
|
|
177
|
+
recordEcho(paths.html, next);
|
|
178
|
+
writer(paths.html, next);
|
|
179
|
+
lastHtml = next;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function writeCssIfChanged(): void {
|
|
183
|
+
if (!paths.css) return;
|
|
184
|
+
const next = cssFromDoc(doc);
|
|
185
|
+
if (next === lastCss) return;
|
|
186
|
+
lastCss = next;
|
|
187
|
+
if (next === null) return; // doc carries no css yet — nothing to write
|
|
188
|
+
if (!withinCap(paths.css, next, MAX_CSS_BYTES)) return;
|
|
189
|
+
recordEcho(paths.css, next);
|
|
190
|
+
writer(paths.css, next);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeMetaIfChanged(): void {
|
|
194
|
+
if (!paths.meta) return;
|
|
195
|
+
const shared = metaFromDoc(doc);
|
|
196
|
+
if (shared === lastMeta) return;
|
|
197
|
+
lastMeta = shared;
|
|
198
|
+
if (shared === null) return; // doc carries no shared meta yet
|
|
199
|
+
const local = readLocal(paths.meta);
|
|
200
|
+
const merged = mergeSharedMetaIntoLocal(local, shared);
|
|
201
|
+
if (merged === null || merged === local) return; // unparseable / disk matches
|
|
202
|
+
if (!withinCap(paths.meta, merged, MAX_META_BYTES)) return;
|
|
203
|
+
recordEcho(paths.meta, merged);
|
|
204
|
+
writer(paths.meta, merged);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function flush(): Promise<void> {
|
|
208
|
+
if (!dirty || stopped) return;
|
|
209
|
+
dirty = false;
|
|
210
|
+
if (flushTimer) {
|
|
211
|
+
clearTimeout(flushTimer);
|
|
212
|
+
flushTimer = null;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
writeHtmlIfChanged();
|
|
216
|
+
writeCssIfChanged();
|
|
217
|
+
writeMetaIfChanged();
|
|
218
|
+
} catch (err) {
|
|
219
|
+
dirty = true;
|
|
220
|
+
console.error(`[projection/${slug}] flush failed:`, err);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ----- file → doc (all five types; diff-import, FILE_IMPORT origin)
|
|
225
|
+
|
|
226
|
+
/** Returns true if the path is currently quarantined for `hash`. */
|
|
227
|
+
function isQuarantined(path: string, hash: string): boolean {
|
|
228
|
+
const q = quarantine.get(path);
|
|
229
|
+
return !!q && q.hash === hash && q.strikes >= maxStrikes;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Record a parse failure; quarantine after maxStrikes for this exact hash. */
|
|
233
|
+
function strike(path: string, hash: string): void {
|
|
234
|
+
const q = quarantine.get(path);
|
|
235
|
+
if (q && q.hash === hash) q.strikes += 1;
|
|
236
|
+
else quarantine.set(path, { hash, strikes: 1 });
|
|
237
|
+
if ((quarantine.get(path)?.strikes ?? 0) >= maxStrikes) {
|
|
238
|
+
console.warn(
|
|
239
|
+
`[projection/${slug}] quarantined ${path} after ${maxStrikes} parse failures; ignoring until its checksum changes.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Clear any circuit-breaker state for a path (it parsed / changed). */
|
|
245
|
+
function clearStrike(path: string): void {
|
|
246
|
+
quarantine.delete(path);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
|
|
250
|
+
if (stopped) return false;
|
|
251
|
+
// Echo of our own doc→file write — drop.
|
|
252
|
+
if (opts.echoGuard?.consume(evt.path, evt.hash)) return false;
|
|
253
|
+
// Circuit breaker — a file that won't parse can't spin the loop.
|
|
254
|
+
if (isQuarantined(evt.path, evt.hash)) return false;
|
|
255
|
+
|
|
256
|
+
const str = bytesToString(evt.bytes);
|
|
257
|
+
|
|
258
|
+
if (evt.path === paths.html) {
|
|
259
|
+
clearStrike(evt.path);
|
|
260
|
+
const changed = applyHtmlToDoc(doc, str, importOrigin);
|
|
261
|
+
if (changed) lastHtml = htmlFromDoc(doc);
|
|
262
|
+
return changed;
|
|
263
|
+
}
|
|
264
|
+
if (paths.css && evt.path === paths.css) {
|
|
265
|
+
clearStrike(evt.path);
|
|
266
|
+
const changed = applyCssToDoc(doc, str, importOrigin);
|
|
267
|
+
if (changed) lastCss = str;
|
|
268
|
+
return changed;
|
|
269
|
+
}
|
|
270
|
+
if (paths.meta && evt.path === paths.meta) {
|
|
271
|
+
// applyMetaToDoc parses internally; a parse failure returns false. Detect
|
|
272
|
+
// it explicitly so the circuit breaker can quarantine a broken sidecar.
|
|
273
|
+
if (!isParseableJsonObject(str)) {
|
|
274
|
+
strike(evt.path, evt.hash);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
clearStrike(evt.path);
|
|
278
|
+
const changed = applyMetaToDoc(doc, str, importOrigin);
|
|
279
|
+
if (changed) lastMeta = metaFromDoc(doc);
|
|
280
|
+
return changed;
|
|
281
|
+
}
|
|
282
|
+
if (evt.path === paths.comments) {
|
|
283
|
+
const parsed = tryParseJsonArray(str);
|
|
284
|
+
if (parsed === null) {
|
|
285
|
+
strike(evt.path, evt.hash);
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
clearStrike(evt.path);
|
|
289
|
+
return applyCommentsToDoc(doc, parsed, importOrigin);
|
|
290
|
+
}
|
|
291
|
+
if (evt.path === paths.annotations) {
|
|
292
|
+
clearStrike(evt.path);
|
|
293
|
+
return applyAnnotationsToDoc(doc, str, importOrigin);
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function reconcile(): void {
|
|
299
|
+
if (stopped) return;
|
|
300
|
+
// Materialize the converged doc to disk (html/css/meta). The *IfChanged
|
|
301
|
+
// writers already guard against clobbering non-empty local with empty doc
|
|
302
|
+
// values, so this is safe to run at cold start before the authoritative
|
|
303
|
+
// seed (Phase E) — it only writes what the doc actually holds.
|
|
304
|
+
writeHtmlIfChanged();
|
|
305
|
+
writeCssIfChanged();
|
|
306
|
+
writeMetaIfChanged();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
slug,
|
|
311
|
+
importOrigin,
|
|
312
|
+
start() {
|
|
313
|
+
if (started) return;
|
|
314
|
+
doc.on('update', onDocUpdate);
|
|
315
|
+
started = true;
|
|
316
|
+
},
|
|
317
|
+
applyFromFs,
|
|
318
|
+
reconcile,
|
|
319
|
+
flush,
|
|
320
|
+
stop() {
|
|
321
|
+
stopped = true;
|
|
322
|
+
doc.off('update', onDocUpdate);
|
|
323
|
+
if (flushTimer) {
|
|
324
|
+
clearTimeout(flushTimer);
|
|
325
|
+
flushTimer = null;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ---------------------------------------------------------------- helpers */
|
|
332
|
+
|
|
333
|
+
function readLocal(p: string): string | null {
|
|
334
|
+
if (!existsSync(p)) return null;
|
|
335
|
+
try {
|
|
336
|
+
return readFileSync(p, 'utf8');
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function bytesToString(bytes: Uint8Array): string {
|
|
343
|
+
return new TextDecoder().decode(bytes);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isParseableJsonObject(s: string): boolean {
|
|
347
|
+
try {
|
|
348
|
+
const v = JSON.parse(s);
|
|
349
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Same proto-pollution-safe reviver the agent uses (DDR-054 §2g): strip
|
|
356
|
+
// dangerous keys at parse time so a hostile file can't seed __proto__ into the
|
|
357
|
+
// comment objects yjs serializes to peers.
|
|
358
|
+
function tryParseJsonArray(s: string): unknown[] | null {
|
|
359
|
+
try {
|
|
360
|
+
const parsed = JSON.parse(s, (key, value) => {
|
|
361
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
|
|
362
|
+
return value;
|
|
363
|
+
});
|
|
364
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|