@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.
Files changed (47) hide show
  1. package/apps/studio/annotations-model.ts +33 -1
  2. package/apps/studio/api.ts +19 -0
  3. package/apps/studio/canvas-comment-mount.tsx +48 -3
  4. package/apps/studio/canvas-lib.tsx +5 -0
  5. package/apps/studio/canvas-shell.tsx +110 -14
  6. package/apps/studio/canvas-source-memo.ts +39 -0
  7. package/apps/studio/canvas-text-patch.ts +117 -0
  8. package/apps/studio/client/app.jsx +108 -60
  9. package/apps/studio/client/comments-overlay.css +10 -0
  10. package/apps/studio/client/file-tree.jsx +157 -0
  11. package/apps/studio/client/index.html +1 -1
  12. package/apps/studio/client/panels/DiffView.jsx +37 -8
  13. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  14. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  15. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  16. package/apps/studio/client/styles/4-components.css +4 -2
  17. package/apps/studio/collab/index.ts +9 -0
  18. package/apps/studio/collab/persistence.ts +49 -1
  19. package/apps/studio/comment-anchor.ts +116 -0
  20. package/apps/studio/comments-overlay.tsx +78 -35
  21. package/apps/studio/context.ts +1 -0
  22. package/apps/studio/cursors-overlay.tsx +181 -107
  23. package/apps/studio/dist/client.bundle.js +1086 -1086
  24. package/apps/studio/dist/comment-mount.js +2 -2
  25. package/apps/studio/dist/styles.css +1 -1
  26. package/apps/studio/hmr-broadcast.ts +59 -3
  27. package/apps/studio/http.ts +3 -0
  28. package/apps/studio/sync/accepted-cold-start.ts +95 -7
  29. package/apps/studio/sync/accepted-link.ts +165 -29
  30. package/apps/studio/sync/agent.ts +28 -2
  31. package/apps/studio/sync/comment-ledger.ts +229 -0
  32. package/apps/studio/sync/file-plane.ts +101 -2
  33. package/apps/studio/sync/index.ts +124 -17
  34. package/apps/studio/sync/journal-client.ts +9 -3
  35. package/apps/studio/sync/migrate-seed.ts +9 -2
  36. package/apps/studio/sync/projection.ts +114 -11
  37. package/apps/studio/sync/remote-docs.ts +3 -2
  38. package/apps/studio/sync/source-recovery.ts +67 -0
  39. package/apps/studio/sync/status.ts +11 -3
  40. package/apps/studio/sync/transaction-client.ts +49 -4
  41. package/apps/studio/use-collab.tsx +28 -1
  42. package/apps/studio/use-selection-set.tsx +13 -3
  43. package/apps/studio/use-undo-stack.tsx +22 -2
  44. package/apps/studio/whats-new.json +45 -0
  45. package/cli/lib/workspace-plan.mjs +10 -0
  46. package/package.json +8 -8
  47. 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
- if (isRemote(rel.replace(/\\/g, '/')) || isRemote(msg.file)) msg.remote = true;
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
  }
@@ -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
- // Comments and annotations are never re-proposed from disk here: every change
22
- // the studio made to them went through the durable outbox (drained before any
23
- // cold start), so a difference on disk is an older accepted state the room had
24
- // not re-projected — the accepted value wins, and the local file is kept in a
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: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null,
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
- verdicts.push({ lane: 'html', ...d, ...(localHtml !== null ? { local: localHtml } : {}) });
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
- verdicts.push({ lane: 'css', ...d, ...(localCss !== null ? { local: localCss } : {}) });
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
- let mode: AcceptedMode = 'unknown';
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) => client.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') mode = 'legacy';
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) log.log(`[sync/tx] the project switched its save mode: ${mode} → ${next}`);
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
- client
137
- .propose({
138
- kind: 'edit',
139
- label: LANE_LABEL[p.lane],
140
- transactionId: p.transactionId,
141
- dependsOn,
142
- operations: [
143
- {
144
- op: 'lane.replace',
145
- doc,
146
- lane: p.lane,
147
- content: p.content,
148
- baseContent: p.baseContent,
149
- ...(p.writeId ? { writeId: p.writeId } : {}),
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 = client.propose({ kind, label, operations }).then(outcome);
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
- return structural(
218
- 'Create canvas',
219
- 'canvas.create',
220
- [{ op: 'doc.create', doc: opts.docNameFor(slug), path: rel, lanes: clean }],
221
- waitMs
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 localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
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) -----