@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.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. 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). The fix for Risk 1 (the duplication-on-merge trap).
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 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.
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): identicalkeep; 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 (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';
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 before a hub-wins overwrite, for rollback. Best-effort. */
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 = 'hub-wins' | 'local-adopt' | 'empty';
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
- const hasLocal =
101
- !!localHtml || !!localComments || !!localAnnotations || !!localMeta || !!localCss;
102
- if (!hasLocal) return 'empty';
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
- 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);
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
- 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);
263
+ }
114
264
 
115
- return 'local-adopt';
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
- const changed = applyHtmlToDoc(doc, str, importOrigin);
261
- if (changed) lastHtml = htmlFromDoc(doc);
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) lastCss = str;
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
- export type ConflictKind = 'cold-start-hub-wins' | 'git-pull';
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: { slug: string; kind: ConflictKind }): void;
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({ slug, kind }) {
109
- conflicts.push({ slug, kind, at: now() });
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
  },