@1agh/maude 0.60.6 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -6
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +919 -214
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +90 -16
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +99 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -383,5 +383,10 @@ function subPathExternals(_pkg: RuntimePackage): string[] {
383
383
  * own cold-start is already the longest tail.
384
384
  */
385
385
  export async function prewarmRuntimeBundles(): Promise<void> {
386
- await Promise.all(RUNTIME_PACKAGES.map(getRuntimeBundle));
386
+ // Arrow, not a bare reference: `.map` passes (value, index, array), so
387
+ // `.map(getRuntimeBundle)` handed the ARRAY INDEX to the function's optional
388
+ // `opts` parameter — every pre-warm after the first ran with a nonsense
389
+ // options object. Silent, because a pre-warm that misbehaves just means the
390
+ // real build happens on first GET.
391
+ await Promise.all(RUNTIME_PACKAGES.map((pkg) => getRuntimeBundle(pkg)));
387
392
  }
@@ -34,6 +34,7 @@ import { createHttp } from './http.ts';
34
34
  import { createInspectRegistry } from './inspect.ts';
35
35
  import { startHeapWatch } from './mem.ts';
36
36
  import { normalizeSessionKey, runInSession, SESSION_HEADER } from './session-scope.ts';
37
+ import { sharedDocEnabled } from './sync/cell-pairing.ts';
37
38
  import { createSyncSupervisor } from './sync/supervisor.ts';
38
39
  import {
39
40
  assertContainment,
@@ -56,13 +57,15 @@ await bootSelfHeal();
56
57
 
57
58
  const ctx = createContext();
58
59
 
59
- // Phase 9.2 (DDR-064) — `MAUDE_SHARED_DOC` feature flag. OPT-IN (default OFF),
60
- // the inverse of MAUDE_CANVAS_ORIGIN_SPLIT's opt-out parsing: only an explicit
61
- // truthy value enables the single-shared-doc path. OFF = the proven two-doc +
62
- // disk-reconcile path = byte-for-byte current behavior. The flag is threaded
63
- // onto ctx here (before createCollab / createSyncRuntime read it) so every
64
- // downstream consumer sees one source of truth.
65
- ctx.sharedDoc = /^(1|true|on|yes)$/i.test(process.env.MAUDE_SHARED_DOC ?? '');
60
+ // DDR-064 cutover (Sync v2 Increment 7) — `MAUDE_SHARED_DOC` now defaults ON:
61
+ // the collab room's Y.Doc is THE doc per canvas, everywhere. The two-doc +
62
+ // disk-reconcile path still exists in full and `MAUDE_SHARED_DOC=0` flips a
63
+ // machine back onto it — that config flip is this release's rollback, which is
64
+ // why NOTHING is deleted here (the relay's deletion is the next increment,
65
+ // one soak later). The flag is threaded onto ctx here (before createCollab /
66
+ // createSyncRuntime read it) so every downstream consumer sees one source of
67
+ // truth, parsed by the same function the cell-pairing interlock uses.
68
+ ctx.sharedDoc = sharedDocEnabled(process.env);
66
69
 
67
70
  // Forward-declared so the api.commentsAdd/patch/delete/addReply callback can
68
71
  // reach into the collab registry (Phase 8 Task 3 bridge). collab is initialized
@@ -97,6 +100,19 @@ const api = createApi(ctx, {
97
100
  // + `_active.json` retarget, bridged the same forward-declared way as the
98
101
  // comments/annotations hooks above.
99
102
  isRoomPinned: (slug) => collab?.registry.isPinned(slug) ?? false,
103
+ // The move protocol (codec stampMovedTo): the sync runtime stamps the old
104
+ // document retired + detaches, which unpins the room — after which the
105
+ // forceDrop below actually drops it and the rename is safe. Forward-declared
106
+ // like the other hooks; `ctx.syncControl` is set once the supervisor exists.
107
+ retireCanvasForMove: async (fromSlug, toRel) => {
108
+ try {
109
+ const runtime = ctx.syncControl?.current?.();
110
+ return (await runtime?.retireForMove?.(fromSlug, toRel)) ?? false;
111
+ } catch (err) {
112
+ console.warn(`[move] retire failed for ${fromSlug}:`, err);
113
+ return false;
114
+ }
115
+ },
100
116
  flushAndDropRoom: async (slug) => {
101
117
  if (collab) await collab.registry.forceDrop(slug);
102
118
  },
@@ -580,7 +596,26 @@ const publicShellOrigin = (() => {
580
596
  return '';
581
597
  }
582
598
  })();
583
- ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}${publicShellOrigin}`;
599
+ // Additional legit embedders, space-separated (validated per-origin). A cell
600
+ // can genuinely have MORE than one shell name — the local rig serves its shell
601
+ // on `http://studio.cell.localhost:<port>` (same-site with the canvas origin,
602
+ // so the SameSite=Strict capability cookie flows exactly as it does in
603
+ // production) while `127.0.0.1:<port>` stays alive for tooling — and listing
604
+ // only one of them turns the other into a silent sad-page iframe refusal.
605
+ const extraShellOrigins = (process.env.MAUDE_EXTRA_SHELL_ORIGINS ?? '')
606
+ .split(/[\s,]+/)
607
+ .filter(Boolean)
608
+ .map((u) => {
609
+ try {
610
+ return new URL(u).origin;
611
+ } catch {
612
+ return '';
613
+ }
614
+ })
615
+ .filter(Boolean)
616
+ .map((o) => ` ${o}`)
617
+ .join('');
618
+ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}${publicShellOrigin}${extraShellOrigins}`;
584
619
 
585
620
  // T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
586
621
  // phase-9.1: a second listener binds an OS-assigned free port, advertised as
@@ -55,6 +55,8 @@ import {
55
55
  markSeeded,
56
56
  mergeSharedMetaIntoLocal,
57
57
  metaFromDoc,
58
+ movedToFromDoc,
59
+ repairSharedMeta,
58
60
  seededByFromDoc,
59
61
  stampAnnotationsEdit,
60
62
  stampBodyEdit,
@@ -66,6 +68,7 @@ import {
66
68
  isExactRepeat,
67
69
  unionCommentsById,
68
70
  } from './cold-start.ts';
71
+ import { applyColdStart } from './cold-start-apply.ts';
69
72
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
70
73
  import type { SyncJournal } from './journal.ts';
71
74
 
@@ -258,6 +261,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
258
261
 
259
262
  async function flush(): Promise<void> {
260
263
  if (!dirty || stopped) return;
264
+ // A RETIRED document is write-inert. Its canvas moved to a new path in a
265
+ // new document; landing another byte from THIS one is how a moved canvas
266
+ // resurrected itself at its old path on every machine (see stampMovedTo).
267
+ if (movedToFromDoc(doc) !== null) {
268
+ dirty = false;
269
+ return;
270
+ }
261
271
  dirty = false;
262
272
  if (flushTimer) {
263
273
  clearTimeout(flushTimer);
@@ -345,6 +355,9 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
345
355
 
346
356
  function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
347
357
  if (stopped) return false;
358
+ // Write-inert both ways — a local edit to a stale pre-move file must not
359
+ // revive the retired document either (see stampMovedTo).
360
+ if (movedToFromDoc(doc) !== null) return false;
348
361
  // Echo of our own atomicWrite — drop.
349
362
  if (echoGuard.consume(evt.path, evt.hash)) return false;
350
363
 
@@ -404,6 +417,10 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
404
417
 
405
418
  async function reconcile(): Promise<void> {
406
419
  if (stopped) return;
420
+ // A retired doc reconciles NOTHING — materialising it is the resurrection
421
+ // this stamp exists to end. The runtime's retirement watcher owns what
422
+ // happens to the stale local file (quarantine to _trash/).
423
+ if (movedToFromDoc(doc) !== null) return;
407
424
  const localHtml = readLocal(paths.html);
408
425
  const localComments = readLocal(paths.comments);
409
426
  const localAnnotations = readLocal(paths.annotations);
@@ -472,9 +489,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
472
489
  docBodyEditAtMs: bodyEditAtFromDoc(doc),
473
490
  });
474
491
 
475
- // Which side owns the visually-coupled lanes (annotations/css) below.
476
- let bodyWinner: 'local' | 'hub' = 'hub';
477
-
478
492
  const writeBodyFromDoc = (): void => {
479
493
  const hash = hashBytes(docHtml);
480
494
  echoGuard.record(paths.html, hash);
@@ -497,84 +511,27 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
497
511
  opts.journal?.record(slug, { bodyHash: hashBytes(body) });
498
512
  };
499
513
 
500
- switch (decision.action) {
501
- case 'noop':
502
- lastHtml = docHtml;
503
- // Identical non-empty sides: checkpoint so the next boot fast-forwards.
504
- if (localHtml !== null && localHtml === docHtml && docHtml !== '') {
505
- opts.journal?.record(slug, { bodyHash: hashBytes(docHtml) });
506
- }
507
- break;
508
- case 'materialize-hub':
509
- case 'fast-forward-hub':
510
- writeBodyFromDoc();
511
- break;
512
- case 'seed-local-up':
513
- // The DDR-064 empty-hub guard as a named decision row: an empty hub doc
514
- // means the hub holds no body for this slug yet — NOT an authoritative
515
- // "this canvas is blank". Seed the doc FROM local so the body survives
516
- // AND the hub gets our content.
517
- seedBodyUp(localHtml as string);
518
- bodyWinner = 'local';
519
- break;
520
- case 'recover-seed-dup':
521
- // F1 — booting against a hub whose body is our local body repeated (a
522
- // concurrent cold-seed that already duplicated). Re-apply local so the
523
- // diff deletes the extra copy/copies; the content equals local, so the
524
- // visually-coupled lanes follow local too. Idempotent across peers.
525
- seedBodyUp(localHtml as string);
526
- bodyWinner = 'local';
527
- break;
528
- case 'conflict': {
529
- // Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
530
- // write, then apply the newest-wins winner. Even a wrong pick costs
531
- // one /design:rollback (the incident's class of loss is closed).
532
- const snapshots: { local?: string; hub?: string } = {};
533
- let snapshotAttempted = false;
534
- if (opts.snapshot) {
535
- snapshotAttempted = true;
536
- try {
537
- const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
538
- if (localTs) snapshots.local = localTs;
539
- const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
540
- if (hubTs) snapshots.hub = hubTs;
541
- } catch {
542
- /* swallowed below — the missing snapshot ref drives the fail-closed guard */
543
- }
544
- }
545
- // DDR-102 fail-closed (security F1): the whole guarantee is "the loser is
546
- // recoverable from _history". A hub-wins resolution OVERWRITES local — so
547
- // if we asked for a snapshot but the local one did NOT land (full disk,
548
- // read-only `_history/`, a Bun.write error), refuse the destructive
549
- // overwrite. Keep local on disk and seed it UP instead, so nothing is
550
- // lost on either side (local survives; the hub still gets our content).
551
- // `snapshotAttempted` gates this to production wiring — a test/standalone
552
- // agent with no snapshot fn keeps the plain newest-wins behavior.
553
- const localSnapshotMissing = snapshotAttempted && !snapshots.local;
554
- let winner = decision.winner;
555
- if (winner === 'hub' && localSnapshotMissing) {
556
- winner = 'local';
557
- console.error(
558
- `[sync/${slug}] cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Keeping local + pushing it up; resolve the _history/ write failure (disk full / read-only?) to restore newest-wins.`
559
- );
560
- }
561
- if (winner === 'local') {
562
- seedBodyUp(localHtml as string);
563
- bodyWinner = 'local';
564
- } else {
565
- writeBodyFromDoc();
566
- }
567
- console.warn(`[sync/${slug}] cold-start divergence — ${decision.reason}`);
568
- opts.onConflict?.({
569
- slug,
570
- kind: 'cold-start-diverged',
571
- winner,
572
- ...(snapshots.local || snapshots.hub ? { snapshots } : {}),
573
- ...(localSnapshotMissing ? { snapshotFailed: true } : {}),
574
- });
575
- break;
576
- }
577
- }
514
+ // ONE application body, shared with migrate-seed.ts (DDR-226 Increment 0):
515
+ // exhaustive over every action with a compile-time `never` default, so the
516
+ // fail-closed snapshot guard, the conflict report and the row set can never
517
+ // drift between the two architectures again. Only the three EFFECTS differ
518
+ // and they are injected here.
519
+ const applied = await applyColdStart({
520
+ slug,
521
+ decision,
522
+ localBody: localHtml,
523
+ docBody: docHtml,
524
+ takeHub: writeBodyFromDoc,
525
+ takeLocal: seedBodyUp,
526
+ checkpointIdentity: (body) => opts.journal?.record(slug, { bodyHash: hashBytes(body) }),
527
+ ...(opts.snapshot ? { snapshot: opts.snapshot } : {}),
528
+ ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
529
+ });
530
+ // `noop` leaves disk and doc alone; the local mirror still tracks the doc.
531
+ if (applied.action === 'noop') lastHtml = docHtml;
532
+
533
+ // Which side owns the visually-coupled lanes (annotations fallback / css).
534
+ const bodyWinner = applied.bodyWinner;
578
535
 
579
536
  // ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
580
537
  const localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
@@ -656,7 +613,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
656
613
  }
657
614
  }
658
615
 
659
- // ---- meta: shared-subset merge, unchanged in all cases -----------------
616
+ // ---- meta: hub wins where it has an opinion, local seeds where it does not
617
+ //
618
+ // Repair first. Two peers publishing the same meta into an empty lane leave
619
+ // two identical copies in the Y.Text, which every consumer's `JSON.parse`
620
+ // rejects — so the canvas reads as having no meta at all on any machine
621
+ // that syncs it afterwards. Collapsing it here, on a doc that has synced,
622
+ // is the only place the duplication is provable rather than suspected.
623
+ repairSharedMeta(doc, origin);
660
624
  lastMeta = docMeta;
661
625
  if (paths.meta && docMeta !== null) {
662
626
  const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
@@ -665,6 +629,30 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
665
629
  echoGuard.record(paths.meta, hash);
666
630
  writer(paths.meta, merged);
667
631
  }
632
+ } else if (paths.meta && localMeta !== null) {
633
+ // AN EMPTY DOC IS NOT A CANVAS WITH NO TITLE.
634
+ //
635
+ // This branch used to not exist, and the comment above it said meta was
636
+ // "unchanged in all cases". The consequence: a canvas created on a peer
637
+ // reached the hub as a body with NO meta — no title, no kind, no
638
+ // design-system binding — and stayed that way until somebody happened to
639
+ // move an artboard, because a meta EDIT was the only thing that ever
640
+ // pushed meta up.
641
+ //
642
+ // It looked fine from the cloud, which is what kept it hidden: a cell's
643
+ // studio child arms `activity:suppress` on create, the container write
644
+ // bridge turns that into an `fs:any` a quarter-second later, and by then
645
+ // the new canvas has an agent to receive it. A desktop has no bridge — its
646
+ // real `fs.watch` fires immediately, before the agent for a
647
+ // just-created canvas exists, and the event lands nowhere. So the race
648
+ // was won on one side and lost on the other, and the underlying gap
649
+ // (cold-start meta was doc→file only) was invisible from the winning end.
650
+ //
651
+ // Seeding here is safe by construction: it runs ONLY when the doc carries
652
+ // no shared meta at all, so it cannot overwrite another peer's opinion —
653
+ // the same "absence is never authority" rule the rest of the sync applies
654
+ // to files, applied to the one sidecar that was exempt from it.
655
+ if (applyMetaToDoc(doc, localMeta, origin)) lastMeta = metaFromDoc(doc);
668
656
  }
669
657
  }
670
658
 
@@ -1,6 +1,17 @@
1
- // Desktop→cell file push — DDR-217, the 2026-08-11 addendum, and the
2
- // feature-sync-file-plane widening (binding decision
3
- // maude/sync-two-plane-manifest-architecture).
1
+ // THE LEGACY PUSH CLIENT — journal-less hubs only (Sync v2 Increment 5).
2
+ //
3
+ // On any hub that carries a journal, the file plane (`file-plane.ts`) is the
4
+ // one lane for every project file, both directions, and this module is never
5
+ // invoked (the capability probe in `sync/index.ts` decides the lane once per
6
+ // boot). What remains here is the compat client for self-hosted hubs that
7
+ // have not upgraded: the bounded, sequential, in-process push pass the pre-v2
8
+ // desktop ran. Retained AT LEAST TWO RELEASES after the burn-down (Open
9
+ // decision 4 of the journal arc) — delete it only against a compat matrix
10
+ // that says the window has closed.
11
+ //
12
+ // Original charter (still accurate for the hubs this serves) — DDR-217, the
13
+ // 2026-08-11 addendum, and the feature-sync-file-plane widening (binding
14
+ // decision maude/sync-two-plane-manifest-architecture):
4
15
  //
5
16
  // The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
6
17
  // desktop-linked project's other files never reached the cell — first seen as
@@ -124,8 +135,9 @@ const UPLOAD_CONNECTION_HEADERS = { connection: 'close' } as const;
124
135
  * over a 182-file sweep is one per request, not a leak. Second, the actual
125
136
  * fault was isolated elsewhere and fixed: an HTTP/1.1 keep-alive desync after a
126
137
  * peer refused a PUT before draining its body (see UPLOAD_CONNECTION_HEADERS).
127
- * The sweep now also runs in its own process, so whatever these do or do not
128
- * contribute costs the sweep and not the editor.
138
+ * That fix is also why this pass runs in-process again — the out-of-process
139
+ * boundary (deleted in Increment 5) was protecting the editor from a transport
140
+ * fault that no longer exists.
129
141
  *
130
142
  * Removing them would trade a suspicion for a certainty: a request with no
131
143
  * budget is how a sweep hangs forever with nothing to report — the exact
@@ -176,7 +188,12 @@ function readProjectConfig(designRoot: string): {
176
188
  canvasGroups: Array.isArray(parsed?.canvasGroups)
177
189
  ? (parsed.canvasGroups as CanvasGroupLike[])
178
190
  : undefined,
179
- syncFiles: process.env.MAUDE_SYNC_FILES === '1' || parsed?.linkedHub?.syncFiles === true,
191
+ // Default ON — mirrors `sync/index.ts`. Both read the same key and a drift
192
+ // between them would mean the sweep and the plane disagree about which
193
+ // files exist, which is the jurisdiction overlap Sync v2 exists to end.
194
+ syncFiles:
195
+ parsed?.linkedHub?.syncFiles !== false &&
196
+ (process.env.MAUDE_SYNC_FILES !== '0' || parsed?.linkedHub?.syncFiles === true),
180
197
  };
181
198
  }
182
199
 
@@ -425,6 +442,10 @@ export async function pushAssets(opts: {
425
442
  onProgress?: (progress: AssetPushProgress) => void;
426
443
  /** Injectable clock for the throttle (tests). */
427
444
  now?: () => number;
445
+ /** Asked before every upload — true abandons the rest of the pass (the Sync
446
+ * panel's cancel; a multi-hundred-MB upload must be killable). The final
447
+ * progress emit still fires, so the panel never freezes mid-count. */
448
+ cancelled?: () => boolean;
428
449
  /** Injectable pause for the 429 backoff (tests — a fake clock, not a wait). */
429
450
  sleep?: (ms: number) => Promise<void>;
430
451
  /** Injectable per-request time budget (tests — seconds, not minutes). */
@@ -486,6 +507,7 @@ export async function pushAssets(opts: {
486
507
  : null;
487
508
 
488
509
  for (const rel of assets) {
510
+ if (opts.cancelled?.()) break;
489
511
  emitProgress(rel, false);
490
512
  const url = `${base}${routeFor(rel).url}`;
491
513
  const headers = { authorization: `Bearer ${opts.token()}` };
@@ -25,9 +25,11 @@
25
25
  // commit a precondition of the save would turn a transient git error into
26
26
  // data loss, which is precisely backwards.
27
27
 
28
+ import { existsSync } from 'node:fs';
28
29
  import path from 'node:path';
29
30
 
30
31
  import { withRepoLock } from '../git/repo-lock.ts';
32
+ import { partitionSafeGitRels } from '../git/safe-rel.ts';
31
33
 
32
34
  /** How long the tree must be quiet before a commit fires. */
33
35
  const DEFAULT_DEBOUNCE_MS = 3000;
@@ -72,6 +74,23 @@ export interface AutoCommitOptions {
72
74
  /** Committer identity — the machine, never the human. */
73
75
  bot?: EditAttribution;
74
76
  log?: Pick<Console, 'warn' | 'error' | 'log'>;
77
+ /**
78
+ * Stage paths git is ignoring (`git add -f`). **Hub only.**
79
+ *
80
+ * On a desktop this must stay off: the ignore file is the user's, and
81
+ * force-adding would commit what they told git to skip. On the HUB it is the
82
+ * opposite — the design root IS the product there, not a mirror of it.
83
+ *
84
+ * This exists because of a real hole. Once a project goes hub-owned
85
+ * (DDR-228) its `.gitignore` carries `/.design/`, the hub's checkout is
86
+ * seeded from that repo and inherits it, and the hub quietly stops
87
+ * committing the design root. Generation backups are `git bundle --all`, so
88
+ * they carry committed objects only — meaning the design system had no copy
89
+ * in any backup, on top of having none in object storage (only `assets/` is
90
+ * mirrored). A deletion would then have been unrecoverable everywhere except
91
+ * a per-machine `_trash/` nothing prunes or indexes.
92
+ */
93
+ stageIgnored?: boolean;
75
94
  }
76
95
 
77
96
  export interface AutoCommit {
@@ -173,6 +192,7 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
173
192
  maxDebounceMs = DEFAULT_MAX_DEBOUNCE_MS,
174
193
  bot = DEFAULT_BOT,
175
194
  log = console,
195
+ stageIgnored = false,
176
196
  } = opts;
177
197
 
178
198
  const touched = new Set<string>();
@@ -272,22 +292,103 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
272
292
  return inFlight;
273
293
  }
274
294
 
295
+ /**
296
+ * Split a batch into what git can stage and what it cannot.
297
+ *
298
+ * `stage` — on disk, or gone but TRACKED (a real deletion to record).
299
+ * `drop` — gone and never tracked: git has nothing to say about it, and
300
+ * retrying it forever is what wedged the whole agent.
301
+ *
302
+ * One `ls-files` call for the whole batch, not one per path: this runs inside
303
+ * the repo lock and a per-file probe would put a fork on the critical section
304
+ * for every canvas in a busy window.
305
+ */
306
+ async function partitionForStaging(
307
+ input: string[]
308
+ ): Promise<{ stage: string[]; drop: string[] }> {
309
+ // SHAPE GATE FIRST (F-13/B12). These rels are built from `designRel` plus a
310
+ // path the file plane delivered, and they end up in `git add -- <paths>`.
311
+ // The `--` defuses a leading `-`, but nothing defuses `..`: a rel escaping
312
+ // the design root is a well-formed pathspec that stages a file the sync
313
+ // lane has no business touching — `../../.github/workflows/*` reached from
314
+ // a lane scoped to `.design/`. Refused paths are DROPPED, not thrown on:
315
+ // one poisoned rel must not wedge the queue for everybody else's work
316
+ // (the failure mode this whole function was written to avoid).
317
+ const { safe: files, refused } = partitionSafeGitRels(input);
318
+ if (refused.length > 0) {
319
+ console.warn(
320
+ `[autocommit] refusing ${refused.length} path(s) that are not safe repo-relative: ${refused
321
+ .slice(0, 5)
322
+ .map((r) => JSON.stringify(r))
323
+ .join(', ')}`
324
+ );
325
+ }
326
+ const missing = files.filter((f) => !existsSync(path.join(repoRoot, f)));
327
+ if (missing.length === 0) return { stage: files, drop: refused };
328
+
329
+ const known = await run(['ls-files', '--', ...missing], { cwd: repoRoot });
330
+ // If the probe itself fails, keep every path: guessing "untracked" here
331
+ // would DROP a real deletion, and a missed deletion is worse than a retry.
332
+ const tracked =
333
+ known.code === 0
334
+ ? new Set(
335
+ known.stdout
336
+ .split('\n')
337
+ .map((l) => l.trim())
338
+ .filter(Boolean)
339
+ )
340
+ : new Set(missing);
341
+ const drop = [...refused, ...missing.filter((f) => !tracked.has(f))];
342
+ if (drop.length === 0) return { stage: files, drop: [] };
343
+ const dropSet = new Set(drop);
344
+ return { stage: files.filter((f) => !dropSet.has(f)), drop };
345
+ }
346
+
275
347
  /** The critical section: stage exactly these files, commit them, report. */
276
348
  async function commitCycle(files: string[], who: EditAttribution): Promise<CommitOutcome> {
277
349
  // Stage ONLY what changed. `git add -A` in a workspace would sweep in
278
350
  // whatever else is in the tree — including files a future feature drops
279
351
  // there — and the cell must never commit something it wasn't told about.
280
- const add = await run(['add', '--', ...files], { cwd: repoRoot });
352
+ // A PATH THAT VANISHED AND WAS NEVER TRACKED IS NOT AN ERROR TO RETRY.
353
+ //
354
+ // `git add -- <path that never existed in the index and is gone from disk>`
355
+ // exits 128 with "did not match any files". The batch then failed, re-queued
356
+ // ITSELF INCLUDING THAT PATH, and failed again the same way forever — so the
357
+ // first canvas anybody deleted stopped the cell committing ANYTHING, for the
358
+ // life of the process. Observed on a local cell: five commits, then
359
+ // twenty-plus identical `git add failed` lines and thirty canvases sitting
360
+ // untracked while `/health` answered 200 throughout.
361
+ //
362
+ // Paths are noted on the strength of a read taken up to `debounceMs`
363
+ // earlier, for files whose lifetime this process does not own, so a path
364
+ // disappearing mid-window is ROUTINE. The question is which kind of gone it
365
+ // is, and git already knows: a path in the index must be staged as a
366
+ // deletion (or the checkout and its history diverge permanently); a path
367
+ // git never heard of has nothing to record and is simply dropped.
368
+ const staging = await partitionForStaging(files);
369
+ if (staging.drop.length > 0) {
370
+ log.warn?.(
371
+ `[autocommit] ${staging.drop.length} path(s) vanished before staging and were never tracked; nothing to record: ${staging.drop.slice(0, 3).join(', ')}${staging.drop.length > 3 ? '…' : ''}`
372
+ );
373
+ }
374
+ if (staging.stage.length === 0) return { ok: false, reason: 'nothing-to-commit', files };
375
+ files = staging.stage;
376
+
377
+ // `-A` so a tracked path that is gone stages as a DELETION rather than
378
+ // erroring. It does not widen the scope: the pathspec is still this exact
379
+ // file list, so the "never commit something it wasn't told about" rule the
380
+ // comment above states is intact.
381
+ // `-A` so a tracked path that is gone stages as a DELETION rather than
382
+ // erroring; `-f` only where the caller asked for it — see `stageIgnored`.
383
+ const add = await run(['add', '-A', ...(stageIgnored ? ['-f'] : []), '--', ...files], {
384
+ cwd: repoRoot,
385
+ });
281
386
  if (add.code !== 0) {
282
387
  log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
283
388
  // RE-QUEUE, exactly as the `commit` branch below does. `touched` was
284
389
  // cleared by `flush()` before we got here, so returning without this
285
390
  // drops the whole coalesced batch — including other people's edits in
286
391
  // the same window — from history, permanently and silently.
287
- // One bad pathspec is enough: `git add -- <path that vanished>` exits
288
- // 128, and paths are now noted on the strength of a read taken up to
289
- // `debounceMs` earlier, for files whose lifetime this process does not
290
- // own (a canvas deleted inside the quiescence window).
291
392
  for (const f of files) touched.add(f);
292
393
  return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
293
394
  }
@@ -0,0 +1,117 @@
1
+ // The cell child's file-event wiring — Sync v2 Increment 2/3 (DDR-226 §4/§6).
2
+ //
3
+ // The doorbell, both buttons, assembled once at boot:
4
+ //
5
+ // resolveCellCtl → do we have a loopback hub and a token? (workspace mode)
6
+ // createCtlProvider → hold the read-only `maude.files` channel open
7
+ // createCtlHealer → hub → child: poke ⇒ read the journal ⇒ emit the
8
+ // `fs:any` the container's watcher owed us
9
+ // createCellWriteNudge → child → hub: we wrote ⇒ name the paths ⇒ the hub
10
+ // re-reads its own disk and journals them
11
+ //
12
+ // The two directions are deliberately symmetric and deliberately unequal in
13
+ // what they carry: the hub's frame carries a head and no path (DDR-054 — a path
14
+ // would be a path the hub chose), and the child's carries paths and no content
15
+ // (the hub re-stats and re-hashes for itself). Neither end can make the other
16
+ // believe something about bytes it did not look at.
17
+ //
18
+ // Downstream of the heal, everything is machinery that already exists:
19
+ // `fs:any` → `createHmrBroadcaster` → `canvas-hmr {mode:'asset'|'css'|'module'}`
20
+ // → the iframe repoints a broken `<img>` (DDR-224) or swaps a stylesheet. No new
21
+ // UI path, no new reload semantics.
22
+ //
23
+ // GATED ON WORKSPACE MODE, NOT ON CELL PAIRING — the whole point. Pairing is a
24
+ // one-tenant pilot allowlist; the watcher gap is on every cell. See the header
25
+ // of `ctl-provider.ts` for why the pairing preconditions do not apply to a
26
+ // read-only stateless channel.
27
+ //
28
+ // Locally this never starts: outside a container `fs.watch` fires and a second
29
+ // event source would double-reload every canvas on every edit, exactly the
30
+ // reason `createContainerWriteBridge` carries the same gate.
31
+
32
+ import type { Context } from '../context.ts';
33
+ import { createCellWriteNudge } from './cell-write-nudge.ts';
34
+ import { createCtlHealer } from './ctl-heal.ts';
35
+ import { createCtlProvider, resolveCellCtl } from './ctl-provider.ts';
36
+
37
+ export interface CellFileEvents {
38
+ stop(): void;
39
+ /** Pokes received on the channel. */
40
+ received(): number;
41
+ /** Paths announced onward as `fs:any`. */
42
+ healed(): number;
43
+ /** Nudge requests the hub accepted. */
44
+ nudged(): number;
45
+ /** Paths named in those requests. */
46
+ nudgedPaths(): number;
47
+ }
48
+
49
+ /**
50
+ * Start the hub→child file-event bridge. Returns null when this process is not
51
+ * a cell studio child (which is every desktop, and every local dev server).
52
+ *
53
+ * Never throws: the studio must start whether or not it has a doorbell.
54
+ */
55
+ export function startCellFileEvents(
56
+ ctx: Context,
57
+ env: Record<string, string | undefined> = process.env
58
+ ): CellFileEvents | null {
59
+ const target = resolveCellCtl(env);
60
+ if (!target) return null;
61
+
62
+ const nudge = createCellWriteNudge({
63
+ hubUrl: target.url,
64
+ token: target.token,
65
+ });
66
+
67
+ const healer = createCtlHealer({
68
+ hubUrl: target.url,
69
+ token: target.token,
70
+ // The one side effect: announce a path onto the bus. Nothing here writes a
71
+ // file, and nothing here trusts the hub's row beyond its shape — the
72
+ // canvas layer re-reads the real disk to decide what to do about it.
73
+ //
74
+ // The mute is what stops us telling the hub a fact it just told us. It is
75
+ // not a loop guard — `recordWrite` is a no-op on identical bytes, so the
76
+ // echo would die on its own — it is simply not paying for the round trip.
77
+ emit: (rel) => {
78
+ nudge.mute(rel);
79
+ ctx.bus.emit('fs:any', rel);
80
+ },
81
+ });
82
+
83
+ // Baseline the heal cursor from the hub's head BEFORE the first poke — see
84
+ // `anchor`. Fire-and-forget: the studio must start whether or not the hub
85
+ // answers, and an unanchored healer still works the way it always did.
86
+ void healer.anchor();
87
+
88
+ const provider = createCtlProvider({
89
+ url: target.url,
90
+ token: target.token,
91
+ onPoke: (head) => healer.onPoke(head),
92
+ });
93
+
94
+ // Every write this process makes surfaces here, because both synthetic-event
95
+ // sources (`createContainerWriteBridge` off `activity:suppress`, and
96
+ // `announceWrite` off the projector) converge on `fs:any`. See the module
97
+ // header for why that is the complete set and why incompleteness would only
98
+ // cost latency.
99
+ const offFsAny = ctx.bus.on('fs:any', (rel: string) => nudge.note(rel));
100
+
101
+ console.log(
102
+ '[sync/ctl] file-event channel attached to this cell’s own hub — hub-process writes heal open canvases without a reload, and writes made here reach the journal at once instead of on the walk-import belt.'
103
+ );
104
+
105
+ return {
106
+ stop() {
107
+ offFsAny();
108
+ provider.stop();
109
+ healer.stop();
110
+ nudge.stop();
111
+ },
112
+ received: () => provider.received(),
113
+ healed: () => healer.healed(),
114
+ nudged: () => nudge.sent(),
115
+ nudgedPaths: () => nudge.named(),
116
+ };
117
+ }