@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.
- package/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +190 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -6
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +919 -214
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +90 -16
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +99 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- 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
|
+
});
|