@1agh/maude 1.2.0 → 1.3.1

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 (91) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. package/plugins/flow/dependencies.json +3 -3
@@ -0,0 +1,343 @@
1
+ // AI and multi-file action boundaries — plan T16 (DDR-241).
2
+ //
3
+ // An agent turn (the in-app chat) or a `/design:edit` run changes several
4
+ // files, one tool call at a time. Proposed as they land, the project would
5
+ // accept a canvas edited against a module the agent has not written yet, and
6
+ // an agent that fails half way would leave that half published. So while an
7
+ // AI action is OPEN, the file changes it makes are STAGED here instead of
8
+ // proposed: kept on disk, remembered with the value each was derived from,
9
+ // and proposed together — one transaction, one history action — when the
10
+ // action ends well.
11
+ //
12
+ // When it does not (the agent errors, is cancelled, stops at its token limit,
13
+ // or its heartbeat goes silent) the stage is HELD: nothing is published and
14
+ // nothing is thrown away. The person decides — publish it as it stands, or
15
+ // discard it (the accepted version returns; the candidate stays in the
16
+ // recovery slots). A held stage survives a restart (`_state/ai-stage.json`),
17
+ // so a crash can never turn into a partial publish by the next cold start.
18
+ //
19
+ // What is staged is only a WATCHER change (a tool writing a file). An edit the
20
+ // person makes in the UI is theirs and is proposed at once — unless it is made
21
+ // on top of a staged file, in which case it carries the agent's unpublished
22
+ // bytes and waits behind the stage (`dependents`), exactly as U2 waits on U1.
23
+
24
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ import type { LaneProposal, ProposalOutcome } from './projection.ts';
28
+ import type { Operation, ProposalResult } from './transaction-client.ts';
29
+
30
+ export type StageState = 'open' | 'held';
31
+
32
+ export interface StagedLane {
33
+ slug: string;
34
+ doc: string;
35
+ lane: LaneProposal['lane'];
36
+ /** The value the FIRST staged change was derived from — the group's base. */
37
+ baseContent: string;
38
+ /** The latest staged value. */
39
+ content: string;
40
+ writeId?: string;
41
+ /** Every staged proposal's transaction id — aliased to the group's on commit. */
42
+ txs: string[];
43
+ resolvers: Array<(o: ProposalOutcome) => void>;
44
+ }
45
+
46
+ export interface StageSummary {
47
+ state: StageState;
48
+ label: string;
49
+ canvases: string[];
50
+ since: number;
51
+ }
52
+
53
+ interface Dependent {
54
+ send: (dependsOn: string[] | undefined) => void;
55
+ drop: (o: ProposalOutcome) => void;
56
+ dependsOn: string[];
57
+ }
58
+
59
+ export interface ActionStageOptions {
60
+ designRoot: string;
61
+ /** Send one grouped action; resolves with the final result. */
62
+ propose: (action: {
63
+ label: string;
64
+ kind: string;
65
+ operations: Operation[];
66
+ transactionId: string;
67
+ }) => Promise<ProposalResult>;
68
+ newTransactionId: () => string;
69
+ onChange?: (summary: StageSummary | null) => void;
70
+ log?: Pick<Console, 'log' | 'warn'>;
71
+ now?: () => number;
72
+ }
73
+
74
+ export function createActionStage(opts: ActionStageOptions) {
75
+ const log = opts.log ?? console;
76
+ const now = opts.now ?? Date.now;
77
+ const file = path.join(opts.designRoot, '_state', 'ai-stage.json');
78
+
79
+ let state: StageState | null = null;
80
+ let label = '';
81
+ let since = 0;
82
+ const keys = new Set<string>();
83
+ let failed = false;
84
+ const lanes = new Map<string, StagedLane>();
85
+ const dependents: Dependent[] = [];
86
+ /** A staged transaction id → the group transaction that carried it. */
87
+ const alias = new Map<string, string>();
88
+ /** Slugs a restored (held) stage covers — cold-start changes to them are staged. */
89
+ const restoredSlugs = new Set<string>();
90
+ /** Bases restored from disk after a restart: `${slug}|${lane}` → baseContent. */
91
+ const restoredBases = new Map<string, string>();
92
+
93
+ function summary(): StageSummary | null {
94
+ if (!state) return null;
95
+ const canvases = new Set<string>([...restoredSlugs]);
96
+ for (const l of lanes.values()) canvases.add(l.slug);
97
+ return { state, label, canvases: [...canvases].sort(), since };
98
+ }
99
+
100
+ function persist(): void {
101
+ try {
102
+ if (!state || (lanes.size === 0 && restoredSlugs.size === 0)) {
103
+ rmSync(file, { force: true });
104
+ return;
105
+ }
106
+ mkdirSync(path.dirname(file), { recursive: true });
107
+ const body = {
108
+ v: 1,
109
+ state,
110
+ label,
111
+ since,
112
+ lanes: [...lanes.values()].map((l) => ({
113
+ slug: l.slug,
114
+ doc: l.doc,
115
+ lane: l.lane,
116
+ baseContent: l.baseContent,
117
+ })),
118
+ restored: [...restoredBases.entries()].map(([k, baseContent]) => {
119
+ const [slug, lane] = k.split('|');
120
+ return { slug, lane, baseContent };
121
+ }),
122
+ };
123
+ writeFileSync(`${file}.tmp`, JSON.stringify(body));
124
+ renameSync(`${file}.tmp`, file);
125
+ } catch (err) {
126
+ log.warn(`[sync/ai] could not record the unfinished action: ${(err as Error).message}`);
127
+ }
128
+ }
129
+
130
+ function changed(): void {
131
+ persist();
132
+ opts.onChange?.(summary());
133
+ }
134
+
135
+ /** A previous process left a stage: it comes back HELD. */
136
+ function restore(): void {
137
+ if (!existsSync(file)) return;
138
+ try {
139
+ const body = JSON.parse(readFileSync(file, 'utf8')) as {
140
+ label?: string;
141
+ since?: number;
142
+ lanes?: { slug: string; lane: string; baseContent: string }[];
143
+ restored?: { slug: string; lane: string; baseContent: string }[];
144
+ };
145
+ const all = [...(body.lanes ?? []), ...(body.restored ?? [])];
146
+ if (all.length === 0) return;
147
+ state = 'held';
148
+ label = typeof body.label === 'string' ? body.label : 'AI edit';
149
+ since = typeof body.since === 'number' ? body.since : now();
150
+ for (const l of all) {
151
+ restoredSlugs.add(l.slug);
152
+ restoredBases.set(`${l.slug}|${l.lane}`, l.baseContent);
153
+ }
154
+ log.warn(
155
+ `[sync/ai] an unfinished AI edit (${label}) to ${restoredSlugs.size} canvas(es) was kept from the last session — it is not published until you choose.`
156
+ );
157
+ opts.onChange?.(summary());
158
+ } catch {
159
+ /* unreadable → nothing to restore */
160
+ }
161
+ }
162
+
163
+ function begin(key: string, nextLabel: string): void {
164
+ keys.add(key);
165
+ if (state === 'open') return;
166
+ if (state === 'held') {
167
+ // A new action on top of an unfinished one continues it: its files may
168
+ // already carry the held bytes. It stays one decision for the person.
169
+ state = 'open';
170
+ failed = false;
171
+ changed();
172
+ return;
173
+ }
174
+ state = 'open';
175
+ failed = false;
176
+ label = nextLabel.slice(0, 120) || 'AI edit';
177
+ since = now();
178
+ changed();
179
+ }
180
+
181
+ /** Should this proposal be staged instead of sent? */
182
+ function captures(slug: string, stageable: boolean): boolean {
183
+ if (!stageable || !state) return false;
184
+ return (
185
+ state === 'open' ||
186
+ restoredSlugs.has(slug) ||
187
+ [...lanes.values()].some((l) => l.slug === slug)
188
+ );
189
+ }
190
+
191
+ function capture(slug: string, doc: string, p: LaneProposal): Promise<ProposalOutcome> {
192
+ const k = `${slug}|${p.lane}`;
193
+ return new Promise((resolve) => {
194
+ const existing = lanes.get(k);
195
+ if (existing) {
196
+ existing.content = p.content;
197
+ existing.txs.push(p.transactionId);
198
+ existing.resolvers.push(resolve);
199
+ if (p.writeId) existing.writeId = p.writeId;
200
+ } else {
201
+ lanes.set(k, {
202
+ slug,
203
+ doc,
204
+ lane: p.lane,
205
+ baseContent: restoredBases.get(k) ?? p.baseContent,
206
+ content: p.content,
207
+ ...(p.writeId ? { writeId: p.writeId } : {}),
208
+ txs: [p.transactionId],
209
+ resolvers: [resolve],
210
+ });
211
+ changed();
212
+ }
213
+ });
214
+ }
215
+
216
+ function isStagedTx(id: string): boolean {
217
+ for (const l of lanes.values()) if (l.txs.includes(id)) return true;
218
+ return false;
219
+ }
220
+
221
+ /** A non-staged proposal authored on top of a staged one waits behind the stage. */
222
+ function holdsDependency(dependsOn: string[] | undefined): boolean {
223
+ return !!dependsOn?.some((id) => isStagedTx(id));
224
+ }
225
+
226
+ function wait(dep: Dependent): void {
227
+ dependents.push(dep);
228
+ }
229
+
230
+ /** Rewrite a dependency on a staged proposal to the group that carried it. */
231
+ function mapDeps(dependsOn: string[] | undefined): string[] | undefined {
232
+ if (!dependsOn?.length) return dependsOn;
233
+ return dependsOn.map((id) => alias.get(id) ?? id);
234
+ }
235
+
236
+ function laneOutcome(r: ProposalResult, l: StagedLane): ProposalOutcome {
237
+ const mine = r.doc === l.doc && r.lane === l.lane;
238
+ return {
239
+ status: r.status,
240
+ ...(r.code ? { code: r.code } : {}),
241
+ ...(mine && typeof r.head === 'string' ? { head: r.head } : {}),
242
+ ...(typeof r.actionId === 'string' ? { actionId: r.actionId } : {}),
243
+ };
244
+ }
245
+
246
+ function reset(): void {
247
+ state = null;
248
+ label = '';
249
+ since = 0;
250
+ keys.clear();
251
+ failed = false;
252
+ lanes.clear();
253
+ restoredSlugs.clear();
254
+ restoredBases.clear();
255
+ changed();
256
+ }
257
+
258
+ /** Propose everything staged as ONE action. */
259
+ async function publish(): Promise<ProposalResult | null> {
260
+ if (!state) return null;
261
+ const staged = [...lanes.values()];
262
+ const waiting = dependents.splice(0);
263
+ const groupLabel = label || 'AI edit';
264
+ if (staged.length === 0) {
265
+ reset();
266
+ for (const d of waiting) d.send(mapDeps(d.dependsOn));
267
+ return null;
268
+ }
269
+ const transactionId = opts.newTransactionId();
270
+ for (const l of staged) for (const tx of l.txs) alias.set(tx, transactionId);
271
+ const operations: Operation[] = staged.map((l) => ({
272
+ op: 'lane.replace',
273
+ doc: l.doc,
274
+ lane: l.lane,
275
+ content: l.content,
276
+ baseContent: l.baseContent,
277
+ ...(l.writeId ? { writeId: l.writeId } : {}),
278
+ }));
279
+ // The stage is closed before the answer: the next action starts clean,
280
+ // and the dependents go out right behind the group (same ordered outbox).
281
+ reset();
282
+ const sent = opts.propose({ label: groupLabel, kind: 'ai', operations, transactionId });
283
+ for (const d of waiting) d.send(mapDeps(d.dependsOn));
284
+ const r = await sent.catch((err: unknown) => {
285
+ const code = (err as { code?: string })?.code ?? 'retryable';
286
+ return { protocol: 1, status: 'rejected', code, transactionId } as ProposalResult;
287
+ });
288
+ for (const l of staged) for (const fn of l.resolvers) fn(laneOutcome(r, l));
289
+ log.log(
290
+ `[sync/ai] ${groupLabel}: ${staged.length} change(s) ${r.status === 'accepted' ? 'published as one action' : `refused (${r.code ?? 'rejected'})`}.`
291
+ );
292
+ return r;
293
+ }
294
+
295
+ /** End one participant. The last `done` publishes; any failure holds. */
296
+ async function end(key: string, outcome: 'done' | 'failed'): Promise<ProposalResult | null> {
297
+ if (!keys.has(key)) return null;
298
+ keys.delete(key);
299
+ if (outcome === 'failed') failed = true;
300
+ if (keys.size > 0 || state !== 'open') return null;
301
+ if (failed) {
302
+ state = 'held';
303
+ changed();
304
+ log.warn(
305
+ `[sync/ai] ${label}: the action did not finish — ${lanes.size} change(s) kept on this device, not published.`
306
+ );
307
+ return null;
308
+ }
309
+ return publish();
310
+ }
311
+
312
+ /** Throw the held changes away: every lane returns to the accepted version. */
313
+ function discard(): number {
314
+ if (state !== 'held') return 0;
315
+ const staged = [...lanes.values()];
316
+ const waiting = dependents.splice(0);
317
+ const slugs = new Set([...restoredSlugs, ...staged.map((l) => l.slug)]);
318
+ reset();
319
+ for (const l of staged)
320
+ for (const fn of l.resolvers) fn({ status: 'rejected', code: 'discarded' });
321
+ for (const d of waiting) d.drop({ status: 'rejected', code: 'discarded' });
322
+ return slugs.size;
323
+ }
324
+
325
+ return {
326
+ restore,
327
+ begin,
328
+ end,
329
+ publish,
330
+ discard,
331
+ captures,
332
+ capture,
333
+ holdsDependency,
334
+ wait,
335
+ mapDeps,
336
+ summary,
337
+ get state() {
338
+ return state;
339
+ },
340
+ };
341
+ }
342
+
343
+ export type ActionStage = ReturnType<typeof createActionStage>;
@@ -52,12 +52,10 @@ import {
52
52
  cssFromDoc,
53
53
  htmlFromDoc,
54
54
  isEmptyAnnotationsSvg,
55
- markSeeded,
56
55
  mergeSharedMetaIntoLocal,
57
56
  metaFromDoc,
58
57
  movedToFromDoc,
59
58
  repairSharedMeta,
60
- seededByFromDoc,
61
59
  stampAnnotationsEdit,
62
60
  stampBodyEdit,
63
61
  Y_SYNC_TYPES,
@@ -66,13 +64,13 @@ import {
66
64
  decideAnnotationsColdStart,
67
65
  decideColdStart,
68
66
  decideCssColdStart,
69
- isExactRepeat,
70
67
  unionCommentsById,
71
68
  } from './cold-start.ts';
72
69
  import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
73
70
  import { dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
74
71
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
75
72
  import type { SyncJournal } from './journal.ts';
73
+ import { rememberSeed, repairSeedDuplication } from './seed-repair.ts';
76
74
 
77
75
  export const DOC_FLUSH_MS = 800;
78
76
 
@@ -82,7 +80,7 @@ export const DOC_FLUSH_MS = 800;
82
80
  * the cold-start race — a genuine peer edit that arrives minutes later is NEVER
83
81
  * collapsed back to our original seed. Generous relative to the hub sync RTT.
84
82
  */
85
- export const SEED_REPAIR_WINDOW_MS = 10_000;
83
+ export { SEED_REPAIR_WINDOW_MS } from './seed-repair.ts';
86
84
 
87
85
  export interface CanvasSyncPaths {
88
86
  /** Absolute path to <designRoot>/<canvas>.html. */
@@ -187,18 +185,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
187
185
  let lastMeta: string | null = null;
188
186
  let lastCss: string | null = null;
189
187
 
190
- // F1 — the content this agent pushed on `seed-local-up`, plus the deadline
191
- // past which the de-dup repair stops firing. Set on seed; nulled only when the
192
- // window lapses (after convergence the repair is a cheap no-op — the
193
- // `=== seedInfo.x` guards short-circuit, so we don't bother clearing).
194
- // Null = this agent never seeded (so it never repairs).
195
- //
196
- // `css` rides along with the body (issue #114): a seed writes BOTH lanes in
197
- // the same breath, so both can collide with a concurrent peer's identical
198
- // seed, and repairing only the body left the canvas un-buildable anyway —
199
- // duplicated css is as fatal to the build as a duplicated `export default`.
200
- let seedInfo: { body: string; css: string | null; until: number } | null = null;
201
-
202
188
  function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
203
189
  if (stopped) return;
204
190
  // Self-applied (we just synced from disk) — disk is already current.
@@ -246,45 +232,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
246
232
  * variant is an accepted follow-up, not handled in-flight.
247
233
  */
248
234
  function maybeRepairSeedDuplication(): void {
249
- if (!seedInfo) return;
250
- if (Date.now() > seedInfo.until) {
251
- seedInfo = null;
252
- return;
253
- }
254
- const owner = seededByFromDoc(doc);
255
- if (owner === null || owner !== doc.clientID) return; // not the elected writer
256
- const seeded = seedInfo;
257
- const repaired: string[] = [];
258
-
259
- const body = htmlFromDoc(doc);
260
- // `=== seeded.body` → already a single copy; not an exact repeat → a
261
- // divergent edit, which carries genuine bytes and belongs to the conflict
262
- // path, not to a silent collapse.
263
- if (body !== seeded.body && isExactRepeat(body, seeded.body)) {
264
- const canonical = seeded.body;
265
- doc.transact(() => {
266
- if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
267
- }, origin);
268
- lastHtml = canonical;
269
- opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
270
- repaired.push('body');
235
+ const repaired = repairSeedDuplication(doc, origin);
236
+ if (repaired.includes('body')) {
237
+ lastHtml = htmlFromDoc(doc);
238
+ opts.journal?.record(slug, { bodyHash: hashBytes(lastHtml) });
271
239
  }
272
-
273
- // The css half of the same collision (issue #114). Same proof obligation as
274
- // the body — an exact integer repeat of what WE seeded — and the same
275
- // idempotence: the collapse deletes the same CRDT items on every peer that
276
- // runs it, so concurrent recoveries converge instead of fighting.
277
- if (paths.css && seeded.css !== null) {
278
- const css = cssFromDoc(doc);
279
- if (css !== null && css !== seeded.css && isExactRepeat(css, seeded.css)) {
280
- const canonicalCss = seeded.css;
281
- doc.transact(() => {
282
- applyCssToDoc(doc, canonicalCss, origin);
283
- }, origin);
284
- lastCss = canonicalCss;
285
- opts.journal?.record(slug, { cssHash: hashBytes(canonicalCss) });
286
- repaired.push('css');
287
- }
240
+ if (repaired.includes('css')) {
241
+ lastCss = cssFromDoc(doc);
242
+ if (lastCss !== null) opts.journal?.record(slug, { cssHash: hashBytes(lastCss) });
288
243
  }
289
244
 
290
245
  if (repaired.length > 0) {
@@ -518,9 +473,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
518
473
  // F1 — adopt is also a "seed local up" (first link / fresh hub); claim
519
474
  // it + arm the repair window so two peers adopting the same draft into
520
475
  // one hub at once self-heal the same way the decision-table seed does.
521
- markSeeded(doc, origin);
476
+ rememberSeed(doc, localHtml, localCss, origin);
522
477
  }, origin);
523
- seedInfo = { body: localHtml, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
524
478
  }
525
479
  if (localComments !== null) {
526
480
  const parsed = tryParseJsonArray(localComments);
@@ -575,15 +529,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
575
529
  if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
576
530
  // F1 — claim the seed (clientID marker) in the SAME update so a
577
531
  // concurrent second seeder's merge resolves a single elected writer.
578
- markSeeded(doc, origin);
532
+ rememberSeed(doc, body, localCss, origin);
579
533
  }, origin);
580
534
  lastHtml = body;
581
535
  // Arm the de-dup repair window: if another peer seeded the same content at
582
536
  // the same instant, the merged Y.Text will double — maybeRepairSeedDuplication
583
537
  // (on the elected owner) collapses it back during this window. `localCss`
584
- // is armed too: the css lane is seeded by the same cold start a few lines
585
- // below and collides identically (issue #114).
586
- seedInfo = { body, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
538
+ // is remembered too, since it collides identically (issue #114).
587
539
  opts.journal?.record(slug, { bodyHash: hashBytes(body) });
588
540
  };
589
541
 
@@ -89,6 +89,9 @@ export function startCellFileEvents(
89
89
  url: target.url,
90
90
  token: target.token,
91
91
  onPoke: (head) => healer.onPoke(head),
92
+ // The paired cell runtime does not open a second control provider. Route
93
+ // metadata invalidation to its bounded discovery queue, not the journal.
94
+ onDocuments: () => ctx.bus.emit('sync:documents-changed'),
92
95
  });
93
96
 
94
97
  // Every write this process makes surfaces here, because both synthetic-event
@@ -1,3 +1,4 @@
1
+ import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
1
2
  // Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
2
3
  //
3
4
  // The agent shuttles three classes of files between disk and the Y.Doc the
@@ -22,11 +23,11 @@
22
23
  // in the unchanged regions.
23
24
 
24
25
  import { hostname } from 'node:os';
25
-
26
+ import { diffChars } from 'diff';
26
27
  import type * as Y from 'yjs';
27
28
 
28
29
  import { Y_TYPES } from '../collab/persistence.ts';
29
- import { commentKey } from './comment-identity.ts';
30
+ import { commentKey, dedupeCommentsById } from './comment-identity.ts';
30
31
  import {
31
32
  MAX_ANNOTATIONS_BYTES,
32
33
  MAX_COMMENTS_BYTES,
@@ -97,8 +98,8 @@ export function htmlFromDoc(doc: Y.Doc): string {
97
98
  }
98
99
 
99
100
  /**
100
- * Apply `next` to one Y.Text lane as a minimal common-prefix / common-suffix
101
- * replace, so peers see a small op rather than a full replace.
101
+ * Apply `next` as bounded text hunks, preserving unchanged interior CRDT
102
+ * anchors as well as the common prefix/suffix (#121).
102
103
  *
103
104
  * THIS SHAPE IS A CONVERGENCE PROPERTY, NOT AN OPTIMIZATION (issue #114).
104
105
  * Collapsing a concurrency-duplicated lane — `X + X` back to `X` — comes out of
@@ -137,12 +138,37 @@ function applyTextLane(doc: Y.Doc, lane: string, next: string, origin?: unknown)
137
138
  suffix++;
138
139
  }
139
140
 
141
+ // Y.Text offsets are UTF-16; never leave half a surrogate outside a hunk.
142
+ if (prefix > 0 && /[\uD800-\uDBFF]/.test(current[prefix - 1] ?? '')) prefix--;
143
+ if (suffix > 0 && /[\uDC00-\uDFFF]/.test(current[current.length - suffix] ?? '')) suffix--;
140
144
  const deleteLen = current.length - prefix - suffix;
141
145
  const insertStr = next.slice(prefix, next.length - suffix);
142
146
 
147
+ // Keep pure inserts/deletes (especially repeat collapse) as one operation.
148
+ // For replacements preserve interior anchors: one prefix/suffix splice can
149
+ // move a concurrent insertion inside an unrelated JSX attribute (#121).
150
+ const changes =
151
+ deleteLen > 0 && insertStr.length > 0
152
+ ? diffChars(current.slice(prefix, current.length - suffix), insertStr, {
153
+ maxEditLength: 4096,
154
+ timeout: 50,
155
+ })
156
+ : null;
157
+ if (changes === undefined) throw new Error('Source diff exceeds the safe merge budget');
143
158
  doc.transact(() => {
144
- if (deleteLen > 0) yText.delete(prefix, deleteLen);
145
- if (insertStr.length > 0) yText.insert(prefix, insertStr);
159
+ if (changes) {
160
+ let offset = prefix;
161
+ for (const change of changes) {
162
+ if (change.removed) yText.delete(offset, change.value.length);
163
+ else {
164
+ if (change.added) yText.insert(offset, change.value);
165
+ offset += change.value.length;
166
+ }
167
+ }
168
+ } else {
169
+ if (deleteLen > 0) yText.delete(prefix, deleteLen);
170
+ if (insertStr.length > 0) yText.insert(prefix, insertStr);
171
+ }
146
172
  }, origin);
147
173
 
148
174
  return true;
@@ -306,6 +332,8 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
306
332
  if (currentStr === next) return false;
307
333
 
308
334
  doc.transact(() => {
335
+ // A filesystem import is a new operation, never the previous UI author's echo.
336
+ map.delete(ANNOTATION_WRITE_ID);
309
337
  if (next === null || next === '') {
310
338
  map.delete('svg');
311
339
  } else {
@@ -389,6 +417,45 @@ function parsesAsObject(s: string): boolean {
389
417
  }
390
418
 
391
419
  /** The synced shared-meta JSON string held in the doc, or null when unset. */
420
+ /**
421
+ * The canonical LANE value for a file's text — what the accepted-revisions
422
+ * kernel stores and what `readLaneFromDoc` returns once it is accepted
423
+ * (DDR-241). `null` when the file text cannot be a value of that lane.
424
+ */
425
+ export function laneValueFromFile(
426
+ lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments',
427
+ text: string
428
+ ): string | null {
429
+ if (lane === 'html' || lane === 'css' || lane === 'annotations') return text;
430
+ let parsed: unknown;
431
+ try {
432
+ parsed = parseJsonSafe(text);
433
+ } catch {
434
+ return null;
435
+ }
436
+ if (lane === 'meta') {
437
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
438
+ return sharedMetaCanonical(parsed as Record<string, unknown>);
439
+ }
440
+ if (!Array.isArray(parsed)) return null;
441
+ const list = dedupeCommentsById(parsed);
442
+ return list.length ? JSON.stringify(list) : '';
443
+ }
444
+
445
+ /** A lane's value as the accepted replica holds it (same canonical form). */
446
+ export function readLaneFromDoc(
447
+ doc: Y.Doc,
448
+ lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments'
449
+ ): string {
450
+ if (lane === 'html' || lane === 'css' || lane === 'meta') return doc.getText(lane).toString();
451
+ if (lane === 'annotations') {
452
+ const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
453
+ return typeof svg === 'string' ? svg : '';
454
+ }
455
+ const list = doc.getArray<unknown>(Y_TYPES.comments).toArray();
456
+ return list.length ? JSON.stringify(list) : '';
457
+ }
458
+
392
459
  export function metaFromDoc(doc: Y.Doc): string | null {
393
460
  return normalizeSharedMeta(doc.getText(Y_SYNC_TYPES.meta).toString());
394
461
  }
@@ -95,6 +95,8 @@ export interface CtlProviderOptions {
95
95
  token: string;
96
96
  /** Fired with the hub's head on every well-formed poke. */
97
97
  onPoke: (head: number) => void;
98
+ /** Optional fast document-list invalidation; older consumers use onPoke. */
99
+ onDocuments?: () => void;
98
100
  documentName?: string;
99
101
  log?: Pick<Console, 'log' | 'warn' | 'error'>;
100
102
  /** Injected in tests; production builds one from `@hocuspocus/provider`. */
@@ -155,9 +157,19 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
155
157
 
156
158
  const wire = (p: CtlProviderLike): void => {
157
159
  p.on('status', (data: { status?: string }) => {
158
- if (typeof data?.status === 'string') status = data.status;
160
+ if (typeof data?.status !== 'string') return;
161
+ const reconnect = data.status === 'connected' && status !== 'connected';
162
+ status = data.status;
163
+ if (reconnect && !stopped) {
164
+ try {
165
+ opts.onDocuments?.();
166
+ } catch (error) {
167
+ log.error?.(`[sync/ctl] discovery handler threw: ${(error as Error).message}`);
168
+ }
169
+ }
159
170
  });
160
171
  p.on('stateless', (data: { payload: string }) => {
172
+ if (stopped) return;
161
173
  const poke = parsePoke(data?.payload);
162
174
  if (poke === null) {
163
175
  malformed += 1;
@@ -172,7 +184,8 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
172
184
  }
173
185
  received += 1;
174
186
  try {
175
- opts.onPoke(poke.head);
187
+ if (poke.documents && opts.onDocuments) opts.onDocuments();
188
+ else opts.onPoke(poke.head);
176
189
  } catch (err) {
177
190
  log.error?.(`[sync/ctl] poke handler threw: ${(err as Error).message}`);
178
191
  }