@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,508 @@
1
+ // The file-event control channel, receiver side — Sync v2 Increment 2
2
+ // (DDR-226 §4/§6).
3
+ //
4
+ // Four things are pinned:
5
+ //
6
+ // 1. **The gate.** `resolveCellCtl` needs workspace mode + a LOOPBACK hub +
7
+ // a token, and NOTHING about cell pairing. Getting that wrong is what
8
+ // would leave the watcher-gap bug alive on every cell but the pilot.
9
+ // 2. **The wire format is a twin, and twins drift.** `sync/poke.ts` and the
10
+ // hub's `files-ctl.mjs` are pinned against each other over an adversarial
11
+ // corpus — the `file-membership` precedent, imported across the package
12
+ // boundary on purpose.
13
+ // 3. **A poke is a doorbell.** It triggers a read; it never carries content,
14
+ // and the receiver only ever emits a bus event for a path.
15
+ // 4. **Fail-closed on the cursor.** A `reanchor` is honoured, not read as
16
+ // "nothing changed"; a page we could not read never advances the cursor.
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+
20
+ // The HUB's implementation, across the package boundary — this file is what
21
+ // pins the two ends of the wire to each other.
22
+ import * as hub from '../../hub/src/files-ctl.mjs';
23
+ import { createCtlHealer } from '../sync/ctl-heal.ts';
24
+ import {
25
+ type CtlProviderLike,
26
+ createCtlProvider,
27
+ resolveCellCtl,
28
+ toWsUrl,
29
+ } from '../sync/ctl-provider.ts';
30
+ import { parsePoke } from '../sync/poke.ts';
31
+
32
+ const CELL_ENV = {
33
+ MAUDE_WORKSPACE_MODE: '1',
34
+ MAUDE_LOOPBACK_SYNC_URL: 'http://127.0.0.1:4321',
35
+ MAUDE_LOOPBACK_SYNC_TOKEN: 'tok',
36
+ };
37
+
38
+ describe('resolveCellCtl — the gate, and what it deliberately does NOT check', () => {
39
+ test('workspace mode + a loopback hub + a token is enough', () => {
40
+ expect(resolveCellCtl(CELL_ENV)).toEqual({
41
+ url: 'http://127.0.0.1:4321',
42
+ token: 'tok',
43
+ });
44
+ });
45
+
46
+ test('it does NOT require cell pairing — that is the whole point', () => {
47
+ // Every pairing precondition absent. `CELL_LIVE_PAIRING` is a one-tenant
48
+ // pilot allowlist; the container watcher gap is on every cell, so the
49
+ // control channel must not inherit the pilot's gate.
50
+ expect(
51
+ resolveCellCtl({
52
+ ...CELL_ENV,
53
+ MAUDE_CELL_PAIRING: undefined,
54
+ MAUDE_SHARED_DOC: undefined,
55
+ MAUDE_SYNC_NO_AUTOCOMMIT: undefined,
56
+ })
57
+ ).not.toBeNull();
58
+ });
59
+
60
+ test('never outside workspace mode — a desktop must not start this', () => {
61
+ expect(resolveCellCtl({ ...CELL_ENV, MAUDE_WORKSPACE_MODE: undefined })).toBeNull();
62
+ expect(resolveCellCtl({ ...CELL_ENV, MAUDE_WORKSPACE_MODE: '0' })).toBeNull();
63
+ });
64
+
65
+ test('a cell talks to ITSELF or to nothing (DDR-209)', () => {
66
+ for (const url of [
67
+ 'http://hub.example.com',
68
+ 'https://evil.test',
69
+ 'http://10.0.0.5:4321',
70
+ 'http://169.254.169.254',
71
+ 'not-a-url',
72
+ '',
73
+ ]) {
74
+ expect(resolveCellCtl({ ...CELL_ENV, MAUDE_LOOPBACK_SYNC_URL: url })).toBeNull();
75
+ }
76
+ for (const url of ['http://localhost:1', 'http://127.0.0.1:2', 'http://[::1]:3']) {
77
+ expect(resolveCellCtl({ ...CELL_ENV, MAUDE_LOOPBACK_SYNC_URL: url })).not.toBeNull();
78
+ }
79
+ });
80
+
81
+ test('no token, no channel', () => {
82
+ expect(resolveCellCtl({ ...CELL_ENV, MAUDE_LOOPBACK_SYNC_TOKEN: '' })).toBeNull();
83
+ });
84
+
85
+ test('MAUDE_FILE_EVENTS=0 is the operator kill switch', () => {
86
+ expect(resolveCellCtl({ ...CELL_ENV, MAUDE_FILE_EVENTS: '0' })).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe('toWsUrl', () => {
91
+ test('maps the scheme and trims the trailing slash', () => {
92
+ expect(toWsUrl('http://127.0.0.1:9/')).toBe('ws://127.0.0.1:9');
93
+ expect(toWsUrl('https://x.cloud.maude.sh//')).toBe('wss://x.cloud.maude.sh');
94
+ });
95
+ });
96
+
97
+ describe('the poke frame is a TWIN — hub and studio must agree', () => {
98
+ const corpus: unknown[] = [
99
+ JSON.stringify({ t: 'files', head: 0 }),
100
+ JSON.stringify({ t: 'files', head: 1 }),
101
+ JSON.stringify({ t: 'files', head: 999999 }),
102
+ JSON.stringify({ t: 'files', head: -1 }),
103
+ JSON.stringify({ t: 'files', head: 1.5 }),
104
+ JSON.stringify({ t: 'files', head: '3' }),
105
+ JSON.stringify({ t: 'files', head: null }),
106
+ JSON.stringify({ t: 'files' }),
107
+ JSON.stringify({ t: 'other', head: 3 }),
108
+ JSON.stringify({ head: 3 }),
109
+ JSON.stringify([1, 2]),
110
+ JSON.stringify('files'),
111
+ 'not json',
112
+ '',
113
+ `{"t":"files","head":1,"pad":"${'x'.repeat(600)}"}`,
114
+ null,
115
+ undefined,
116
+ 42,
117
+ {},
118
+ ];
119
+
120
+ test('both parsers agree on every entry of an adversarial corpus', () => {
121
+ for (const input of corpus) {
122
+ const mine = parsePoke(input);
123
+ const theirs = hub.parsePoke(input);
124
+ expect(mine).toEqual(theirs as typeof mine);
125
+ }
126
+ });
127
+
128
+ test('and they agree on the document name', () => {
129
+ expect(hub.FILES_CTL_DOC).toBe('maude.files');
130
+ });
131
+
132
+ test('extra fields are dropped, not refused — an additive hub must not mute us', () => {
133
+ expect(parsePoke(JSON.stringify({ t: 'files', head: 7, future: 'field' }))).toEqual({
134
+ head: 7,
135
+ });
136
+ });
137
+ });
138
+
139
+ /** A provider stub that lets a test push frames in. */
140
+ function fakeProvider() {
141
+ const handlers = new Map<string, (d: unknown) => void>();
142
+ let destroyed = false;
143
+ const provider: CtlProviderLike = {
144
+ on: ((event: string, cb: (d: unknown) => void) => {
145
+ handlers.set(event, cb);
146
+ }) as CtlProviderLike['on'],
147
+ destroy() {
148
+ destroyed = true;
149
+ },
150
+ };
151
+ return {
152
+ provider,
153
+ emit: (event: string, data: unknown) => handlers.get(event)?.(data),
154
+ destroyed: () => destroyed,
155
+ ready: () => handlers.size > 0,
156
+ };
157
+ }
158
+
159
+ describe('createCtlProvider', () => {
160
+ test('a well-formed frame reaches onPoke; a malformed one is counted, not passed', async () => {
161
+ const f = fakeProvider();
162
+ const heads: number[] = [];
163
+ const ctl = createCtlProvider({
164
+ url: 'http://127.0.0.1:1',
165
+ token: 't',
166
+ onPoke: (h) => heads.push(h),
167
+ log: { log() {}, warn() {}, error() {} },
168
+ connect: () => f.provider,
169
+ });
170
+ // The attach is one microtask late by design.
171
+ await Promise.resolve();
172
+ await Promise.resolve();
173
+
174
+ f.emit('stateless', { payload: JSON.stringify({ t: 'files', head: 11 }) });
175
+ f.emit('stateless', { payload: 'garbage' });
176
+ f.emit('stateless', { payload: JSON.stringify({ t: 'files', head: -2 }) });
177
+
178
+ expect(heads).toEqual([11]);
179
+ expect(ctl.received()).toBe(1);
180
+ expect(ctl.malformed()).toBe(2);
181
+ ctl.stop();
182
+ expect(f.destroyed()).toBe(true);
183
+ });
184
+
185
+ test('a handler that throws never escapes the channel', async () => {
186
+ const f = fakeProvider();
187
+ const errors: string[] = [];
188
+ createCtlProvider({
189
+ url: 'http://127.0.0.1:1',
190
+ token: 't',
191
+ onPoke: () => {
192
+ throw new Error('boom');
193
+ },
194
+ log: { log() {}, warn() {}, error: (m: string) => errors.push(m) },
195
+ connect: () => f.provider,
196
+ });
197
+ await Promise.resolve();
198
+ await Promise.resolve();
199
+ f.emit('stateless', { payload: JSON.stringify({ t: 'files', head: 1 }) });
200
+ expect(errors.length).toBe(1);
201
+ });
202
+
203
+ test('a connect that fails degrades to no channel, never to a throw', async () => {
204
+ const warns: string[] = [];
205
+ const ctl = createCtlProvider({
206
+ url: 'http://127.0.0.1:1',
207
+ token: 't',
208
+ onPoke: () => {},
209
+ log: { log() {}, warn: (m: string) => warns.push(m), error() {} },
210
+ connect: () => {
211
+ throw new Error('no provider module');
212
+ },
213
+ });
214
+ await Promise.resolve();
215
+ await Promise.resolve();
216
+ expect(warns.join(' ')).toContain('falling back to the reconciler poll');
217
+ expect(ctl.connected()).toBe(false);
218
+ ctl.stop();
219
+ });
220
+
221
+ test('stopping before the attach resolves still destroys the provider', async () => {
222
+ const f = fakeProvider();
223
+ let release: (() => void) | null = null;
224
+ const gate = new Promise<void>((r) => {
225
+ release = r;
226
+ });
227
+ const ctl = createCtlProvider({
228
+ url: 'http://127.0.0.1:1',
229
+ token: 't',
230
+ onPoke: () => {},
231
+ log: { log() {}, warn() {}, error() {} },
232
+ connect: async () => {
233
+ await gate;
234
+ return f.provider;
235
+ },
236
+ });
237
+ ctl.stop();
238
+ release?.();
239
+ await Promise.resolve();
240
+ await Promise.resolve();
241
+ await Promise.resolve();
242
+ expect(f.destroyed()).toBe(true);
243
+ });
244
+ });
245
+
246
+ /** A journal the healer can read, with a scripted response per call. */
247
+ function journalServer(pages: Array<Record<string, unknown>>) {
248
+ const asked: string[] = [];
249
+ let i = 0;
250
+ const fetchImpl = (async (url: string) => {
251
+ asked.push(String(url));
252
+ const body = pages[Math.min(i, pages.length - 1)];
253
+ i += 1;
254
+ return new Response(JSON.stringify(body), { status: 200 });
255
+ }) as unknown as typeof fetch;
256
+ return { fetchImpl, asked };
257
+ }
258
+
259
+ describe('createCtlHealer — poke ⇒ journal ⇒ fs:any', () => {
260
+ const base = { hubUrl: 'http://127.0.0.1:1', token: 't' };
261
+ const silent = { log() {}, warn() {} };
262
+
263
+ test('the FIRST poke only sets the baseline — a boot must not replay history', async () => {
264
+ const emitted: string[] = [];
265
+ const { fetchImpl, asked } = journalServer([{ epoch: 'e', head: 5, entries: [] }]);
266
+ const healer = createCtlHealer({
267
+ ...base,
268
+ emit: (r) => emitted.push(r),
269
+ fetchImpl,
270
+ log: silent,
271
+ });
272
+ healer.onPoke(5);
273
+ await healer.drain();
274
+ // Everything at or below head 5 is already on disk and already rendered;
275
+ // replaying it would be a reload storm on every cell wake.
276
+ expect(asked).toEqual([]);
277
+ expect(emitted).toEqual([]);
278
+ });
279
+
280
+ // THE HOLE THE FIRST-POKE BASELINE LEFT. The hub pokes only when the journal
281
+ // APPENDS, so the first poke a freshly booted child sees is itself a change
282
+ // it has never seen — and adopting its head as the baseline swallowed it.
283
+ // On a cell that is "the first asset a peer delivered after boot never
284
+ // healed the open canvas", which reads exactly like a dead channel.
285
+ test('anchor() takes the baseline from the head, so the first poke is a real read', async () => {
286
+ const emitted: string[] = [];
287
+ const { fetchImpl, asked } = journalServer([
288
+ { epoch: 'e', head: 5, entries: [] },
289
+ {
290
+ epoch: 'e',
291
+ head: 6,
292
+ entries: [
293
+ {
294
+ seq: 6,
295
+ path: 'assets/late.png',
296
+ sha256: 'c'.repeat(64),
297
+ size: 1,
298
+ class: 'inert-media',
299
+ },
300
+ ],
301
+ },
302
+ ]);
303
+ const healer = createCtlHealer({
304
+ ...base,
305
+ emit: (r) => emitted.push(r),
306
+ fetchImpl,
307
+ log: silent,
308
+ debounceMs: 0,
309
+ });
310
+ await healer.anchor();
311
+ healer.onPoke(6);
312
+ await healer.drain();
313
+
314
+ expect(emitted).toEqual(['assets/late.png']);
315
+ expect(asked[1]).toContain('since=5');
316
+ });
317
+
318
+ test('anchor() never overrules a cursor a poke already set', async () => {
319
+ const { fetchImpl } = journalServer([{ epoch: 'e', head: 99, entries: [] }]);
320
+ const healer = createCtlHealer({ ...base, emit: () => {}, fetchImpl, log: silent });
321
+ healer.onPoke(5);
322
+ await healer.anchor();
323
+ // Still anchored at 5 — a poke at 5 is noise, and one above it is a read.
324
+ healer.onPoke(5);
325
+ expect(healer.ignored()).toBe(1);
326
+ });
327
+
328
+ test('a later poke reads the journal and announces each path', async () => {
329
+ const emitted: string[] = [];
330
+ const { fetchImpl, asked } = journalServer([
331
+ {
332
+ epoch: 'e',
333
+ head: 7,
334
+ entries: [
335
+ { seq: 6, path: 'assets/a.png', sha256: 'a'.repeat(64), size: 1, class: 'inert-media' },
336
+ {
337
+ seq: 7,
338
+ path: 'system/ds/brand.css',
339
+ sha256: 'b'.repeat(64),
340
+ size: 2,
341
+ class: 'companion-text',
342
+ },
343
+ ],
344
+ },
345
+ ]);
346
+ const healer = createCtlHealer({
347
+ ...base,
348
+ emit: (r) => emitted.push(r),
349
+ fetchImpl,
350
+ log: silent,
351
+ debounceMs: 0,
352
+ });
353
+ healer.onPoke(5);
354
+ healer.onPoke(7);
355
+ await healer.drain();
356
+
357
+ expect(emitted).toEqual(['assets/a.png', 'system/ds/brand.css']);
358
+ expect(asked[0]).toContain('since=5');
359
+ expect(healer.healed()).toBe(2);
360
+
361
+ // And the NEXT read carries the epoch it learned, so a rotation fails
362
+ // closed hub-side instead of silently handing back a foreign log.
363
+ healer.onPoke(8);
364
+ await healer.drain();
365
+ expect(asked[1]).toContain('epoch=e');
366
+ });
367
+
368
+ test('a poke AT the cursor is counted and ignored', async () => {
369
+ const { fetchImpl, asked } = journalServer([{ epoch: 'e', head: 5, entries: [] }]);
370
+ const healer = createCtlHealer({ ...base, emit: () => {}, fetchImpl, log: silent });
371
+ healer.onPoke(5);
372
+ healer.onPoke(5);
373
+ await healer.drain();
374
+ expect(healer.ignored()).toBe(1);
375
+ expect(asked).toEqual([]);
376
+ });
377
+
378
+ // A BACKWARD head is the one thing "at or below" must not swallow: an epoch
379
+ // rotation, a compaction and a restore-from-backup all move the head down, and
380
+ // they are exactly what `reanchor` was written to recover from — so the old
381
+ // rule made that recovery unreachable from the states it existed for, and a
382
+ // cursor parked above the log left the healer deaf for good. The cursor is not
383
+ // moved on the poke's word (a coalesced frame can carry a stale head); the
384
+ // journal is asked, and its own `since > head` rule answers.
385
+ test('a poke BELOW the cursor asks the journal instead of being ignored', async () => {
386
+ const { fetchImpl, asked } = journalServer([
387
+ { epoch: 'e2', head: 2, reanchor: true, reason: 'cursor not in this log' },
388
+ { epoch: 'e2', head: 3, entries: [{ seq: 3, path: 'assets/a.png', class: 'inert-media' }] },
389
+ ]);
390
+ const healer = createCtlHealer({
391
+ ...base,
392
+ emit: () => {},
393
+ fetchImpl,
394
+ log: silent,
395
+ debounceMs: 0,
396
+ });
397
+ healer.onPoke(5);
398
+ healer.onPoke(3);
399
+ await healer.drain();
400
+ expect(asked.length).toBe(1);
401
+ expect(asked[0]).toContain('since=5');
402
+ });
403
+
404
+ test('a TOMBSTONE is not a heal — deletion is Increment 6', async () => {
405
+ const emitted: string[] = [];
406
+ const { fetchImpl } = journalServer([
407
+ {
408
+ epoch: 'e',
409
+ head: 9,
410
+ entries: [
411
+ { seq: 8, path: 'assets/gone.png', sha256: null, deleted: true, class: 'inert-media' },
412
+ { seq: 9, path: 'assets/here.png', sha256: 'c'.repeat(64), class: 'inert-media' },
413
+ ],
414
+ },
415
+ ]);
416
+ const healer = createCtlHealer({
417
+ ...base,
418
+ emit: (r) => emitted.push(r),
419
+ fetchImpl,
420
+ log: silent,
421
+ debounceMs: 0,
422
+ });
423
+ healer.onPoke(7);
424
+ healer.onPoke(9);
425
+ await healer.drain();
426
+ expect(emitted).toEqual(['assets/here.png']);
427
+ });
428
+
429
+ test('a malformed row is dropped, the rest of the page still lands', async () => {
430
+ const emitted: string[] = [];
431
+ const { fetchImpl } = journalServer([
432
+ {
433
+ epoch: 'e',
434
+ head: 4,
435
+ entries: [
436
+ { seq: 3, path: '../../etc/passwd', class: 'inert-media' },
437
+ { seq: 0, path: 'assets/zero.png' },
438
+ { path: 'assets/no-seq.png' },
439
+ { seq: 4, path: 'assets/ok.png', class: 'inert-media' },
440
+ ],
441
+ },
442
+ ]);
443
+ const healer = createCtlHealer({
444
+ ...base,
445
+ emit: (r) => emitted.push(r),
446
+ fetchImpl,
447
+ log: silent,
448
+ debounceMs: 0,
449
+ });
450
+ healer.onPoke(2);
451
+ healer.onPoke(4);
452
+ await healer.drain();
453
+ expect(emitted).toEqual(['assets/ok.png']);
454
+ });
455
+
456
+ test('a reanchor is HONOURED — never read as "nothing changed"', async () => {
457
+ const warns: string[] = [];
458
+ const emitted: string[] = [];
459
+ const { fetchImpl } = journalServer([
460
+ { epoch: 'e2', head: 40, reanchor: true, reason: 'epoch changed' },
461
+ ]);
462
+ const healer = createCtlHealer({
463
+ ...base,
464
+ emit: (r) => emitted.push(r),
465
+ fetchImpl,
466
+ log: { log() {}, warn: (m: string) => warns.push(m) },
467
+ debounceMs: 0,
468
+ });
469
+ healer.onPoke(1);
470
+ healer.onPoke(2);
471
+ await healer.drain();
472
+ expect(emitted).toEqual([]);
473
+ expect(warns.join(' ')).toContain('re-anchor');
474
+ });
475
+
476
+ test('an unreadable page never advances the cursor', async () => {
477
+ const emitted: string[] = [];
478
+ let call = 0;
479
+ const fetchImpl = (async () => {
480
+ call += 1;
481
+ if (call === 1) return new Response('nope', { status: 500 });
482
+ return new Response(
483
+ JSON.stringify({
484
+ epoch: 'e',
485
+ head: 3,
486
+ entries: [{ seq: 3, path: 'assets/late.png', class: 'inert-media' }],
487
+ }),
488
+ { status: 200 }
489
+ );
490
+ }) as unknown as typeof fetch;
491
+
492
+ const healer = createCtlHealer({
493
+ ...base,
494
+ emit: (r) => emitted.push(r),
495
+ fetchImpl,
496
+ log: silent,
497
+ debounceMs: 0,
498
+ });
499
+ healer.onPoke(2);
500
+ healer.onPoke(3);
501
+ await healer.drain();
502
+ expect(emitted).toEqual([]);
503
+ // The cursor stayed at 2, so the retry asks for the SAME range and the
504
+ // file that was missed the first time still arrives.
505
+ await healer.drain();
506
+ expect(emitted).toEqual(['assets/late.png']);
507
+ });
508
+ });