@1agh/maude 1.4.4 → 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 +19 -0
- package/apps/studio/canvas-comment-mount.tsx +48 -3
- package/apps/studio/canvas-lib.tsx +5 -0
- package/apps/studio/canvas-shell.tsx +110 -14
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +108 -60
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- 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 +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +95 -7
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/agent.ts +28 -2
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-plane.ts +101 -2
- package/apps/studio/sync/index.ts +124 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/migrate-seed.ts +9 -2
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/use-selection-set.tsx +13 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +45 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +21 -0
|
@@ -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
|
+
}
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
statSync,
|
|
62
62
|
writeFileSync,
|
|
63
63
|
} from 'node:fs';
|
|
64
|
+
import { open as openAsync } from 'node:fs/promises';
|
|
64
65
|
import path from 'node:path';
|
|
65
66
|
|
|
66
67
|
import { conflictCopyName, decideFile, type FileState } from './decide-file.ts';
|
|
@@ -112,6 +113,28 @@ export function sha256File(abs: string): string {
|
|
|
112
113
|
return hash.digest('hex');
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The same digest, yielding to the event loop between chunks. A received video
|
|
118
|
+
* is verified on the path every canvas sync shares: hashing 513 MiB in one
|
|
119
|
+
* synchronous run held a teammate's edit back ~5 s on a receiving peer
|
|
120
|
+
* (F3 S14, 2026-09-23).
|
|
121
|
+
*/
|
|
122
|
+
export async function sha256FileAsync(abs: string): Promise<string> {
|
|
123
|
+
const hash = createHash('sha256');
|
|
124
|
+
const buf = Buffer.allocUnsafe(1024 * 1024);
|
|
125
|
+
const fh = await openAsync(abs, 'r');
|
|
126
|
+
try {
|
|
127
|
+
for (;;) {
|
|
128
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
|
129
|
+
if (bytesRead <= 0) break;
|
|
130
|
+
hash.update(bytesRead === buf.length ? buf : buf.subarray(0, bytesRead));
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
await fh.close();
|
|
134
|
+
}
|
|
135
|
+
return hash.digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
/**
|
|
116
139
|
* What the door accepts, until it tells us otherwise.
|
|
117
140
|
*
|
|
@@ -617,6 +640,21 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
617
640
|
let reanchorsInARow = 0;
|
|
618
641
|
let reanchorHeldSince = 0;
|
|
619
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
|
+
|
|
620
658
|
/**
|
|
621
659
|
* When the plane may talk to the hub again. Issue #109.
|
|
622
660
|
*
|
|
@@ -901,7 +939,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
901
939
|
}
|
|
902
940
|
let got: string;
|
|
903
941
|
try {
|
|
904
|
-
got =
|
|
942
|
+
got = await sha256FileAsync(staged);
|
|
905
943
|
} catch {
|
|
906
944
|
return { ok: false, reason: 'could not read the downloaded file back' };
|
|
907
945
|
}
|
|
@@ -1474,7 +1512,14 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1474
1512
|
requestsThisPass = 0;
|
|
1475
1513
|
|
|
1476
1514
|
// ── 1. The hub's side ────────────────────────────────────────────────
|
|
1477
|
-
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();
|
|
1478
1523
|
let fullRead = startedFrom === 0;
|
|
1479
1524
|
let page = await fetchJournal({
|
|
1480
1525
|
hubUrl: opts.hubUrl,
|
|
@@ -1625,6 +1670,45 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1625
1670
|
ledger.forget(rel);
|
|
1626
1671
|
continue;
|
|
1627
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
|
+
}
|
|
1628
1712
|
// What the hub holds: this page when it spoke about the path, otherwise
|
|
1629
1713
|
// what we last learned. `undefined` (never learned) reads as null only
|
|
1630
1714
|
// after a full read has had the chance to say so.
|
|
@@ -1650,6 +1734,20 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1650
1734
|
continue;
|
|
1651
1735
|
}
|
|
1652
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
|
+
}
|
|
1653
1751
|
drop(
|
|
1654
1752
|
out,
|
|
1655
1753
|
rel,
|
|
@@ -2059,6 +2157,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
2059
2157
|
ledger.setState(rel, 'conflict', {
|
|
2060
2158
|
reason: 'the hub changed this file while the upload was in flight',
|
|
2061
2159
|
});
|
|
2160
|
+
fullReadOwed = true;
|
|
2062
2161
|
out.conflicts.push({ rel, copy: null });
|
|
2063
2162
|
return true;
|
|
2064
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,
|
|
@@ -119,6 +120,14 @@ export interface SyncProvider {
|
|
|
119
120
|
* Optional: a provider without it is treated as writable.
|
|
120
121
|
*/
|
|
121
122
|
isWritable?(): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* The hub's own word on this connection's write right, carried by a save-mode
|
|
125
|
+
* notice (`maude.mode` with `writable`). It outranks the scope the handshake
|
|
126
|
+
* returned: a connection admitted read-only while the project took proposals
|
|
127
|
+
* is writable again the moment the project returns to legacy — the hub fences
|
|
128
|
+
* per message, not per handshake — and nothing re-authenticates it.
|
|
129
|
+
*/
|
|
130
|
+
noteWritable?(writable: boolean): void;
|
|
122
131
|
/** The hub authenticated this connection (again) — scope may have changed. */
|
|
123
132
|
onAuthenticated?(cb: (scope: string) => void): () => void;
|
|
124
133
|
/** Out-of-band messages from the hub on this document's socket. */
|
|
@@ -512,6 +521,14 @@ export interface SyncRuntime {
|
|
|
512
521
|
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
|
|
513
522
|
/** True while the linked project is in accepted-revisions mode. */
|
|
514
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;
|
|
515
532
|
/** Tripwire count: local writes that reached an accepted replica. */
|
|
516
533
|
acceptedWriteViolations?(): number;
|
|
517
534
|
/** Accepted revisions: the project's logical history (T27). Null when legacy. */
|
|
@@ -915,6 +932,8 @@ export function createSyncRuntime(
|
|
|
915
932
|
// change is a proposal through the durable outbox, and the documents change
|
|
916
933
|
// when the project publishes the accepted revision. Shared-doc only: the
|
|
917
934
|
// two-doc agent path has no proposal lane and stays legacy.
|
|
935
|
+
/** Resolves once a previous run's outbox is drained: doc → own accepted html. */
|
|
936
|
+
let outboxDrained: Promise<Map<string, string>> = Promise.resolve(new Map());
|
|
918
937
|
const acceptedLink: AcceptedLink | null = useSharedDoc
|
|
919
938
|
? createAcceptedLink({
|
|
920
939
|
hubUrl: linkedHub.url,
|
|
@@ -925,7 +944,16 @@ export function createSyncRuntime(
|
|
|
925
944
|
retryMs: opts.transactionRetryMs,
|
|
926
945
|
onStats: (stats) => statusStore?.updateAccepted?.(stats),
|
|
927
946
|
onStage: (summary) => statusStore?.updateAiAction?.(summary),
|
|
928
|
-
onBootstrap: (b) =>
|
|
947
|
+
onBootstrap: (b) => {
|
|
948
|
+
noteProjectConfig(b.projectConfig);
|
|
949
|
+
// F3 S17 — a save made as the socket died is held (the connection
|
|
950
|
+
// was not writable). After the reconnect the handshake re-admits the
|
|
951
|
+
// socket read-only BEFORE this peer learns the project now takes
|
|
952
|
+
// proposals, so its retry found the write still blocked and nothing
|
|
953
|
+
// retried again: the save stayed on disk, and the status said
|
|
954
|
+
// synced, until a restart. Knowing the mode, hand it back now.
|
|
955
|
+
if (b.mode === 'transactions') for (const p of projections.values()) p.retryDeferred();
|
|
956
|
+
},
|
|
929
957
|
})
|
|
930
958
|
: null;
|
|
931
959
|
/**
|
|
@@ -2038,16 +2066,15 @@ export function createSyncRuntime(
|
|
|
2038
2066
|
): Awaited<ReturnType<typeof fetchRemoteListing>> {
|
|
2039
2067
|
const manifest = acceptedOn() ? acceptedLink?.manifest : null;
|
|
2040
2068
|
if (!manifest) return listing;
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
}
|
|
2069
|
+
// An open Yjs room is not necessarily an accepted canvas. Its author can
|
|
2070
|
+
// connect before doc.create commits; pulling that empty room would lock
|
|
2071
|
+
// the receiver onto a lossy slug-derived path before the real path arrives.
|
|
2072
|
+
// The accepted manifest alone names live canvases, including successors
|
|
2073
|
+
// of retired documents whose transport rows may still linger.
|
|
2074
|
+
const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
|
|
2075
|
+
const documents = manifest.docs
|
|
2076
|
+
.filter((d) => !d.retired)
|
|
2077
|
+
.map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
|
|
2051
2078
|
return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
|
|
2052
2079
|
}
|
|
2053
2080
|
|
|
@@ -2248,10 +2275,29 @@ export function createSyncRuntime(
|
|
|
2248
2275
|
);
|
|
2249
2276
|
// Work a previous run left unanswered goes first, in creation order —
|
|
2250
2277
|
// before any cold start can propose something built on top of it.
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2278
|
+
// What each canvas's disk was last saved as, when that save was one of
|
|
2279
|
+
// these: the base its cold start judges the disk against.
|
|
2280
|
+
const own = new Map<string, string>();
|
|
2281
|
+
outboxDrained = acceptedLink.client
|
|
2282
|
+
.drainOutbox((result, operations) => {
|
|
2283
|
+
for (const o of operations) {
|
|
2284
|
+
const html =
|
|
2285
|
+
o.op === 'lane.replace' && o.lane === 'html'
|
|
2286
|
+
? o.content
|
|
2287
|
+
: o.op === 'doc.create'
|
|
2288
|
+
? (o.lanes as Record<string, unknown> | undefined)?.html
|
|
2289
|
+
: undefined;
|
|
2290
|
+
if (typeof o.doc !== 'string' || typeof html !== 'string') continue;
|
|
2291
|
+
if (result.status === 'accepted') own.set(o.doc, html);
|
|
2292
|
+
else own.delete(o.doc);
|
|
2293
|
+
}
|
|
2294
|
+
})
|
|
2295
|
+
.then((results) => {
|
|
2296
|
+
if (results.length)
|
|
2297
|
+
console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
|
|
2298
|
+
return own;
|
|
2299
|
+
})
|
|
2300
|
+
.catch(() => own);
|
|
2255
2301
|
applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
2256
2302
|
proposeLocalFolders();
|
|
2257
2303
|
}
|
|
@@ -2585,6 +2631,21 @@ export function createSyncRuntime(
|
|
|
2585
2631
|
if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
|
|
2586
2632
|
});
|
|
2587
2633
|
activityUnsubs.push(unsubSourceOp);
|
|
2634
|
+
// Only the trusted API emits this after its write has finished. External
|
|
2635
|
+
// fs events retain the quiet window; both paths use the same projection,
|
|
2636
|
+
// proposal, validation and echo/deduplication rules.
|
|
2637
|
+
const unsubSourceWritten = ctx.bus.on('source-written', (payload: unknown) => {
|
|
2638
|
+
const p = payload as { rel?: unknown; content?: unknown } | null;
|
|
2639
|
+
if (!acceptedOn() || typeof p?.content !== 'string') return;
|
|
2640
|
+
const proj = projectionForRel(p.rel);
|
|
2641
|
+
if (!proj) return;
|
|
2642
|
+
proj.applyFromFs({
|
|
2643
|
+
path: path.join(ctx.paths.designRoot, p.rel as string),
|
|
2644
|
+
bytes: new TextEncoder().encode(p.content),
|
|
2645
|
+
hash: hashBytes(p.content),
|
|
2646
|
+
});
|
|
2647
|
+
});
|
|
2648
|
+
activityUnsubs.push(unsubSourceWritten);
|
|
2588
2649
|
const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
|
|
2589
2650
|
projectionForRel(rel)?.cancelLocalWrite();
|
|
2590
2651
|
});
|
|
@@ -2670,6 +2731,11 @@ export function createSyncRuntime(
|
|
|
2670
2731
|
// in `_history/<slug>/` via history.ts so /design:rollback recovers them.
|
|
2671
2732
|
journal = loadJournal(ctx.paths.designRoot);
|
|
2672
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
|
+
);
|
|
2673
2739
|
const history = createHistory(ctx);
|
|
2674
2740
|
|
|
2675
2741
|
// ---- DDR-102 helpers: auth aggregation, re-probe, settle bookkeeping ----
|
|
@@ -3058,6 +3124,10 @@ export function createSyncRuntime(
|
|
|
3058
3124
|
inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
|
|
3059
3125
|
}
|
|
3060
3126
|
const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
|
|
3127
|
+
// A save a previous run left unanswered is answered first: a disk
|
|
3128
|
+
// edited after it was edited ON it (F3 S14 on the cloud cell — judged
|
|
3129
|
+
// against the older base, the save conflicted with itself).
|
|
3130
|
+
const ownAccepted = (await outboxDrained).get(docName) ?? null;
|
|
3061
3131
|
await acceptedColdStart({
|
|
3062
3132
|
slug: canvas.slug,
|
|
3063
3133
|
doc: provider.document,
|
|
@@ -3067,6 +3137,9 @@ export function createSyncRuntime(
|
|
|
3067
3137
|
projection,
|
|
3068
3138
|
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
3069
3139
|
journal: journal ?? undefined,
|
|
3140
|
+
commentLedger: commentLedgerFor(ctx.paths.designRoot),
|
|
3141
|
+
ownAccepted,
|
|
3142
|
+
wasAccepted: (content) => acceptedLink.client.holdsValue(content),
|
|
3070
3143
|
createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
|
|
3071
3144
|
});
|
|
3072
3145
|
projection.reconcile();
|
|
@@ -3084,6 +3157,7 @@ export function createSyncRuntime(
|
|
|
3084
3157
|
paths: canvasPaths,
|
|
3085
3158
|
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
3086
3159
|
journal: journal ?? undefined,
|
|
3160
|
+
commentLedger: commentLedgerFor(ctx.paths.designRoot),
|
|
3087
3161
|
snapshot: async (content, reason) => {
|
|
3088
3162
|
try {
|
|
3089
3163
|
const snap = await history.writeSnapshot(relBody, content, reason);
|
|
@@ -3697,6 +3771,14 @@ export function createSyncRuntime(
|
|
|
3697
3771
|
echoGuard,
|
|
3698
3772
|
adopt: adoptOnce,
|
|
3699
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
|
+
},
|
|
3700
3782
|
snapshot: async (content, reason) => {
|
|
3701
3783
|
try {
|
|
3702
3784
|
const snap = await history.writeSnapshot(relBody, content, reason);
|
|
@@ -3733,6 +3815,8 @@ export function createSyncRuntime(
|
|
|
3733
3815
|
}
|
|
3734
3816
|
if (msg?.type !== 'maude.mode') return;
|
|
3735
3817
|
if (msg.mode === 'transactions' || msg.mode === 'legacy') {
|
|
3818
|
+
const writable = (msg as { writable?: unknown }).writable;
|
|
3819
|
+
if (typeof writable === 'boolean') provider.noteWritable?.(writable);
|
|
3736
3820
|
acceptedLink.noteMode(msg.mode);
|
|
3737
3821
|
void refreshAcceptedMode();
|
|
3738
3822
|
}
|
|
@@ -4876,6 +4960,19 @@ export function createSyncRuntime(
|
|
|
4876
4960
|
},
|
|
4877
4961
|
proposeFolder,
|
|
4878
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
|
+
},
|
|
4879
4976
|
acceptedWriteViolations: () => acceptedWriteViolations,
|
|
4880
4977
|
acceptedHistory: async (q) => {
|
|
4881
4978
|
if (!acceptedOn() || !acceptedLink) return null;
|
|
@@ -5740,11 +5837,17 @@ export function createDefaultProviderFactory(
|
|
|
5740
5837
|
socket.on('status', resetSyncedOnDrop);
|
|
5741
5838
|
|
|
5742
5839
|
let authedThisConnection = false;
|
|
5840
|
+
/** A save-mode notice's verdict on THIS connection; a new handshake supersedes it. */
|
|
5841
|
+
let modeWritable: boolean | null = null;
|
|
5743
5842
|
provider.on('authenticated', () => {
|
|
5744
5843
|
authedThisConnection = true;
|
|
5844
|
+
modeWritable = null;
|
|
5745
5845
|
});
|
|
5746
5846
|
const forgetAuthOnDrop = (evt: { status?: string }) => {
|
|
5747
|
-
if (evt?.status !== 'connected')
|
|
5847
|
+
if (evt?.status !== 'connected') {
|
|
5848
|
+
authedThisConnection = false;
|
|
5849
|
+
modeWritable = null;
|
|
5850
|
+
}
|
|
5748
5851
|
};
|
|
5749
5852
|
socket.on('status', forgetAuthOnDrop);
|
|
5750
5853
|
return {
|
|
@@ -5757,7 +5860,11 @@ export function createDefaultProviderFactory(
|
|
|
5757
5860
|
// over from before a drop says nothing about the hub now: a project
|
|
5758
5861
|
// switched to accepted revisions while this peer was away re-admits it
|
|
5759
5862
|
// read-only, and anything written in between would be dropped there.
|
|
5760
|
-
isWritable: () =>
|
|
5863
|
+
isWritable: () =>
|
|
5864
|
+
authedThisConnection && (modeWritable ?? provider.authorizedScope === 'read-write'),
|
|
5865
|
+
noteWritable(writable: boolean) {
|
|
5866
|
+
if (authedThisConnection) modeWritable = writable;
|
|
5867
|
+
},
|
|
5761
5868
|
onAuthenticated(cb: (scope: string) => void): () => void {
|
|
5762
5869
|
const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
|
|
5763
5870
|
provider.on('authenticated', handler);
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// it as "no changes" is exactly the shape that lets a stale peer believe it is
|
|
18
18
|
// current, which is the failure DDR-214's ordering amendment exists to prevent.
|
|
19
19
|
|
|
20
|
+
import { isProjectFileShape } from './file-membership.ts';
|
|
21
|
+
|
|
20
22
|
/** How long to wait for a journal page. Same figure as the manifest fetch. */
|
|
21
23
|
const JOURNAL_TIMEOUT_MS = 6000;
|
|
22
24
|
|
|
@@ -58,8 +60,12 @@ export interface JournalPage {
|
|
|
58
60
|
overflowed?: true;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
// A designRoot-relative path a peer will turn into a real file: the SAME shape
|
|
64
|
+
// rule the hub's file door admits (`isProjectFileShape`). A private, narrower
|
|
65
|
+
// regex here dropped every journalled path with a space in it — `ui/Studio
|
|
66
|
+
// Docs.registry.json` was accepted from its author, journalled, and then
|
|
67
|
+
// silently discarded by every other peer, with no ledger row and no refusal
|
|
68
|
+
// shown (F3 S14, 2026-09-23).
|
|
63
69
|
|
|
64
70
|
function parseEntry(raw: unknown): JournalEntry | null {
|
|
65
71
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -67,7 +73,7 @@ function parseEntry(raw: unknown): JournalEntry | null {
|
|
|
67
73
|
const seq = e.seq;
|
|
68
74
|
const p = e.path;
|
|
69
75
|
if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return null;
|
|
70
|
-
if (typeof p !== 'string' || !
|
|
76
|
+
if (typeof p !== 'string' || !isProjectFileShape(p)) return null;
|
|
71
77
|
const sha = typeof e.sha256 === 'string' && /^[0-9a-f]{64}$/.test(e.sha256) ? e.sha256 : null;
|
|
72
78
|
return {
|
|
73
79
|
seq,
|