@1agh/maude 1.4.3 → 1.4.5

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 (34) hide show
  1. package/apps/studio/api.ts +4 -0
  2. package/apps/studio/canvas-comment-mount.tsx +13 -1
  3. package/apps/studio/canvas-shell.tsx +62 -13
  4. package/apps/studio/canvas-source-memo.ts +39 -0
  5. package/apps/studio/canvas-text-patch.ts +117 -0
  6. package/apps/studio/client/app.jsx +105 -60
  7. package/apps/studio/client/file-tree.jsx +157 -0
  8. package/apps/studio/client/index.html +1 -1
  9. package/apps/studio/client/panels/DiffView.jsx +37 -8
  10. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  11. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  12. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  13. package/apps/studio/client/styles/4-components.css +4 -2
  14. package/apps/studio/dist/client.bundle.js +1086 -1086
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/hmr-broadcast.ts +59 -3
  18. package/apps/studio/http.ts +3 -0
  19. package/apps/studio/sync/accepted-cold-start.ts +35 -3
  20. package/apps/studio/sync/accepted-link.ts +165 -29
  21. package/apps/studio/sync/file-plane.ts +24 -1
  22. package/apps/studio/sync/index.ts +87 -17
  23. package/apps/studio/sync/journal-client.ts +9 -3
  24. package/apps/studio/sync/projection.ts +114 -11
  25. package/apps/studio/sync/remote-docs.ts +3 -2
  26. package/apps/studio/sync/source-recovery.ts +67 -0
  27. package/apps/studio/sync/status.ts +11 -3
  28. package/apps/studio/sync/transaction-client.ts +49 -4
  29. package/apps/studio/use-selection-set.tsx +9 -3
  30. package/apps/studio/use-undo-stack.tsx +22 -2
  31. package/apps/studio/whats-new.json +9 -0
  32. package/cli/lib/workspace-plan.mjs +10 -0
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +21 -0
@@ -119,6 +119,14 @@ export interface SyncProvider {
119
119
  * Optional: a provider without it is treated as writable.
120
120
  */
121
121
  isWritable?(): boolean;
122
+ /**
123
+ * The hub's own word on this connection's write right, carried by a save-mode
124
+ * notice (`maude.mode` with `writable`). It outranks the scope the handshake
125
+ * returned: a connection admitted read-only while the project took proposals
126
+ * is writable again the moment the project returns to legacy — the hub fences
127
+ * per message, not per handshake — and nothing re-authenticates it.
128
+ */
129
+ noteWritable?(writable: boolean): void;
122
130
  /** The hub authenticated this connection (again) — scope may have changed. */
123
131
  onAuthenticated?(cb: (scope: string) => void): () => void;
124
132
  /** Out-of-band messages from the hub on this document's socket. */
@@ -915,6 +923,8 @@ export function createSyncRuntime(
915
923
  // change is a proposal through the durable outbox, and the documents change
916
924
  // when the project publishes the accepted revision. Shared-doc only: the
917
925
  // two-doc agent path has no proposal lane and stays legacy.
926
+ /** Resolves once a previous run's outbox is drained: doc → own accepted html. */
927
+ let outboxDrained: Promise<Map<string, string>> = Promise.resolve(new Map());
918
928
  const acceptedLink: AcceptedLink | null = useSharedDoc
919
929
  ? createAcceptedLink({
920
930
  hubUrl: linkedHub.url,
@@ -925,7 +935,16 @@ export function createSyncRuntime(
925
935
  retryMs: opts.transactionRetryMs,
926
936
  onStats: (stats) => statusStore?.updateAccepted?.(stats),
927
937
  onStage: (summary) => statusStore?.updateAiAction?.(summary),
928
- onBootstrap: (b) => noteProjectConfig(b.projectConfig),
938
+ onBootstrap: (b) => {
939
+ noteProjectConfig(b.projectConfig);
940
+ // F3 S17 — a save made as the socket died is held (the connection
941
+ // was not writable). After the reconnect the handshake re-admits the
942
+ // socket read-only BEFORE this peer learns the project now takes
943
+ // proposals, so its retry found the write still blocked and nothing
944
+ // retried again: the save stayed on disk, and the status said
945
+ // synced, until a restart. Knowing the mode, hand it back now.
946
+ if (b.mode === 'transactions') for (const p of projections.values()) p.retryDeferred();
947
+ },
929
948
  })
930
949
  : null;
931
950
  /**
@@ -2038,16 +2057,15 @@ export function createSyncRuntime(
2038
2057
  ): Awaited<ReturnType<typeof fetchRemoteListing>> {
2039
2058
  const manifest = acceptedOn() ? acceptedLink?.manifest : null;
2040
2059
  if (!manifest) return listing;
2041
- // A document the project RETIRED (moved away) is not a canvas to fetch,
2042
- // even while its storage row lingers — its successor is in the manifest.
2043
- const retired = new Set(manifest.docs.filter((d) => d.retired).map((d) => d.doc));
2044
- const documents = (listing?.documents ?? []).filter((d) => !retired.has(d.name));
2045
- const names = new Set(documents.map((d) => d.name));
2046
- for (const d of manifest.docs) {
2047
- if (d.retired || names.has(d.doc)) continue;
2048
- documents.push({ name: d.doc, bytes: 1 });
2049
- names.add(d.doc);
2050
- }
2060
+ // An open Yjs room is not necessarily an accepted canvas. Its author can
2061
+ // connect before doc.create commits; pulling that empty room would lock
2062
+ // the receiver onto a lossy slug-derived path before the real path arrives.
2063
+ // The accepted manifest alone names live canvases, including successors
2064
+ // of retired documents whose transport rows may still linger.
2065
+ const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
2066
+ const documents = manifest.docs
2067
+ .filter((d) => !d.retired)
2068
+ .map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
2051
2069
  return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
2052
2070
  }
2053
2071
 
@@ -2248,10 +2266,29 @@ export function createSyncRuntime(
2248
2266
  );
2249
2267
  // Work a previous run left unanswered goes first, in creation order —
2250
2268
  // before any cold start can propose something built on top of it.
2251
- void acceptedLink.client.drainOutbox().then((results) => {
2252
- if (results.length)
2253
- console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
2254
- });
2269
+ // What each canvas's disk was last saved as, when that save was one of
2270
+ // these: the base its cold start judges the disk against.
2271
+ const own = new Map<string, string>();
2272
+ outboxDrained = acceptedLink.client
2273
+ .drainOutbox((result, operations) => {
2274
+ for (const o of operations) {
2275
+ const html =
2276
+ o.op === 'lane.replace' && o.lane === 'html'
2277
+ ? o.content
2278
+ : o.op === 'doc.create'
2279
+ ? (o.lanes as Record<string, unknown> | undefined)?.html
2280
+ : undefined;
2281
+ if (typeof o.doc !== 'string' || typeof html !== 'string') continue;
2282
+ if (result.status === 'accepted') own.set(o.doc, html);
2283
+ else own.delete(o.doc);
2284
+ }
2285
+ })
2286
+ .then((results) => {
2287
+ if (results.length)
2288
+ console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
2289
+ return own;
2290
+ })
2291
+ .catch(() => own);
2255
2292
  applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
2256
2293
  proposeLocalFolders();
2257
2294
  }
@@ -2585,6 +2622,21 @@ export function createSyncRuntime(
2585
2622
  if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
2586
2623
  });
2587
2624
  activityUnsubs.push(unsubSourceOp);
2625
+ // Only the trusted API emits this after its write has finished. External
2626
+ // fs events retain the quiet window; both paths use the same projection,
2627
+ // proposal, validation and echo/deduplication rules.
2628
+ const unsubSourceWritten = ctx.bus.on('source-written', (payload: unknown) => {
2629
+ const p = payload as { rel?: unknown; content?: unknown } | null;
2630
+ if (!acceptedOn() || typeof p?.content !== 'string') return;
2631
+ const proj = projectionForRel(p.rel);
2632
+ if (!proj) return;
2633
+ proj.applyFromFs({
2634
+ path: path.join(ctx.paths.designRoot, p.rel as string),
2635
+ bytes: new TextEncoder().encode(p.content),
2636
+ hash: hashBytes(p.content),
2637
+ });
2638
+ });
2639
+ activityUnsubs.push(unsubSourceWritten);
2588
2640
  const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
2589
2641
  projectionForRel(rel)?.cancelLocalWrite();
2590
2642
  });
@@ -3058,6 +3110,10 @@ export function createSyncRuntime(
3058
3110
  inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
3059
3111
  }
3060
3112
  const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
3113
+ // A save a previous run left unanswered is answered first: a disk
3114
+ // edited after it was edited ON it (F3 S14 on the cloud cell — judged
3115
+ // against the older base, the save conflicted with itself).
3116
+ const ownAccepted = (await outboxDrained).get(docName) ?? null;
3061
3117
  await acceptedColdStart({
3062
3118
  slug: canvas.slug,
3063
3119
  doc: provider.document,
@@ -3067,6 +3123,8 @@ export function createSyncRuntime(
3067
3123
  projection,
3068
3124
  historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3069
3125
  journal: journal ?? undefined,
3126
+ ownAccepted,
3127
+ wasAccepted: (content) => acceptedLink.client.holdsValue(content),
3070
3128
  createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
3071
3129
  });
3072
3130
  projection.reconcile();
@@ -3733,6 +3791,8 @@ export function createSyncRuntime(
3733
3791
  }
3734
3792
  if (msg?.type !== 'maude.mode') return;
3735
3793
  if (msg.mode === 'transactions' || msg.mode === 'legacy') {
3794
+ const writable = (msg as { writable?: unknown }).writable;
3795
+ if (typeof writable === 'boolean') provider.noteWritable?.(writable);
3736
3796
  acceptedLink.noteMode(msg.mode);
3737
3797
  void refreshAcceptedMode();
3738
3798
  }
@@ -5740,11 +5800,17 @@ export function createDefaultProviderFactory(
5740
5800
  socket.on('status', resetSyncedOnDrop);
5741
5801
 
5742
5802
  let authedThisConnection = false;
5803
+ /** A save-mode notice's verdict on THIS connection; a new handshake supersedes it. */
5804
+ let modeWritable: boolean | null = null;
5743
5805
  provider.on('authenticated', () => {
5744
5806
  authedThisConnection = true;
5807
+ modeWritable = null;
5745
5808
  });
5746
5809
  const forgetAuthOnDrop = (evt: { status?: string }) => {
5747
- if (evt?.status !== 'connected') authedThisConnection = false;
5810
+ if (evt?.status !== 'connected') {
5811
+ authedThisConnection = false;
5812
+ modeWritable = null;
5813
+ }
5748
5814
  };
5749
5815
  socket.on('status', forgetAuthOnDrop);
5750
5816
  return {
@@ -5757,7 +5823,11 @@ export function createDefaultProviderFactory(
5757
5823
  // over from before a drop says nothing about the hub now: a project
5758
5824
  // switched to accepted revisions while this peer was away re-admits it
5759
5825
  // read-only, and anything written in between would be dropped there.
5760
- isWritable: () => authedThisConnection && provider.authorizedScope === 'read-write',
5826
+ isWritable: () =>
5827
+ authedThisConnection && (modeWritable ?? provider.authorizedScope === 'read-write'),
5828
+ noteWritable(writable: boolean) {
5829
+ if (authedThisConnection) modeWritable = writable;
5830
+ },
5761
5831
  onAuthenticated(cb: (scope: string) => void): () => void {
5762
5832
  const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
5763
5833
  provider.on('authenticated', handler);
@@ -17,6 +17,8 @@
17
17
  // it as "no changes" is exactly the shape that lets a stale peer believe it is
18
18
  // current, which is the failure DDR-214's ordering amendment exists to prevent.
19
19
 
20
+ import { isProjectFileShape } from './file-membership.ts';
21
+
20
22
  /** How long to wait for a journal page. Same figure as the manifest fetch. */
21
23
  const JOURNAL_TIMEOUT_MS = 6000;
22
24
 
@@ -58,8 +60,12 @@ export interface JournalPage {
58
60
  overflowed?: true;
59
61
  }
60
62
 
61
- /** A designRoot-relative path shape a peer will turn into a real file. */
62
- const ENTRY_PATH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
63
+ // A designRoot-relative path a peer will turn into a real file: the SAME shape
64
+ // rule the hub's file door admits (`isProjectFileShape`). A private, narrower
65
+ // regex here dropped every journalled path with a space in it — `ui/Studio
66
+ // Docs.registry.json` was accepted from its author, journalled, and then
67
+ // silently discarded by every other peer, with no ledger row and no refusal
68
+ // shown (F3 S14, 2026-09-23).
63
69
 
64
70
  function parseEntry(raw: unknown): JournalEntry | null {
65
71
  if (!raw || typeof raw !== 'object') return null;
@@ -67,7 +73,7 @@ function parseEntry(raw: unknown): JournalEntry | null {
67
73
  const seq = e.seq;
68
74
  const p = e.path;
69
75
  if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return null;
70
- if (typeof p !== 'string' || !ENTRY_PATH_RE.test(p) || p.split('/').includes('..')) return null;
76
+ if (typeof p !== 'string' || !isProjectFileShape(p)) return null;
71
77
  const sha = typeof e.sha256 === 'string' && /^[0-9a-f]{64}$/.test(e.sha256) ? e.sha256 : null;
72
78
  return {
73
79
  seq,
@@ -54,7 +54,12 @@ import type { RevisionBarrier } from './revision-barrier.ts';
54
54
  import { repairSeedDuplication } from './seed-repair.ts';
55
55
  import { mergeSource } from './source-merge.ts';
56
56
  import type { SourceOp } from './source-ops.ts';
57
- import { saveRecoveryBody } from './source-recovery.ts';
57
+ import {
58
+ preserveRecoveryCandidate,
59
+ readRecoveryCandidate,
60
+ resolveRecoveryCandidate,
61
+ saveRecoveryBody,
62
+ } from './source-recovery.ts';
58
63
  import { sourceError } from './source-validation.ts';
59
64
  import { laneHash } from './transaction-client.ts';
60
65
 
@@ -222,6 +227,13 @@ export interface DocProjection {
222
227
  * write stays blocked with a visible conflict and a later save can merge.
223
228
  */
224
229
  adoptBase(body: string): void;
230
+ /**
231
+ * Accepted mode, cold start: `value` is this disk's own html proposal that a
232
+ * previous run left in the outbox and the drain just had accepted. It is
233
+ * what the disk was saved as — the base of whatever it holds next — even
234
+ * while this replica still shows the value before it.
235
+ */
236
+ adoptOwnAccepted(value: string): void;
225
237
  /**
226
238
  * Accepted-revisions mode: propose one lane value that did not come through
227
239
  * the watcher (a comment/annotation API write). Resolves with the outcome;
@@ -240,8 +252,13 @@ export interface DocProjection {
240
252
  * recovery slots. Returns false when nothing is held.
241
253
  */
242
254
  takeAccepted(): boolean;
243
- /** T28 — the two sides of a held source conflict (null when none). */
244
- conflictSides(): { mine: string | null; theirs: string } | null;
255
+ /** T28 — current sides plus the first candidate and proven base (null when unknown). */
256
+ conflictSides(): {
257
+ mine: string | null;
258
+ theirs: string;
259
+ original: string | null;
260
+ base: string | null;
261
+ } | null;
245
262
  /** Re-deliver file changes held while the document was not writable. */
246
263
  retryDeferred(): void;
247
264
  /**
@@ -290,6 +307,22 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
290
307
  let validationCache: { body: string; error: string | null } | null = null;
291
308
 
292
309
  function recovered(): void {
310
+ // An older accepted proposal is not resolution of a later pending/held one.
311
+ if (pending.has('html') || held.has('html')) return;
312
+ try {
313
+ if (opts.historyDir) resolveRecoveryCandidate(opts.historyDir, paths.html);
314
+ } catch {
315
+ // The accepted action is real even if updating our local recovery record
316
+ // fails. Keep the original pinned and the storage problem visible.
317
+ rejectedKey ??= 'recovery-failed';
318
+ opts.onConflict?.({
319
+ slug,
320
+ kind: 'body-rejected',
321
+ reason: 'history-failed',
322
+ snapshotFailed: true,
323
+ });
324
+ return;
325
+ }
293
326
  if (rejectedKey !== null) opts.onRecovered?.();
294
327
  rejectedKey = null;
295
328
  }
@@ -325,13 +358,16 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
325
358
  reason: BodyRejection['reason'],
326
359
  local: string | null,
327
360
  incoming: string,
328
- file = paths.html
361
+ file = paths.html,
362
+ base: string | null = lastHtml
329
363
  ): void {
330
364
  const key = `${file}:${reason}:${hashBytes(local ?? '')}:${hashBytes(incoming)}`;
331
365
  if (key === rejectedKey) return;
332
366
  rejectedKey = key;
333
367
  let snapshotFailed = false;
334
368
  try {
369
+ if (file === paths.html && opts.historyDir)
370
+ preserveRecoveryCandidate(opts.historyDir, file, local, base);
335
371
  if (file === paths.html) preserveLocal(local);
336
372
  if (opts.historyDir) {
337
373
  if (local !== null) saveRecoveryBody(opts.historyDir, file, 'local', local);
@@ -461,11 +497,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
461
497
  next = repeated.unit;
462
498
  }
463
499
  }
500
+ if (acceptedOwn !== null && next !== lastHtml) {
501
+ // The replica moved. When it moved to exactly our accepted value and the
502
+ // disk already holds a newer save, that save is ours on top of it: agree
503
+ // on the accepted value and leave the file for the watcher to propose.
504
+ const own = next === acceptedOwn;
505
+ acceptedOwn = null;
506
+ if (own) {
507
+ const local = readLocal(paths.html);
508
+ if (local !== null && local !== next && local !== observedBody) {
509
+ lastHtml = next;
510
+ rememberBase(next);
511
+ return true;
512
+ }
513
+ }
514
+ }
464
515
  if (next === lastHtml) return true;
465
516
  // Don't clobber a non-empty local body with an empty doc (cold-start before
466
517
  // the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
467
518
  if (next === '') {
468
- lastHtml = next;
519
+ // Accepted mode: an empty replica is one the project's publication has
520
+ // not reached yet, never a value this disk agreed on — a base already
521
+ // known (a canvas this disk just added, adopted on acceptance) stays.
522
+ if (lastHtml === null || !acceptedOn()) lastHtml = next;
469
523
  return true;
470
524
  }
471
525
  if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return false;
@@ -675,14 +729,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
675
729
  return lane === 'comments' ? paths.comments : paths.annotations;
676
730
  }
677
731
 
732
+ /**
733
+ * Our own html proposal the hub accepted, whose publication has not reached
734
+ * this replica yet. Until it does, it — not the older replica value — is
735
+ * what the next save on this disk was made on top of (F3 S14, 2026-09-23:
736
+ * a save in that window was proposed on the value before the first save and
737
+ * refused as a base conflict, and the echo itself read as "a local edit
738
+ * overlaps an incoming change").
739
+ */
740
+ let acceptedOwn: string | null = null;
741
+
678
742
  /** The value disk and the accepted replica last agreed on for `lane`. */
679
743
  function agreedValue(lane: ProposalLane): string {
680
- if (lane === 'html') return lastHtml ?? htmlFromDoc(doc);
744
+ if (lane === 'html') return acceptedOwn ?? lastHtml ?? htmlFromDoc(doc);
681
745
  if (lane === 'css') return lastCss ?? cssFromDoc(doc) ?? '';
682
746
  return readLaneFromDoc(doc, lane);
683
747
  }
684
748
 
685
- function onRejected(lane: ProposalLane, local: string, outcome: ProposalOutcome): void {
749
+ function onRejected(
750
+ lane: ProposalLane,
751
+ local: string,
752
+ outcome: ProposalOutcome,
753
+ base: string
754
+ ): void {
686
755
  held.add(lane);
687
756
  // The candidate stays on disk: the projection's local-edit guard protects
688
757
  // it (html), `held` blocks the lane writer (css/meta), and the recovery
@@ -704,7 +773,8 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
704
773
  REJECTION_REASON[outcome.code ?? ''] ?? 'local-edit',
705
774
  local,
706
775
  readLaneFromDoc(doc, lane),
707
- pathOfLane(lane)
776
+ pathOfLane(lane),
777
+ base
708
778
  );
709
779
  }
710
780
 
@@ -772,6 +842,15 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
772
842
  }
773
843
  if (outcome.status === 'accepted') {
774
844
  if (!pending.has(lane)) held.delete(lane);
845
+ if (lane === 'html' && !pending.has(lane)) {
846
+ // Our accepted value is the base of whatever this disk holds next —
847
+ // whichever arrives first, the answer or the publication.
848
+ // Persisted too: a cold start judges the disk against this base.
849
+ if (readLaneFromDoc(doc, 'html') === value) {
850
+ lastHtml = value;
851
+ rememberBase(value);
852
+ } else acceptedOwn = value;
853
+ }
775
854
  if (lane === 'html') recovered();
776
855
  if (outcome.actionId) {
777
856
  try {
@@ -793,7 +872,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
793
872
  lastMeta = null;
794
873
  }
795
874
  } else {
796
- onRejected(lane, local, outcome);
875
+ onRejected(lane, local, outcome, baseContent);
797
876
  }
798
877
  scheduleFlush();
799
878
  return outcome;
@@ -864,6 +943,14 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
864
943
  // 2026-09-15, L09 delete). Every edit to these lanes arrives through the
865
944
  // API with the base it was made from (`proposeLane`).
866
945
  if (lane === 'comments' || lane === 'annotations') return false;
946
+ // A STALE EVENT PROPOSES NOTHING. The reader took these bytes before a
947
+ // later write reached the file — typically this projection materializing a
948
+ // newer accepted value — and delivered them after. Proposing them would
949
+ // put an older body back over a teammate's accepted edit (F3 S15, cloud
950
+ // cell, 2026-09-24: v13 over v14). What the disk holds now arrives with its
951
+ // own event.
952
+ const onDisk = readLocal(evt.path);
953
+ if (onDisk !== null && onDisk !== str) return false;
867
954
  if (lane === 'html') {
868
955
  if (str === lastHtml && !held.has('html')) return false; // a redelivered projection
869
956
  if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
@@ -1127,6 +1214,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
1127
1214
  lastHtml = body;
1128
1215
  observedBody = body;
1129
1216
  },
1217
+ adoptOwnAccepted(value: string) {
1218
+ // Exactly what an answer in this process does (see `submit`): the
1219
+ // replica either holds it already, or its publication is still coming.
1220
+ const replica = htmlFromDoc(doc);
1221
+ if (replica === value) lastHtml = value;
1222
+ else {
1223
+ lastHtml = replica;
1224
+ acceptedOwn = value;
1225
+ }
1226
+ rememberBase(value);
1227
+ },
1130
1228
  hold(lane, base, local) {
1131
1229
  held.add(lane);
1132
1230
  if (lane === 'html') {
@@ -1146,12 +1244,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
1146
1244
  lastHtml = null;
1147
1245
  dirty = true;
1148
1246
  void flush();
1149
- recovered();
1150
1247
  return true;
1151
1248
  },
1152
1249
  conflictSides() {
1153
1250
  if (!held.has('html') && rejectedKey === null) return null;
1154
- return { mine: readLocal(paths.html), theirs: htmlFromDoc(doc) };
1251
+ const candidate = opts.historyDir ? readRecoveryCandidate(opts.historyDir, paths.html) : null;
1252
+ return {
1253
+ mine: readLocal(paths.html),
1254
+ theirs: htmlFromDoc(doc),
1255
+ original: candidate?.original ?? null,
1256
+ base: candidate?.base ?? null,
1257
+ };
1155
1258
  },
1156
1259
  proposeLane(lane, value, o) {
1157
1260
  if (!acceptedOn() || stopped) return null;
@@ -341,8 +341,8 @@ export function pullTargets(
341
341
  * readable. Both hops go through the same function, so "where does this canvas
342
342
  * go" cannot have two answers.
343
343
  *
344
- * Returns null when even the containment check refuses — the only case in which
345
- * a canvas is dropped rather than degraded.
344
+ * Returns null when a present path is refused or containment fails. Only a
345
+ * document with no path may use the legacy slug-derived fallback.
346
346
  */
347
347
  export function resolvePulledTarget(args: {
348
348
  slug: string;
@@ -376,6 +376,7 @@ export function resolvePulledTarget(args: {
376
376
  allowUndeclaredGroup: args.allowUndeclaredGroup,
377
377
  onRefused: args.onRefused,
378
378
  });
379
+ if (args.path !== undefined && args.path !== null && !fromPath) return null;
379
380
  const bodyAbs = args.join(args.designRoot, rel);
380
381
  // Belt and braces at a create. The validator already refuses everything that
381
382
  // could escape lexically; this catches whatever a platform's own `resolve`
@@ -12,6 +12,73 @@ import { sourceError } from './source-validation.ts';
12
12
  */
13
13
  export type RecoverySlot = 'last-valid' | 'local' | 'incoming' | 'base';
14
14
 
15
+ /** The first rejected draft and its proven base, not the mutable latest draft. */
16
+ export interface RecoveryCandidate {
17
+ version: 1;
18
+ original: string | null;
19
+ base: string | null;
20
+ resolved: boolean;
21
+ }
22
+
23
+ function candidatePath(historyDir: string, file: string): string {
24
+ return path.join(historyDir, 'sync-recovery', `candidate${path.extname(file)}.json`);
25
+ }
26
+
27
+ function boundedBody(body: unknown): body is string | null {
28
+ return (
29
+ body === null || (typeof body === 'string' && Buffer.byteLength(body, 'utf8') <= MAX_HTML_BYTES)
30
+ );
31
+ }
32
+
33
+ /** Corrupt/inaccessible records fail closed: never replace an unreadable draft. */
34
+ export function readRecoveryCandidate(historyDir: string, file: string): RecoveryCandidate | null {
35
+ const target = candidatePath(historyDir, file);
36
+ let size: number;
37
+ try {
38
+ size = statSync(target).size;
39
+ } catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
41
+ throw error;
42
+ }
43
+ // Two byte-capped strings, each potentially JSON-escaped as six bytes/char.
44
+ if (size > 12 * MAX_HTML_BYTES + 256) throw new Error('Recovery candidate exceeds size limit');
45
+ const value: unknown = JSON.parse(readFileSync(target, 'utf8'));
46
+ if (!value || typeof value !== 'object') throw new Error('Invalid recovery candidate');
47
+ const record = value as Record<string, unknown>;
48
+ if (
49
+ record.version !== 1 ||
50
+ typeof record.resolved !== 'boolean' ||
51
+ !boundedBody(record.original) ||
52
+ !boundedBody(record.base)
53
+ )
54
+ throw new Error('Invalid recovery candidate');
55
+ return { version: 1, original: record.original, base: record.base, resolved: record.resolved };
56
+ }
57
+
58
+ /** One bounded, atomic record per source file; retries/restarts cannot evict it. */
59
+ export function preserveRecoveryCandidate(
60
+ historyDir: string,
61
+ file: string,
62
+ original: string | null,
63
+ base: string | null
64
+ ): void {
65
+ if (!boundedBody(original) || !boundedBody(base))
66
+ throw new Error('Recovery source exceeds size limit');
67
+ const previous = readRecoveryCandidate(historyDir, file);
68
+ if (previous && !previous.resolved) return;
69
+ atomicWrite(
70
+ candidatePath(historyDir, file),
71
+ JSON.stringify({ version: 1, original, base, resolved: false })
72
+ );
73
+ }
74
+
75
+ /** Retain the bytes after resolution, allowing replacement only by a new episode. */
76
+ export function resolveRecoveryCandidate(historyDir: string, file: string): void {
77
+ const previous = readRecoveryCandidate(historyDir, file);
78
+ if (!previous || previous.resolved) return;
79
+ atomicWrite(candidatePath(historyDir, file), JSON.stringify({ ...previous, resolved: true }));
80
+ }
81
+
15
82
  export function saveRecoveryBody(
16
83
  historyDir: string,
17
84
  file: string,
@@ -447,9 +447,17 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
447
447
  clearSourceConflict(slug) {
448
448
  const id = `source-conflict-${slug}`;
449
449
  const index = notices.findIndex((n) => n.id === id);
450
- if (index < 0) return;
451
- notices.splice(index, 1);
452
- flush(true);
450
+ let changed = index >= 0;
451
+ if (index >= 0) notices.splice(index, 1);
452
+ // The presentation reads these facts, not the dismissible notice. Clear
453
+ // every rejection of this source while keeping other sources and notes.
454
+ for (let i = conflicts.length - 1; i >= 0; i--) {
455
+ if (conflicts[i].slug === slug && conflicts[i].kind === 'body-rejected') {
456
+ conflicts.splice(i, 1);
457
+ changed = true;
458
+ }
459
+ }
460
+ if (changed) flush(true);
453
461
  },
454
462
  get: payload,
455
463
  };
@@ -337,9 +337,33 @@ export function createTransactionClient(opts: TransactionClientOptions) {
337
337
  * process knew the project is bound first — it was never sent, so its bytes
338
338
  * may still change; once sent, they never do.
339
339
  */
340
+ /**
341
+ * Learn the project (id + epoch) before binding or rebasing an entry. A
342
+ * transport failure is WAITED OUT like an unknown delivery — a desktop that
343
+ * restarts offline drains its outbox before the hub is reachable, and a
344
+ * throw here was an unhandled rejection that ended the process (F3/S06).
345
+ * An answer — a legacy hub (`absent`), a refused sign-in — is still thrown.
346
+ */
347
+ async function bootstrapWhenReachable(): Promise<void> {
348
+ for (let attempt = 1; ; attempt++) {
349
+ if (stopped) throw new TransactionError('client stopped', 'stopped');
350
+ try {
351
+ await bootstrap();
352
+ return;
353
+ } catch (err) {
354
+ if (err instanceof TransactionError) throw err;
355
+ if (attempt === 1 || attempt % 10 === 0)
356
+ log.warn(
357
+ `[sync/tx] the project is not reachable yet (${(err as Error).message}); waiting`
358
+ );
359
+ await sleep(Math.min(retryMs * 2 ** Math.min(attempt - 1, 4), 30_000));
360
+ }
361
+ }
362
+ }
363
+
340
364
  async function settle(file: string, entry: OutboxEntry): Promise<ProposalResult> {
341
365
  if (entry.unbound || projectId === null) {
342
- if (projectId === null) await bootstrap();
366
+ if (projectId === null) await bootstrapWhenReachable();
343
367
  if (entry.unbound && entry.action) {
344
368
  entry = { ...entry, bytes: envelope(entry.action, entry.transactionId) };
345
369
  delete entry.unbound;
@@ -351,7 +375,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
351
375
  if (result.status === 'rejected' && result.code === 'epoch-stale' && entry.action) {
352
376
  // A rebase is a NEW transaction under the current epoch, never a mutated
353
377
  // retry — and anything that depended on the old id now depends on this.
354
- await bootstrap();
378
+ await bootstrapWhenReachable();
355
379
  const action = {
356
380
  ...entry.action,
357
381
  ...(entry.action.dependsOn
@@ -433,7 +457,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
433
457
  * Resend what a previous process left in the outbox — in creation order, the
434
458
  * same bytes, each resolved first in case its answer was simply lost.
435
459
  */
436
- function drainOutbox(): Promise<ProposalResult[]> {
460
+ function drainOutbox(
461
+ onEach?: (result: ProposalResult, operations: Operation[]) => void
462
+ ): Promise<ProposalResult[]> {
437
463
  return enqueue(async () => {
438
464
  const entries = readOutbox().filter(({ file }) => !owned.has(file));
439
465
  const results: ProposalResult[] = [];
@@ -441,7 +467,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
441
467
  waiting.set(entry.transactionId, entry.createdAt);
442
468
  setPending(1);
443
469
  try {
444
- results.push(await settle(file, entry));
470
+ const result = await settle(file, entry);
471
+ results.push(result);
472
+ onEach?.(result, entry.action?.operations ?? []);
445
473
  } finally {
446
474
  waiting.delete(entry.transactionId);
447
475
  setPending(-1);
@@ -451,6 +479,22 @@ export function createTransactionClient(opts: TransactionClientOptions) {
451
479
  });
452
480
  }
453
481
 
482
+ /**
483
+ * Is `content` a value the project store holds — i.e. one it accepted at
484
+ * some revision? `null` when it cannot tell (unreachable, older hub).
485
+ */
486
+ async function holdsValue(content: string): Promise<boolean | null> {
487
+ try {
488
+ if (projectId === null) await bootstrap();
489
+ const hash = createHash('sha256').update(content, 'utf8').digest('hex');
490
+ const { status, json } = await request('GET', `blobs/${hash}`);
491
+ if (status === 200) return (json as { body?: unknown } | null)?.body === content;
492
+ return status === 404 ? false : null;
493
+ } catch {
494
+ return null;
495
+ }
496
+ }
497
+
454
498
  /** A read route (`history`, `lane`, `revisions`) — no retry, bounded. */
455
499
  async function read(route: string, params: Record<string, string | number>): Promise<unknown> {
456
500
  if (projectId === null) await bootstrap();
@@ -468,6 +512,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
468
512
  read,
469
513
  newTransactionId,
470
514
  drainOutbox,
515
+ holdsValue,
471
516
  /** The epoch proposals are currently made under. */
472
517
  get epoch() {
473
518
  return epoch;