@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
@@ -0,0 +1,46 @@
1
+ // The hub's listing, and the one question the seed asks of it.
2
+ //
3
+ // THE FINDING (attacker seat, 2026-08-19, HIGH). The listing was indexed by
4
+ // `slugFromDocName`, which strips `ws/<workspace>/<branch>/`, while
5
+ // `diffRemoteDocs` — fed the SAME listing twelve lines later — compares full
6
+ // document names. So a `hero` on `main` answered for another peer's `hero` on
7
+ // `feat/x`: that peer's cold-start seed returned `defer-hub-state` forever,
8
+ // logging that the hub's state was on its way, and the canvas never synced in
9
+ // either direction. Peer tokens are commonly `scope: '*'`, so one listing spans
10
+ // every namespace on the hub — no hostile hub required, one teammate reusing a
11
+ // canvas name on another branch is the whole exploit.
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import { hubHolds, indexHubDocs } from '../sync/hub-listing.ts';
16
+
17
+ describe('the hub listing index', () => {
18
+ test('a same-slug document in ANOTHER namespace does not answer for mine', () => {
19
+ const index = indexHubDocs([{ name: 'ws/acme/main/ui-hero', bytes: 2931 }]);
20
+ expect(hubHolds(index, 'ws/acme/feat-x/ui-hero')).toBe(false);
21
+ expect(hubHolds(index, 'ws/acme/main/ui-hero')).toBe(true);
22
+ });
23
+
24
+ test('a legacy flat name is its own key, not a match for a namespaced one', () => {
25
+ const index = indexHubDocs([{ name: 'ui-hero', bytes: 10 }]);
26
+ expect(hubHolds(index, 'ws/acme/main/ui-hero')).toBe(false);
27
+ expect(hubHolds(index, 'ui-hero')).toBe(true);
28
+ });
29
+
30
+ test('a row that exists but carries nothing is not state', () => {
31
+ // Deferring to an empty document would leave a canvas that never syncs in
32
+ // either direction — the document exists, and holds none of the work.
33
+ const index = indexHubDocs([{ name: 'ui-hero', bytes: 0 }, { name: 'ui-other' }]);
34
+ expect(hubHolds(index, 'ui-hero')).toBe(false);
35
+ expect(hubHolds(index, 'ui-other')).toBe(false);
36
+ });
37
+
38
+ test('an unnamed row is dropped rather than coerced into a blank key', () => {
39
+ const index = indexHubDocs([
40
+ { name: '', bytes: 5 },
41
+ { name: undefined as unknown as string, bytes: 5 },
42
+ ]);
43
+ expect(index.size).toBe(0);
44
+ expect(hubHolds(index, '')).toBe(false);
45
+ });
46
+ });
@@ -16,6 +16,8 @@ import {
16
16
  META_LOCAL_KEYS,
17
17
  mergeSharedMetaIntoLocal,
18
18
  metaFromDoc,
19
+ normalizeSharedMeta,
20
+ repairSharedMeta,
19
21
  } from '../sync/codec.ts';
20
22
 
21
23
  describe('Gap 2 — meta codec: shared layout syncs, per-user keys stay local', () => {
@@ -121,3 +123,77 @@ describe('Gap 3 — css codec (opaque wholesale text)', () => {
121
123
  expect(applyCssToDoc(doc, '.x{ color: blue }')).toBe(true);
122
124
  });
123
125
  });
126
+
127
+ describe('a duplicated meta lane — the shape two peers publishing at once leaves', () => {
128
+ // Meta is a WHOLE VALUE in a Y.Text, written delete-all + insert-all. Two
129
+ // peers inserting the same string into an empty lane produce two inserts Yjs
130
+ // has no reason to merge, and the result — `{"a":1}{"a":1}` — is not empty
131
+ // and not parseable. Every consumer ran it through JSON.parse and bailed, so
132
+ // a canvas silently lost its title, kind and design-system binding on every
133
+ // machine that synced the project afterwards.
134
+ //
135
+ // Observed live on nine canvases at once, all created on the same laptop.
136
+
137
+ const ONE = '{"kind":"web","title":"Home"}';
138
+
139
+ function docWith(text: string): Y.Doc {
140
+ const d = new Y.Doc();
141
+ d.getText('meta').insert(0, text);
142
+ return d;
143
+ }
144
+
145
+ test('normalizeSharedMeta collapses identical copies to one', () => {
146
+ expect(normalizeSharedMeta(ONE + ONE)).toBe(ONE);
147
+ expect(normalizeSharedMeta(ONE + ONE + ONE)).toBe(ONE);
148
+ });
149
+
150
+ test('a single value passes through untouched', () => {
151
+ expect(normalizeSharedMeta(ONE)).toBe(ONE);
152
+ });
153
+
154
+ test('empty stays null — that is a real answer, not a broken one', () => {
155
+ expect(normalizeSharedMeta('')).toBeNull();
156
+ expect(normalizeSharedMeta(null)).toBeNull();
157
+ });
158
+
159
+ test('it repairs only what it can PROVE — differing copies stay null', () => {
160
+ // Two DIFFERENT values concatenated is not a duplication; it is a
161
+ // disagreement, and picking one would be inventing a winner. Null routes it
162
+ // to "the doc has no usable opinion", where local meta seeds instead.
163
+ expect(normalizeSharedMeta('{"title":"A"}{"title":"B"}')).toBeNull();
164
+ expect(normalizeSharedMeta('not json at all')).toBeNull();
165
+ expect(normalizeSharedMeta('[1,2]')).toBeNull();
166
+ });
167
+
168
+ test('a value CONTAINING "}{" is not mistaken for a seam', () => {
169
+ // Each segment must itself parse as a complete object, so a brace pair
170
+ // inside a string cannot manufacture a false split.
171
+ const tricky = JSON.stringify({ title: 'a}{b' });
172
+ expect(normalizeSharedMeta(tricky)).toBe(tricky);
173
+ });
174
+
175
+ test('metaFromDoc hands consumers the single value', () => {
176
+ expect(metaFromDoc(docWith(ONE + ONE))).toBe(ONE);
177
+ });
178
+
179
+ test('repairSharedMeta collapses the lane in place', () => {
180
+ const d = docWith(ONE + ONE);
181
+ expect(repairSharedMeta(d)).toBe(true);
182
+ expect(d.getText('meta').toString()).toBe(ONE);
183
+ // Idempotent — a repaired lane is not repaired again, so this cannot churn.
184
+ expect(repairSharedMeta(d)).toBe(false);
185
+ });
186
+
187
+ test('repair leaves an unrepairable lane exactly as it found it', () => {
188
+ const d = docWith('{"title":"A"}{"title":"B"}');
189
+ expect(repairSharedMeta(d)).toBe(false);
190
+ expect(d.getText('meta').toString()).toBe('{"title":"A"}{"title":"B"}');
191
+ });
192
+
193
+ test('applying the same meta over a duplicated lane is a no-op, not a rewrite', () => {
194
+ // Comparing against the literal text would see a difference every pass and
195
+ // rewrite forever.
196
+ const d = docWith(ONE + ONE);
197
+ expect(applyMetaToDoc(d, ONE)).toBe(false);
198
+ });
199
+ });
@@ -0,0 +1,231 @@
1
+ // The MOVE protocol — a moved canvas retires its old document (stampMovedTo).
2
+ //
3
+ // Before this existed, moving a canvas minted a new slug + new document and
4
+ // the OLD document simply lived on: the hub kept materialising it at the old
5
+ // path, every peer's cold start saw "doc has a body, disk has no file" and
6
+ // resurrected it, and the canvas came back as a duplicate on every machine.
7
+ // Observed live: `shoj` moved into a folder left BOTH paths on both machines
8
+ // and two documents on the hub.
9
+ //
10
+ // What is pinned here:
11
+ // 1. the stamp round-trips and is idempotent;
12
+ // 2. a retired doc is WRITE-INERT in every direction, for both disk
13
+ // handlers (agent and projection) — no doc→file materialise, no
14
+ // file→doc revival;
15
+ // 3. retirement is an explicit statement, never inferred from emptiness —
16
+ // an un-stamped doc behaves exactly as before.
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import * as Y from 'yjs';
23
+
24
+ import { createCanvasSyncAgent } from '../sync/agent.ts';
25
+ import { applyHtmlToDoc, htmlFromDoc, movedToFromDoc, stampMovedTo } from '../sync/codec.ts';
26
+ import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
27
+ import { createDocProjection } from '../sync/projection.ts';
28
+
29
+ function tempDir(): string {
30
+ return mkdtempSync(join(tmpdir(), 'sync-retire-'));
31
+ }
32
+
33
+ describe('the stamp', () => {
34
+ test('round-trips, with where the canvas went', () => {
35
+ const doc = new Y.Doc();
36
+ expect(movedToFromDoc(doc)).toBe(null);
37
+ expect(stampMovedTo(doc, 'ui/folder/screen.tsx')).toBe(true);
38
+ expect(movedToFromDoc(doc)).toBe('ui/folder/screen.tsx');
39
+ });
40
+
41
+ test('is idempotent — restamping the same target writes nothing', () => {
42
+ const doc = new Y.Doc();
43
+ stampMovedTo(doc, 'ui/a/b.tsx');
44
+ let updates = 0;
45
+ doc.on('update', () => updates++);
46
+ expect(stampMovedTo(doc, 'ui/a/b.tsx')).toBe(false);
47
+ expect(updates).toBe(0);
48
+ });
49
+
50
+ test('crosses the wire like any syncMeta', () => {
51
+ const a = new Y.Doc();
52
+ const b = new Y.Doc();
53
+ a.on('update', (u: Uint8Array) => Y.applyUpdate(b, u));
54
+ stampMovedTo(a, 'ui/x.tsx');
55
+ expect(movedToFromDoc(b)).toBe('ui/x.tsx');
56
+ });
57
+ });
58
+
59
+ describe('a retired doc is write-inert — the agent', () => {
60
+ test('doc→file: a peer update on a retired doc lands nothing on disk', async () => {
61
+ const dir = tempDir();
62
+ try {
63
+ const doc = new Y.Doc();
64
+ const paths = {
65
+ html: join(dir, 'screen.html'),
66
+ comments: join(dir, '_comments', 'screen.json'),
67
+ annotations: join(dir, 'screen.annotations.svg'),
68
+ };
69
+ const agent = createCanvasSyncAgent({
70
+ slug: 'screen',
71
+ doc,
72
+ paths,
73
+ echoGuard: createEchoGuard(),
74
+ flushMs: 0,
75
+ });
76
+ agent.start();
77
+
78
+ stampMovedTo(doc, 'ui/moved/screen.tsx');
79
+ applyHtmlToDoc(doc, '<main>ghost</main>');
80
+ await agent.flush();
81
+
82
+ // The resurrection: this file appearing is exactly the bug.
83
+ expect(existsSync(paths.html)).toBe(false);
84
+ agent.stop();
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ test('file→doc: a local edit to a stale pre-move file does not revive the doc', () => {
91
+ const dir = tempDir();
92
+ try {
93
+ const doc = new Y.Doc();
94
+ const paths = {
95
+ html: join(dir, 'screen.html'),
96
+ comments: join(dir, '_comments', 'screen.json'),
97
+ annotations: join(dir, 'screen.annotations.svg'),
98
+ };
99
+ const agent = createCanvasSyncAgent({
100
+ slug: 'screen',
101
+ doc,
102
+ paths,
103
+ echoGuard: createEchoGuard(),
104
+ flushMs: 0,
105
+ });
106
+ agent.start();
107
+ stampMovedTo(doc, 'ui/moved/screen.tsx');
108
+
109
+ const bytes = new TextEncoder().encode('<main>stale local edit</main>');
110
+ const applied = agent.applyFromFs({
111
+ path: paths.html,
112
+ bytes,
113
+ hash: hashBytes('<main>stale local edit</main>'),
114
+ });
115
+
116
+ expect(applied).toBe(false);
117
+ expect(htmlFromDoc(doc)).toBe('');
118
+ agent.stop();
119
+ } finally {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('reconcile materialises nothing from a retired doc', async () => {
125
+ const dir = tempDir();
126
+ try {
127
+ const doc = new Y.Doc();
128
+ applyHtmlToDoc(doc, '<main>content from before the move</main>');
129
+ stampMovedTo(doc, 'ui/moved/screen.tsx');
130
+ const paths = {
131
+ html: join(dir, 'screen.html'),
132
+ comments: join(dir, '_comments', 'screen.json'),
133
+ annotations: join(dir, 'screen.annotations.svg'),
134
+ };
135
+ const agent = createCanvasSyncAgent({
136
+ slug: 'screen',
137
+ doc,
138
+ paths,
139
+ echoGuard: createEchoGuard(),
140
+ flushMs: 0,
141
+ });
142
+ agent.start();
143
+ await agent.reconcile();
144
+
145
+ expect(existsSync(paths.html)).toBe(false);
146
+ agent.stop();
147
+ } finally {
148
+ rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('and the CONTROL: without the stamp, the very same doc materialises', async () => {
153
+ // The guard must be the stamp, not some accident of the harness.
154
+ const dir = tempDir();
155
+ try {
156
+ const doc = new Y.Doc();
157
+ applyHtmlToDoc(doc, '<main>live</main>');
158
+ const paths = {
159
+ html: join(dir, 'screen.html'),
160
+ comments: join(dir, '_comments', 'screen.json'),
161
+ annotations: join(dir, 'screen.annotations.svg'),
162
+ };
163
+ const agent = createCanvasSyncAgent({
164
+ slug: 'screen',
165
+ doc,
166
+ paths,
167
+ echoGuard: createEchoGuard(),
168
+ flushMs: 0,
169
+ });
170
+ agent.start();
171
+ await agent.reconcile();
172
+
173
+ expect(readFileSync(paths.html, 'utf8')).toBe('<main>live</main>');
174
+ agent.stop();
175
+ } finally {
176
+ rmSync(dir, { recursive: true, force: true });
177
+ }
178
+ });
179
+ });
180
+
181
+ describe('a retired doc is write-inert — the projection', () => {
182
+ test('doc→file reconcile + flush land nothing', async () => {
183
+ const dir = tempDir();
184
+ try {
185
+ const doc = new Y.Doc();
186
+ applyHtmlToDoc(doc, '<main>ghost</main>');
187
+ stampMovedTo(doc, 'ui/moved/screen.tsx');
188
+ const proj = createDocProjection({
189
+ slug: 'screen',
190
+ doc,
191
+ paths: { html: join(dir, 'screen.tsx') },
192
+ flushMs: 0,
193
+ });
194
+ proj.start();
195
+ proj.reconcile();
196
+ await proj.flush();
197
+
198
+ expect(existsSync(join(dir, 'screen.tsx'))).toBe(false);
199
+ proj.stop();
200
+ } finally {
201
+ rmSync(dir, { recursive: true, force: true });
202
+ }
203
+ });
204
+
205
+ test('file→doc refuses too', () => {
206
+ const dir = tempDir();
207
+ try {
208
+ const doc = new Y.Doc();
209
+ stampMovedTo(doc, 'ui/moved/screen.tsx');
210
+ const abs = join(dir, 'screen.tsx');
211
+ writeFileSync(abs, '<main>stale</main>');
212
+ const proj = createDocProjection({
213
+ slug: 'screen',
214
+ doc,
215
+ paths: { html: abs },
216
+ flushMs: 0,
217
+ });
218
+ proj.start();
219
+ const applied = proj.applyFromFs({
220
+ path: abs,
221
+ bytes: new TextEncoder().encode('<main>stale</main>'),
222
+ hash: hashBytes('<main>stale</main>'),
223
+ });
224
+ expect(applied).toBe(false);
225
+ expect(htmlFromDoc(doc)).toBe('');
226
+ proj.stop();
227
+ } finally {
228
+ rmSync(dir, { recursive: true, force: true });
229
+ }
230
+ });
231
+ });
@@ -131,3 +131,23 @@ describe('the Resync control', () => {
131
131
  expect(PANEL).toMatch(/cloud = null,/);
132
132
  });
133
133
  });
134
+
135
+ describe('a held breaker is visible, because a log line is not a surface', () => {
136
+ // The finding: `deleteHeld` / `firstAnchorHeld` / `reanchorHeld` shipped
137
+ // with no consumer at all — declared, assigned, and read by nothing. So the
138
+ // breakers the release leans on had their entire output in a dev-server
139
+ // console, on a product whose premise (DDR-177) is that the user never opens
140
+ // a terminal. A hold that nobody can see is a stall, not a safety control.
141
+ test('the panel renders every hold the status carries', () => {
142
+ expect(PANEL).toContain('files.held');
143
+ expect(PANEL).toMatch(/data-testid=\{`sync-held-\$\{h\.kind\}`\}/);
144
+ });
145
+
146
+ test('it says nothing was removed — the reassurance IS the message', () => {
147
+ expect(PANEL).toContain('nothing was removed');
148
+ });
149
+
150
+ test('it names the files, so a person can judge whether it was deliberate', () => {
151
+ expect(PANEL).toMatch(/h\.paths/);
152
+ });
153
+ });
@@ -115,7 +115,7 @@ function makeCtx(canvasGroups?: DevServerConfig['canvasGroups']): Context {
115
115
  * from the listing.
116
116
  */
117
117
  function hubDocProviderFactory(
118
- seed: Record<string, { body: string; path?: string; css?: string }>,
118
+ seed: Record<string, { body: string; path?: string; css?: string; movedTo?: string }>,
119
119
  docs?: Map<string, Y.Doc>
120
120
  ) {
121
121
  return (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
@@ -126,6 +126,7 @@ function hubDocProviderFactory(
126
126
  doc.transact(() => {
127
127
  doc.getText('html').insert(0, entry.body);
128
128
  if (entry.path) doc.getMap('syncMeta').set('path', entry.path);
129
+ if (entry.movedTo) doc.getMap('syncMeta').set('movedTo', entry.movedTo);
129
130
  if (entry.css) doc.getText('css').insert(0, entry.css);
130
131
  }, 'hub');
131
132
  }
@@ -233,6 +234,71 @@ describe('a peer with an empty design root pulls the project down whole', () =>
233
234
  });
234
235
  });
235
236
 
237
+ describe('a refused path ends the pull — it does not redirect it', () => {
238
+ // `canvasSlugFromRel` is lossy: `ui/Desk A.tsx` flattens to `ui-desk_a`, and
239
+ // the provisional target derived back from that slug is `ui/desk_a.tsx` — a
240
+ // DIFFERENT file. So when the document's own path was refused (here: the file
241
+ // is already on this disk, which is what `admitPullTarget` protects), falling
242
+ // through left the descriptor on the provisional target and the reconcile
243
+ // wrote the canvas there. The peer then held the canvas twice, both copies
244
+ // flattening to one slug — a collision, which takes the canvas out of sync on
245
+ // that machine entirely. Observed on a live cell for every canvas whose name
246
+ // contains a space.
247
+ test('a document whose path already exists locally writes no slug-shaped ghost', async () => {
248
+ const SPACED_REL = 'ui/Desk A.tsx';
249
+ const SPACED_SLUG = 'ui-desk_a';
250
+ const LOCAL = 'export default () => <main>the copy already here</main>;\n';
251
+ hubListing([{ name: `ws/acme/main/${SPACED_SLUG}`, bytes: BODY.length }]);
252
+ const ctx = makeCtx();
253
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
254
+ // Present, but out of the sync set — a `syncable: false` sidecar, the TSX
255
+ // sandbox gate, or (on a cell) a file the hub's own workspace agent wrote.
256
+ writeFileSync(join(ctx.paths.designRoot, SPACED_REL), LOCAL);
257
+ writeFileSync(
258
+ join(ctx.paths.designRoot, 'ui', 'Desk A.meta.json'),
259
+ JSON.stringify({ syncable: false })
260
+ );
261
+ const runtime = createSyncRuntime(ctx, {
262
+ providerFactory: hubDocProviderFactory({
263
+ [SPACED_SLUG]: { body: BODY, path: SPACED_REL },
264
+ }),
265
+ });
266
+ await runtime?.start();
267
+ await runtime?.stop();
268
+
269
+ expect(existsSync(join(ctx.paths.designRoot, 'ui', 'desk_a.tsx'))).toBe(false);
270
+ // And the file that WAS there is kept, byte for byte.
271
+ expect(readFileSync(join(ctx.paths.designRoot, SPACED_REL), 'utf8')).toBe(LOCAL);
272
+ });
273
+ });
274
+
275
+ describe('a PULLED document may not talk itself out of being retired', () => {
276
+ // THE FINDING (attacker seat, 2026-08-19, HIGH). The self-stamp repair exists
277
+ // for the machine that performed a move, whose canvas descriptor came from its
278
+ // own disk scan. A PULLED canvas has no such descriptor: its path is the
279
+ // provisional one derived from the document NAME — which the hub chose. Since
280
+ // the hub also writes `movedTo`, it would control both sides of the "it moved
281
+ // to where it already is" comparison, and a repair would become a write to a
282
+ // path `resolvePulledTarget` and `admitPullTarget` never approved. That is the
283
+ // resurrection primitive the retirement release exists to deny.
284
+ test('a hub-named document whose movedTo equals its own provisional path is NOT written', async () => {
285
+ const SLUG = 'ui-evil';
286
+ hubListing([{ name: SLUG, bytes: BODY.length }]);
287
+ const ctx = makeCtx();
288
+ const runtime = createSyncRuntime(ctx, {
289
+ providerFactory: hubDocProviderFactory({
290
+ // Both halves chosen by the hub: the name fixes the provisional target
291
+ // `ui/evil.tsx`, and the stamp claims the canvas moved exactly there.
292
+ [SLUG]: { body: BODY, movedTo: 'ui/evil.tsx' },
293
+ }),
294
+ });
295
+ await runtime?.start();
296
+ await runtime?.stop();
297
+
298
+ expect(existsSync(join(ctx.paths.designRoot, 'ui', 'evil.tsx'))).toBe(false);
299
+ });
300
+ });
301
+
236
302
  describe('boot runs the flat-fallback quarantine BEFORE the scan (fix 4)', () => {
237
303
  test('a pre-fix-5 flat twin is in _trash by the time the runtime settles', async () => {
238
304
  const ctx = makeCtx();
@@ -0,0 +1,169 @@
1
+ // F6 — the aggregate per-pass byte budget on the downward lanes (Sync v2
2
+ // Increment 0, DDR-226 §9).
3
+ //
4
+ // The gap the Plane-B flip gate named: every lane capped ONE file and the COUNT
5
+ // of files, nothing capped the product. 200 × 512 MB = 100 GB, and the hub
6
+ // chooses both factors. These tests pin that a pass stops on bytes, that it
7
+ // stops LOUDLY, and — the part that matters for an untrusted hub — that a
8
+ // manifest UNDERSTATING a file's size buys no extra transfer.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import { pullFiles } from '../sync/file-pull.ts';
16
+ import { createPullBudget, MAX_PULL_BYTES_PER_PASS } from '../sync/pull-budget.ts';
17
+
18
+ const silent = { log: () => {}, warn: () => {} };
19
+
20
+ describe('createPullBudget', () => {
21
+ test('commits what fits and refuses what does not', () => {
22
+ const b = createPullBudget({ maxBytes: 100, label: 't', log: silent });
23
+ expect(b.take(60)).toBe(true);
24
+ expect(b.spent()).toBe(60);
25
+ expect(b.remaining()).toBe(40);
26
+ expect(b.take(50)).toBe(false);
27
+ expect(b.spent()).toBe(60); // a refused charge is not committed
28
+ expect(b.exhausted()).toBe(true);
29
+ });
30
+
31
+ test('an exact fit is allowed', () => {
32
+ const b = createPullBudget({ maxBytes: 100, label: 't', log: silent });
33
+ expect(b.take(100)).toBe(true);
34
+ expect(b.exhausted()).toBe(false);
35
+ expect(b.remaining()).toBe(0);
36
+ expect(b.take(1)).toBe(false);
37
+ });
38
+
39
+ test('a negative or NaN size can never refund budget', () => {
40
+ const b = createPullBudget({ maxBytes: 100, label: 't', log: silent });
41
+ expect(b.take(-1_000_000)).toBe(true);
42
+ expect(b.take(Number.NaN)).toBe(true);
43
+ expect(b.spent()).toBe(0);
44
+ expect(b.take(101)).toBe(false);
45
+ });
46
+
47
+ test('the wall is named exactly once, and loudly', () => {
48
+ const warns: string[] = [];
49
+ const b = createPullBudget({
50
+ maxBytes: 10,
51
+ label: 'sync/files',
52
+ log: { warn: (m: string) => warns.push(m) },
53
+ });
54
+ b.take(20);
55
+ b.take(20);
56
+ expect(warns).toHaveLength(1);
57
+ expect(warns[0]).toContain('[sync/files]');
58
+ expect(warns[0]).toContain('byte budget');
59
+ expect(warns[0]).toContain('next pass');
60
+ });
61
+
62
+ test('the default ceiling is the documented 2 GiB', () => {
63
+ expect(MAX_PULL_BYTES_PER_PASS).toBe(2 * 1024 * 1024 * 1024);
64
+ const b = createPullBudget({ label: 't', log: silent });
65
+ expect(b.remaining()).toBe(MAX_PULL_BYTES_PER_PASS);
66
+ });
67
+ });
68
+
69
+ describe('file-pull honours the pass budget', () => {
70
+ const manifest = (files: { path: string; size: number; sha256: string }[]) =>
71
+ ({
72
+ files: files.map((f) => ({
73
+ path: f.path,
74
+ sha256: f.sha256,
75
+ size: f.size,
76
+ mtimeMs: Date.now(),
77
+ class: 'inert-media',
78
+ })),
79
+ }) as const;
80
+
81
+ const sha = async (bytes: Uint8Array) =>
82
+ Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)))
83
+ .map((b) => b.toString(16).padStart(2, '0'))
84
+ .join('');
85
+
86
+ test('charges from the manifest BEFORE the request — an over-budget batch costs no transfer', async () => {
87
+ const dir = mkdtempSync(join(tmpdir(), 'pull-budget-files-'));
88
+ try {
89
+ mkdirSync(join(dir, 'assets'), { recursive: true });
90
+ const bytes = new Uint8Array(400);
91
+ const hash = await sha(bytes);
92
+ const fetched: string[] = [];
93
+ const fetchImpl = (async (url: string) => {
94
+ const u = String(url);
95
+ if (u.endsWith('/api/files')) {
96
+ return new Response(
97
+ JSON.stringify(
98
+ manifest([
99
+ { path: 'assets/a.png', size: 400, sha256: hash },
100
+ { path: 'assets/b.png', size: 400, sha256: hash },
101
+ { path: 'assets/c.png', size: 400, sha256: hash },
102
+ ])
103
+ ),
104
+ { status: 200 }
105
+ );
106
+ }
107
+ fetched.push(u);
108
+ return new Response(bytes, { status: 200 });
109
+ }) as unknown as typeof fetch;
110
+
111
+ const out = await pullFiles({
112
+ designRoot: dir,
113
+ hubUrl: 'https://hub.example',
114
+ token: () => 't',
115
+ allowCodeModules: false,
116
+ fetchImpl,
117
+ log: silent,
118
+ maxPassBytes: 900,
119
+ });
120
+
121
+ expect(out.pulled).toHaveLength(2);
122
+ expect(out.budgetExhausted).toBe(true);
123
+ // The third file was never even requested — the manifest priced it.
124
+ expect(fetched).toHaveLength(2);
125
+ } finally {
126
+ rmSync(dir, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ test('a manifest that UNDERSTATES a size buys no extra transfer', async () => {
131
+ const dir = mkdtempSync(join(tmpdir(), 'pull-budget-files-lie-'));
132
+ try {
133
+ mkdirSync(join(dir, 'assets'), { recursive: true });
134
+ const big = new Uint8Array(5_000);
135
+ const hash = await sha(big);
136
+ const fetchImpl = (async (url: string) => {
137
+ const u = String(url);
138
+ if (u.endsWith('/api/files')) {
139
+ return new Response(
140
+ JSON.stringify(
141
+ // Declares 1 byte; sends 5,000.
142
+ manifest([{ path: 'assets/lie.png', size: 1, sha256: hash }])
143
+ ),
144
+ { status: 200 }
145
+ );
146
+ }
147
+ return new Response(big, { status: 200 });
148
+ }) as unknown as typeof fetch;
149
+
150
+ const out = await pullFiles({
151
+ designRoot: dir,
152
+ hubUrl: 'https://hub.example',
153
+ token: () => 't',
154
+ allowCodeModules: false,
155
+ fetchImpl,
156
+ log: silent,
157
+ maxPassBytes: 1_000,
158
+ });
159
+
160
+ expect(out.pulled).toEqual([]);
161
+ expect(out.budgetExhausted).toBe(true);
162
+ expect(out.failed[0]?.reason).toContain('declared 1 B, sent 5000 B');
163
+ // And crucially: nothing landed on disk.
164
+ expect(readdirSync(join(dir, 'assets'))).toEqual([]);
165
+ } finally {
166
+ rmSync(dir, { recursive: true, force: true });
167
+ }
168
+ });
169
+ });