@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.
- package/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +533 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops.ts +474 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +97 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +262 -68
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-comment-mount.tsx +35 -2
- package/apps/studio/canvas-lib.tsx +18 -1
- package/apps/studio/canvas-shell.tsx +54 -1
- package/apps/studio/client/app.jsx +101 -35
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +58 -5
- package/apps/studio/collab/persistence.ts +80 -11
- package/apps/studio/collab/registry.ts +63 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/comment-anchor.ts +116 -0
- package/apps/studio/comments-overlay.tsx +78 -35
- package/apps/studio/context.ts +1 -0
- package/apps/studio/cursors-overlay.tsx +337 -109
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +72 -8
- package/apps/studio/sync/agent.ts +36 -6
- package/apps/studio/sync/codec.ts +95 -40
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +78 -2
- package/apps/studio/sync/index.ts +75 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +17 -7
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +104 -1
- package/apps/studio/use-selection-set.tsx +4 -0
- package/apps/studio/whats-new.json +63 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
3879
|
-
if (
|
|
3913
|
+
const board = replicaBoardText(provider.document);
|
|
3914
|
+
if (board !== null) reg.syncRoomFromAnnotations?.(slug, board);
|
|
3880
3915
|
};
|
|
3881
3916
|
provComments.observe(onComments);
|
|
3882
|
-
provAnn.
|
|
3917
|
+
provAnn.observeDeep(onAnn);
|
|
3883
3918
|
noteDetach(statusDetaches, canvas.slug, () => {
|
|
3884
3919
|
provComments.unobserve(onComments);
|
|
3885
|
-
provAnn.
|
|
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.
|
|
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
|
-
/**
|
|
19
|
-
|
|
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. */
|