@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
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
// loaded via the importmap + Bun.build-produced ESM — there's no React Fast
|
|
25
25
|
// Refresh runtime to register with. Full-reload is the reliable path.
|
|
26
26
|
|
|
27
|
-
import { existsSync } from 'node:fs';
|
|
27
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
|
|
30
|
+
import { recallCanvasBuild } from './canvas-source-memo.ts';
|
|
31
|
+
import { type TextPatch, textOnlyPatches } from './canvas-text-patch.ts';
|
|
30
32
|
import type { Context } from './context.ts';
|
|
31
33
|
|
|
32
34
|
const DEBOUNCE_MS = 50;
|
|
@@ -54,6 +56,12 @@ export interface HmrMessage {
|
|
|
54
56
|
* lost race re-applied onto theirs).
|
|
55
57
|
*/
|
|
56
58
|
remote?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* F4 — `module` only: the change is nothing but the text of this element
|
|
61
|
+
* (versus the source of the canvas's last build). The iframe may show them at once; the remount it still
|
|
62
|
+
* performs confirms them. Absent whenever anything else changed.
|
|
63
|
+
*/
|
|
64
|
+
patches?: TextPatch[];
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/** How long after a sync write its file's change still counts as remote. */
|
|
@@ -93,6 +101,30 @@ export function createHmrBroadcaster(
|
|
|
93
101
|
hard: 3,
|
|
94
102
|
};
|
|
95
103
|
|
|
104
|
+
// Diff the canvas as it is now against the source the iframe renders (its
|
|
105
|
+
// last build). Read at flush, not per event, so a burst diffs end to end.
|
|
106
|
+
// String scanning plus the build's locator — never a parse (see
|
|
107
|
+
// canvas-text-patch.ts: in a cell this is the credential-holding process).
|
|
108
|
+
function withTextPatches(msg: HmrMessage): HmrMessage {
|
|
109
|
+
const root = ctx.paths?.designRoot;
|
|
110
|
+
if (msg.mode !== 'module' || !msg.file?.endsWith('.tsx') || !root) return msg;
|
|
111
|
+
const abs = path.join(root, msg.file);
|
|
112
|
+
let now: string;
|
|
113
|
+
try {
|
|
114
|
+
now = readFileSync(abs, 'utf8');
|
|
115
|
+
} catch {
|
|
116
|
+
return msg;
|
|
117
|
+
}
|
|
118
|
+
const built = recallCanvasBuild(abs);
|
|
119
|
+
if (built === undefined) return msg;
|
|
120
|
+
try {
|
|
121
|
+
const patches = textOnlyPatches(built.source, now, built.locator);
|
|
122
|
+
return patches?.length ? { ...msg, patches } : msg;
|
|
123
|
+
} catch {
|
|
124
|
+
return msg; // the remount still renders it
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
96
128
|
function flush() {
|
|
97
129
|
// A pending `hard` supersedes the per-file queue — every open canvas does a
|
|
98
130
|
// full reload anyway, so the softer messages would be redundant churn.
|
|
@@ -100,7 +132,7 @@ export function createHmrBroadcaster(
|
|
|
100
132
|
if (hard?.mode === 'hard') {
|
|
101
133
|
broadcast(hard);
|
|
102
134
|
} else {
|
|
103
|
-
for (const msg of pendingByKey.values()) broadcast(msg);
|
|
135
|
+
for (const msg of pendingByKey.values()) broadcast(withTextPatches(msg));
|
|
104
136
|
}
|
|
105
137
|
pendingByKey.clear();
|
|
106
138
|
pending = null;
|
|
@@ -116,6 +148,11 @@ export function createHmrBroadcaster(
|
|
|
116
148
|
|
|
117
149
|
// Files sync just wrote (see HmrMessage.remote), with the time of the write.
|
|
118
150
|
const projectedAt = new Map<string, number>();
|
|
151
|
+
// A real watcher and the cell fallback can report the same completed write
|
|
152
|
+
// more than 50 ms apart. A second import invalidates the first even when its
|
|
153
|
+
// bytes are already arriving. Deduplicate disk VERSIONS, never merely paths
|
|
154
|
+
// or a time window: another edit to this file must still reach the canvas.
|
|
155
|
+
const remoteVersions = new Map<string, string>();
|
|
119
156
|
const isRemote = (rel: string | undefined): boolean => {
|
|
120
157
|
if (!rel) return false;
|
|
121
158
|
const at = projectedAt.get(rel);
|
|
@@ -146,7 +183,25 @@ export function createHmrBroadcaster(
|
|
|
146
183
|
const offAny = ctx.bus.on('fs:any', (rel: string) => {
|
|
147
184
|
const msg = classify(rel);
|
|
148
185
|
if (!msg) return;
|
|
149
|
-
|
|
186
|
+
const normalized = rel.replace(/\\/g, '/');
|
|
187
|
+
if (isRemote(normalized) || isRemote(msg.file)) msg.remote = true;
|
|
188
|
+
if (msg.remote && (msg.mode === 'module' || msg.mode === 'css') && ctx.paths.designRoot) {
|
|
189
|
+
try {
|
|
190
|
+
const s = statSync(path.join(ctx.paths.designRoot, normalized), { bigint: true });
|
|
191
|
+
// Nanosecond ctime also detects a same-size rewrite with restored mtime;
|
|
192
|
+
// inode detects atomic replacement. No source or large media is read.
|
|
193
|
+
const version = `${msg.mode}:${msg.file}:${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`;
|
|
194
|
+
if (remoteVersions.get(normalized) === version) return;
|
|
195
|
+
remoteVersions.delete(normalized);
|
|
196
|
+
remoteVersions.set(normalized, version);
|
|
197
|
+
if (remoteVersions.size > 256) {
|
|
198
|
+
const oldest = remoteVersions.keys().next().value;
|
|
199
|
+
if (oldest !== undefined) remoteVersions.delete(oldest);
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
remoteVersions.delete(normalized); // missing/unreadable: fail open
|
|
203
|
+
}
|
|
204
|
+
} else remoteVersions.delete(normalized);
|
|
150
205
|
enqueue(msg);
|
|
151
206
|
});
|
|
152
207
|
|
|
@@ -157,6 +212,7 @@ export function createHmrBroadcaster(
|
|
|
157
212
|
if (pending) clearTimeout(pending);
|
|
158
213
|
pending = null;
|
|
159
214
|
pendingByKey.clear();
|
|
215
|
+
remoteVersions.clear();
|
|
160
216
|
},
|
|
161
217
|
};
|
|
162
218
|
}
|
package/apps/studio/http.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { buildCanvasModule } from './canvas-build.ts';
|
|
|
34
34
|
import { buildCanvasSandboxed, buildStats } from './canvas-build-sandbox.ts';
|
|
35
35
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
36
36
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
37
|
+
import { rememberCanvasBuild } from './canvas-source-memo.ts';
|
|
37
38
|
import { createCloudEndpoints } from './cloud/endpoints.ts';
|
|
38
39
|
import type { AiActivity } from './collab/ai-activity.ts';
|
|
39
40
|
import type { Context } from './context.ts';
|
|
@@ -687,6 +688,7 @@ export async function serveCanvasTsx(
|
|
|
687
688
|
deps,
|
|
688
689
|
};
|
|
689
690
|
canvasCache.set(absPath, cached);
|
|
691
|
+
rememberCanvasBuild(absPath, { source, locator: result.locator });
|
|
690
692
|
await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
|
|
691
693
|
return respondWithCanvasModule(req, cached);
|
|
692
694
|
}
|
|
@@ -725,6 +727,7 @@ export async function serveCanvasTsx(
|
|
|
725
727
|
deps,
|
|
726
728
|
};
|
|
727
729
|
canvasCache.set(absPath, cached);
|
|
730
|
+
rememberCanvasBuild(absPath, { source, locator: result.locator });
|
|
728
731
|
// Persist the locator map. Awaited so the inspector / Phase-12 layers
|
|
729
732
|
// panel sees a consistent (cdId -> source) view by the time the canvas
|
|
730
733
|
// mounts. Per-path mutex inside writeLocator() makes concurrent transpiles
|
|
@@ -18,16 +18,31 @@
|
|
|
18
18
|
// A canvas the project does not know yet is proposed as `doc.create` with all
|
|
19
19
|
// of its lanes, so it arrives on every peer as ONE action.
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
21
|
+
// Annotations are never re-proposed from disk here: every change the studio
|
|
22
|
+
// made to them went through the durable outbox (drained before any cold start),
|
|
23
|
+
// so a difference on disk is an older accepted state the room had not
|
|
24
|
+
// re-projected — the accepted value wins, and the local file is kept in a
|
|
25
25
|
// recovery slot in case a raw edit made while the studio was down lived there.
|
|
26
|
+
//
|
|
27
|
+
// Comments had the same rule, and the premise did not hold for them (issue
|
|
28
|
+
// #133, reproduced end-to-end): a comment added while the sync runtime was not
|
|
29
|
+
// up yet (`proposeLane` unavailable, server.ts) went to the local room and disk
|
|
30
|
+
// only, never into the outbox — so the web never saw it, and the file then
|
|
31
|
+
// froze behind it. Comments carry stable ids, so the difference is decidable:
|
|
32
|
+
// an id on disk that the accepted state lacks and that the comment ledger says
|
|
33
|
+
// was never synced from here is a local comment still owed to the project; it
|
|
34
|
+
// is proposed, as the accepted list plus those comments (a three-way id merge
|
|
35
|
+
// on the hub). Ids the ledger knows were synced are deletions made while this
|
|
36
|
+
// machine was away and are not proposed back. With no ledger record for the
|
|
37
|
+
// canvas (first launch after upgrading) nothing is decidable: the old rule —
|
|
38
|
+
// accepted wins, local kept in recovery — stands.
|
|
26
39
|
|
|
27
40
|
import { existsSync, readFileSync } from 'node:fs';
|
|
28
41
|
import type * as Y from 'yjs';
|
|
29
42
|
|
|
30
43
|
import { cssFromDoc, htmlFromDoc, laneValueFromFile, readLaneFromDoc } from './codec.ts';
|
|
44
|
+
import { commentKey } from './comment-identity.ts';
|
|
45
|
+
import type { CommentLedger } from './comment-ledger.ts';
|
|
31
46
|
import { hashBytes } from './echo-guard.ts';
|
|
32
47
|
import type { SyncJournal } from './journal.ts';
|
|
33
48
|
import type { DocProjection, ProjectionPaths, ProposalLane } from './projection.ts';
|
|
@@ -53,6 +68,38 @@ function readText(p: string | undefined): string | null {
|
|
|
53
68
|
}
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
function parseList(v: string): unknown[] {
|
|
72
|
+
if (!v) return [];
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(v) as unknown;
|
|
75
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The comments list to propose at cold start — the accepted list plus the local
|
|
83
|
+
* comments never synced from here — or null when there is nothing owed (or no
|
|
84
|
+
* ledger record to decide by). See the header.
|
|
85
|
+
*/
|
|
86
|
+
export function commentsOwedToProject(
|
|
87
|
+
local: string,
|
|
88
|
+
accepted: string,
|
|
89
|
+
slug: string,
|
|
90
|
+
ledger: CommentLedger | undefined
|
|
91
|
+
): string | null {
|
|
92
|
+
if (!ledger?.known(slug)) return null;
|
|
93
|
+
const acceptedList = parseList(accepted);
|
|
94
|
+
const inProject = new Set(acceptedList.map(commentKey));
|
|
95
|
+
const synced = ledger.get(slug);
|
|
96
|
+
const owed = parseList(local).filter((c) => {
|
|
97
|
+
const k = commentKey(c);
|
|
98
|
+
return !inProject.has(k) && !synced.has(k);
|
|
99
|
+
});
|
|
100
|
+
return owed.length > 0 ? JSON.stringify([...acceptedList, ...owed]) : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
56
103
|
/**
|
|
57
104
|
* Pure decision for the source lanes. `knownBase` is the last value disk and
|
|
58
105
|
* the accepted replica agreed on (recovery slot), `baseHash` the journal's
|
|
@@ -92,6 +139,21 @@ export interface AcceptedColdStartInput {
|
|
|
92
139
|
projection: DocProjection;
|
|
93
140
|
historyDir?: string;
|
|
94
141
|
journal?: SyncJournal;
|
|
142
|
+
/** Issue #133 — comment ids synced from here before (see the header). */
|
|
143
|
+
commentLedger?: CommentLedger;
|
|
144
|
+
/**
|
|
145
|
+
* This disk's own html proposal a previous run left unanswered, which the
|
|
146
|
+
* outbox drain has just had accepted. The disk was saved on top of it, so it
|
|
147
|
+
* is the base — the recovery slot still holds the value before it.
|
|
148
|
+
*/
|
|
149
|
+
ownAccepted?: string | null;
|
|
150
|
+
/**
|
|
151
|
+
* Did the project ever accept exactly this value? A disk with no record of
|
|
152
|
+
* its base that holds a value the store accepted at some revision holds an
|
|
153
|
+
* OLDER accepted state (a checkout restored from a backup — every cloud
|
|
154
|
+
* cell wake), not an edit: it is materialized, never held.
|
|
155
|
+
*/
|
|
156
|
+
wasAccepted?: (content: string) => Promise<boolean | null>;
|
|
95
157
|
createDoc: (lanes: Partial<Record<ProposalLane, string>>) => Promise<{
|
|
96
158
|
status: 'accepted' | 'rejected';
|
|
97
159
|
code?: string;
|
|
@@ -145,14 +207,27 @@ export async function acceptedColdStart(
|
|
|
145
207
|
// An invalid local body cannot be proposed; the projection keeps its bytes
|
|
146
208
|
// in recovery and reports it when it materializes the accepted source.
|
|
147
209
|
verdicts.push({ lane: 'html', decision: 'materialize', local: localHtml });
|
|
210
|
+
} else if (i.ownAccepted != null && localHtml === i.ownAccepted) {
|
|
211
|
+
// Disk IS the drained, accepted save; its publication may still be on the
|
|
212
|
+
// way. Nothing to propose, and nothing older to write over it.
|
|
213
|
+
verdicts.push({ lane: 'html', decision: 'agreed', local: localHtml });
|
|
214
|
+
i.projection.adoptOwnAccepted(i.ownAccepted);
|
|
148
215
|
} else {
|
|
149
216
|
const d = decideSourceLane({
|
|
150
217
|
local: localHtml,
|
|
151
218
|
accepted: acceptedHtml,
|
|
152
|
-
knownBase:
|
|
219
|
+
knownBase:
|
|
220
|
+
i.ownAccepted ??
|
|
221
|
+
(i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null),
|
|
153
222
|
baseHash: i.journal?.get(i.slug)?.bodyHash ?? null,
|
|
154
223
|
});
|
|
155
|
-
|
|
224
|
+
const older =
|
|
225
|
+
d.decision === 'hold' && localHtml !== null && (await i.wasAccepted?.(localHtml)) === true;
|
|
226
|
+
verdicts.push({
|
|
227
|
+
lane: 'html',
|
|
228
|
+
...(older ? { decision: 'materialize' as const } : d),
|
|
229
|
+
...(localHtml !== null ? { local: localHtml } : {}),
|
|
230
|
+
});
|
|
156
231
|
}
|
|
157
232
|
|
|
158
233
|
// ---- css — opaque text, journal-checkpointed
|
|
@@ -164,7 +239,13 @@ export async function acceptedColdStart(
|
|
|
164
239
|
knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.css, 'base') : null,
|
|
165
240
|
baseHash: i.journal?.get(i.slug)?.cssHash ?? null,
|
|
166
241
|
});
|
|
167
|
-
|
|
242
|
+
const older =
|
|
243
|
+
d.decision === 'hold' && localCss !== null && (await i.wasAccepted?.(localCss)) === true;
|
|
244
|
+
verdicts.push({
|
|
245
|
+
lane: 'css',
|
|
246
|
+
...(older ? { decision: 'materialize' as const } : d),
|
|
247
|
+
...(localCss !== null ? { local: localCss } : {}),
|
|
248
|
+
});
|
|
168
249
|
}
|
|
169
250
|
|
|
170
251
|
// ---- meta — the shared layout keys (viewport never travels)
|
|
@@ -193,6 +274,13 @@ export async function acceptedColdStart(
|
|
|
193
274
|
verdicts.push({ lane, decision: local === null ? 'materialize' : 'agreed' });
|
|
194
275
|
continue;
|
|
195
276
|
}
|
|
277
|
+
if (lane === 'comments') {
|
|
278
|
+
const owed = commentsOwedToProject(local, accepted, i.slug, i.commentLedger);
|
|
279
|
+
if (owed) {
|
|
280
|
+
verdicts.push({ lane, decision: 'propose', base: accepted, local: owed });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
196
284
|
if (i.historyDir && text) {
|
|
197
285
|
try {
|
|
198
286
|
saveRecoveryBody(i.historyDir, p, 'local', text);
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// Nothing here writes a Y.Doc. The documents change when the hub publishes the
|
|
13
13
|
// accepted revision, through the same providers that deliver a peer's edit.
|
|
14
14
|
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
15
18
|
import { createActionStage, type StageSummary } from './action-stage.ts';
|
|
16
19
|
import type { AcceptedLaneLink, LaneProposal, ProposalOutcome } from './projection.ts';
|
|
17
20
|
import {
|
|
@@ -71,13 +74,61 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
71
74
|
retryMs: opts.retryMs,
|
|
72
75
|
});
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
// Every proposal this link sends goes out AFTER the creates queued before it
|
|
78
|
+
// (a lane edit of a canvas being added must never overtake its doc.create on
|
|
79
|
+
// the serialized queue); see `queueCreate` below.
|
|
80
|
+
const propose: TransactionClient['propose'] = (action) => {
|
|
81
|
+
flushCreates();
|
|
82
|
+
// A proposal about a canvas whose batched create is still unanswered waits
|
|
83
|
+
// until that create is settled or re-queued on its own: a refused batch
|
|
84
|
+
// falls back to one create each, and those must stay AHEAD of it.
|
|
85
|
+
const waits = [
|
|
86
|
+
...new Set(
|
|
87
|
+
action.operations
|
|
88
|
+
.map((op) => (typeof op.doc === 'string' ? createsInFlight.get(op.doc) : undefined))
|
|
89
|
+
.filter((w): w is Promise<void> => !!w)
|
|
90
|
+
),
|
|
91
|
+
];
|
|
92
|
+
if (!waits.length) return client.propose(action);
|
|
93
|
+
return Promise.all(waits).then(() => client.propose(action));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// THE LAST VERDICT SURVIVES A RESTART (F3/S06). Held only in memory, a
|
|
97
|
+
// desktop that restarted without the network came back 'unknown', treated
|
|
98
|
+
// its offline edits as legacy shared-document writes, and the hub fenced
|
|
99
|
+
// those raw updates: the work never reached the project while the status
|
|
100
|
+
// read "synced". The hub still has the last word — the next bootstrap
|
|
101
|
+
// replaces this — but until it answers, the answer it last gave stands.
|
|
102
|
+
// Keyed by hub, so a relinked copy starts from 'unknown' again.
|
|
103
|
+
const modeFile = path.join(opts.designRoot, '_state', 'accepted-mode.json');
|
|
104
|
+
const loadMode = (): AcceptedMode => {
|
|
105
|
+
try {
|
|
106
|
+
const v = JSON.parse(readFileSync(modeFile, 'utf8'));
|
|
107
|
+
if (v?.v === 1 && v.hub === opts.hubUrl && (v.mode === 'transactions' || v.mode === 'legacy'))
|
|
108
|
+
return v.mode;
|
|
109
|
+
} catch {
|
|
110
|
+
/* absent or unreadable — ask the hub */
|
|
111
|
+
}
|
|
112
|
+
return 'unknown';
|
|
113
|
+
};
|
|
114
|
+
const saveMode = (next: AcceptedMode): void => {
|
|
115
|
+
if (next === 'unknown') return;
|
|
116
|
+
try {
|
|
117
|
+
mkdirSync(path.dirname(modeFile), { recursive: true });
|
|
118
|
+
const tmp = `${modeFile}.tmp`;
|
|
119
|
+
writeFileSync(tmp, JSON.stringify({ v: 1, hub: opts.hubUrl, mode: next, at: Date.now() }));
|
|
120
|
+
renameSync(tmp, modeFile);
|
|
121
|
+
} catch {
|
|
122
|
+
/* the in-memory verdict still holds for this run */
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
let mode: AcceptedMode = loadMode();
|
|
75
126
|
let manifest: Bootstrap | null = null;
|
|
76
127
|
|
|
77
128
|
// T16 — AI and multi-file action boundaries (see action-stage.ts).
|
|
78
129
|
const stage = createActionStage({
|
|
79
130
|
designRoot: opts.designRoot,
|
|
80
|
-
propose: (action) =>
|
|
131
|
+
propose: (action) => propose(action),
|
|
81
132
|
newTransactionId: client.newTransactionId,
|
|
82
133
|
onChange: opts.onStage,
|
|
83
134
|
log,
|
|
@@ -96,12 +147,16 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
96
147
|
if (next !== mode && mode !== 'unknown') {
|
|
97
148
|
log.log(`[sync/tx] project save mode changed: ${mode} → ${next}`);
|
|
98
149
|
}
|
|
150
|
+
if (next !== mode) saveMode(next);
|
|
99
151
|
mode = next;
|
|
100
152
|
manifest = b;
|
|
101
153
|
opts.onBootstrap?.(b);
|
|
102
154
|
return b;
|
|
103
155
|
} catch (err) {
|
|
104
|
-
if (err instanceof TransactionError && err.code === 'absent')
|
|
156
|
+
if (err instanceof TransactionError && err.code === 'absent') {
|
|
157
|
+
if (mode !== 'legacy') saveMode('legacy');
|
|
158
|
+
mode = 'legacy';
|
|
159
|
+
}
|
|
105
160
|
return null;
|
|
106
161
|
}
|
|
107
162
|
}
|
|
@@ -114,7 +169,10 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
114
169
|
* confirmed by the next `refresh()`.
|
|
115
170
|
*/
|
|
116
171
|
function noteMode(next: 'transactions' | 'legacy'): void {
|
|
117
|
-
if (next !== mode)
|
|
172
|
+
if (next !== mode) {
|
|
173
|
+
log.log(`[sync/tx] the project switched its save mode: ${mode} → ${next}`);
|
|
174
|
+
saveMode(next);
|
|
175
|
+
}
|
|
118
176
|
mode = next;
|
|
119
177
|
}
|
|
120
178
|
|
|
@@ -133,24 +191,22 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
133
191
|
const doc = opts.docNameFor(slug);
|
|
134
192
|
if (stage.captures(slug, p.stageable === true)) return stage.capture(slug, doc, p);
|
|
135
193
|
const send = (dependsOn: string[] | undefined) =>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
})
|
|
153
|
-
.then(outcome);
|
|
194
|
+
propose({
|
|
195
|
+
kind: 'edit',
|
|
196
|
+
label: LANE_LABEL[p.lane],
|
|
197
|
+
transactionId: p.transactionId,
|
|
198
|
+
dependsOn,
|
|
199
|
+
operations: [
|
|
200
|
+
{
|
|
201
|
+
op: 'lane.replace',
|
|
202
|
+
doc,
|
|
203
|
+
lane: p.lane,
|
|
204
|
+
content: p.content,
|
|
205
|
+
baseContent: p.baseContent,
|
|
206
|
+
...(p.writeId ? { writeId: p.writeId } : {}),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
}).then(outcome);
|
|
154
210
|
if (stage.holdsDependency(p.dependsOn)) {
|
|
155
211
|
// Authored on top of an agent's unpublished bytes: it waits behind
|
|
156
212
|
// the stage, and goes out right after the group (or is discarded
|
|
@@ -175,13 +231,86 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
175
231
|
* it). The caller proceeds with its local change either way — the designer
|
|
176
232
|
* never waits on the network to move a canvas.
|
|
177
233
|
*/
|
|
234
|
+
// ONE PROPOSAL FOR A BURST OF NEW CANVASES. A fresh link adds every local
|
|
235
|
+
// canvas the project does not have, each from its own cold start, all at
|
|
236
|
+
// once; sent one by one they are that many round trips on the one
|
|
237
|
+
// serialized transaction queue, and the first edit waits behind all of them
|
|
238
|
+
// (F3 S14 on the cloud cell: 77 creates, 44 s). Creates that arrive within a
|
|
239
|
+
// short window travel together; a refused batch falls back to one proposal
|
|
240
|
+
// each, so one canvas the project will not take cannot hold back the rest.
|
|
241
|
+
const CREATE_WINDOW_MS = 25;
|
|
242
|
+
const CREATE_BATCH_MAX = 100;
|
|
243
|
+
const CREATE_BATCH_BYTES = 4 * 1024 * 1024;
|
|
244
|
+
type PendingCreate = { op: Operation; bytes: number; resolve: (o: ProposalOutcome) => void };
|
|
245
|
+
let createBatch: PendingCreate[] = [];
|
|
246
|
+
let createBatchBytes = 0;
|
|
247
|
+
let createTimer: ReturnType<typeof setTimeout> | null = null;
|
|
248
|
+
/** doc → the gate a batched create of it holds until settled or re-queued. */
|
|
249
|
+
const createsInFlight = new Map<string, Promise<void>>();
|
|
250
|
+
const proposeCreates = (ops: Operation[]) =>
|
|
251
|
+
client
|
|
252
|
+
.propose({
|
|
253
|
+
kind: 'canvas.create',
|
|
254
|
+
label: ops.length === 1 ? 'Create canvas' : `Add ${ops.length} canvases`,
|
|
255
|
+
operations: ops,
|
|
256
|
+
})
|
|
257
|
+
.then(outcome);
|
|
258
|
+
function flushCreates(): void {
|
|
259
|
+
if (createTimer) clearTimeout(createTimer);
|
|
260
|
+
createTimer = null;
|
|
261
|
+
const batch = createBatch;
|
|
262
|
+
createBatch = [];
|
|
263
|
+
createBatchBytes = 0;
|
|
264
|
+
if (!batch.length) return;
|
|
265
|
+
const docs = batch.map((c) => c.op.doc).filter((d): d is string => typeof d === 'string');
|
|
266
|
+
let settled: () => void = () => {};
|
|
267
|
+
const gate = new Promise<void>((r) => {
|
|
268
|
+
settled = r;
|
|
269
|
+
});
|
|
270
|
+
for (const d of docs) createsInFlight.set(d, gate);
|
|
271
|
+
const release = () => {
|
|
272
|
+
for (const d of docs) if (createsInFlight.get(d) === gate) createsInFlight.delete(d);
|
|
273
|
+
settled();
|
|
274
|
+
};
|
|
275
|
+
const fail = (err: unknown) => {
|
|
276
|
+
release();
|
|
277
|
+
for (const c of batch)
|
|
278
|
+
c.resolve({ status: 'rejected', code: (err as TransactionError)?.code ?? 'failed' });
|
|
279
|
+
};
|
|
280
|
+
void proposeCreates(batch.map((c) => c.op)).then((o) => {
|
|
281
|
+
if (o.status === 'accepted' || batch.length === 1) {
|
|
282
|
+
release();
|
|
283
|
+
for (const c of batch) c.resolve(o);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Each create is queued (and so ahead of anything waiting on the gate)
|
|
287
|
+
// before the gate opens.
|
|
288
|
+
const each = batch.map((c) => proposeCreates([c.op]));
|
|
289
|
+
release();
|
|
290
|
+
each.forEach((answer, i) => void answer.then(batch[i].resolve, () => batch[i].resolve(o)));
|
|
291
|
+
}, fail);
|
|
292
|
+
}
|
|
293
|
+
function queueCreate(op: Operation): Promise<ProposalOutcome> {
|
|
294
|
+
const bytes = Buffer.byteLength(JSON.stringify(op), 'utf8');
|
|
295
|
+
if (
|
|
296
|
+
createBatch.length &&
|
|
297
|
+
(createBatch.length >= CREATE_BATCH_MAX || createBatchBytes + bytes > CREATE_BATCH_BYTES)
|
|
298
|
+
)
|
|
299
|
+
flushCreates();
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
createBatch.push({ op, bytes, resolve });
|
|
302
|
+
createBatchBytes += bytes;
|
|
303
|
+
createTimer ??= setTimeout(flushCreates, CREATE_WINDOW_MS);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
178
307
|
function structural(
|
|
179
308
|
label: string,
|
|
180
309
|
kind: string,
|
|
181
310
|
operations: Operation[],
|
|
182
311
|
waitMs: number
|
|
183
312
|
): Promise<StructuralOutcome> {
|
|
184
|
-
const answer =
|
|
313
|
+
const answer = propose({ kind, label, operations }).then(outcome);
|
|
185
314
|
if (!Number.isFinite(waitMs)) return answer;
|
|
186
315
|
return Promise.race([
|
|
187
316
|
answer,
|
|
@@ -214,12 +343,19 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
214
343
|
): Promise<StructuralOutcome> {
|
|
215
344
|
const clean: Record<string, string> = {};
|
|
216
345
|
for (const [lane, v] of Object.entries(lanes)) if (v) clean[lane] = v;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
346
|
+
const answer = queueCreate({
|
|
347
|
+
op: 'doc.create',
|
|
348
|
+
doc: opts.docNameFor(slug),
|
|
349
|
+
path: rel,
|
|
350
|
+
lanes: clean,
|
|
351
|
+
});
|
|
352
|
+
if (!Number.isFinite(waitMs)) return answer;
|
|
353
|
+
return Promise.race([
|
|
354
|
+
answer,
|
|
355
|
+
new Promise<StructuralOutcome>((resolve) =>
|
|
356
|
+
setTimeout(() => resolve({ status: 'accepted', queued: true }), waitMs)
|
|
357
|
+
),
|
|
358
|
+
]);
|
|
223
359
|
},
|
|
224
360
|
deleteDoc(slug: string, waitMs = 8_000): Promise<StructuralOutcome> {
|
|
225
361
|
return structural(
|
|
@@ -67,7 +67,8 @@ import {
|
|
|
67
67
|
unionCommentsById,
|
|
68
68
|
} from './cold-start.ts';
|
|
69
69
|
import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
|
|
70
|
-
import { dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
|
|
70
|
+
import { commentKey, dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
|
|
71
|
+
import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
|
|
71
72
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
72
73
|
import type { SyncJournal } from './journal.ts';
|
|
73
74
|
import { rememberSeed, repairSeedDuplication } from './seed-repair.ts';
|
|
@@ -119,6 +120,20 @@ export interface CanvasSyncAgentOptions {
|
|
|
119
120
|
* without it simply degrade to the conservative conflict path.
|
|
120
121
|
*/
|
|
121
122
|
journal?: SyncJournal;
|
|
123
|
+
/**
|
|
124
|
+
* Issue #133 — comment ids synced from this machine before
|
|
125
|
+
* (sync/comment-ledger.ts). The cold-start union drops a local comment the
|
|
126
|
+
* ledger knows and the hub's doc no longer holds: that is a delete made
|
|
127
|
+
* while this machine was away, not a local-only comment. Optional — absent,
|
|
128
|
+
* the union keeps everything, as before.
|
|
129
|
+
*/
|
|
130
|
+
commentLedger?: CommentLedger;
|
|
131
|
+
/**
|
|
132
|
+
* Does the hub hold this doc's current state (nothing unacknowledged)? The
|
|
133
|
+
* ledger records comment ids as synced only then — see
|
|
134
|
+
* `commentsConfirmedOnHub` in sync/index.ts. Absent → never records.
|
|
135
|
+
*/
|
|
136
|
+
commentsConfirmed?: () => boolean;
|
|
122
137
|
/**
|
|
123
138
|
* Snapshot writer (DDR-102 conflict protocol) — persists a body version to
|
|
124
139
|
* `_history/<slug>/` and resolves with the snapshot's ISO ts (null on
|
|
@@ -339,6 +354,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
339
354
|
echoGuard.record(paths.comments, hash);
|
|
340
355
|
writer(paths.comments, serialized);
|
|
341
356
|
lastComments = serialized;
|
|
357
|
+
if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, next.map(commentKey));
|
|
342
358
|
}
|
|
343
359
|
|
|
344
360
|
function writeAnnotationsIfChanged(): void {
|
|
@@ -411,6 +427,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
411
427
|
if (parsed === null) return false;
|
|
412
428
|
const changed = applyCommentsToDoc(doc, parsed, origin);
|
|
413
429
|
if (changed) lastComments = str;
|
|
430
|
+
if (opts.commentsConfirmed?.())
|
|
431
|
+
opts.commentLedger?.record(slug, commentsFromDoc(doc).map(commentKey));
|
|
414
432
|
return changed;
|
|
415
433
|
}
|
|
416
434
|
if (evt.path === paths.annotations) {
|
|
@@ -562,7 +580,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
562
580
|
const bodyWinner = applied.bodyWinner;
|
|
563
581
|
|
|
564
582
|
// ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
|
|
565
|
-
const
|
|
583
|
+
const localParsed = localComments !== null ? tryParseJsonArray(localComments) : null;
|
|
584
|
+
// Issue #133 — a local comment that was synced from here before and that
|
|
585
|
+
// the hub's doc no longer holds was deleted while this machine was away.
|
|
586
|
+
// Unioning it back resurrected it for everyone.
|
|
587
|
+
const localParsedComments = localParsed
|
|
588
|
+
? withoutRemotelyDeleted(localParsed, docComments, opts.commentLedger?.get(slug))
|
|
589
|
+
: null;
|
|
566
590
|
if (localParsedComments !== null && localParsedComments.length > 0) {
|
|
567
591
|
const merged = unionCommentsById(docComments, localParsedComments);
|
|
568
592
|
const mergedStr = merged.length > 0 ? `${JSON.stringify(merged, null, 2)}\n` : '';
|
|
@@ -573,6 +597,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
573
597
|
writer(paths.comments, mergedStr);
|
|
574
598
|
}
|
|
575
599
|
lastComments = mergedStr;
|
|
600
|
+
if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, merged.map(commentKey));
|
|
576
601
|
} else {
|
|
577
602
|
// No (parseable) local comments — hub state materializes as before.
|
|
578
603
|
lastComments = docCommentsStr;
|
|
@@ -581,6 +606,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
581
606
|
echoGuard.record(paths.comments, hash);
|
|
582
607
|
writer(paths.comments, docCommentsStr);
|
|
583
608
|
}
|
|
609
|
+
if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, docComments.map(commentKey));
|
|
584
610
|
}
|
|
585
611
|
|
|
586
612
|
// ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix) -----
|