@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
@@ -37,9 +37,11 @@ import {
37
37
  applyMetaToDoc,
38
38
  cssFromDoc,
39
39
  htmlFromDoc,
40
+ laneValueFromFile,
40
41
  mergeSharedMetaIntoLocal,
41
42
  metaFromDoc,
42
43
  movedToFromDoc,
44
+ readLaneFromDoc,
43
45
  stampAnnotationsEdit,
44
46
  stampBodyEdit,
45
47
  } from './codec.ts';
@@ -47,8 +49,29 @@ import { type EchoGuard, hashBytes } from './echo-guard.ts';
47
49
  import type { SyncJournal } from './journal.ts';
48
50
  import { MAX_CSS_BYTES, MAX_HTML_BYTES, MAX_META_BYTES, withinByteCap } from './limits.ts';
49
51
  import { ORIGINS } from './origins.ts';
52
+ import { collapseRepeatedModule, collapseRepeatedText } from './repeated-module.ts';
53
+ import type { RevisionBarrier } from './revision-barrier.ts';
54
+ import { repairSeedDuplication } from './seed-repair.ts';
55
+ import { mergeSource } from './source-merge.ts';
56
+ import type { SourceOp } from './source-ops.ts';
57
+ import { saveRecoveryBody } from './source-recovery.ts';
58
+ import { sourceError } from './source-validation.ts';
59
+ import { laneHash } from './transaction-client.ts';
50
60
 
51
61
  export const PROJECT_FLUSH_MS = 800;
62
+ /**
63
+ * Accepted revisions arrive as whole, validated states — not a keystroke
64
+ * stream from a peer's editor that the 800 ms debounce exists to coalesce — so
65
+ * the receiving disk (and the canvas that renders from it) follows almost at
66
+ * once.
67
+ */
68
+ export const ACCEPTED_FLUSH_MS = 30;
69
+ /** T24 — how many times one UI operation is re-applied after losing races. */
70
+ export const MAX_OP_REPLAYS = 3;
71
+ /** How long an announced API write may hold the doc→file writer at most. */
72
+ export const LOCAL_WRITE_HOLD_MS = 3_000;
73
+ /** `MAUDE_SYNC_DEBUG=1` — one line per proposal and per disk write (diagnosis). */
74
+ const SYNC_DEBUG = process.env.MAUDE_SYNC_DEBUG === '1';
52
75
  export const CIRCUIT_MAX_STRIKES = 3;
53
76
 
54
77
  export interface ProjectionPaths {
@@ -64,6 +87,13 @@ export interface ProjectionPaths {
64
87
  css?: string;
65
88
  }
66
89
 
90
+ export interface BodyRejection {
91
+ slug: string;
92
+ kind: 'body-rejected';
93
+ reason: 'invalid-source' | 'local-edit' | 'history-failed' | 'merge-budget';
94
+ snapshotFailed: boolean;
95
+ }
96
+
67
97
  export interface DocProjectionOptions {
68
98
  slug: string;
69
99
  doc: Y.Doc;
@@ -97,6 +127,77 @@ export interface DocProjectionOptions {
97
127
  /** DDR-102 — per-machine sync journal; every successful disk↔doc body/css
98
128
  * traversal checkpoints here (same discipline as the agent). Optional. */
99
129
  journal?: SyncJournal;
130
+ /** Bounded recovery slots, separate from rolling history. */
131
+ historyDir?: string;
132
+ onConflict?: (info: BodyRejection) => void;
133
+ onRecovered?: () => void;
134
+ /** Runtime waits for cold-start snapshots before allowing any projection. */
135
+ waitForReconcile?: boolean;
136
+ /**
137
+ * ACCEPTED-REVISIONS MODE (DDR-241). While `accepted.on()` holds, a local
138
+ * change is never written into the shared document: it is PROPOSED with the
139
+ * value it was derived from, and the document changes only when the hub
140
+ * publishes the accepted (possibly merged) revision. The file is the
141
+ * candidate until then; a rejection keeps it and reports a conflict.
142
+ */
143
+ accepted?: AcceptedLaneLink;
144
+ /**
145
+ * Plan T14 — the revision barrier shared by every projection of this
146
+ * runtime: a document stamped with a multi-document revision is written to
147
+ * disk together with the rest of that revision (see revision-barrier.ts).
148
+ */
149
+ revisionBarrier?: RevisionBarrier;
150
+ /** T24 — re-apply a UI operation onto the version that won (sync/source-ops). */
151
+ replayOp?: (
152
+ op: SourceOp,
153
+ head: string
154
+ ) => { ok: true; source: string } | { ok: false; reason: string };
155
+ /** T29 — this checkout now holds a document at this accepted revision. */
156
+ onRevisionApplied?: (revision: number) => void;
157
+ /** T26 — a lane value of ours was accepted as this action. */
158
+ onAccepted?: (info: { lane: ProposalLane; value: string; actionId: string }) => void;
159
+ /**
160
+ * May a write to the shared document reach the hub right now? When false a
161
+ * file change is HELD (not imported) and `onWriteBlocked` fires; the runtime
162
+ * re-delivers it via `retryDeferred()` once the connection is writable or
163
+ * the project turns out to be in accepted-revisions mode.
164
+ */
165
+ canWriteDoc?: () => boolean;
166
+ onWriteBlocked?: () => void;
167
+ }
168
+
169
+ export type ProposalLane = 'html' | 'css' | 'meta' | 'annotations' | 'comments';
170
+ export interface ProposalOutcome {
171
+ status: 'accepted' | 'rejected';
172
+ code?: string;
173
+ /** On a base conflict: the hash of the accepted value that won. */
174
+ head?: string;
175
+ /** On acceptance: the project action that carries it (T26 — Cmd+Z binds to it). */
176
+ actionId?: string;
177
+ }
178
+
179
+ export interface LaneProposal {
180
+ lane: ProposalLane;
181
+ content: string;
182
+ /** The value this edit was derived from — the hub merges three-way from it. */
183
+ baseContent: string;
184
+ writeId?: string;
185
+ transactionId: string;
186
+ /** An earlier proposal of the same lane this one was authored on top of. */
187
+ dependsOn?: string[];
188
+ /**
189
+ * T16 — a change a TOOL wrote (watcher import, or a cold-start difference):
190
+ * joins an open AI action instead of going out on its own. A change the
191
+ * person made through the UI is never stageable.
192
+ */
193
+ stageable?: boolean;
194
+ }
195
+
196
+ export interface AcceptedLaneLink {
197
+ /** Is the project in accepted-revisions mode right now? */
198
+ on(): boolean;
199
+ newTransactionId(): string;
200
+ propose(p: LaneProposal): Promise<ProposalOutcome>;
100
201
  }
101
202
 
102
203
  export interface DocProjection {
@@ -115,6 +216,58 @@ export interface DocProjection {
115
216
  reconcile(): void;
116
217
  /** Force the pending doc→file flush immediately. */
117
218
  flush(): Promise<void>;
219
+ /**
220
+ * Adopt `body` as the shared base before `reconcile()`. A restart that could
221
+ * not merge a local candidate (cold start) hands the base back here, so the
222
+ * write stays blocked with a visible conflict and a later save can merge.
223
+ */
224
+ adoptBase(body: string): void;
225
+ /**
226
+ * Accepted-revisions mode: propose one lane value that did not come through
227
+ * the watcher (a comment/annotation API write). Resolves with the outcome;
228
+ * `null` when the projection is not in accepted mode.
229
+ */
230
+ proposeLane(
231
+ lane: ProposalLane,
232
+ value: string,
233
+ opts?: { baseContent?: string; writeId?: string; stageable?: boolean }
234
+ ): Promise<ProposalOutcome> | null;
235
+ /** Lanes with an unresolved proposal (status surfaces). */
236
+ pendingCount(): number;
237
+ /**
238
+ * T28 — resolve a held source conflict by taking the project's version:
239
+ * the accepted body returns to disk; the local candidate is already in the
240
+ * recovery slots. Returns false when nothing is held.
241
+ */
242
+ takeAccepted(): boolean;
243
+ /** T28 — the two sides of a held source conflict (null when none). */
244
+ conflictSides(): { mine: string | null; theirs: string } | null;
245
+ /** Re-deliver file changes held while the document was not writable. */
246
+ retryDeferred(): void;
247
+ /**
248
+ * Accepted mode: a privileged API route is about to rewrite the canvas
249
+ * source (it announces this with `activity:suppress`). Captures the exact
250
+ * bytes that edit is based on, and holds the doc→file writer until the
251
+ * edit's own file event has been proposed — so a peer revision landing in
252
+ * the watcher's quiet window can neither overwrite the edit nor read it as
253
+ * a conflicting stale local change.
254
+ */
255
+ noteLocalWrite(): void;
256
+ /**
257
+ * T24 — the API write in flight is this UI operation (see sync/source-ops):
258
+ * if its proposal loses a race, the operation is re-applied onto the
259
+ * version that won and proposed again, instead of becoming a conflict.
260
+ */
261
+ noteSourceOp(op: SourceOp): void;
262
+ /** The announced write did not happen (no-op or failure). */
263
+ cancelLocalWrite(): void;
264
+ /**
265
+ * Accepted-revisions cold start: disk differs from the accepted value and
266
+ * nothing proves what it was derived from. Keep both — block the lane's
267
+ * writer, report the conflict, and let the next save (based on `base`, the
268
+ * accepted value shown in the conflict) resolve it.
269
+ */
270
+ hold(lane: ProposalLane, base: string, local: string): void;
118
271
  /** Stop the doc listener + timers. */
119
272
  stop(): void;
120
273
  /** Test/inspection — the origin used on file→doc imports. */
@@ -131,6 +284,66 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
131
284
  let started = false;
132
285
  let stopped = false;
133
286
  let dirty = false;
287
+ let ready = !opts.waitForReconcile;
288
+ let observedBody = readLocal(paths.html);
289
+ let rejectedKey: string | null = null;
290
+ let validationCache: { body: string; error: string | null } | null = null;
291
+
292
+ function recovered(): void {
293
+ if (rejectedKey !== null) opts.onRecovered?.();
294
+ rejectedKey = null;
295
+ }
296
+
297
+ function validation(body: string): string | null {
298
+ if (validationCache?.body === body) return validationCache.error;
299
+ const error = sourceError(paths.html, body);
300
+ validationCache = { body, error };
301
+ return error;
302
+ }
303
+
304
+ /** Persist the agreed body beside the journal checkpoint (best-effort). */
305
+ function rememberBase(body: string): void {
306
+ if (!opts.historyDir) return;
307
+ try {
308
+ saveRecoveryBody(opts.historyDir, paths.html, 'base', body);
309
+ } catch {
310
+ /* a restart then falls back to newest-wins — the pre-existing behaviour */
311
+ }
312
+ }
313
+
314
+ function preserveLocal(body: string | null): void {
315
+ if (opts.historyDir && body?.trim()) {
316
+ // An invalid local draft can still contain authored work. Keep its raw
317
+ // bytes too before accepting a valid remote replacement.
318
+ saveRecoveryBody(opts.historyDir, paths.html, 'local', body);
319
+ if (validation(body) === null)
320
+ saveRecoveryBody(opts.historyDir, paths.html, 'last-valid', body);
321
+ }
322
+ }
323
+
324
+ function reject(
325
+ reason: BodyRejection['reason'],
326
+ local: string | null,
327
+ incoming: string,
328
+ file = paths.html
329
+ ): void {
330
+ const key = `${file}:${reason}:${hashBytes(local ?? '')}:${hashBytes(incoming)}`;
331
+ if (key === rejectedKey) return;
332
+ rejectedKey = key;
333
+ let snapshotFailed = false;
334
+ try {
335
+ if (file === paths.html) preserveLocal(local);
336
+ if (opts.historyDir) {
337
+ if (local !== null) saveRecoveryBody(opts.historyDir, file, 'local', local);
338
+ saveRecoveryBody(opts.historyDir, file, 'incoming', incoming);
339
+ }
340
+ } catch {
341
+ snapshotFailed = true;
342
+ }
343
+ console.warn(`[projection/${slug}] source sync blocked (${reason}); local file kept.`);
344
+ opts.onConflict?.({ slug, kind: 'body-rejected', reason, snapshotFailed });
345
+ }
346
+
134
347
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
135
348
 
136
349
  // doc→file last-written hashes (skip redundant writes).
@@ -157,6 +370,9 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
157
370
  ) {
158
371
  return;
159
372
  }
373
+ // The accepted replica is the hub's to change — a local repair would be a
374
+ // write the fenced connection drops, leaving this replica diverged.
375
+ if (!acceptedOn()) repairSeedDuplication(doc, ORIGINS.DISK_PROJECTION);
160
376
  scheduleFlush();
161
377
  }
162
378
 
@@ -167,10 +383,13 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
167
383
  return;
168
384
  }
169
385
  if (flushTimer) clearTimeout(flushTimer);
170
- flushTimer = setTimeout(() => {
171
- flushTimer = null;
172
- void flush();
173
- }, flushMs);
386
+ flushTimer = setTimeout(
387
+ () => {
388
+ flushTimer = null;
389
+ void flush();
390
+ },
391
+ acceptedOn() ? Math.min(flushMs, ACCEPTED_FLUSH_MS) : flushMs
392
+ );
174
393
  }
175
394
 
176
395
  function recordEcho(path: string, value: string): void {
@@ -216,25 +435,84 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
216
435
 
217
436
  // ----- doc → file (html / css / meta only; room owns comments/annotations)
218
437
 
219
- function writeHtmlIfChanged(): void {
220
- const next = htmlFromDoc(doc);
221
- if (next === lastHtml) return;
438
+ function writeHtmlIfChanged(): boolean {
439
+ // A proposal for this lane is in flight: the file is our candidate and the
440
+ // accepted value is about to be republished. Writing the document's
441
+ // current value now would briefly undo the user's own edit.
442
+ if (pending.has('html') || localWriteActive()) return false;
443
+ let next = htmlFromDoc(doc);
444
+ // The same module written twice or more in a row is a merge of two
445
+ // independent seeds, never an edit (repeated-module.ts). Keep one copy —
446
+ // in the shared document too, so every peer and the hub are repaired,
447
+ // not just this disk. (Accepted revisions: the replica is the hub's; the
448
+ // import there collapses it instead.)
449
+ if (!acceptedOn()) {
450
+ const repeated = collapseRepeatedModule(next);
451
+ if (repeated) {
452
+ console.warn(
453
+ `[projection/${slug}] the shared document held this canvas ${repeated.times}× over${
454
+ repeated.partialTail ? ' (the last copy cut short)' : ''
455
+ } — keeping one copy.`
456
+ );
457
+ doc.transact(() => {
458
+ applyHtmlToDoc(doc, repeated.unit, ORIGINS.DISK_PROJECTION);
459
+ stampBodyEdit(doc, ORIGINS.DISK_PROJECTION);
460
+ }, ORIGINS.DISK_PROJECTION);
461
+ next = repeated.unit;
462
+ }
463
+ }
464
+ if (next === lastHtml) return true;
222
465
  // Don't clobber a non-empty local body with an empty doc (cold-start before
223
466
  // the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
224
467
  if (next === '') {
225
468
  lastHtml = next;
226
- return;
469
+ return true;
470
+ }
471
+ if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return false;
472
+ const local = readLocal(paths.html);
473
+ if (validation(next) !== null) {
474
+ reject('invalid-source', local, next);
475
+ return false;
476
+ }
477
+ if (local !== observedBody && local !== next) {
478
+ reject('local-edit', local, next);
479
+ return false;
480
+ }
481
+ try {
482
+ preserveLocal(local);
483
+ } catch {
484
+ reject('history-failed', local, next);
485
+ return false;
486
+ }
487
+ if (SYNC_DEBUG) {
488
+ console.log(
489
+ `[projection/${slug}] write html ${hashBytes(next).slice(0, 8)} (disk was ${local === null ? '-' : hashBytes(local).slice(0, 8)})`
490
+ );
227
491
  }
228
- if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return;
229
- recordEcho(paths.html, next);
230
492
  writeAndAnnounce(paths.html, next);
493
+ recordEcho(paths.html, next);
494
+ observedBody = next;
495
+ recovered();
231
496
  lastHtml = next;
232
497
  opts.journal?.record(slug, { bodyHash: hashBytes(next) }); // DDR-102 checkpoint
498
+ rememberBase(next);
499
+ return true;
233
500
  }
234
501
 
235
502
  function writeCssIfChanged(): void {
236
503
  if (!paths.css) return;
237
- const next = cssFromDoc(doc);
504
+ if (pending.has('css') || held.has('css')) return;
505
+ let next = cssFromDoc(doc);
506
+ if (next !== null && !acceptedOn()) {
507
+ const repeated = collapseRepeatedText(next);
508
+ if (repeated) {
509
+ console.warn(
510
+ `[projection/${slug}] the shared stylesheet held itself ${repeated.times}× over — keeping one copy.`
511
+ );
512
+ applyCssToDoc(doc, repeated.unit, ORIGINS.DISK_PROJECTION);
513
+ next = repeated.unit;
514
+ }
515
+ }
238
516
  if (next === lastCss) return;
239
517
  lastCss = next;
240
518
  if (next === null) return; // doc carries no css yet — nothing to write
@@ -242,10 +520,18 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
242
520
  recordEcho(paths.css, next);
243
521
  writeAndAnnounce(paths.css, next);
244
522
  opts.journal?.record(slug, { cssHash: hashBytes(next) }); // DDR-102 checkpoint
523
+ if (opts.historyDir) {
524
+ try {
525
+ saveRecoveryBody(opts.historyDir, paths.css, 'base', next);
526
+ } catch {
527
+ /* the journal hash still covers the common case */
528
+ }
529
+ }
245
530
  }
246
531
 
247
532
  function writeMetaIfChanged(): void {
248
533
  if (!paths.meta) return;
534
+ if (pending.has('meta') || held.has('meta')) return;
249
535
  const shared = metaFromDoc(doc);
250
536
  if (shared === lastMeta) return;
251
537
  lastMeta = shared;
@@ -258,8 +544,31 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
258
544
  writeAndAnnounce(paths.meta, merged);
259
545
  }
260
546
 
547
+ /** The newest multi-document revision this projection has been released for. */
548
+ let releasedRevision = 0;
549
+ /**
550
+ * The first stamp a projection sees is the state it started from (boot, or
551
+ * a document that arrived by pull) — already whole, never held. Only a
552
+ * revision that arrives WHILE it runs waits for its cohort.
553
+ */
554
+ let seenStamp = false;
555
+ function observeStamp(stamped: [number, number]): void {
556
+ seenStamp = true;
557
+ releasedRevision = Math.max(releasedRevision, stamped[0]);
558
+ opts.revisionBarrier?.present(stamped[0], stamped[1], slug);
559
+ }
560
+
561
+ /** `[revision, cohort]` the hub stamped on this document (cohort ≥ 1). */
562
+ function stampedCohort(): [number, number] | null {
563
+ const meta = doc.getMap('syncMeta');
564
+ const rev = meta.get('acceptedRevision');
565
+ const cohort = meta.get('acceptedCohort');
566
+ if (typeof rev !== 'number') return null;
567
+ return [rev, typeof cohort === 'number' && cohort > 1 ? cohort : 1];
568
+ }
569
+
261
570
  async function flush(): Promise<void> {
262
- if (!dirty || stopped) return;
571
+ if (!dirty || stopped || !ready) return;
263
572
  // A RETIRED document is write-inert — its canvas moved to a new path in a
264
573
  // new document, and materialising this one is how a moved canvas
265
574
  // resurrected itself at its old path (see codec stampMovedTo).
@@ -267,15 +576,30 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
267
576
  dirty = false;
268
577
  return;
269
578
  }
579
+ // T14 — part of a multi-document revision: wait for the rest of it, so
580
+ // the checkout never shows half an action.
581
+ const stamped = acceptedOn() && opts.revisionBarrier ? stampedCohort() : null;
582
+ if (stamped && !seenStamp) observeStamp(stamped);
583
+ else if (stamped && stamped[1] > 1 && stamped[0] > releasedRevision) {
584
+ const [rev, cohort] = stamped;
585
+ const held = opts.revisionBarrier?.arrive(rev, cohort, slug, () => {
586
+ releasedRevision = Math.max(releasedRevision, rev);
587
+ dirty = true;
588
+ void flush();
589
+ });
590
+ if (held) return;
591
+ }
270
592
  dirty = false;
271
593
  if (flushTimer) {
272
594
  clearTimeout(flushTimer);
273
595
  flushTimer = null;
274
596
  }
275
597
  try {
276
- writeHtmlIfChanged();
277
- writeCssIfChanged();
598
+ if (writeHtmlIfChanged()) writeCssIfChanged();
278
599
  writeMetaIfChanged();
600
+ const applied = acceptedOn() ? stampedCohort() : null;
601
+ if (applied && !held.has('html') && !pending.has('html'))
602
+ opts.onRevisionApplied?.(applied[0]);
279
603
  } catch (err) {
280
604
  dirty = true;
281
605
  console.error(`[projection/${slug}] flush failed:`, err);
@@ -307,6 +631,308 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
307
631
  quarantine.delete(path);
308
632
  }
309
633
 
634
+ // ----- accepted-revisions mode (DDR-241)
635
+
636
+ const acceptedOn = (): boolean => !!opts.accepted?.on();
637
+ /** Per lane: proposals not yet answered, and the newest one's id + value. */
638
+ const pending = new Map<ProposalLane, { count: number; lastTx: string; lastValue: string }>();
639
+ /** Lanes whose last answer was a rejection — the local file is the held candidate. */
640
+ const held = new Set<ProposalLane>();
641
+ /** Accepted mode: file events that arrived before the cold start decided. */
642
+ const deferredBeforeReady = new Map<string, string>();
643
+ /** Legacy mode: file events held while the connection was not writable. */
644
+ const heldWhileReadOnly = new Map<string, string>();
645
+ /** Accepted mode: an API source write announced but not yet seen by the watcher. */
646
+ let localWrite: { base: string | null; at: number; op?: SourceOp } | null = null;
647
+ let localWriteTimer: ReturnType<typeof setTimeout> | null = null;
648
+ const localWriteActive = (): boolean => {
649
+ if (!localWrite) return false;
650
+ if (Date.now() - localWrite.at < LOCAL_WRITE_HOLD_MS) return true;
651
+ localWrite = null;
652
+ return false;
653
+ };
654
+
655
+ const REJECTION_REASON: Record<string, BodyRejection['reason']> = {
656
+ 'source-invalid': 'invalid-source',
657
+ capacity: 'merge-budget',
658
+ // The change could not even be saved to this machine's outbox.
659
+ 'local-persistence': 'history-failed',
660
+ };
661
+
662
+ function laneOfPath(p: string): ProposalLane | null {
663
+ if (p === paths.html) return 'html';
664
+ if (paths.css && p === paths.css) return 'css';
665
+ if (paths.meta && p === paths.meta) return 'meta';
666
+ if (p === paths.comments) return 'comments';
667
+ if (p === paths.annotations) return 'annotations';
668
+ return null;
669
+ }
670
+
671
+ function pathOfLane(lane: ProposalLane): string {
672
+ if (lane === 'html') return paths.html;
673
+ if (lane === 'css') return paths.css ?? paths.html;
674
+ if (lane === 'meta') return paths.meta ?? paths.html;
675
+ return lane === 'comments' ? paths.comments : paths.annotations;
676
+ }
677
+
678
+ /** The value disk and the accepted replica last agreed on for `lane`. */
679
+ function agreedValue(lane: ProposalLane): string {
680
+ if (lane === 'html') return lastHtml ?? htmlFromDoc(doc);
681
+ if (lane === 'css') return lastCss ?? cssFromDoc(doc) ?? '';
682
+ return readLaneFromDoc(doc, lane);
683
+ }
684
+
685
+ function onRejected(lane: ProposalLane, local: string, outcome: ProposalOutcome): void {
686
+ held.add(lane);
687
+ // The candidate stays on disk: the projection's local-edit guard protects
688
+ // it (html), `held` blocks the lane writer (css/meta), and the recovery
689
+ // slots keep the bytes whatever happens next.
690
+ //
691
+ // The NEXT save is the resolution, and it is made looking at the version
692
+ // the conflict reports as incoming — so that version becomes its base.
693
+ // Keeping the old base would make every resolution that touches the same
694
+ // region conflict again, forever (there is no way out but typing the
695
+ // other side's bytes back exactly).
696
+ const incoming = readLaneFromDoc(doc, lane);
697
+ if (lane === 'html') {
698
+ observedBody = null;
699
+ lastHtml = incoming;
700
+ } else if (lane === 'css') {
701
+ lastCss = incoming;
702
+ }
703
+ reject(
704
+ REJECTION_REASON[outcome.code ?? ''] ?? 'local-edit',
705
+ local,
706
+ readLaneFromDoc(doc, lane),
707
+ pathOfLane(lane)
708
+ );
709
+ }
710
+
711
+ /**
712
+ * Send one lane proposal. A proposal made while an earlier one of the same
713
+ * lane is unanswered was authored ON TOP of it: it is based on that value and
714
+ * DEPENDS on it, so a rejected U1 can never be bypassed by an accepted U2
715
+ * that silently carries half of it (plan T7/T13).
716
+ */
717
+ function submit(
718
+ lane: ProposalLane,
719
+ value: string,
720
+ baseContent: string,
721
+ local: string,
722
+ writeId?: string,
723
+ stageable = false,
724
+ op?: SourceOp,
725
+ replays = 0
726
+ ): Promise<ProposalOutcome> {
727
+ const link = opts.accepted as AcceptedLaneLink;
728
+ const prior = pending.get(lane);
729
+ const transactionId = link.newTransactionId();
730
+ if (SYNC_DEBUG) {
731
+ console.log(
732
+ `[projection/${slug}] propose ${lane} tx=${transactionId.slice(0, 11)} value=${hashBytes(value).slice(0, 8)} base=${hashBytes(baseContent).slice(0, 8)} doc=${hashBytes(readLaneFromDoc(doc, lane)).slice(0, 8)} last=${lastHtml === null ? '-' : hashBytes(lastHtml).slice(0, 8)} via=${new Error().stack?.split('\n')[3]?.trim().slice(0, 60)}`
733
+ );
734
+ }
735
+ pending.set(lane, {
736
+ count: (prior?.count ?? 0) + 1,
737
+ lastTx: transactionId,
738
+ lastValue: value,
739
+ });
740
+ const settle = async (outcome: ProposalOutcome): Promise<ProposalOutcome> => {
741
+ // A rejection can outrun the publication of the version that beat it
742
+ // (HTTP answer vs. WebSocket update). The conflict must report — and the
743
+ // resolution be based on — THAT version, so wait briefly for it.
744
+ if (outcome.status === 'rejected' && outcome.head) await untilLaneHash(lane, outcome.head);
745
+ const p = pending.get(lane);
746
+ if (p) {
747
+ p.count -= 1;
748
+ if (p.count <= 0) pending.delete(lane);
749
+ }
750
+ // T24 — a UI operation that lost a race is re-applied onto the version
751
+ // that won (same property: the later acceptance wins; everything else
752
+ // of both people survives) and proposed again, instead of a conflict.
753
+ if (
754
+ outcome.status === 'rejected' &&
755
+ outcome.code === 'base-conflict' &&
756
+ lane === 'html' &&
757
+ op &&
758
+ opts.replayOp &&
759
+ replays < MAX_OP_REPLAYS &&
760
+ !pending.has(lane) &&
761
+ readLocal(paths.html) === local
762
+ ) {
763
+ const head = readLaneFromDoc(doc, 'html');
764
+ const r = opts.replayOp(op, head);
765
+ if (r.ok && r.source !== head && validation(r.source) === null) {
766
+ if (SYNC_DEBUG) console.log(`[projection/${slug}] replay ${op.kind} onto the winner`);
767
+ observedBody = r.source;
768
+ recordEcho(paths.html, r.source);
769
+ writeAndAnnounce(paths.html, r.source);
770
+ return submit('html', r.source, head, r.source, undefined, false, op, replays + 1);
771
+ }
772
+ }
773
+ if (outcome.status === 'accepted') {
774
+ if (!pending.has(lane)) held.delete(lane);
775
+ if (lane === 'html') recovered();
776
+ if (outcome.actionId) {
777
+ try {
778
+ opts.onAccepted?.({ lane, value, actionId: outcome.actionId });
779
+ } catch {
780
+ /* bookkeeping only */
781
+ }
782
+ }
783
+ } else if (outcome.code === 'discarded') {
784
+ // T16 — the person discarded an unfinished AI edit: the accepted
785
+ // version returns to disk (the candidate is in the recovery slots).
786
+ held.delete(lane);
787
+ if (lane === 'html') {
788
+ observedBody = readLocal(paths.html);
789
+ lastHtml = null;
790
+ } else if (lane === 'css') {
791
+ lastCss = null;
792
+ } else if (lane === 'meta') {
793
+ lastMeta = null;
794
+ }
795
+ } else {
796
+ onRejected(lane, local, outcome);
797
+ }
798
+ scheduleFlush();
799
+ return outcome;
800
+ };
801
+ return link
802
+ .propose({
803
+ lane,
804
+ content: value,
805
+ baseContent,
806
+ transactionId,
807
+ ...(prior ? { dependsOn: [prior.lastTx] } : {}),
808
+ ...(writeId ? { writeId } : {}),
809
+ ...(stageable ? { stageable: true } : {}),
810
+ })
811
+ .then(settle, (err: unknown) => {
812
+ const code = (err as { code?: unknown })?.code;
813
+ if (code === 'stopped') {
814
+ // The runtime is going away with this proposal unanswered. It is
815
+ // still in the durable outbox and the next runtime resends it — so
816
+ // it is neither a failure nor a rejection, and nothing is reported.
817
+ const p = pending.get(lane);
818
+ if (p) {
819
+ p.count -= 1;
820
+ if (p.count <= 0) pending.delete(lane);
821
+ }
822
+ return { status: 'rejected', code: 'stopped' } as ProposalOutcome;
823
+ }
824
+ console.error(`[projection/${slug}] proposing ${lane} failed:`, err);
825
+ return settle({
826
+ status: 'rejected',
827
+ code: typeof code === 'string' ? code : 'client-error',
828
+ });
829
+ });
830
+ }
831
+
832
+ function untilLaneHash(lane: ProposalLane, head: string, ms = 3_000): Promise<void> {
833
+ const matches = () => laneHash(readLaneFromDoc(doc, lane)) === head;
834
+ if (matches()) return Promise.resolve();
835
+ return new Promise((resolve) => {
836
+ const done = () => {
837
+ clearTimeout(timer);
838
+ doc.off('update', onUpdate);
839
+ resolve();
840
+ };
841
+ const onUpdate = () => {
842
+ if (matches()) done();
843
+ };
844
+ const timer = setTimeout(done, ms);
845
+ doc.on('update', onUpdate);
846
+ });
847
+ }
848
+
849
+ /** Accepted-revisions import: propose the file's lane value; never touch the doc. */
850
+ function proposeFromFs(evt: { path: string; hash: string }, str: string): boolean {
851
+ const lane = laneOfPath(evt.path);
852
+ if (!lane) return false;
853
+ if (SYNC_DEBUG && lane === 'html') {
854
+ console.log(
855
+ `[projection/${slug}] fs event html ${hashBytes(str).slice(0, 8)} evt=${evt.hash.slice(0, 8)} doc=${hashBytes(htmlFromDoc(doc)).slice(0, 8)} last=${lastHtml === null ? '-' : hashBytes(lastHtml).slice(0, 8)} held=${held.has('html')} pending=${pending.has('html')}`
856
+ );
857
+ }
858
+ // COMMENTS AND ANNOTATIONS ARE NEVER PROPOSED FROM A FILE EVENT. Their
859
+ // files have a second writer — the collab room projects the accepted
860
+ // document onto them — so a file change here is as likely the room
861
+ // writing a value the document has since moved past as it is an edit.
862
+ // Proposing that with the current head as its base REPLACES the head:
863
+ // a deleted shape resurrected by a stale projection (surface run
864
+ // 2026-09-15, L09 delete). Every edit to these lanes arrives through the
865
+ // API with the base it was made from (`proposeLane`).
866
+ if (lane === 'comments' || lane === 'annotations') return false;
867
+ if (lane === 'html') {
868
+ if (str === lastHtml && !held.has('html')) return false; // a redelivered projection
869
+ if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
870
+ if (validation(str) !== null) {
871
+ strike(evt.path, evt.hash);
872
+ reject('invalid-source', str, htmlFromDoc(doc));
873
+ return false;
874
+ }
875
+ } else if (lane === 'css' && str === lastCss && !held.has('css')) {
876
+ return false;
877
+ }
878
+ const value = laneValueFromFile(lane, str);
879
+ if (value === null) {
880
+ strike(evt.path, evt.hash);
881
+ return false;
882
+ }
883
+ clearStrike(evt.path);
884
+ const inFlight = pending.get(lane);
885
+ // The watcher redelivering what we already proposed is not a new edit.
886
+ if (inFlight?.lastValue === value) return false;
887
+ const current = readLaneFromDoc(doc, lane);
888
+ if (value === current) {
889
+ if (inFlight) return false;
890
+ held.delete(lane);
891
+ if (lane === 'html') {
892
+ observedBody = str;
893
+ lastHtml = str;
894
+ recovered();
895
+ } else if (lane === 'css') {
896
+ lastCss = str;
897
+ }
898
+ return false;
899
+ }
900
+ // NOT "skip a value proposed before": the reader reads the file as it is
901
+ // NOW, so an event carrying an earlier proposal's bytes after that
902
+ // proposal was answered is the user going BACK to them (A→B→A, every
903
+ // undo/redo) — skipping it turned a real edit into a false conflict
904
+ // (surface run 2026-09-15, L18).
905
+ if (lane === 'html') {
906
+ try {
907
+ preserveLocal(str);
908
+ } catch {
909
+ reject('history-failed', str, current);
910
+ return false;
911
+ }
912
+ observedBody = str;
913
+ }
914
+ // An API edit announced its base (noteLocalWrite): that is exactly what
915
+ // it was derived from — better than the last agreed body, which a peer
916
+ // revision may have advanced inside the watcher's quiet window.
917
+ const apiBase = lane === 'html' ? (localWrite?.base ?? null) : null;
918
+ // T16 — a change the person made through the UI announced itself; any
919
+ // other file change is a tool's, and may belong to an open AI action.
920
+ const fromUi = localWrite !== null;
921
+ // T24 — the UI operation this write was, when it said.
922
+ const op = lane === 'html' ? localWrite?.op : undefined;
923
+ if (lane === 'html') localWrite = null;
924
+ void submit(
925
+ lane,
926
+ value,
927
+ inFlight ? inFlight.lastValue : (apiBase ?? agreedValue(lane)),
928
+ str,
929
+ undefined,
930
+ !fromUi,
931
+ op
932
+ );
933
+ return true;
934
+ }
935
+
310
936
  function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
311
937
  if (stopped) return false;
312
938
  // Write-inert both ways — a local edit to a stale pre-move file must not
@@ -318,25 +944,100 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
318
944
  if (isQuarantined(evt.path, evt.hash)) return false;
319
945
 
320
946
  const str = bytesToString(evt.bytes);
947
+ if (acceptedOn()) {
948
+ // Before the cold start has decided, a proposal would race it (and a
949
+ // brand-new canvas is not in the project yet). Remember the path; the
950
+ // file is re-read once the decision is made.
951
+ if (!ready) {
952
+ deferredBeforeReady.set(evt.path, evt.hash);
953
+ return false;
954
+ }
955
+ return proposeFromFs(evt, str);
956
+ }
957
+ // Legacy import: a write the hub would drop must not be made at all.
958
+ if (opts.canWriteDoc && !opts.canWriteDoc()) {
959
+ heldWhileReadOnly.set(evt.path, evt.hash);
960
+ opts.onWriteBlocked?.();
961
+ return false;
962
+ }
321
963
 
322
964
  if (evt.path === paths.html) {
965
+ // Watchers can deliver the same projection more than once, including
966
+ // after the one-shot echo token was consumed or expired. These bytes
967
+ // contain no new local edit; diffing them against a newer remote body
968
+ // would turn a delayed notification into a rollback of peer work.
969
+ // Compare only the current baseline, so a deliberate A → B → A edit
970
+ // still imports after B advances lastHtml.
971
+ if (str === lastHtml) return false;
972
+ if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
973
+ if (validation(str) !== null) {
974
+ strike(evt.path, evt.hash);
975
+ reject('invalid-source', str, htmlFromDoc(doc));
976
+ return false;
977
+ }
323
978
  clearStrike(evt.path);
979
+ // A PEER CHANGED THE DOC SINCE DISK AND DOC LAST AGREED (`lastHtml`), and
980
+ // this save was authored against that older body. Importing it as a
981
+ // whole-file diff against the doc turned every stale byte into an edit
982
+ // and reverted the peer's work (audit 2026-09-13 P0 #1). Merge from the
983
+ // shared base instead; what the merge cannot prove independent — or a
984
+ // merged body that no longer validates — is preserved and blocked, and
985
+ // the conflict stays until a later save actually resolves it.
986
+ const current = htmlFromDoc(doc);
987
+ let next = str;
988
+ if (lastHtml !== null && current !== lastHtml && current !== str) {
989
+ const merged = mergeSource(lastHtml, str, current);
990
+ if (!merged.ok) {
991
+ reject(merged.reason === 'budget' ? 'merge-budget' : 'local-edit', str, current);
992
+ return false;
993
+ }
994
+ if (validation(merged.merged) !== null) {
995
+ reject('local-edit', str, current);
996
+ return false;
997
+ }
998
+ next = merged.merged;
999
+ }
1000
+ try {
1001
+ preserveLocal(str);
1002
+ } catch {
1003
+ reject('history-failed', str, htmlFromDoc(doc));
1004
+ return false;
1005
+ }
324
1006
  // Body import + syncMeta stamp in ONE transaction (same FILE_IMPORT
325
1007
  // origin) — peers get a single update carrying the newest-wins stamp.
326
1008
  let changed = false;
327
- doc.transact(() => {
328
- changed = applyHtmlToDoc(doc, str, importOrigin);
329
- if (changed) stampBodyEdit(doc, importOrigin);
330
- }, importOrigin);
331
- if (changed) {
332
- lastHtml = htmlFromDoc(doc);
1009
+ try {
1010
+ doc.transact(() => {
1011
+ changed = applyHtmlToDoc(doc, next, importOrigin);
1012
+ if (changed) stampBodyEdit(doc, importOrigin);
1013
+ }, importOrigin);
1014
+ } catch {
1015
+ reject('merge-budget', str, htmlFromDoc(doc));
1016
+ return false;
1017
+ }
1018
+ observedBody = str;
1019
+ lastHtml = str;
1020
+ recovered();
1021
+ if (htmlFromDoc(doc) !== str) {
1022
+ // A synchronous peer update can land during the import transaction.
1023
+ // Only bytes actually on disk count as the projection's baseline.
1024
+ scheduleFlush();
1025
+ } else if (changed) {
333
1026
  opts.journal?.record(slug, { bodyHash: evt.hash }); // DDR-102 checkpoint
1027
+ rememberBase(str);
334
1028
  }
335
1029
  return changed;
336
1030
  }
337
1031
  if (paths.css && evt.path === paths.css) {
1032
+ let changed: boolean;
1033
+ try {
1034
+ changed = applyCssToDoc(doc, str, importOrigin);
1035
+ } catch {
1036
+ strike(evt.path, evt.hash);
1037
+ reject('merge-budget', str, cssFromDoc(doc) ?? '', paths.css);
1038
+ return false;
1039
+ }
338
1040
  clearStrike(evt.path);
339
- const changed = applyCssToDoc(doc, str, importOrigin);
340
1041
  if (changed) {
341
1042
  lastCss = str;
342
1043
  opts.journal?.record(slug, { cssHash: evt.hash }); // DDR-102 checkpoint
@@ -383,13 +1084,25 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
383
1084
  if (stopped) return;
384
1085
  // A retired doc materialises NOTHING (see codec stampMovedTo).
385
1086
  if (movedToFromDoc(doc) !== null) return;
1087
+ ready = true;
1088
+ if (deferredBeforeReady.size && acceptedOn()) {
1089
+ const owed = [...deferredBeforeReady];
1090
+ deferredBeforeReady.clear();
1091
+ for (const [p, hash] of owed) {
1092
+ const text = readLocal(p);
1093
+ if (text !== null) proposeFromFs({ path: p, hash }, text);
1094
+ }
1095
+ }
386
1096
  // Materialize the converged doc to disk (html/css/meta). The *IfChanged
387
1097
  // writers already guard against clobbering non-empty local with empty doc
388
1098
  // values, so this is safe to run at cold start before the authoritative
389
1099
  // seed (Phase E) — it only writes what the doc actually holds.
390
- writeHtmlIfChanged();
391
- writeCssIfChanged();
1100
+ if (writeHtmlIfChanged()) writeCssIfChanged();
392
1101
  writeMetaIfChanged();
1102
+ // A document that arrives by pull as part of a multi-document revision is
1103
+ // on disk now: the rest of its revision may show.
1104
+ const stamped = acceptedOn() ? stampedCohort() : null;
1105
+ if (stamped && !seenStamp) observeStamp(stamped);
393
1106
  }
394
1107
 
395
1108
  return {
@@ -397,14 +1110,98 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
397
1110
  importOrigin,
398
1111
  start() {
399
1112
  if (started) return;
1113
+ // Snapshot before a provider can replace disk. Failure remains fail-closed
1114
+ // at the write boundary and is surfaced when a write is attempted.
1115
+ try {
1116
+ preserveLocal(observedBody);
1117
+ } catch {
1118
+ /* checked before writes */
1119
+ }
400
1120
  doc.on('update', onDocUpdate);
401
1121
  started = true;
402
1122
  },
403
1123
  applyFromFs,
404
1124
  reconcile,
405
1125
  flush,
1126
+ adoptBase(body: string) {
1127
+ lastHtml = body;
1128
+ observedBody = body;
1129
+ },
1130
+ hold(lane, base, local) {
1131
+ held.add(lane);
1132
+ if (lane === 'html') {
1133
+ lastHtml = base;
1134
+ observedBody = null;
1135
+ } else if (lane === 'css') {
1136
+ lastCss = base;
1137
+ }
1138
+ reject('local-edit', local, readLaneFromDoc(doc, lane), pathOfLane(lane));
1139
+ },
1140
+ takeAccepted() {
1141
+ if (!held.has('html') && rejectedKey === null) return false;
1142
+ held.delete('html');
1143
+ // The local-edit guard compares disk with `observedBody`; declaring the
1144
+ // current disk bytes observed lets the writer replace them.
1145
+ observedBody = readLocal(paths.html);
1146
+ lastHtml = null;
1147
+ dirty = true;
1148
+ void flush();
1149
+ recovered();
1150
+ return true;
1151
+ },
1152
+ conflictSides() {
1153
+ if (!held.has('html') && rejectedKey === null) return null;
1154
+ return { mine: readLocal(paths.html), theirs: htmlFromDoc(doc) };
1155
+ },
1156
+ proposeLane(lane, value, o) {
1157
+ if (!acceptedOn() || stopped) return null;
1158
+ if (lane === 'html') observedBody = value;
1159
+ const inFlight = pending.get(lane);
1160
+ if (inFlight?.lastValue === value) return Promise.resolve({ status: 'accepted' });
1161
+ if (!inFlight && value === readLaneFromDoc(doc, lane)) {
1162
+ held.delete(lane);
1163
+ return Promise.resolve({ status: 'accepted' });
1164
+ }
1165
+ const base = o?.baseContent ?? (inFlight ? inFlight.lastValue : agreedValue(lane));
1166
+ return submit(lane, value, base, value, o?.writeId, o?.stageable === true);
1167
+ },
1168
+ noteSourceOp(op) {
1169
+ if (localWrite) localWrite.op = op;
1170
+ },
1171
+ noteLocalWrite() {
1172
+ if (!acceptedOn() || stopped) return;
1173
+ localWrite = { base: readLocal(paths.html), at: Date.now() };
1174
+ if (localWriteTimer) clearTimeout(localWriteTimer);
1175
+ // The watcher event normally clears this long before; if it never comes
1176
+ // (a write that changed nothing), the writer resumes on its own.
1177
+ localWriteTimer = setTimeout(() => {
1178
+ localWriteTimer = null;
1179
+ scheduleFlush();
1180
+ }, LOCAL_WRITE_HOLD_MS + 10);
1181
+ localWriteTimer.unref?.();
1182
+ },
1183
+ cancelLocalWrite() {
1184
+ localWrite = null;
1185
+ scheduleFlush();
1186
+ },
1187
+ retryDeferred() {
1188
+ if (stopped || heldWhileReadOnly.size === 0) return;
1189
+ if (!acceptedOn() && opts.canWriteDoc && !opts.canWriteDoc()) return;
1190
+ const owed = [...heldWhileReadOnly];
1191
+ heldWhileReadOnly.clear();
1192
+ for (const [p, hash] of owed) {
1193
+ const text = readLocal(p);
1194
+ if (text !== null) applyFromFs({ path: p, bytes: new TextEncoder().encode(text), hash });
1195
+ }
1196
+ },
1197
+ pendingCount() {
1198
+ let n = 0;
1199
+ for (const p of pending.values()) n += p.count;
1200
+ return n;
1201
+ },
406
1202
  stop() {
407
1203
  stopped = true;
1204
+ if (localWriteTimer) clearTimeout(localWriteTimer);
408
1205
  doc.off('update', onDocUpdate);
409
1206
  if (flushTimer) {
410
1207
  clearTimeout(flushTimer);