@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.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. 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 });