@1agh/maude 1.4.5 → 1.4.6
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-model.ts +33 -1
- package/apps/studio/api.ts +15 -0
- package/apps/studio/canvas-comment-mount.tsx +35 -2
- package/apps/studio/canvas-lib.tsx +5 -0
- package/apps/studio/canvas-shell.tsx +48 -1
- package/apps/studio/client/app.jsx +3 -0
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/collab/index.ts +9 -0
- package/apps/studio/collab/persistence.ts +49 -1
- 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 +181 -107
- package/apps/studio/dist/client.bundle.js +7 -7
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/sync/accepted-cold-start.ts +60 -4
- package/apps/studio/sync/agent.ts +28 -2
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-plane.ts +77 -1
- package/apps/studio/sync/index.ts +37 -0
- package/apps/studio/sync/migrate-seed.ts +9 -2
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/use-selection-set.tsx +4 -0
- package/apps/studio/whats-new.json +36 -0
- package/package.json +8 -8
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
stampCanvasPath,
|
|
54
54
|
stampMovedTo,
|
|
55
55
|
} from './codec.ts';
|
|
56
|
+
import { commentLedgerFor } from './comment-ledger.ts';
|
|
56
57
|
import {
|
|
57
58
|
type ConnectionMonitor,
|
|
58
59
|
createConnectionMonitor,
|
|
@@ -520,6 +521,14 @@ export interface SyncRuntime {
|
|
|
520
521
|
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
|
|
521
522
|
/** True while the linked project is in accepted-revisions mode. */
|
|
522
523
|
acceptedMode?(): boolean;
|
|
524
|
+
/**
|
|
525
|
+
* Issue #133 — may the comment ledger record `slug`'s current comments as
|
|
526
|
+
* synced? Only when the hub is known to hold them: accepted mode (the doc IS
|
|
527
|
+
* the accepted replica), or a synced provider with nothing unacknowledged. A
|
|
528
|
+
* comment added offline and not yet delivered must never read as "synced",
|
|
529
|
+
* or the next cold start would take it for a remote delete.
|
|
530
|
+
*/
|
|
531
|
+
commentsConfirmedOnHub?(slug: string): boolean;
|
|
523
532
|
/** Tripwire count: local writes that reached an accepted replica. */
|
|
524
533
|
acceptedWriteViolations?(): number;
|
|
525
534
|
/** Accepted revisions: the project's logical history (T27). Null when legacy. */
|
|
@@ -2722,6 +2731,11 @@ export function createSyncRuntime(
|
|
|
2722
2731
|
// in `_history/<slug>/` via history.ts so /design:rollback recovers them.
|
|
2723
2732
|
journal = loadJournal(ctx.paths.designRoot);
|
|
2724
2733
|
journal.invalidateIfHubChanged(linkedHub.url);
|
|
2734
|
+
// Issue #133 — same per-hub rule for the comment ledger: "synced before"
|
|
2735
|
+
// against one hub says nothing about another.
|
|
2736
|
+
commentLedgerFor(ctx.paths.designRoot).invalidateIfHubChanged(
|
|
2737
|
+
`${linkedHub.url} ${docNameFor('_')}`
|
|
2738
|
+
);
|
|
2725
2739
|
const history = createHistory(ctx);
|
|
2726
2740
|
|
|
2727
2741
|
// ---- DDR-102 helpers: auth aggregation, re-probe, settle bookkeeping ----
|
|
@@ -3123,6 +3137,7 @@ export function createSyncRuntime(
|
|
|
3123
3137
|
projection,
|
|
3124
3138
|
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
3125
3139
|
journal: journal ?? undefined,
|
|
3140
|
+
commentLedger: commentLedgerFor(ctx.paths.designRoot),
|
|
3126
3141
|
ownAccepted,
|
|
3127
3142
|
wasAccepted: (content) => acceptedLink.client.holdsValue(content),
|
|
3128
3143
|
createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
|
|
@@ -3142,6 +3157,7 @@ export function createSyncRuntime(
|
|
|
3142
3157
|
paths: canvasPaths,
|
|
3143
3158
|
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
3144
3159
|
journal: journal ?? undefined,
|
|
3160
|
+
commentLedger: commentLedgerFor(ctx.paths.designRoot),
|
|
3145
3161
|
snapshot: async (content, reason) => {
|
|
3146
3162
|
try {
|
|
3147
3163
|
const snap = await history.writeSnapshot(relBody, content, reason);
|
|
@@ -3755,6 +3771,14 @@ export function createSyncRuntime(
|
|
|
3755
3771
|
echoGuard,
|
|
3756
3772
|
adopt: adoptOnce,
|
|
3757
3773
|
journal: journal ?? undefined,
|
|
3774
|
+
commentLedger: commentLedgerFor(ctx.paths.designRoot),
|
|
3775
|
+
commentsConfirmed: () => {
|
|
3776
|
+
const p = provider as unknown as {
|
|
3777
|
+
synced?: boolean;
|
|
3778
|
+
hasUnsyncedChanges?: boolean;
|
|
3779
|
+
};
|
|
3780
|
+
return p.synced === true && p.hasUnsyncedChanges === false;
|
|
3781
|
+
},
|
|
3758
3782
|
snapshot: async (content, reason) => {
|
|
3759
3783
|
try {
|
|
3760
3784
|
const snap = await history.writeSnapshot(relBody, content, reason);
|
|
@@ -4936,6 +4960,19 @@ export function createSyncRuntime(
|
|
|
4936
4960
|
},
|
|
4937
4961
|
proposeFolder,
|
|
4938
4962
|
acceptedMode: acceptedOn,
|
|
4963
|
+
commentsConfirmedOnHub: (slug) => {
|
|
4964
|
+
// The canvas must be live on the hub right now: a provider that has
|
|
4965
|
+
// synced. In accepted mode its doc is the accepted replica (local writes
|
|
4966
|
+
// are refused), but only once THIS canvas's projection exists — before
|
|
4967
|
+
// that, a comment falls back to the local room and is not on the hub
|
|
4968
|
+
// (security review F1b). In legacy mode nothing may be unacknowledged.
|
|
4969
|
+
const p = providers.get(slug) as unknown as
|
|
4970
|
+
| { synced?: boolean; hasUnsyncedChanges?: boolean }
|
|
4971
|
+
| undefined;
|
|
4972
|
+
if (!p || p.synced !== true) return false;
|
|
4973
|
+
if (acceptedOn()) return projections.has(slug);
|
|
4974
|
+
return p.hasUnsyncedChanges === false;
|
|
4975
|
+
},
|
|
4939
4976
|
acceptedWriteViolations: () => acceptedWriteViolations,
|
|
4940
4977
|
acceptedHistory: async (q) => {
|
|
4941
4978
|
if (!acceptedOn() || !acceptedLink) return null;
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
unionCommentsById,
|
|
59
59
|
} from './cold-start.ts';
|
|
60
60
|
import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
|
|
61
|
+
import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
|
|
61
62
|
import { hashBytes } from './echo-guard.ts';
|
|
62
63
|
import type { SyncJournal } from './journal.ts';
|
|
63
64
|
import { ORIGINS } from './origins.ts';
|
|
@@ -85,6 +86,8 @@ export interface MigrateSeedOptions {
|
|
|
85
86
|
historyDir?: string;
|
|
86
87
|
/** DDR-102 — per-machine journal; gates fast-forward vs conflict. */
|
|
87
88
|
journal?: SyncJournal;
|
|
89
|
+
/** Issue #133 — comment ids synced from here before (sync/comment-ledger.ts). */
|
|
90
|
+
commentLedger?: CommentLedger;
|
|
88
91
|
/** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
|
|
89
92
|
snapshot?: (content: string, reason: ColdStartSnapshotReason) => Promise<string | null>;
|
|
90
93
|
/**
|
|
@@ -414,9 +417,13 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
414
417
|
// delete-then-insert codec — same-id entries keep the doc's version, so the
|
|
415
418
|
// duplication trap stays closed; local-only comments survive.
|
|
416
419
|
if (localComments) {
|
|
417
|
-
const
|
|
420
|
+
const docList = doc.getArray(Y_TYPES.comments).toArray();
|
|
421
|
+
const read = tryParseJsonArray(localComments);
|
|
422
|
+
// Issue #133 — never union back a comment a peer deleted while we were away.
|
|
423
|
+
const parsed = read
|
|
424
|
+
? withoutRemotelyDeleted(read, docList, opts.commentLedger?.get(slug))
|
|
425
|
+
: null;
|
|
418
426
|
if (parsed && parsed.length > 0) {
|
|
419
|
-
const docList = doc.getArray(Y_TYPES.comments).toArray();
|
|
420
427
|
const merged = unionCommentsById(docList, parsed);
|
|
421
428
|
if (merged.length !== docList.length) {
|
|
422
429
|
doc.transact(() => {
|
|
@@ -323,6 +323,27 @@ export function useCollab(): CollabValue | null {
|
|
|
323
323
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
324
324
|
// Hook: foreign awareness peers (the cursor overlay subscribes to this).
|
|
325
325
|
|
|
326
|
+
export interface AwarenessChanges {
|
|
327
|
+
added: number[];
|
|
328
|
+
updated: number[];
|
|
329
|
+
removed: number[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Could this awareness `change` alter the foreign peers we show? False only
|
|
334
|
+
* when every touched client is the local one (our own cursor publish — #131).
|
|
335
|
+
* Unknown shape → true, the safe direction.
|
|
336
|
+
*/
|
|
337
|
+
export function touchesForeignClient(changes: AwarenessChanges | undefined, myId: number): boolean {
|
|
338
|
+
if (!changes) return true;
|
|
339
|
+
const touched = [
|
|
340
|
+
...(changes.added ?? []),
|
|
341
|
+
...(changes.updated ?? []),
|
|
342
|
+
...(changes.removed ?? []),
|
|
343
|
+
];
|
|
344
|
+
return touched.length === 0 || touched.some((id) => id !== myId);
|
|
345
|
+
}
|
|
346
|
+
|
|
326
347
|
/**
|
|
327
348
|
* Returns the current set of foreign peers (excludes the local client). The
|
|
328
349
|
* returned array is stable-reference between awareness updates — useful for
|
|
@@ -351,7 +372,13 @@ export function useForeignAwareness(): ForeignAwareness[] {
|
|
|
351
372
|
return out;
|
|
352
373
|
}
|
|
353
374
|
setPeers(compute());
|
|
354
|
-
|
|
375
|
+
// Issue #131 — `change` fires for OUR OWN publishes too (the 30 Hz cursor),
|
|
376
|
+
// and each one rebuilt every peer object and re-rendered every consumer.
|
|
377
|
+
// Only a change that touches another client can change what we show.
|
|
378
|
+
const onChange = (changes?: AwarenessChanges) => {
|
|
379
|
+
if (!touchesForeignClient(changes, awareness.clientID)) return;
|
|
380
|
+
setPeers(compute());
|
|
381
|
+
};
|
|
355
382
|
awareness.on('change', onChange);
|
|
356
383
|
return () => {
|
|
357
384
|
awareness.off('change', onChange);
|
|
@@ -67,6 +67,10 @@ export interface Selection {
|
|
|
67
67
|
worldW?: number;
|
|
68
68
|
worldH?: number;
|
|
69
69
|
html?: string;
|
|
70
|
+
/** Comment drops only — the annotation the click landed on, and the click's
|
|
71
|
+
* world point (comment-anchor.ts). */
|
|
72
|
+
annotationId?: string;
|
|
73
|
+
world?: { x: number; y: number };
|
|
70
74
|
/** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
|
|
71
75
|
authored?: Record<string, string>;
|
|
72
76
|
computed?: Record<string, string>;
|
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "comments-stay-where-you-put-them",
|
|
6
|
+
"version": "1.4.6",
|
|
7
|
+
"date": "2026-09-30",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "Comments stay where you put them",
|
|
10
|
+
"summary": "A comment on a sticky, a drawing or empty canvas no longer disappears a few seconds after you save it. A comment on a sticky moves with it, and if the thing it points at is deleted the comment stays, marked as detached, until you remove it.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "comments-reach-every-peer",
|
|
15
|
+
"version": "1.4.6",
|
|
16
|
+
"date": "2026-09-30",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Web comments reach the desktop app",
|
|
19
|
+
"summary": "Comments added on the web while your desktop app was closed now show up when you reopen it, and a comment deleted elsewhere no longer comes back.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "large-boards-pan-smoothly",
|
|
24
|
+
"version": "1.4.6",
|
|
25
|
+
"date": "2026-09-30",
|
|
26
|
+
"kind": "improvement",
|
|
27
|
+
"title": "Large boards pan smoothly again",
|
|
28
|
+
"summary": "Panning a board with many artboards stays smooth while something is selected or a teammate is on the canvas — on the desktop app it could drop to a frame a second.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "sync-no-phantom-waiting-files",
|
|
33
|
+
"version": "1.4.6",
|
|
34
|
+
"date": "2026-09-30",
|
|
35
|
+
"kind": "fix",
|
|
36
|
+
"title": "Sync stops counting files that are done",
|
|
37
|
+
"summary": "The Sync panel no longer shows files as waiting or stuck when there is nothing left to deliver, and Resync now brings down a file the project changed while you were uploading it.",
|
|
38
|
+
"surface": "design-ui"
|
|
39
|
+
},
|
|
4
40
|
{
|
|
5
41
|
"id": "team-projects-hold-up",
|
|
6
42
|
"version": "1.4.5",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -58,13 +58,13 @@
|
|
|
58
58
|
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
59
59
|
},
|
|
60
60
|
"optionalDependencies": {
|
|
61
|
-
"@1agh/maude-darwin-arm64": "1.4.
|
|
62
|
-
"@1agh/maude-darwin-x64": "1.4.
|
|
63
|
-
"@1agh/maude-linux-arm64": "1.4.
|
|
64
|
-
"@1agh/maude-linux-arm64-musl": "1.4.
|
|
65
|
-
"@1agh/maude-linux-x64": "1.4.
|
|
66
|
-
"@1agh/maude-linux-x64-musl": "1.4.
|
|
67
|
-
"@1agh/maude-win32-x64": "1.4.
|
|
61
|
+
"@1agh/maude-darwin-arm64": "1.4.6",
|
|
62
|
+
"@1agh/maude-darwin-x64": "1.4.6",
|
|
63
|
+
"@1agh/maude-linux-arm64": "1.4.6",
|
|
64
|
+
"@1agh/maude-linux-arm64-musl": "1.4.6",
|
|
65
|
+
"@1agh/maude-linux-x64": "1.4.6",
|
|
66
|
+
"@1agh/maude-linux-x64-musl": "1.4.6",
|
|
67
|
+
"@1agh/maude-win32-x64": "1.4.6"
|
|
68
68
|
},
|
|
69
69
|
"files": [
|
|
70
70
|
"cli",
|