@1agh/maude 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Per-machine sync journal — hub-sync cold-start safety (DDR-102).
|
|
2
|
+
//
|
|
3
|
+
// Records, per slug, the content hash of the LAST state this machine
|
|
4
|
+
// successfully reconciled disk↔doc. It answers git's "do I have uncommitted
|
|
5
|
+
// changes?" question for sync: a cold-start hub-wins overwrite of local disk
|
|
6
|
+
// is allowed ONLY when hash(local) == journal hash (a clean fast-forward —
|
|
7
|
+
// everything local was already synced, the hub is just ahead). Any other
|
|
8
|
+
// combination is divergence and takes the conflict protocol (cold-start.ts:
|
|
9
|
+
// dual snapshot + newest-wins).
|
|
10
|
+
//
|
|
11
|
+
// Per-machine, per-hub: the file lives at `<designRoot>/_state/sync-journal.json`
|
|
12
|
+
// (`_state/` is already in the DDR-056 gitignore block) and carries the hub URL
|
|
13
|
+
// it was recorded against. Relinking to a DIFFERENT hub invalidates every
|
|
14
|
+
// entry — hashes recorded against one hub's docs say nothing about another's.
|
|
15
|
+
//
|
|
16
|
+
// Posture mirrors status.ts: best-effort, NEVER throws into the boot path. A
|
|
17
|
+
// corrupt/unparseable file is treated as absent; the decision module then
|
|
18
|
+
// degrades to the conservative "potential divergence" path, which is safe
|
|
19
|
+
// (snapshot + newest-wins), never destructive. Hashes come exclusively from
|
|
20
|
+
// `hashBytes` (echo-guard.ts) so the journal compares apples to apples with
|
|
21
|
+
// the echo guard and the cold-start decision inputs.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { atomicWrite } from './atomic-write.ts';
|
|
27
|
+
|
|
28
|
+
/** Debounce window for persisting the journal to disk. */
|
|
29
|
+
export const JOURNAL_FLUSH_MS = 1_000;
|
|
30
|
+
|
|
31
|
+
export interface JournalEntry {
|
|
32
|
+
/** hashBytes() of the canvas body as last reconciled disk↔doc. */
|
|
33
|
+
bodyHash: string;
|
|
34
|
+
/** hashBytes() of the sibling .css, when one was reconciled. */
|
|
35
|
+
cssHash?: string;
|
|
36
|
+
/** ms epoch of the checkpoint. */
|
|
37
|
+
at: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface JournalFileShape {
|
|
41
|
+
hubUrl: string | null;
|
|
42
|
+
updatedAt: number;
|
|
43
|
+
slugs: Record<string, JournalEntry>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SyncJournal {
|
|
47
|
+
/** Last-reconciled hashes for a slug, or null when never checkpointed. */
|
|
48
|
+
get(slug: string): JournalEntry | null;
|
|
49
|
+
/** Checkpoint a successful disk↔doc traversal. Lanes checkpoint
|
|
50
|
+
* independently (body flush vs css flush), so omitted hashes MERGE with the
|
|
51
|
+
* existing entry instead of clearing it. Schedules a debounced persist. */
|
|
52
|
+
record(slug: string, hashes: { bodyHash?: string; cssHash?: string }): void;
|
|
53
|
+
/** Relinked to a different hub → wipe every entry (hashes are per-hub). */
|
|
54
|
+
invalidateIfHubChanged(url: string): void;
|
|
55
|
+
/** Persist now (cancels the pending debounce). Best-effort. */
|
|
56
|
+
flush(): void;
|
|
57
|
+
/** Flush + cancel timers. */
|
|
58
|
+
stop(): void;
|
|
59
|
+
/** Test/inspection — number of slugs tracked. */
|
|
60
|
+
size(): number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LoadJournalOptions {
|
|
64
|
+
/** Override the persist debounce. Tests use 0 to persist synchronously. */
|
|
65
|
+
flushMs?: number;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
/** Injected for tests — defaults to atomicWrite. */
|
|
68
|
+
writer?: (path: string, bytes: string) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Absolute path of the journal file for a design root. */
|
|
72
|
+
export function journalPath(designRoot: string): string {
|
|
73
|
+
return path.join(designRoot, '_state', 'sync-journal.json');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function loadJournal(designRoot: string, opts: LoadJournalOptions = {}): SyncJournal {
|
|
77
|
+
const file = journalPath(designRoot);
|
|
78
|
+
const flushMs = opts.flushMs ?? JOURNAL_FLUSH_MS;
|
|
79
|
+
const now = opts.now ?? Date.now;
|
|
80
|
+
// atomicWrite mkdirs the parent (`_state/`) on demand, so no explicit mkdir.
|
|
81
|
+
const writer = opts.writer ?? ((p: string, bytes: string) => void atomicWrite(p, bytes));
|
|
82
|
+
|
|
83
|
+
let hubUrl: string | null = null;
|
|
84
|
+
let slugs: Record<string, JournalEntry> = {};
|
|
85
|
+
let dirty = false;
|
|
86
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
87
|
+
let stopped = false;
|
|
88
|
+
|
|
89
|
+
// Load once at construction. Corrupt / unreadable / wrong-shape → absent.
|
|
90
|
+
try {
|
|
91
|
+
if (existsSync(file)) {
|
|
92
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<JournalFileShape>;
|
|
93
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
94
|
+
hubUrl = typeof parsed.hubUrl === 'string' ? parsed.hubUrl : null;
|
|
95
|
+
const rawSlugs = parsed.slugs;
|
|
96
|
+
if (rawSlugs && typeof rawSlugs === 'object' && !Array.isArray(rawSlugs)) {
|
|
97
|
+
for (const [slug, raw] of Object.entries(rawSlugs)) {
|
|
98
|
+
const e = raw as JournalEntry;
|
|
99
|
+
if (e && typeof e === 'object' && typeof e.bodyHash === 'string') {
|
|
100
|
+
slugs[slug] = {
|
|
101
|
+
bodyHash: e.bodyHash,
|
|
102
|
+
...(typeof e.cssHash === 'string' ? { cssHash: e.cssHash } : {}),
|
|
103
|
+
at: typeof e.at === 'number' ? e.at : 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* corrupt journal → treat as absent; the decision module degrades safely */
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function persist(): void {
|
|
115
|
+
if (!dirty) return;
|
|
116
|
+
dirty = false;
|
|
117
|
+
if (flushTimer) {
|
|
118
|
+
clearTimeout(flushTimer);
|
|
119
|
+
flushTimer = null;
|
|
120
|
+
}
|
|
121
|
+
const payload: JournalFileShape = { hubUrl, updatedAt: now(), slugs };
|
|
122
|
+
try {
|
|
123
|
+
writer(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Best-effort: a failed persist must never throw into the sync hot path.
|
|
126
|
+
console.warn('[sync/journal] persist failed:', err instanceof Error ? err.message : err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function schedulePersist(): void {
|
|
131
|
+
dirty = true;
|
|
132
|
+
if (stopped) return;
|
|
133
|
+
if (flushMs === 0) {
|
|
134
|
+
persist();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
138
|
+
flushTimer = setTimeout(() => {
|
|
139
|
+
flushTimer = null;
|
|
140
|
+
persist();
|
|
141
|
+
}, flushMs);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
get(slug) {
|
|
146
|
+
return slugs[slug] ?? null;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
record(slug, hashes) {
|
|
150
|
+
const prev = slugs[slug];
|
|
151
|
+
const cssHash = hashes.cssHash ?? prev?.cssHash;
|
|
152
|
+
slugs[slug] = {
|
|
153
|
+
// An entry created by a css-only checkpoint carries an empty bodyHash —
|
|
154
|
+
// it can never match a real body hash, so the decision module stays
|
|
155
|
+
// conservative (divergence path) until a body traversal checkpoints.
|
|
156
|
+
bodyHash: hashes.bodyHash ?? prev?.bodyHash ?? '',
|
|
157
|
+
...(cssHash !== undefined ? { cssHash } : {}),
|
|
158
|
+
at: now(),
|
|
159
|
+
};
|
|
160
|
+
schedulePersist();
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
invalidateIfHubChanged(url) {
|
|
164
|
+
if (hubUrl === url) return;
|
|
165
|
+
if (hubUrl !== null) {
|
|
166
|
+
// Different hub than the one these hashes were recorded against — every
|
|
167
|
+
// entry is meaningless there. Wipe so the decision module stays
|
|
168
|
+
// conservative (divergence → snapshot) instead of false-fast-forwarding.
|
|
169
|
+
slugs = {};
|
|
170
|
+
}
|
|
171
|
+
hubUrl = url;
|
|
172
|
+
schedulePersist();
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
flush: persist,
|
|
176
|
+
|
|
177
|
+
stop() {
|
|
178
|
+
persist();
|
|
179
|
+
stopped = true;
|
|
180
|
+
if (flushTimer) {
|
|
181
|
+
clearTimeout(flushTimer);
|
|
182
|
+
flushTimer = null;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
size() {
|
|
187
|
+
return Object.keys(slugs).length;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// One-time authoritative seed for the shared-doc cutover — Phase 9.2 (DDR-064
|
|
2
|
-
// Task 9)
|
|
2
|
+
// Task 9), cold-start resolution upgraded by DDR-102.
|
|
3
3
|
//
|
|
4
4
|
// The trap (dmonad-confirmed, discuss.yjs.dev/t/.../2538): if a shared doc is
|
|
5
5
|
// populated from TWO independent sources — the local file-seed (room.seed
|
|
@@ -12,22 +12,24 @@
|
|
|
12
12
|
// This module is that decision, run ONCE per canvas at cutover, AFTER the
|
|
13
13
|
// provider's first sync (so the doc already holds hub state if the hub had any):
|
|
14
14
|
//
|
|
15
|
-
// - hub
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// local
|
|
20
|
-
//
|
|
21
|
-
//
|
|
15
|
+
// - hub EMPTY (doc empty) → ADOPT. Clear+rebuild the doc from the
|
|
16
|
+
// local files inside `transact(fn, MIGRATION)`.
|
|
17
|
+
// - hub HAD state, body resolution → the DDR-102 decision table
|
|
18
|
+
// (cold-start.ts): identical → keep; journal-matched local → fast-forward
|
|
19
|
+
// (hub keeps); empty local → materialize; divergence → dual snapshot +
|
|
20
|
+
// NEWEST-WINS — winner `local` rebuilds the body from disk inside ONE
|
|
21
|
+
// MIGRATION transaction (one source per type, never a CRDT merge of two
|
|
22
|
+
// histories); winner `hub` keeps the doc.
|
|
23
|
+
// - comments under a non-empty doc → id-union (plain array rebuild from
|
|
24
|
+
// merged JSON via the delete-then-insert codec — NOT a CRDT merge, so the
|
|
25
|
+
// duplication trap stays closed; same-id entries keep the doc's version).
|
|
22
26
|
//
|
|
23
|
-
// Idempotent: re-running with hub state present is a no-op
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
27
|
+
// Idempotent: re-running with hub state present and no divergence is a no-op;
|
|
28
|
+
// re-running an adopt rebuilds from the same files → byte-identical.
|
|
29
|
+
// The companion guard is the room's seed being disabled for pinned
|
|
30
|
+
// (provider-attached) slugs under sharedDoc (see collab/index.ts `shouldSeed`).
|
|
31
|
+
|
|
32
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
31
33
|
import path from 'node:path';
|
|
32
34
|
|
|
33
35
|
import type * as Y from 'yjs';
|
|
@@ -39,8 +41,13 @@ import {
|
|
|
39
41
|
applyCssToDoc,
|
|
40
42
|
applyHtmlToDoc,
|
|
41
43
|
applyMetaToDoc,
|
|
44
|
+
bodyEditAtFromDoc,
|
|
45
|
+
stampBodyEdit,
|
|
42
46
|
Y_SYNC_TYPES,
|
|
43
47
|
} from './codec.ts';
|
|
48
|
+
import { decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
49
|
+
import { hashBytes } from './echo-guard.ts';
|
|
50
|
+
import type { SyncJournal } from './journal.ts';
|
|
44
51
|
import { ORIGINS } from './origins.ts';
|
|
45
52
|
|
|
46
53
|
export interface MigrateSeedPaths {
|
|
@@ -56,11 +63,33 @@ export interface MigrateSeedOptions {
|
|
|
56
63
|
doc: Y.Doc;
|
|
57
64
|
paths: MigrateSeedPaths;
|
|
58
65
|
/** `_history/<slug>/` (or any dir) — when set, local files are snapshotted
|
|
59
|
-
* here
|
|
66
|
+
* here (the legacy `pre-shared-doc-migration/` whole-set copy) before any
|
|
67
|
+
* doc-keeps-state path can overwrite them via the projection. Best-effort. */
|
|
60
68
|
historyDir?: string;
|
|
69
|
+
/** DDR-102 — per-machine journal; gates fast-forward vs conflict. */
|
|
70
|
+
journal?: SyncJournal;
|
|
71
|
+
/** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
|
|
72
|
+
snapshot?: (content: string, reason: 'pre-sync-local' | 'pre-sync-hub') => Promise<string | null>;
|
|
73
|
+
/** DDR-102 — divergence notification, same contract as the agent's. */
|
|
74
|
+
onConflict?: (info: {
|
|
75
|
+
slug: string;
|
|
76
|
+
kind: 'cold-start-diverged';
|
|
77
|
+
winner?: 'local' | 'hub';
|
|
78
|
+
snapshots?: { local?: string; hub?: string };
|
|
79
|
+
/** DDR-102 fail-closed (F1) — local snapshot didn't land; hub-wins refused. */
|
|
80
|
+
snapshotFailed?: boolean;
|
|
81
|
+
}) => void;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
|
-
export type MigrateSeedResult =
|
|
84
|
+
export type MigrateSeedResult =
|
|
85
|
+
| 'hub-wins'
|
|
86
|
+
| 'local-adopt'
|
|
87
|
+
| 'empty'
|
|
88
|
+
/** DDR-102 — doc held state but no body; local body seeded up in-place. */
|
|
89
|
+
| 'body-seed-up'
|
|
90
|
+
/** DDR-102 — divergence resolved newest-wins. */
|
|
91
|
+
| 'conflict-local-wins'
|
|
92
|
+
| 'conflict-hub-wins';
|
|
64
93
|
|
|
65
94
|
/** True when the shared doc holds no synced content for any of the five types. */
|
|
66
95
|
export function docIsEmpty(doc: Y.Doc): boolean {
|
|
@@ -77,42 +106,163 @@ export function docIsEmpty(doc: Y.Doc): boolean {
|
|
|
77
106
|
* Run the one-time authoritative seed. Returns which source won so the caller
|
|
78
107
|
* can log / surface a conflict. Safe to call on every boot (idempotent).
|
|
79
108
|
*/
|
|
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
|
-
}
|
|
109
|
+
export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeedResult> {
|
|
110
|
+
const { slug, doc, paths } = opts;
|
|
90
111
|
|
|
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
112
|
const localHtml = readLocal(paths.html);
|
|
95
113
|
const localComments = readLocal(paths.comments);
|
|
96
114
|
const localAnnotations = readLocal(paths.annotations);
|
|
97
115
|
const localMeta = paths.meta ? readLocal(paths.meta) : null;
|
|
98
116
|
const localCss = paths.css ? readLocal(paths.css) : null;
|
|
99
117
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
// Hub was empty → adopt local. Build the doc from the local files ONCE, inside
|
|
119
|
+
// a single MIGRATION transaction. The apply* codecs delete-then-insert, so
|
|
120
|
+
// this is a clear+rebuild (re-running is a no-op once content matches).
|
|
121
|
+
if (docIsEmpty(doc)) {
|
|
122
|
+
const hasLocal =
|
|
123
|
+
!!localHtml || !!localComments || !!localAnnotations || !!localMeta || !!localCss;
|
|
124
|
+
if (!hasLocal) return 'empty';
|
|
125
|
+
|
|
126
|
+
doc.transact(() => {
|
|
127
|
+
if (localHtml) {
|
|
128
|
+
if (applyHtmlToDoc(doc, localHtml, ORIGINS.MIGRATION)) {
|
|
129
|
+
stampBodyEdit(doc, ORIGINS.MIGRATION);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (localComments) {
|
|
133
|
+
const parsed = tryParseJsonArray(localComments);
|
|
134
|
+
if (parsed) applyCommentsToDoc(doc, parsed, ORIGINS.MIGRATION);
|
|
135
|
+
}
|
|
136
|
+
if (localAnnotations) applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
|
|
137
|
+
if (paths.meta && localMeta) applyMetaToDoc(doc, localMeta, ORIGINS.MIGRATION);
|
|
138
|
+
if (paths.css && localCss) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
139
|
+
}, ORIGINS.MIGRATION);
|
|
140
|
+
|
|
141
|
+
// DDR-102 — the adopt is a disk→doc traversal: checkpoint it.
|
|
142
|
+
if (localHtml) {
|
|
143
|
+
opts.journal?.record(slug, {
|
|
144
|
+
bodyHash: hashBytes(localHtml),
|
|
145
|
+
...(localCss ? { cssHash: hashBytes(localCss) } : {}),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return 'local-adopt';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Hub had state. Legacy whole-set safety copy (fixed dir, overwritten per
|
|
152
|
+
// boot — not spam) so non-body files keep their pre-cutover backup too.
|
|
153
|
+
snapshotLocal(opts);
|
|
154
|
+
|
|
155
|
+
// Body resolution via the DDR-102 decision table.
|
|
156
|
+
const docHtml = doc.getText(Y_SYNC_TYPES.html).toString();
|
|
157
|
+
const decision = decideColdStart({
|
|
158
|
+
localBody: localHtml,
|
|
159
|
+
docBody: docHtml,
|
|
160
|
+
journalHash: opts.journal?.get(slug)?.bodyHash ?? null,
|
|
161
|
+
localMtimeMs: localMtimeMs(paths.html),
|
|
162
|
+
docBodyEditAtMs: bodyEditAtFromDoc(doc),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
let result: MigrateSeedResult = 'hub-wins';
|
|
166
|
+
|
|
167
|
+
/** Rebuild body (+ visually-coupled css/annotations) from local, in ONE
|
|
168
|
+
* MIGRATION transaction — one source per type, never a two-history merge. */
|
|
169
|
+
const rebuildBodyFromLocal = (): void => {
|
|
170
|
+
doc.transact(() => {
|
|
171
|
+
if (applyHtmlToDoc(doc, localHtml as string, ORIGINS.MIGRATION)) {
|
|
172
|
+
stampBodyEdit(doc, ORIGINS.MIGRATION);
|
|
173
|
+
}
|
|
174
|
+
if (paths.css && localCss !== null) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
175
|
+
if (localAnnotations !== null) {
|
|
176
|
+
applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
|
|
177
|
+
}
|
|
178
|
+
}, ORIGINS.MIGRATION);
|
|
179
|
+
opts.journal?.record(slug, {
|
|
180
|
+
bodyHash: hashBytes(localHtml as string),
|
|
181
|
+
...(localCss !== null ? { cssHash: hashBytes(localCss) } : {}),
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
switch (decision.action) {
|
|
186
|
+
case 'noop':
|
|
187
|
+
// Identical (or both empty) — checkpoint identity so the next boot
|
|
188
|
+
// fast-forwards even if the hub then moves ahead.
|
|
189
|
+
if (localHtml !== null && localHtml === docHtml && docHtml !== '') {
|
|
190
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(docHtml) });
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
case 'materialize-hub':
|
|
194
|
+
case 'fast-forward-hub':
|
|
195
|
+
// Keep the doc; the projection materializes it to disk (and records the
|
|
196
|
+
// journal checkpoint on its write).
|
|
197
|
+
break;
|
|
198
|
+
case 'seed-local-up':
|
|
199
|
+
// Doc holds state for OTHER types but no body — seed the local body up.
|
|
200
|
+
rebuildBodyFromLocal();
|
|
201
|
+
result = 'body-seed-up';
|
|
202
|
+
break;
|
|
203
|
+
case 'conflict': {
|
|
204
|
+
const snapshots: { local?: string; hub?: string } = {};
|
|
205
|
+
let snapshotAttempted = false;
|
|
206
|
+
if (opts.snapshot) {
|
|
207
|
+
snapshotAttempted = true;
|
|
208
|
+
try {
|
|
209
|
+
const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
|
|
210
|
+
if (localTs) snapshots.local = localTs;
|
|
211
|
+
const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
|
|
212
|
+
if (hubTs) snapshots.hub = hubTs;
|
|
213
|
+
} catch {
|
|
214
|
+
/* swallowed below — the missing snapshot ref drives the fail-closed guard */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// DDR-102 fail-closed (security F1): a hub-wins resolution keeps the doc
|
|
218
|
+
// (hub) state, which projection.reconcile() then materializes to disk —
|
|
219
|
+
// OVERWRITING local. If the local snapshot didn't land, rebuild the body
|
|
220
|
+
// from local instead so the projection writes local back (a no-op on
|
|
221
|
+
// disk) and nothing is lost. Gated on `snapshotAttempted` so a
|
|
222
|
+
// snapshot-less standalone call keeps plain newest-wins.
|
|
223
|
+
const localSnapshotMissing = snapshotAttempted && !snapshots.local;
|
|
224
|
+
let winner = decision.winner;
|
|
225
|
+
if (winner === 'hub' && localSnapshotMissing) {
|
|
226
|
+
winner = 'local';
|
|
227
|
+
console.error(
|
|
228
|
+
`[sync/${slug}] shared-doc cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Rebuilding the doc from local; resolve the _history/ write failure to restore newest-wins.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (winner === 'local') {
|
|
232
|
+
rebuildBodyFromLocal();
|
|
233
|
+
result = 'conflict-local-wins';
|
|
234
|
+
} else {
|
|
235
|
+
result = 'conflict-hub-wins';
|
|
236
|
+
}
|
|
237
|
+
console.warn(`[sync/${slug}] shared-doc cold-start divergence — ${decision.reason}`);
|
|
238
|
+
opts.onConflict?.({
|
|
239
|
+
slug,
|
|
240
|
+
kind: 'cold-start-diverged',
|
|
241
|
+
winner,
|
|
242
|
+
...(snapshots.local || snapshots.hub ? { snapshots } : {}),
|
|
243
|
+
...(localSnapshotMissing ? { snapshotFailed: true } : {}),
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
103
248
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
249
|
+
// Comments id-union (DDR-102): rebuild from merged JSON via the
|
|
250
|
+
// delete-then-insert codec — same-id entries keep the doc's version, so the
|
|
251
|
+
// duplication trap stays closed; local-only comments survive.
|
|
252
|
+
if (localComments) {
|
|
253
|
+
const parsed = tryParseJsonArray(localComments);
|
|
254
|
+
if (parsed && parsed.length > 0) {
|
|
255
|
+
const docList = doc.getArray(Y_TYPES.comments).toArray();
|
|
256
|
+
const merged = unionCommentsById(docList, parsed);
|
|
257
|
+
if (merged.length !== docList.length) {
|
|
258
|
+
doc.transact(() => {
|
|
259
|
+
applyCommentsToDoc(doc, merged, ORIGINS.MIGRATION);
|
|
260
|
+
}, ORIGINS.MIGRATION);
|
|
261
|
+
}
|
|
109
262
|
}
|
|
110
|
-
|
|
111
|
-
if (paths.meta && localMeta) applyMetaToDoc(doc, localMeta, ORIGINS.MIGRATION);
|
|
112
|
-
if (paths.css && localCss) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
113
|
-
}, ORIGINS.MIGRATION);
|
|
263
|
+
}
|
|
114
264
|
|
|
115
|
-
return
|
|
265
|
+
return result;
|
|
116
266
|
}
|
|
117
267
|
|
|
118
268
|
/* ---------------------------------------------------------------- helpers */
|
|
@@ -147,6 +297,14 @@ function readLocal(p: string): string | null {
|
|
|
147
297
|
}
|
|
148
298
|
}
|
|
149
299
|
|
|
300
|
+
function localMtimeMs(p: string): number | null {
|
|
301
|
+
try {
|
|
302
|
+
return statSync(p).mtimeMs;
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
150
308
|
// Proto-pollution-safe (DDR-054 §2g), mirroring the agent + projection comments
|
|
151
309
|
// parse — a planted local `_comments/<slug>.json` must not seed dangerous keys
|
|
152
310
|
// during adopt.
|
|
@@ -42,8 +42,10 @@ import {
|
|
|
42
42
|
MAX_META_BYTES,
|
|
43
43
|
mergeSharedMetaIntoLocal,
|
|
44
44
|
metaFromDoc,
|
|
45
|
+
stampBodyEdit,
|
|
45
46
|
} from './codec.ts';
|
|
46
47
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
48
|
+
import type { SyncJournal } from './journal.ts';
|
|
47
49
|
import { ORIGINS } from './origins.ts';
|
|
48
50
|
|
|
49
51
|
export const PROJECT_FLUSH_MS = 800;
|
|
@@ -78,6 +80,9 @@ export interface DocProjectionOptions {
|
|
|
78
80
|
flushMs?: number;
|
|
79
81
|
/** Circuit-breaker threshold (consecutive parse failures per path). */
|
|
80
82
|
maxStrikes?: number;
|
|
83
|
+
/** DDR-102 — per-machine sync journal; every successful disk↔doc body/css
|
|
84
|
+
* traversal checkpoints here (same discipline as the agent). Optional. */
|
|
85
|
+
journal?: SyncJournal;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
export interface DocProjection {
|
|
@@ -177,6 +182,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
177
182
|
recordEcho(paths.html, next);
|
|
178
183
|
writer(paths.html, next);
|
|
179
184
|
lastHtml = next;
|
|
185
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(next) }); // DDR-102 checkpoint
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
function writeCssIfChanged(): void {
|
|
@@ -188,6 +194,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
188
194
|
if (!withinCap(paths.css, next, MAX_CSS_BYTES)) return;
|
|
189
195
|
recordEcho(paths.css, next);
|
|
190
196
|
writer(paths.css, next);
|
|
197
|
+
opts.journal?.record(slug, { cssHash: hashBytes(next) }); // DDR-102 checkpoint
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
function writeMetaIfChanged(): void {
|
|
@@ -257,14 +264,26 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
257
264
|
|
|
258
265
|
if (evt.path === paths.html) {
|
|
259
266
|
clearStrike(evt.path);
|
|
260
|
-
|
|
261
|
-
|
|
267
|
+
// Body import + syncMeta stamp in ONE transaction (same FILE_IMPORT
|
|
268
|
+
// origin) — peers get a single update carrying the newest-wins stamp.
|
|
269
|
+
let changed = false;
|
|
270
|
+
doc.transact(() => {
|
|
271
|
+
changed = applyHtmlToDoc(doc, str, importOrigin);
|
|
272
|
+
if (changed) stampBodyEdit(doc, importOrigin);
|
|
273
|
+
}, importOrigin);
|
|
274
|
+
if (changed) {
|
|
275
|
+
lastHtml = htmlFromDoc(doc);
|
|
276
|
+
opts.journal?.record(slug, { bodyHash: evt.hash }); // DDR-102 checkpoint
|
|
277
|
+
}
|
|
262
278
|
return changed;
|
|
263
279
|
}
|
|
264
280
|
if (paths.css && evt.path === paths.css) {
|
|
265
281
|
clearStrike(evt.path);
|
|
266
282
|
const changed = applyCssToDoc(doc, str, importOrigin);
|
|
267
|
-
if (changed)
|
|
283
|
+
if (changed) {
|
|
284
|
+
lastCss = str;
|
|
285
|
+
opts.journal?.record(slug, { cssHash: evt.hash }); // DDR-102 checkpoint
|
|
286
|
+
}
|
|
268
287
|
return changed;
|
|
269
288
|
}
|
|
270
289
|
if (paths.meta && evt.path === paths.meta) {
|
|
@@ -14,12 +14,23 @@
|
|
|
14
14
|
|
|
15
15
|
import type { SyncStatusSnapshot } from './connection-state.ts';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// `cold-start-hub-wins` stays in the union for OLD payload readers (additive
|
|
18
|
+
// evolution — the NoSyncablePayload discriminator pattern); new conflicts are
|
|
19
|
+
// recorded as `cold-start-diverged` with the DDR-102 winner + snapshot refs.
|
|
20
|
+
export type ConflictKind = 'cold-start-hub-wins' | 'cold-start-diverged' | 'git-pull';
|
|
18
21
|
|
|
19
22
|
export interface SyncConflict {
|
|
20
23
|
slug: string;
|
|
21
24
|
kind: ConflictKind;
|
|
22
25
|
at: number;
|
|
26
|
+
/** DDR-102 — which side the newest-wins resolution kept. */
|
|
27
|
+
winner?: 'local' | 'hub';
|
|
28
|
+
/** DDR-102 — ISO timestamps of the `_history/<slug>/` snapshots taken before
|
|
29
|
+
* resolution (`/design:rollback` recovery). */
|
|
30
|
+
snapshots?: { local?: string; hub?: string };
|
|
31
|
+
/** DDR-102 fail-closed (F1) — the local snapshot didn't land, so a hub-wins
|
|
32
|
+
* overwrite was refused (local kept). Surfaces the degraded `_history/` write. */
|
|
33
|
+
snapshotFailed?: boolean;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export interface SyncStatusPayload extends SyncStatusSnapshot {
|
|
@@ -55,7 +66,7 @@ export interface SyncStatusStore {
|
|
|
55
66
|
/** Merge a fresh ConnectionMonitor snapshot + persist + broadcast. */
|
|
56
67
|
update(snapshot: SyncStatusSnapshot): void;
|
|
57
68
|
/** Record a conflict notification + persist + broadcast. */
|
|
58
|
-
addConflict(conflict:
|
|
69
|
+
addConflict(conflict: Omit<SyncConflict, 'at'>): void;
|
|
59
70
|
/** Current payload (defensive copy). */
|
|
60
71
|
get(): SyncStatusPayload;
|
|
61
72
|
}
|
|
@@ -105,8 +116,8 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
105
116
|
snapshot = next;
|
|
106
117
|
flush();
|
|
107
118
|
},
|
|
108
|
-
addConflict(
|
|
109
|
-
conflicts.push({
|
|
119
|
+
addConflict(conflict) {
|
|
120
|
+
conflicts.push({ ...conflict, at: now() });
|
|
110
121
|
if (conflicts.length > maxConflicts) conflicts.splice(0, conflicts.length - maxConflicts);
|
|
111
122
|
flush();
|
|
112
123
|
},
|