@1agh/maude 1.4.5 → 1.5.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 (106) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +97 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +262 -68
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-comment-mount.tsx +35 -2
  56. package/apps/studio/canvas-lib.tsx +18 -1
  57. package/apps/studio/canvas-shell.tsx +54 -1
  58. package/apps/studio/client/app.jsx +101 -35
  59. package/apps/studio/client/comments-overlay.css +10 -0
  60. package/apps/studio/client/hmr.mjs +1 -1
  61. package/apps/studio/client/panels/git-grouping.js +2 -2
  62. package/apps/studio/client/tree-expansion.js +217 -0
  63. package/apps/studio/collab/index.ts +58 -5
  64. package/apps/studio/collab/persistence.ts +80 -11
  65. package/apps/studio/collab/registry.ts +63 -26
  66. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  67. package/apps/studio/comment-anchor.ts +116 -0
  68. package/apps/studio/comments-overlay.tsx +78 -35
  69. package/apps/studio/context.ts +1 -0
  70. package/apps/studio/cursors-overlay.tsx +337 -109
  71. package/apps/studio/dist/client.bundle.js +850 -850
  72. package/apps/studio/dist/comment-mount.js +2 -2
  73. package/apps/studio/figma/to-strokes.ts +31 -10
  74. package/apps/studio/git/endpoints.ts +1 -1
  75. package/apps/studio/git/service.ts +1 -1
  76. package/apps/studio/git/watch.ts +1 -1
  77. package/apps/studio/http.ts +90 -15
  78. package/apps/studio/server.ts +16 -0
  79. package/apps/studio/sync/accepted-cold-start.ts +72 -8
  80. package/apps/studio/sync/agent.ts +36 -6
  81. package/apps/studio/sync/codec.ts +95 -40
  82. package/apps/studio/sync/comment-ledger.ts +229 -0
  83. package/apps/studio/sync/file-membership.ts +12 -2
  84. package/apps/studio/sync/file-plane.ts +78 -2
  85. package/apps/studio/sync/index.ts +75 -16
  86. package/apps/studio/sync/journal-client.ts +5 -0
  87. package/apps/studio/sync/limits.ts +7 -2
  88. package/apps/studio/sync/migrate-seed.ts +17 -7
  89. package/apps/studio/sync/projection.ts +7 -2
  90. package/apps/studio/sync/remote-docs.ts +34 -0
  91. package/apps/studio/sync/writer-registry.ts +10 -0
  92. package/apps/studio/text-caret.ts +11 -2
  93. package/apps/studio/tree-state.ts +45 -0
  94. package/apps/studio/undo-stack.ts +2 -2
  95. package/apps/studio/use-annotation-resize.tsx +48 -23
  96. package/apps/studio/use-annotation-selection.tsx +9 -2
  97. package/apps/studio/use-collab.tsx +104 -1
  98. package/apps/studio/use-selection-set.tsx +4 -0
  99. package/apps/studio/whats-new.json +63 -0
  100. package/cli/lib/design-link.mjs +5 -1
  101. package/cli/lib/gitignore-block.mjs +1 -1
  102. package/cli/lib/gitignore-drift.mjs +2 -1
  103. package/package.json +9 -8
  104. package/plugins/design/templates/brief-board.tsx.template +1 -1
  105. package/apps/studio/annotation-edit-base.ts +0 -36
  106. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -0,0 +1,229 @@
1
+ // Per-machine comment ledger — issue #133.
2
+ //
3
+ // Records, per canvas slug, the comment identities (`commentKey`) that were on
4
+ // this machine's `_comments/<slug>.json` AND in the shared document at the last
5
+ // point the two agreed. It answers the one question neither side can answer
6
+ // alone after a restart: an id that is on disk but not in the document — is it
7
+ // a comment still on its way TO the document (keep it), or one the document
8
+ // DROPPED while this machine was away (a remote delete — let it go)?
9
+ //
10
+ // - in the ledger → it was synced before, the document no longer has it: a
11
+ // delete. The projection may write the file without it, and a cold-start
12
+ // union must not put it back.
13
+ // - not in the ledger → never synced from here: still in flight (a mutation
14
+ // whose import has not landed, an external edit — how /design:edit resolves
15
+ // comments). Keep it; defer, exactly as before.
16
+ //
17
+ // Before this, the "ever carried" knowledge lived only in memory
18
+ // (collab/persistence.ts), so it started empty on every launch. A desktop that
19
+ // was closed while a comment was deleted on the web then read that id as "in
20
+ // flight" forever and never wrote its comments file again (accepted mode: the
21
+ // file froze), and the legacy cold-start union re-added it for everyone.
22
+ //
23
+ // Per hub, like the sync journal (DDR-102): the file carries the hub URL it was
24
+ // recorded against, and relinking to a different hub wipes it — "synced
25
+ // before" against one hub says nothing about another. Best-effort and never
26
+ // throws: a missing or corrupt ledger reads as empty, which degrades to the old
27
+ // behaviour (defer), the safe direction. Lives under `_state/`, which is
28
+ // already per-machine runtime state (DDR-115 taxonomy), so no new path.
29
+
30
+ import { createHash } from 'node:crypto';
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import path from 'node:path';
33
+
34
+ import { atomicWrite } from './atomic-write.ts';
35
+ import { commentKey } from './comment-identity.ts';
36
+
37
+ const LEDGER_FILE = 'comment-ledger.json';
38
+ /**
39
+ * Written immediately, not debounced: a record follows a comments-file write
40
+ * (rare), and a peer quit right after syncing must not lose it — a debounced
41
+ * ledger lost exactly that record in the E2E relaunch run and left the file
42
+ * frozen. Order is file first, ledger second, so a crash between the two
43
+ * leaves ids the ledger does not know: deferred, the safe direction.
44
+ */
45
+ const FLUSH_MS = 0;
46
+ /** Same ceiling as collab/persistence.ts MAX_SEEN_COMMENT_IDS (DDR-054 §2d). */
47
+ const MAX_IDS_PER_SLUG = 10_000;
48
+
49
+ interface LedgerFileShape {
50
+ hubUrl: string | null;
51
+ updatedAt: number;
52
+ slugs: Record<string, string[]>;
53
+ }
54
+
55
+ /** A read-only view of the identities recorded for one canvas. */
56
+ export interface SyncedSet {
57
+ readonly size: number;
58
+ /** Takes a raw `commentKey`; the ledger stores only its hash. */
59
+ has(key: string): boolean;
60
+ }
61
+
62
+ /**
63
+ * The stored form of a comment key. Hashed (security review F3): an id-less
64
+ * comment's key is its whole JSON body, which a peer controls — the ledger must
65
+ * not grow with comment bodies, only with how many there are.
66
+ */
67
+ export function ledgerKey(key: string): string {
68
+ return createHash('sha256').update(key).digest('base64url').slice(0, 22);
69
+ }
70
+
71
+ export interface CommentLedger {
72
+ /** Identities last known synced for `slug` (empty when never recorded). */
73
+ get(slug: string): SyncedSet;
74
+ /** Has `slug` ever been recorded (under the current hub)? Distinguishes
75
+ * "synced, and had no comments" from "no knowledge" (first launch after
76
+ * upgrading, a fresh link) — only the first may act on a difference. */
77
+ known(slug: string): boolean;
78
+ /** Replace `slug`'s record with the identities of a list both sides now agree on. */
79
+ record(slug: string, keys: Iterable<string>): void;
80
+ /**
81
+ * Linked to a (different) hub → drop every record. The identity must name
82
+ * the document namespace, not just the URL: one hub serves several
83
+ * workspaces (and branches), and "synced before" in one says nothing about
84
+ * another (security review F1c). `null` = no hub.
85
+ */
86
+ invalidateIfHubChanged(identity: string | null): void;
87
+ /** Persist now. Best-effort. */
88
+ flush(): void;
89
+ }
90
+
91
+ export function commentLedgerPath(designRoot: string): string {
92
+ return path.join(designRoot, '_state', LEDGER_FILE);
93
+ }
94
+
95
+ export interface LoadCommentLedgerOptions {
96
+ /** Tests use 0 to persist synchronously. */
97
+ flushMs?: number;
98
+ writer?: (file: string, bytes: string) => void;
99
+ }
100
+
101
+ export function loadCommentLedger(
102
+ designRoot: string,
103
+ opts: LoadCommentLedgerOptions = {}
104
+ ): CommentLedger {
105
+ const file = commentLedgerPath(designRoot);
106
+ const flushMs = opts.flushMs ?? FLUSH_MS;
107
+ const writer = opts.writer ?? ((p: string, bytes: string) => void atomicWrite(p, bytes));
108
+
109
+ let hubUrl: string | null = null;
110
+ const slugs = new Map<string, Set<string>>();
111
+ let timer: ReturnType<typeof setTimeout> | null = null;
112
+ let dirty = false;
113
+
114
+ try {
115
+ if (existsSync(file)) {
116
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<LedgerFileShape>;
117
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
118
+ hubUrl = typeof parsed.hubUrl === 'string' ? parsed.hubUrl : null;
119
+ const raw = parsed.slugs;
120
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
121
+ for (const [slug, ids] of Object.entries(raw)) {
122
+ if (!Array.isArray(ids)) continue;
123
+ const keys = ids.filter((k): k is string => typeof k === 'string');
124
+ slugs.set(slug, new Set(keys.slice(-MAX_IDS_PER_SLUG)));
125
+ }
126
+ }
127
+ }
128
+ }
129
+ } catch {
130
+ /* corrupt → empty; every id then reads as "in flight", the old behaviour */
131
+ }
132
+
133
+ function persist(): void {
134
+ if (timer) {
135
+ clearTimeout(timer);
136
+ timer = null;
137
+ }
138
+ if (!dirty) return;
139
+ dirty = false;
140
+ const payload: LedgerFileShape = {
141
+ hubUrl,
142
+ updatedAt: Date.now(),
143
+ slugs: Object.fromEntries([...slugs].map(([s, ids]) => [s, [...ids]])),
144
+ };
145
+ try {
146
+ writer(file, `${JSON.stringify(payload)}\n`);
147
+ } catch (err) {
148
+ console.warn(
149
+ '[sync/comment-ledger] persist failed:',
150
+ err instanceof Error ? err.message : err
151
+ );
152
+ }
153
+ }
154
+
155
+ function schedule(): void {
156
+ dirty = true;
157
+ if (flushMs === 0) {
158
+ persist();
159
+ return;
160
+ }
161
+ if (!timer) timer = setTimeout(persist, flushMs);
162
+ }
163
+
164
+ return {
165
+ get(slug) {
166
+ const set = slugs.get(slug);
167
+ return {
168
+ size: set?.size ?? 0,
169
+ has: (key: string) => !!set && set.has(ledgerKey(key)),
170
+ };
171
+ },
172
+ known(slug) {
173
+ return slugs.has(slug);
174
+ },
175
+ record(slug, keys) {
176
+ const next = new Set<string>();
177
+ for (const k of keys) {
178
+ next.add(ledgerKey(k));
179
+ if (next.size >= MAX_IDS_PER_SLUG) break;
180
+ }
181
+ const prev = slugs.get(slug);
182
+ if (prev && prev.size === next.size && [...next].every((k) => prev.has(k))) return;
183
+ slugs.set(slug, next);
184
+ schedule();
185
+ },
186
+ invalidateIfHubChanged(url) {
187
+ if (url === hubUrl) return;
188
+ hubUrl = url;
189
+ slugs.clear();
190
+ schedule();
191
+ },
192
+ flush: persist,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * The local half of a cold-start comments union, minus the comments a peer
198
+ * deleted while this machine was away: those the ledger knows were synced from
199
+ * here and the document no longer holds. Everything else — comments never
200
+ * synced from here — still unions up, exactly as before. Shared by both
201
+ * cold-start paths (sync/agent.ts reconcile, sync/migrate-seed.ts), which must
202
+ * agree or one would resurrect what the other let go.
203
+ */
204
+ export function withoutRemotelyDeleted(
205
+ local: unknown[],
206
+ docList: unknown[],
207
+ syncedBefore: Pick<SyncedSet, 'size' | 'has'> | undefined
208
+ ): unknown[] {
209
+ if (!syncedBefore || syncedBefore.size === 0) return local;
210
+ const inDoc = new Set(docList.map(commentKey));
211
+ return local.filter((c) => {
212
+ const k = commentKey(c);
213
+ return inDoc.has(k) || !syncedBefore.has(k);
214
+ });
215
+ }
216
+
217
+ // One ledger per design root per process: the room projection (collab/) and the
218
+ // sync agent (sync/) must share it, or two writers would overwrite each other's
219
+ // file and each would only know half of what was synced.
220
+ const shared = new Map<string, CommentLedger>();
221
+
222
+ export function commentLedgerFor(designRoot: string): CommentLedger {
223
+ let l = shared.get(designRoot);
224
+ if (!l) {
225
+ l = loadCommentLedger(designRoot);
226
+ shared.set(designRoot, l);
227
+ }
228
+ return l;
229
+ }
@@ -199,6 +199,16 @@ export interface ClassifyOptions {
199
199
  * `node_modules`, directory segments start alphanumeric, the final segment
200
200
  * may start with `_`.
201
201
  */
202
+ /**
203
+ * The annotations board (DDR-242: `<slug>.annotations.json`) and its legacy v1
204
+ * form (`<slug>.annotations.svg`). BOTH stay canvas-owned: the board is the doc
205
+ * lane's, and a stale legacy sidecar must never ride the file plane between
206
+ * machines (the boot migration quarantines it locally).
207
+ */
208
+ function isAnnotationsSidecar(lowerName: string): boolean {
209
+ return lowerName.endsWith('.annotations.json') || lowerName.endsWith('.annotations.svg');
210
+ }
211
+
202
212
  export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): FileClass {
203
213
  const parts = relShape(rel);
204
214
  if (parts === null) return 'never';
@@ -219,7 +229,7 @@ export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): Fi
219
229
  // The canvas body and its NAMED sidecars — Plane A's, by construction.
220
230
  if (lowerLast.endsWith('.tsx')) return 'canvas-owned';
221
231
  if (lowerLast.endsWith('.meta.json')) return 'canvas-owned';
222
- if (lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
232
+ if (isAnnotationsSidecar(lowerLast)) return 'canvas-owned';
223
233
  if (lowerLast.endsWith('.css') && opts.hasFile) {
224
234
  const sibling = `${rel.slice(0, -'.css'.length)}.tsx`;
225
235
  if (opts.hasFile(sibling)) return 'canvas-owned';
@@ -238,7 +248,7 @@ export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): Fi
238
248
  // pushed over them at 10:50:33). The annotations lane's own stamped
239
249
  // newest-wins protection never saw it coming — it guards the DOC lane, and
240
250
  // this was the file plane acting alone. One owner: the canvas.
241
- if (parts.length === 1 && lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
251
+ if (parts.length === 1 && isAnnotationsSidecar(lowerLast)) return 'canvas-owned';
242
252
 
243
253
  if (COMPANION_SIDECAR_SUFFIXES.some((s) => lowerLast.endsWith(s))) return 'companion-text';
244
254
 
@@ -640,6 +640,21 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
640
640
  let reanchorsInARow = 0;
641
641
  let reanchorHeldSince = 0;
642
642
 
643
+ /**
644
+ * A conflict row is waiting on a remote only a FULL read can tell it.
645
+ *
646
+ * A push answered `conflict` defers to "the next pass, against what the hub
647
+ * holds now" — but for a path the hub then leaves alone, a cursor read says
648
+ * nothing, the remembered remote stays unknown, and the row sat in
649
+ * `conflict` for good: Resync restarted the plane and read the same silence
650
+ * (rca issue-file-ledger-orphaned-rows). So a conflict owes one full read.
651
+ * Seeded from the ledger, which is what makes Resync (a restart) pay it.
652
+ * Capped at one per `REANCHOR_HOLD_RECOVERY_MS`, like the re-anchor storm,
653
+ * so a hub answering `conflict` to every push cannot farm full reads.
654
+ */
655
+ let fullReadOwed = Object.values(ledger.rows()).some((r) => r.state === 'conflict');
656
+ let lastOwedFullReadAt = Number.NEGATIVE_INFINITY;
657
+
643
658
  /**
644
659
  * When the plane may talk to the hub again. Issue #109.
645
660
  *
@@ -1497,7 +1512,14 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1497
1512
  requestsThisPass = 0;
1498
1513
 
1499
1514
  // ── 1. The hub's side ────────────────────────────────────────────────
1500
- const startedFrom = ledger.cursor();
1515
+ const payOwedRead = fullReadOwed && now() - lastOwedFullReadAt >= REANCHOR_HOLD_RECOVERY_MS;
1516
+ if (payOwedRead) {
1517
+ fullReadOwed = false;
1518
+ lastOwedFullReadAt = now();
1519
+ }
1520
+ // An owed read is a compaction read in the SAME epoch: the ancestors still
1521
+ // describe this log, so nothing is degraded — it only refreshes remotes.
1522
+ const startedFrom = payOwedRead ? 0 : ledger.cursor();
1501
1523
  let fullRead = startedFrom === 0;
1502
1524
  let page = await fetchJournal({
1503
1525
  hubUrl: opts.hubUrl,
@@ -1648,6 +1670,45 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1648
1670
  ledger.forget(rel);
1649
1671
  continue;
1650
1672
  }
1673
+ // NOT OURS ON EITHER SIDE ANY MORE — forget it, do not keep refusing it.
1674
+ //
1675
+ // A path can leave the file plane while the ledger still tracks it: a
1676
+ // `.css` written before its `.tsx` becomes the canvas's sidecar, or the
1677
+ // canvas groups change. The local scan stops offering it, the hub stops
1678
+ // listing it, and the row stayed behind forever — re-stamped `stuck` by
1679
+ // the admission drop against a remembered remote, or frozen in
1680
+ // `conflict` with nothing left to decide — counted as "waiting" in the
1681
+ // panel (rca issue-file-ledger-orphaned-rows). Only when this page did
1682
+ // not offer it: a path the hub is actively naming still goes through
1683
+ // admission, which reports the refusal.
1684
+ if (!here && !row && kept) {
1685
+ const cls = classifyProjectFile(rel, {
1686
+ canvasGroups: opts.canvasGroups,
1687
+ hasFile: (r) => local.has(r) || existsSync(path.join(designRoot, r)),
1688
+ });
1689
+ if (!isFilePlaneClass(cls)) {
1690
+ ledger.forget(rel);
1691
+ continue;
1692
+ }
1693
+ }
1694
+ // A DELETE IS A TRANSFER TOO. The code-module gate below only sees a
1695
+ // non-null remote, so a tombstone walked past it: an ancestor this peer
1696
+ // recorded (by push, or by agreement) was all `tombstone-agreed` needed
1697
+ // to quarantine a code module on the word of a hub that may not move
1698
+ // one. Refuse it here, reported, with the file and its row untouched.
1699
+ if (row?.deleted && here && !opts.allowCodeModules) {
1700
+ const cls = classifyProjectFile(rel, {
1701
+ canvasGroups: opts.canvasGroups,
1702
+ hasFile: (r) => local.has(r) || existsSync(path.join(designRoot, r)),
1703
+ });
1704
+ if (cls === 'code-module') {
1705
+ out.dropped.push({
1706
+ rel,
1707
+ reason: 'code modules are removed only by an owner-vouched or loopback hub',
1708
+ });
1709
+ continue;
1710
+ }
1711
+ }
1651
1712
  // What the hub holds: this page when it spoke about the path, otherwise
1652
1713
  // what we last learned. `undefined` (never learned) reads as null only
1653
1714
  // after a full read has had the chance to say so.
@@ -1673,6 +1734,20 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1673
1734
  continue;
1674
1735
  }
1675
1736
  if (cls === 'code-module' && !opts.allowCodeModules) {
1737
+ // AGREEMENT IS NOT A TRANSFER. The gate refuses to move a code
1738
+ // module from an unvouched hub (DDR-054); identical bytes on both
1739
+ // sides move nothing, and reporting them as refused left a
1740
+ // converged file permanently "stuck".
1741
+ if (here && here.hash === remoteHash) {
1742
+ void ledger.adoptAfter(rel, here.hash, () => {}, {
1743
+ ...(row ? { remoteSeq: row.seq } : {}),
1744
+ size: here.size,
1745
+ mtimeMs: here.mtimeMs,
1746
+ state: 'on-hub',
1747
+ });
1748
+ out.synced += 1;
1749
+ continue;
1750
+ }
1676
1751
  drop(
1677
1752
  out,
1678
1753
  rel,
@@ -2082,6 +2157,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
2082
2157
  ledger.setState(rel, 'conflict', {
2083
2158
  reason: 'the hub changed this file while the upload was in flight',
2084
2159
  });
2160
+ fullReadOwed = true;
2085
2161
  out.conflicts.push({ rel, copy: null });
2086
2162
  return true;
2087
2163
  }
@@ -2321,7 +2397,7 @@ function holdOversized(out: FilePlaneResult, ledger: FileLedger, file: Oversized
2321
2397
  function referencedAssetNames(designRoot: string): Set<string> {
2322
2398
  const out = new Set<string>();
2323
2399
  const RE = /assets\/([A-Za-z0-9._-]+\.[A-Za-z0-9]+)/g;
2324
- const SCANNED = /\.(?:annotations\.svg|tsx|jsx|css|meta\.json)$/i;
2400
+ const SCANNED = /\.(?:annotations\.(?:svg|json)|tsx|jsx|css|meta\.json)$/i;
2325
2401
  const walk = (dir: string, depth: number): void => {
2326
2402
  if (depth > MAX_WALK_DEPTH) return;
2327
2403
  let entries: Dirent[];
@@ -33,6 +33,8 @@ import { hostname } from 'node:os';
33
33
  import path from 'node:path';
34
34
  import type { Awareness } from 'y-protocols/awareness';
35
35
  import * as Y from 'yjs';
36
+ import { migrateAnnotationsV2 } from '../annotations/migrate-boot.ts';
37
+ import { REPLICA_TYPE, replicaBoardText } from '../annotations/replica.ts';
36
38
  import { renewHubCredential } from '../cloud/renew.ts';
37
39
  import { Y_TYPES } from '../collab/persistence.ts';
38
40
  import type { Context, LinkedHub } from '../context.ts';
@@ -53,6 +55,7 @@ import {
53
55
  stampCanvasPath,
54
56
  stampMovedTo,
55
57
  } from './codec.ts';
58
+ import { commentLedgerFor } from './comment-ledger.ts';
56
59
  import {
57
60
  type ConnectionMonitor,
58
61
  createConnectionMonitor,
@@ -70,13 +73,14 @@ import { createFsReader, type FsReader } from './fs-mirror.ts';
70
73
  import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
71
74
  import { getHubRecord } from './hubs-config.ts';
72
75
  import { loadJournal, type SyncJournal } from './journal.ts';
73
- import { hasLedger, hubCapabilities } from './journal-client.ts';
76
+ import { hasAnnotationsV2, hasLedger, hubCapabilities } from './journal-client.ts';
74
77
  import { isLoopbackHost } from './loopback.ts';
75
78
  import { migrateFlatFallback } from './migrate-flat-fallback.ts';
76
79
  import { migrateSeed } from './migrate-seed.ts';
77
80
  import { ORIGINS } from './origins.ts';
78
81
  import { createDocProjection, type DocProjection } from './projection.ts';
79
82
  import {
83
+ acceptedListing,
80
84
  describeRemoteDiff,
81
85
  diffRemoteDocs,
82
86
  fetchRemoteListing,
@@ -520,6 +524,14 @@ export interface SyncRuntime {
520
524
  ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
521
525
  /** True while the linked project is in accepted-revisions mode. */
522
526
  acceptedMode?(): boolean;
527
+ /**
528
+ * Issue #133 — may the comment ledger record `slug`'s current comments as
529
+ * synced? Only when the hub is known to hold them: accepted mode (the doc IS
530
+ * the accepted replica), or a synced provider with nothing unacknowledged. A
531
+ * comment added offline and not yet delivered must never read as "synced",
532
+ * or the next cold start would take it for a remote delete.
533
+ */
534
+ commentsConfirmedOnHub?(slug: string): boolean;
523
535
  /** Tripwire count: local writes that reached an accepted replica. */
524
536
  acceptedWriteViolations?(): number;
525
537
  /** Accepted revisions: the project's logical history (T27). Null when legacy. */
@@ -2061,12 +2073,10 @@ export function createSyncRuntime(
2061
2073
  // connect before doc.create commits; pulling that empty room would lock
2062
2074
  // the receiver onto a lossy slug-derived path before the real path arrives.
2063
2075
  // The accepted manifest alone names live canvases, including successors
2064
- // of retired documents whose transport rows may still linger.
2065
- const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
2066
- const documents = manifest.docs
2067
- .filter((d) => !d.retired)
2068
- .map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
2069
- return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
2076
+ // of retired documents whose transport rows may still linger — and a
2077
+ // legacy tombstone for a name the manifest lists live no longer buries it
2078
+ // (see `acceptedListing`).
2079
+ return { ...(listing ?? {}), ...acceptedListing(listing, manifest.docs) };
2070
2080
  }
2071
2081
 
2072
2082
  /**
@@ -2233,6 +2243,9 @@ export function createSyncRuntime(
2233
2243
  designRoot: ctx.paths.designRoot,
2234
2244
  designRel: ctx.paths.designRel,
2235
2245
  });
2246
+ // DDR-242 — idempotent; also covers a runtime cycled after boot (a
2247
+ // project linked from the cloud panel) whose tree gained a v1 board.
2248
+ migrateAnnotationsV2({ designRoot: ctx.paths.designRoot });
2236
2249
  }
2237
2250
 
2238
2251
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
@@ -2373,18 +2386,23 @@ export function createSyncRuntime(
2373
2386
  path.sep,
2374
2387
  { ...pathOpts, realpath: realpathOfDeepestExisting, pathFor: manifestPathFor }
2375
2388
  );
2376
- const pullNote = describeRemoteDiff(remoteDiff);
2389
+ // Named as it will actually happen: a canvas the project deleted is listed
2390
+ // for a tick after its tombstone and is not pulled, so it is not announced.
2391
+ const pullNote = describeRemoteDiff({
2392
+ ...remoteDiff,
2393
+ hubOnly: remoteDiff.hubOnly.filter((d) => !tombstoned.has(slugFromDocName(d.name) ?? '')),
2394
+ });
2377
2395
  if (pullNote) console.log(`[sync] ${pullNote}`);
2378
2396
  /** Descriptor paths for one slug at one body path. The sidecar rules live
2379
2397
  * here, once: `.meta.json`/`.css` are SIBLINGS of the body, while
2380
- * `.annotations.svg` is keyed by the flat slug at the design root — the
2398
+ * `.annotations.json` is keyed by the flat slug at the design root — the
2381
2399
  * asymmetry `workspace-files.mjs` documents, and which moving the body
2382
2400
  * must not quietly change. */
2383
2401
  const descriptorFor = (slug: string, bodyAbs: string): CanvasDescriptor => ({
2384
2402
  slug,
2385
2403
  html: bodyAbs,
2386
2404
  comments: path.join(ctx.paths.commentsDir, `${slug}.json`),
2387
- annotations: path.join(ctx.paths.designRoot, `${slug}.annotations.svg`),
2405
+ annotations: path.join(ctx.paths.designRoot, `${slug}.annotations.json`),
2388
2406
  meta: bodyAbs.replace(/\.tsx$/i, '.meta.json'),
2389
2407
  css: bodyAbs.replace(/\.tsx$/i, '.css'),
2390
2408
  });
@@ -2722,6 +2740,11 @@ export function createSyncRuntime(
2722
2740
  // in `_history/<slug>/` via history.ts so /design:rollback recovers them.
2723
2741
  journal = loadJournal(ctx.paths.designRoot);
2724
2742
  journal.invalidateIfHubChanged(linkedHub.url);
2743
+ // Issue #133 — same per-hub rule for the comment ledger: "synced before"
2744
+ // against one hub says nothing about another.
2745
+ commentLedgerFor(ctx.paths.designRoot).invalidateIfHubChanged(
2746
+ `${linkedHub.url} ${docNameFor('_')}`
2747
+ );
2725
2748
  const history = createHistory(ctx);
2726
2749
 
2727
2750
  // ---- DDR-102 helpers: auth aggregation, re-probe, settle bookkeeping ----
@@ -3123,6 +3146,7 @@ export function createSyncRuntime(
3123
3146
  projection,
3124
3147
  historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3125
3148
  journal: journal ?? undefined,
3149
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3126
3150
  ownAccepted,
3127
3151
  wasAccepted: (content) => acceptedLink.client.holdsValue(content),
3128
3152
  createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
@@ -3142,6 +3166,7 @@ export function createSyncRuntime(
3142
3166
  paths: canvasPaths,
3143
3167
  historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3144
3168
  journal: journal ?? undefined,
3169
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3145
3170
  snapshot: async (content, reason) => {
3146
3171
  try {
3147
3172
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -3755,6 +3780,14 @@ export function createSyncRuntime(
3755
3780
  echoGuard,
3756
3781
  adopt: adoptOnce,
3757
3782
  journal: journal ?? undefined,
3783
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3784
+ commentsConfirmed: () => {
3785
+ const p = provider as unknown as {
3786
+ synced?: boolean;
3787
+ hasUnsyncedChanges?: boolean;
3788
+ };
3789
+ return p.synced === true && p.hasUnsyncedChanges === false;
3790
+ },
3758
3791
  snapshot: async (content, reason) => {
3759
3792
  try {
3760
3793
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -3868,21 +3901,23 @@ export function createSyncRuntime(
3868
3901
  const agentOrigin = agent.origin;
3869
3902
  const slug = canvas.slug;
3870
3903
  const provComments = provider.document.getArray(Y_TYPES.comments);
3871
- const provAnn = provider.document.getMap(Y_TYPES.annotations);
3904
+ // DDR-242 — the annotations replica: relay the board, the room
3905
+ // diffs it per element (writeReplica), so only changes cross.
3906
+ const provAnn = provider.document.getMap(REPLICA_TYPE);
3872
3907
  const onComments = (_e: unknown, tx: { origin: unknown }) => {
3873
3908
  if (tx.origin === agentOrigin) return;
3874
3909
  reg.syncRoomFromComments?.(slug, provComments.toArray());
3875
3910
  };
3876
3911
  const onAnn = (_e: unknown, tx: { origin: unknown }) => {
3877
3912
  if (tx.origin === agentOrigin) return;
3878
- const svg = provAnn.get('svg');
3879
- if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
3913
+ const board = replicaBoardText(provider.document);
3914
+ if (board !== null) reg.syncRoomFromAnnotations?.(slug, board);
3880
3915
  };
3881
3916
  provComments.observe(onComments);
3882
- provAnn.observe(onAnn);
3917
+ provAnn.observeDeep(onAnn);
3883
3918
  noteDetach(statusDetaches, canvas.slug, () => {
3884
3919
  provComments.unobserve(onComments);
3885
- provAnn.unobserve(onAnn);
3920
+ provAnn.unobserveDeep(onAnn);
3886
3921
  });
3887
3922
  }
3888
3923
  },
@@ -4622,6 +4657,17 @@ export function createSyncRuntime(
4622
4657
  void hubCapabilities({ hubUrl: linkedHub.url, signal: fileEventsProbe.signal })
4623
4658
  .then((caps) => {
4624
4659
  if (stopped) return;
4660
+ // DDR-242 — a hub that predates the annotations-v2 model still keeps
4661
+ // boards as SVG: its workspace checkout and kernel would not carry
4662
+ // this studio's `.annotations.json` edits. Say so loudly; the
4663
+ // annotations themselves stay safe (a v1 hub never writes the v2
4664
+ // replica, and this studio never reads its SVG as authoritative).
4665
+ if (caps !== null && !hasAnnotationsV2(caps)) {
4666
+ console.warn(
4667
+ `[sync] ${linkedHub.url} does not advertise annotations-v2 — update the hub; ` +
4668
+ 'whiteboard edits will not reach its checkout until it is'
4669
+ );
4670
+ }
4625
4671
  if (!hasLedger(caps)) {
4626
4672
  // No journal on this hub ⇒ the legacy client carries the upward
4627
4673
  // lane, exactly as the pre-v2 desktop did (Open decision 4).
@@ -4936,6 +4982,19 @@ export function createSyncRuntime(
4936
4982
  },
4937
4983
  proposeFolder,
4938
4984
  acceptedMode: acceptedOn,
4985
+ commentsConfirmedOnHub: (slug) => {
4986
+ // The canvas must be live on the hub right now: a provider that has
4987
+ // synced. In accepted mode its doc is the accepted replica (local writes
4988
+ // are refused), but only once THIS canvas's projection exists — before
4989
+ // that, a comment falls back to the local room and is not on the hub
4990
+ // (security review F1b). In legacy mode nothing may be unacknowledged.
4991
+ const p = providers.get(slug) as unknown as
4992
+ | { synced?: boolean; hasUnsyncedChanges?: boolean }
4993
+ | undefined;
4994
+ if (!p || p.synced !== true) return false;
4995
+ if (acceptedOn()) return projections.has(slug);
4996
+ return p.hasUnsyncedChanges === false;
4997
+ },
4939
4998
  acceptedWriteViolations: () => acceptedWriteViolations,
4940
4999
  acceptedHistory: async (q) => {
4941
5000
  if (!acceptedOn() || !acceptedLink) return null;
@@ -5318,7 +5377,7 @@ async function walk(
5318
5377
  slug,
5319
5378
  html: abs,
5320
5379
  comments: path.join(commentsDir, `${slug}.json`),
5321
- annotations: path.join(designRoot, `${slug}.annotations.svg`),
5380
+ annotations: path.join(designRoot, `${slug}.annotations.json`),
5322
5381
  // The `.meta.json` sidecar sits next to the body: `Foo.tsx` → `Foo.meta.json`.
5323
5382
  meta: abs.replace(/\.(tsx|html)$/i, '.meta.json'),
5324
5383
  // The `.css` sibling: `Foo.tsx` → `Foo.css` (absent for inline-CSS canvases).
@@ -212,6 +212,11 @@ export async function hubCapabilities(opts: {
212
212
  }
213
213
  }
214
214
 
215
+ /** Does this hub keep annotations as the v2 element model (DDR-242)? */
216
+ export function hasAnnotationsV2(capabilities: string[] | null): boolean {
217
+ return Array.isArray(capabilities) && capabilities.includes('annotations-v2');
218
+ }
219
+
215
220
  /** Does this hub carry the journal file plane? */
216
221
  export function hasLedger(capabilities: string[] | null): boolean {
217
222
  return Array.isArray(capabilities) && capabilities.includes('ledger');
@@ -15,8 +15,13 @@
15
15
  export const MAX_HTML_BYTES = 4 * 1024 * 1024;
16
16
  /** `_comments/<slug>.json`, serialized. */
17
17
  export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
18
- /** `<slug>.annotations.svg`. */
19
- export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
18
+ /**
19
+ * `<slug>.annotations.json` (DDR-242). Must equal `MAX_BOARD_BYTES` in
20
+ * annotations/constants.ts — annotations-v2-replica.test.ts pins the pair.
21
+ * Raised from v1's 1 MB: the v1 cap bit at ~3.7k elements, and the v2 board is
22
+ * ~3× more compact, so 4 MB is ≈ 20k typical elements (the element-count cap).
23
+ */
24
+ export const MAX_ANNOTATIONS_BYTES = 4 * 1024 * 1024;
20
25
  /** The shared subset of a canvas `.meta.json`. */
21
26
  export const MAX_META_BYTES = 1 * 1024 * 1024;
22
27
  /** The canvas's sibling stylesheet. */