@1agh/maude 0.29.0 → 0.31.0
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/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Concurrent cold-seed de-duplication — F1 regression.
|
|
2
|
+
//
|
|
3
|
+
// RCA: `.ai/logs/rca/issue-collab-cold-seed-tsx-duplication.md`. When TWO
|
|
4
|
+
// dev-servers seed the SAME canvas slug into an EMPTY hub at the SAME instant,
|
|
5
|
+
// each reads `docBody === empty` before the other's `seed-local-up` write has
|
|
6
|
+
// propagated, so both apply their (identical) body. Two `applyHtmlToDoc`
|
|
7
|
+
// insertions from two Yjs clientIDs into one Y.Text are NOT content-deduped by
|
|
8
|
+
// the CRDT — the body doubled (two `export default`), failing the canvas build.
|
|
9
|
+
//
|
|
10
|
+
// The fix elects a single writer (`syncMeta.seededBy` — Y.Map LWW converges it
|
|
11
|
+
// to ONE clientID on every peer) which, inside a bounded post-seed window,
|
|
12
|
+
// re-applies the canonical body so `applyHtmlToDoc`'s diff deletes the trailing
|
|
13
|
+
// duplicate. These tests pin: (1) simultaneous seed → one copy, (2) a fresh peer
|
|
14
|
+
// booting against an already-doubled hub recovers, (3) the sequential control
|
|
15
|
+
// stays clean (no over-eager collapse of legitimately-adopted state).
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import * as Y from 'yjs';
|
|
22
|
+
|
|
23
|
+
import { type CanvasSyncAgent, createCanvasSyncAgent } from '../sync/agent.ts';
|
|
24
|
+
import { htmlFromDoc } from '../sync/codec.ts';
|
|
25
|
+
import { createEchoGuard } from '../sync/echo-guard.ts';
|
|
26
|
+
|
|
27
|
+
// A realistic canvas body — the un-buildable failure is `export default` × 2.
|
|
28
|
+
const BODY = [
|
|
29
|
+
'import { DesignCanvas } from "@maude/canvas-lib";',
|
|
30
|
+
'export default function Screen() {',
|
|
31
|
+
' return <DesignCanvas><h1>Shared draft</h1></DesignCanvas>;',
|
|
32
|
+
'}',
|
|
33
|
+
'',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
const TRANSPORT = Symbol('transport');
|
|
37
|
+
|
|
38
|
+
let dirA: string;
|
|
39
|
+
let dirB: string;
|
|
40
|
+
let docA: Y.Doc;
|
|
41
|
+
let docB: Y.Doc;
|
|
42
|
+
let agentA: CanvasSyncAgent | undefined;
|
|
43
|
+
let agentB: CanvasSyncAgent | undefined;
|
|
44
|
+
|
|
45
|
+
function pathsFor(dir: string) {
|
|
46
|
+
return {
|
|
47
|
+
html: join(dir, 'screen.tsx'),
|
|
48
|
+
comments: join(dir, '_comments', 'screen.json'),
|
|
49
|
+
annotations: join(dir, 'screen.annotations.svg'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeLocalBody(dir: string, body: string): void {
|
|
54
|
+
// Seed disk before the agent reconciles (the local side of the cold start).
|
|
55
|
+
writeFileSync(pathsFor(dir).html, body);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeAgent(doc: Y.Doc, dir: string): CanvasSyncAgent {
|
|
59
|
+
const a = createCanvasSyncAgent({
|
|
60
|
+
slug: 'screen',
|
|
61
|
+
doc,
|
|
62
|
+
paths: pathsFor(dir),
|
|
63
|
+
echoGuard: createEchoGuard(),
|
|
64
|
+
flushMs: 0, // synchronous flush via queueMicrotask
|
|
65
|
+
});
|
|
66
|
+
a.start();
|
|
67
|
+
return a;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bidirectional full-state exchange, looped until the de-dup repair (which
|
|
71
|
+
* fires synchronously inside applyUpdate) has propagated + settled. */
|
|
72
|
+
function syncDocs(d1: Y.Doc, d2: Y.Doc, rounds = 6): void {
|
|
73
|
+
for (let i = 0; i < rounds; i++) {
|
|
74
|
+
Y.applyUpdate(d2, Y.encodeStateAsUpdate(d1), TRANSPORT);
|
|
75
|
+
Y.applyUpdate(d1, Y.encodeStateAsUpdate(d2), TRANSPORT);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
dirA = mkdtempSync(join(tmpdir(), 'cold-seed-a-'));
|
|
81
|
+
dirB = mkdtempSync(join(tmpdir(), 'cold-seed-b-'));
|
|
82
|
+
docA = new Y.Doc();
|
|
83
|
+
docB = new Y.Doc();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
agentA?.stop();
|
|
88
|
+
agentB?.stop();
|
|
89
|
+
agentA = undefined;
|
|
90
|
+
agentB = undefined;
|
|
91
|
+
rmSync(dirA, { recursive: true, force: true });
|
|
92
|
+
rmSync(dirB, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('concurrent cold-seed → single body', () => {
|
|
96
|
+
test('two peers seed identical bodies into an empty hub at once → ONE copy', async () => {
|
|
97
|
+
writeLocalBody(dirA, BODY);
|
|
98
|
+
writeLocalBody(dirB, BODY);
|
|
99
|
+
agentA = makeAgent(docA, dirA);
|
|
100
|
+
agentB = makeAgent(docB, dirB);
|
|
101
|
+
|
|
102
|
+
// The race: BOTH reconcile against an empty doc before either propagates.
|
|
103
|
+
await agentA.reconcile();
|
|
104
|
+
await agentB.reconcile();
|
|
105
|
+
|
|
106
|
+
// Hub merges both seeds — the moment the duplication would surface.
|
|
107
|
+
syncDocs(docA, docB);
|
|
108
|
+
|
|
109
|
+
// Both docs converge to exactly ONE copy (no doubled `export default`).
|
|
110
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
111
|
+
expect(htmlFromDoc(docB)).toBe(BODY);
|
|
112
|
+
// And the count of `export default` is 1 — the build-breaking signature gone.
|
|
113
|
+
expect(htmlFromDoc(docA).match(/export default/g)?.length).toBe(1);
|
|
114
|
+
|
|
115
|
+
// Disk on both peers mirrors the single copy.
|
|
116
|
+
await agentA.flush();
|
|
117
|
+
await agentB.flush();
|
|
118
|
+
expect(readFileSync(pathsFor(dirA).html, 'utf8')).toBe(BODY);
|
|
119
|
+
expect(readFileSync(pathsFor(dirB).html, 'utf8')).toBe(BODY);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('adopt-mode concurrent seed also self-heals to one copy', async () => {
|
|
123
|
+
writeLocalBody(dirA, BODY);
|
|
124
|
+
writeLocalBody(dirB, BODY);
|
|
125
|
+
agentA = createCanvasSyncAgent({
|
|
126
|
+
slug: 'screen',
|
|
127
|
+
doc: docA,
|
|
128
|
+
paths: pathsFor(dirA),
|
|
129
|
+
echoGuard: createEchoGuard(),
|
|
130
|
+
flushMs: 0,
|
|
131
|
+
adopt: true,
|
|
132
|
+
});
|
|
133
|
+
agentB = createCanvasSyncAgent({
|
|
134
|
+
slug: 'screen',
|
|
135
|
+
doc: docB,
|
|
136
|
+
paths: pathsFor(dirB),
|
|
137
|
+
echoGuard: createEchoGuard(),
|
|
138
|
+
flushMs: 0,
|
|
139
|
+
adopt: true,
|
|
140
|
+
});
|
|
141
|
+
agentA.start();
|
|
142
|
+
agentB.start();
|
|
143
|
+
|
|
144
|
+
await agentA.reconcile();
|
|
145
|
+
await agentB.reconcile();
|
|
146
|
+
syncDocs(docA, docB);
|
|
147
|
+
|
|
148
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
149
|
+
expect(htmlFromDoc(docB)).toBe(BODY);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('recovery of an already-duplicated hub (requirement #3)', () => {
|
|
154
|
+
test('a peer booting against a doubled doc collapses it on reconcile', async () => {
|
|
155
|
+
writeLocalBody(dirA, BODY);
|
|
156
|
+
// Simulate a hub that already concatenated two identical seeds.
|
|
157
|
+
const t = docA.getText('html');
|
|
158
|
+
t.insert(0, BODY);
|
|
159
|
+
t.insert(BODY.length, BODY);
|
|
160
|
+
expect(htmlFromDoc(docA)).toBe(BODY + BODY);
|
|
161
|
+
|
|
162
|
+
agentA = makeAgent(docA, dirA);
|
|
163
|
+
await agentA.reconcile();
|
|
164
|
+
|
|
165
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
166
|
+
expect(htmlFromDoc(docA).match(/export default/g)?.length).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('sequential control — no over-eager collapse', () => {
|
|
171
|
+
test('B joining after A seeded adopts the single copy (no duplication)', async () => {
|
|
172
|
+
writeLocalBody(dirA, BODY);
|
|
173
|
+
writeLocalBody(dirB, BODY);
|
|
174
|
+
agentA = makeAgent(docA, dirA);
|
|
175
|
+
agentB = makeAgent(docB, dirB);
|
|
176
|
+
|
|
177
|
+
// A seeds first and FULLY propagates before B reconciles.
|
|
178
|
+
await agentA.reconcile();
|
|
179
|
+
syncDocs(docA, docB);
|
|
180
|
+
// B now sees the hub already holds the body → noop (adopt), no second seed.
|
|
181
|
+
await agentB.reconcile();
|
|
182
|
+
syncDocs(docA, docB);
|
|
183
|
+
|
|
184
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
185
|
+
expect(htmlFromDoc(docB)).toBe(BODY);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Cold-start decision matrix — hub-sync cold-start safety (DDR-102).
|
|
2
|
+
//
|
|
3
|
+
// Exhaustive table over (local: absent/empty/clean/diverged) × (doc:
|
|
4
|
+
// empty/same/different) × (journal: absent/match/stale), plus both winner
|
|
5
|
+
// directions and the null-timestamp fallbacks. Mirrors the table style of
|
|
6
|
+
// sync-codec.test.ts: every case is a row with explicit inputs + expected
|
|
7
|
+
// action.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import { type ColdStartInput, decideColdStart, isExactRepeat } from '../sync/cold-start.ts';
|
|
12
|
+
import { hashBytes } from '../sync/echo-guard.ts';
|
|
13
|
+
|
|
14
|
+
const BODY_LOCAL = '<div>local work — a day of mascot edits</div>';
|
|
15
|
+
const BODY_HUB = '<div>hub state</div>';
|
|
16
|
+
|
|
17
|
+
/** Convenience: build an input with safe defaults, override per case. */
|
|
18
|
+
function input(over: Partial<ColdStartInput>): ColdStartInput {
|
|
19
|
+
return {
|
|
20
|
+
localBody: null,
|
|
21
|
+
docBody: '',
|
|
22
|
+
journalHash: null,
|
|
23
|
+
localMtimeMs: null,
|
|
24
|
+
docBodyEditAtMs: null,
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('decideColdStart — empty/absent sides', () => {
|
|
30
|
+
test('local absent + doc empty → noop', () => {
|
|
31
|
+
expect(decideColdStart(input({})).action).toBe('noop');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('local empty-string + doc empty → noop', () => {
|
|
35
|
+
expect(decideColdStart(input({ localBody: '' })).action).toBe('noop');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('whitespace-only local counts as empty (mirrors the trim guard)', () => {
|
|
39
|
+
const d = decideColdStart(input({ localBody: ' \n\t ', docBody: BODY_HUB }));
|
|
40
|
+
expect(d.action).toBe('materialize-hub');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('local absent + doc non-empty → materialize-hub (clean first sync)', () => {
|
|
44
|
+
const d = decideColdStart(input({ docBody: BODY_HUB }));
|
|
45
|
+
expect(d.action).toBe('materialize-hub');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('local empty + doc non-empty → materialize-hub', () => {
|
|
49
|
+
const d = decideColdStart(input({ localBody: '', docBody: BODY_HUB }));
|
|
50
|
+
expect(d.action).toBe('materialize-hub');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('local non-empty + doc empty → seed-local-up (DDR-064 guard as a named case)', () => {
|
|
54
|
+
const d = decideColdStart(input({ localBody: BODY_LOCAL, docBody: '' }));
|
|
55
|
+
expect(d.action).toBe('seed-local-up');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('local non-empty + whitespace-only doc → seed-local-up', () => {
|
|
59
|
+
const d = decideColdStart(input({ localBody: BODY_LOCAL, docBody: ' \n ' }));
|
|
60
|
+
expect(d.action).toBe('seed-local-up');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('seed-local-up holds regardless of journal state (stale journal must not block it)', () => {
|
|
64
|
+
const d = decideColdStart(
|
|
65
|
+
input({ localBody: BODY_LOCAL, docBody: '', journalHash: 'stale-hash' })
|
|
66
|
+
);
|
|
67
|
+
expect(d.action).toBe('seed-local-up');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('decideColdStart — identical sides', () => {
|
|
72
|
+
test('local == doc → noop (caller records journal)', () => {
|
|
73
|
+
const d = decideColdStart(input({ localBody: BODY_HUB, docBody: BODY_HUB }));
|
|
74
|
+
expect(d.action).toBe('noop');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('local == doc with a stale journal is still a noop (content equality wins)', () => {
|
|
78
|
+
const d = decideColdStart(
|
|
79
|
+
input({ localBody: BODY_HUB, docBody: BODY_HUB, journalHash: 'stale' })
|
|
80
|
+
);
|
|
81
|
+
expect(d.action).toBe('noop');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('decideColdStart — concurrent cold-seed duplication (F1)', () => {
|
|
86
|
+
const BODY = '<div>export default fn — one copy</div>';
|
|
87
|
+
|
|
88
|
+
test('doc == local repeated twice → recover-seed-dup (collapse, not conflict)', () => {
|
|
89
|
+
const d = decideColdStart(input({ localBody: BODY, docBody: BODY + BODY }));
|
|
90
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('doc == local repeated three times (3-way seed) → recover-seed-dup', () => {
|
|
94
|
+
const d = decideColdStart(input({ localBody: BODY, docBody: BODY.repeat(3) }));
|
|
95
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('duplication recovery beats the conflict path even with divergent timestamps', () => {
|
|
99
|
+
// A doubled body is NOT new content — newest-wins must not get to pick it.
|
|
100
|
+
const d = decideColdStart(
|
|
101
|
+
input({
|
|
102
|
+
localBody: BODY,
|
|
103
|
+
docBody: BODY + BODY,
|
|
104
|
+
localMtimeMs: 1_000,
|
|
105
|
+
docBodyEditAtMs: 9_999,
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('duplication recovery fires even with a stale journal', () => {
|
|
112
|
+
const d = decideColdStart(
|
|
113
|
+
input({ localBody: BODY, docBody: BODY + BODY, journalHash: 'stale' })
|
|
114
|
+
);
|
|
115
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('doc = local + genuinely different bytes is NOT a repeat → conflict (snapshots both)', () => {
|
|
119
|
+
const d = decideColdStart(
|
|
120
|
+
input({
|
|
121
|
+
localBody: BODY,
|
|
122
|
+
docBody: `${BODY}<div>a real concurrent edit</div>`,
|
|
123
|
+
localMtimeMs: 2_000,
|
|
124
|
+
docBodyEditAtMs: 1_000,
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
expect(d.action).toBe('conflict');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('exact equality (×1) stays a noop, never recover-seed-dup', () => {
|
|
131
|
+
expect(decideColdStart(input({ localBody: BODY, docBody: BODY })).action).toBe('noop');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('isExactRepeat — exact multiples only', () => {
|
|
135
|
+
expect(isExactRepeat(BODY + BODY, BODY)).toBe(true);
|
|
136
|
+
expect(isExactRepeat(BODY.repeat(4), BODY)).toBe(true);
|
|
137
|
+
expect(isExactRepeat(BODY, BODY)).toBe(false); // ×1 is equality, not duplication
|
|
138
|
+
expect(isExactRepeat(`${BODY}x`, BODY)).toBe(false); // not a clean multiple
|
|
139
|
+
expect(isExactRepeat(`${BODY}${BODY}x`, BODY)).toBe(false);
|
|
140
|
+
expect(isExactRepeat('', BODY)).toBe(false);
|
|
141
|
+
expect(isExactRepeat(BODY, '')).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('decideColdStart — journal-gated fast-forward', () => {
|
|
146
|
+
test('local ≠ doc, journal matches hash(local) → fast-forward-hub (no conflict)', () => {
|
|
147
|
+
const d = decideColdStart(
|
|
148
|
+
input({
|
|
149
|
+
localBody: BODY_LOCAL,
|
|
150
|
+
docBody: BODY_HUB,
|
|
151
|
+
journalHash: hashBytes(BODY_LOCAL),
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
expect(d.action).toBe('fast-forward-hub');
|
|
155
|
+
expect(d.winner).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('journal match overrides timestamps — even a NEWER local mtime fast-forwards', () => {
|
|
159
|
+
// mtime newer than the doc stamp, but the bytes are exactly what we last
|
|
160
|
+
// synced (e.g. touch / checkout changed mtime only) → not a conflict.
|
|
161
|
+
const d = decideColdStart(
|
|
162
|
+
input({
|
|
163
|
+
localBody: BODY_LOCAL,
|
|
164
|
+
docBody: BODY_HUB,
|
|
165
|
+
journalHash: hashBytes(BODY_LOCAL),
|
|
166
|
+
localMtimeMs: 2_000,
|
|
167
|
+
docBodyEditAtMs: 1_000,
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
expect(d.action).toBe('fast-forward-hub');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('decideColdStart — divergence (conflict + newest-wins)', () => {
|
|
175
|
+
test('journal ABSENT, local newer → conflict, winner=local (the incident shape)', () => {
|
|
176
|
+
const d = decideColdStart(
|
|
177
|
+
input({
|
|
178
|
+
localBody: BODY_LOCAL,
|
|
179
|
+
docBody: BODY_HUB,
|
|
180
|
+
localMtimeMs: 2_000,
|
|
181
|
+
docBodyEditAtMs: 1_000,
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
expect(d.action).toBe('conflict');
|
|
185
|
+
expect(d.winner).toBe('local');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('journal ABSENT, doc newer → conflict, winner=hub', () => {
|
|
189
|
+
const d = decideColdStart(
|
|
190
|
+
input({
|
|
191
|
+
localBody: BODY_LOCAL,
|
|
192
|
+
docBody: BODY_HUB,
|
|
193
|
+
localMtimeMs: 1_000,
|
|
194
|
+
docBodyEditAtMs: 2_000,
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
expect(d.action).toBe('conflict');
|
|
198
|
+
expect(d.winner).toBe('hub');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('journal STALE (≠ hash(local)), local newer → conflict, winner=local', () => {
|
|
202
|
+
const d = decideColdStart(
|
|
203
|
+
input({
|
|
204
|
+
localBody: BODY_LOCAL,
|
|
205
|
+
docBody: BODY_HUB,
|
|
206
|
+
journalHash: hashBytes('<div>some older synced state</div>'),
|
|
207
|
+
localMtimeMs: 5_000,
|
|
208
|
+
docBodyEditAtMs: 4_000,
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
expect(d.action).toBe('conflict');
|
|
212
|
+
expect(d.winner).toBe('local');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('journal STALE, doc newer → conflict, winner=hub', () => {
|
|
216
|
+
const d = decideColdStart(
|
|
217
|
+
input({
|
|
218
|
+
localBody: BODY_LOCAL,
|
|
219
|
+
docBody: BODY_HUB,
|
|
220
|
+
journalHash: hashBytes('<div>some older synced state</div>'),
|
|
221
|
+
localMtimeMs: 4_000,
|
|
222
|
+
docBodyEditAtMs: 5_000,
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
expect(d.action).toBe('conflict');
|
|
226
|
+
expect(d.winner).toBe('hub');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('journal matching hash(DOC) — not local — is still divergence', () => {
|
|
230
|
+
// The journal proves we once synced what the HUB now holds reverted to;
|
|
231
|
+
// local moved past it → local is unreconciled work, must conflict.
|
|
232
|
+
const d = decideColdStart(
|
|
233
|
+
input({
|
|
234
|
+
localBody: BODY_LOCAL,
|
|
235
|
+
docBody: BODY_HUB,
|
|
236
|
+
journalHash: hashBytes(BODY_HUB),
|
|
237
|
+
localMtimeMs: 2_000,
|
|
238
|
+
docBodyEditAtMs: 1_000,
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
expect(d.action).toBe('conflict');
|
|
242
|
+
expect(d.winner).toBe('local');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('doc stamp NULL (older peer never stamped) → hub-wins fallback', () => {
|
|
246
|
+
const d = decideColdStart(
|
|
247
|
+
input({
|
|
248
|
+
localBody: BODY_LOCAL,
|
|
249
|
+
docBody: BODY_HUB,
|
|
250
|
+
localMtimeMs: 2_000,
|
|
251
|
+
docBodyEditAtMs: null,
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
expect(d.action).toBe('conflict');
|
|
255
|
+
expect(d.winner).toBe('hub');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('local mtime NULL → hub-wins fallback', () => {
|
|
259
|
+
const d = decideColdStart(
|
|
260
|
+
input({
|
|
261
|
+
localBody: BODY_LOCAL,
|
|
262
|
+
docBody: BODY_HUB,
|
|
263
|
+
localMtimeMs: null,
|
|
264
|
+
docBodyEditAtMs: 2_000,
|
|
265
|
+
})
|
|
266
|
+
);
|
|
267
|
+
expect(d.action).toBe('conflict');
|
|
268
|
+
expect(d.winner).toBe('hub');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('both timestamps NULL → hub-wins fallback', () => {
|
|
272
|
+
const d = decideColdStart(input({ localBody: BODY_LOCAL, docBody: BODY_HUB }));
|
|
273
|
+
expect(d.action).toBe('conflict');
|
|
274
|
+
expect(d.winner).toBe('hub');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('timestamps TIED → hub-wins fallback', () => {
|
|
278
|
+
const d = decideColdStart(
|
|
279
|
+
input({
|
|
280
|
+
localBody: BODY_LOCAL,
|
|
281
|
+
docBody: BODY_HUB,
|
|
282
|
+
localMtimeMs: 1_000,
|
|
283
|
+
docBodyEditAtMs: 1_000,
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
expect(d.action).toBe('conflict');
|
|
287
|
+
expect(d.winner).toBe('hub');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('every decision carries a human-readable reason', () => {
|
|
291
|
+
const cases: ColdStartInput[] = [
|
|
292
|
+
input({}),
|
|
293
|
+
input({ docBody: BODY_HUB }),
|
|
294
|
+
input({ localBody: BODY_LOCAL }),
|
|
295
|
+
input({ localBody: BODY_HUB, docBody: BODY_HUB }),
|
|
296
|
+
input({ localBody: BODY_LOCAL, docBody: BODY_HUB, journalHash: hashBytes(BODY_LOCAL) }),
|
|
297
|
+
input({ localBody: BODY_LOCAL, docBody: BODY_HUB }),
|
|
298
|
+
];
|
|
299
|
+
for (const c of cases) {
|
|
300
|
+
const d = decideColdStart(c);
|
|
301
|
+
expect(d.reason.length).toBeGreaterThan(0);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -144,3 +144,71 @@ describe('connection monitor', () => {
|
|
|
144
144
|
expect(changes.length).toBe(before); // no offline transition fired
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
// DDR-102 — per-doc states + real lastSyncAt.
|
|
149
|
+
describe('connection monitor — per-doc states (DDR-102)', () => {
|
|
150
|
+
test('noteDocState rolls up into docs counts + rejectedSlugs', () => {
|
|
151
|
+
const { monitor } = makeMonitor();
|
|
152
|
+
monitor.noteDocState('ui-a', 'pending');
|
|
153
|
+
monitor.noteDocState('ui-b', 'connected');
|
|
154
|
+
monitor.noteDocState('ui-c', 'auth-rejected');
|
|
155
|
+
monitor.noteDocState('ui-d', 'auth-rejected');
|
|
156
|
+
|
|
157
|
+
const snap = monitor.snapshot();
|
|
158
|
+
expect(snap.docs).toEqual({ synced: 1, pending: 1, rejected: 2 });
|
|
159
|
+
expect(snap.rejectedSlugs?.sort()).toEqual(['ui-c', 'ui-d']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('doc state transitions update the rollup (rejected → connected after re-link)', () => {
|
|
163
|
+
const { monitor, changes } = makeMonitor();
|
|
164
|
+
monitor.noteDocState('ui-a', 'auth-rejected');
|
|
165
|
+
expect(monitor.snapshot().docs?.rejected).toBe(1);
|
|
166
|
+
monitor.noteDocState('ui-a', 'connected');
|
|
167
|
+
const snap = monitor.snapshot();
|
|
168
|
+
expect(snap.docs).toEqual({ synced: 1, pending: 0, rejected: 0 });
|
|
169
|
+
expect(snap.rejectedSlugs).toEqual([]);
|
|
170
|
+
// Each transition emitted (no-op repeats don't).
|
|
171
|
+
const emitted = changes.length;
|
|
172
|
+
monitor.noteDocState('ui-a', 'connected');
|
|
173
|
+
expect(changes.length).toBe(emitted);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('rejectedSlugs caps at 20 while docs.rejected carries the true count', () => {
|
|
177
|
+
const { monitor } = makeMonitor();
|
|
178
|
+
for (let i = 0; i < 25; i++) monitor.noteDocState(`ui-${i}`, 'auth-rejected');
|
|
179
|
+
const snap = monitor.snapshot();
|
|
180
|
+
expect(snap.docs?.rejected).toBe(25);
|
|
181
|
+
expect(snap.rejectedSlugs).toHaveLength(20);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('noteSyncActivity sets lastSyncAt to now and promotes a pending doc', () => {
|
|
185
|
+
const { clock, monitor } = makeMonitor();
|
|
186
|
+
monitor.noteDocState('ui-a', 'pending');
|
|
187
|
+
expect(monitor.snapshot().lastSyncAt).toBeNull();
|
|
188
|
+
|
|
189
|
+
clock.advance(5_000);
|
|
190
|
+
monitor.noteSyncActivity('ui-a');
|
|
191
|
+
const snap = monitor.snapshot();
|
|
192
|
+
expect(snap.lastSyncAt).toBe(clock.now());
|
|
193
|
+
expect(snap.docs?.synced).toBe(1);
|
|
194
|
+
expect(snap.docs?.pending).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('noteSyncActivity does NOT resurrect an auth-rejected doc', () => {
|
|
198
|
+
const { monitor } = makeMonitor();
|
|
199
|
+
monitor.noteDocState('ui-a', 'auth-rejected');
|
|
200
|
+
monitor.noteSyncActivity('ui-a');
|
|
201
|
+
expect(monitor.snapshot().docs?.rejected).toBe(1);
|
|
202
|
+
expect(monitor.snapshot().lastSyncAt).not.toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('lastSyncAt advances on repeated activity (real activity, not just transitions)', () => {
|
|
206
|
+
const { clock, monitor } = makeMonitor();
|
|
207
|
+
monitor.noteDocState('ui-a', 'connected');
|
|
208
|
+
monitor.noteSyncActivity('ui-a');
|
|
209
|
+
const first = monitor.snapshot().lastSyncAt;
|
|
210
|
+
clock.advance(10_000);
|
|
211
|
+
monitor.noteSyncActivity('ui-a');
|
|
212
|
+
expect(monitor.snapshot().lastSyncAt).toBe((first as number) + 10_000);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -54,25 +54,19 @@ describe('DDR-054 §2a — CI environment gate', () => {
|
|
|
54
54
|
savedCi = process.env.CI;
|
|
55
55
|
savedGha = process.env.GITHUB_ACTIONS;
|
|
56
56
|
savedOverride = process.env.MAUDE_SYNC_IN_CI;
|
|
57
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
58
57
|
delete process.env.CI;
|
|
59
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
60
58
|
delete process.env.GITHUB_ACTIONS;
|
|
61
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
62
59
|
delete process.env.MAUDE_SYNC_IN_CI;
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
afterEach(() => {
|
|
66
63
|
if (savedCi === undefined) {
|
|
67
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
68
64
|
delete process.env.CI;
|
|
69
65
|
} else process.env.CI = savedCi;
|
|
70
66
|
if (savedGha === undefined) {
|
|
71
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
72
67
|
delete process.env.GITHUB_ACTIONS;
|
|
73
68
|
} else process.env.GITHUB_ACTIONS = savedGha;
|
|
74
69
|
if (savedOverride === undefined) {
|
|
75
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
76
70
|
delete process.env.MAUDE_SYNC_IN_CI;
|
|
77
71
|
} else process.env.MAUDE_SYNC_IN_CI = savedOverride;
|
|
78
72
|
});
|
|
@@ -443,7 +437,6 @@ describe('DDR-054 §2i — auto-clear adopt after first success', () => {
|
|
|
443
437
|
const savedHubsEnv = process.env.HUBS_CONFIG_PATH;
|
|
444
438
|
process.env.HUBS_CONFIG_PATH = hubsPath;
|
|
445
439
|
const savedCi = process.env.CI;
|
|
446
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
447
440
|
delete process.env.CI;
|
|
448
441
|
|
|
449
442
|
try {
|
|
@@ -504,7 +497,6 @@ describe('DDR-054 §2i — auto-clear adopt after first success', () => {
|
|
|
504
497
|
expect(typeof after.linkedHub.lastAdoptedAt).toBe('number');
|
|
505
498
|
} finally {
|
|
506
499
|
if (savedHubsEnv === undefined) {
|
|
507
|
-
// biome-ignore lint/performance/noDelete: process.env semantics
|
|
508
500
|
delete process.env.HUBS_CONFIG_PATH;
|
|
509
501
|
} else process.env.HUBS_CONFIG_PATH = savedHubsEnv;
|
|
510
502
|
if (savedCi !== undefined) process.env.CI = savedCi;
|
|
@@ -21,7 +21,6 @@ beforeEach(() => {
|
|
|
21
21
|
afterEach(() => {
|
|
22
22
|
// Node's process.env stringifies on assignment; assigning undefined yields
|
|
23
23
|
// the literal string "undefined". delete is the correct restoration.
|
|
24
|
-
// biome-ignore lint/performance/noDelete: process.env semantics.
|
|
25
24
|
if (savedEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
|
|
26
25
|
else process.env.HUBS_CONFIG_PATH = savedEnv;
|
|
27
26
|
rmSync(dir, { recursive: true, force: true });
|