@1agh/maude 0.60.7 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +201 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -0,0 +1,200 @@
1
+ // The journal read client — Sync v2 Increment 2 (DDR-226 §5).
2
+ //
3
+ // One place that knows how to ask a hub "what changed since <seq>", and one
4
+ // place that knows how to ask "do you even have a journal". Increment 2 uses
5
+ // both for the cell child's heal path; Increment 3's pull engine uses the same
6
+ // two functions rather than growing its own.
7
+ //
8
+ // EVERYTHING HERE IS UNTRUSTED INPUT. The hub is semi-trusted by design
9
+ // (DDR-054): it may read and write synced state, and everything it says about
10
+ // that state is a hint. So this module's job is not to fetch — it is to REFUSE.
11
+ // A row whose shape is wrong is dropped; a `reanchor` is honoured rather than
12
+ // argued with; a page that cannot be parsed is `null`, which every caller reads
13
+ // as "ask again later", never as "nothing changed".
14
+ //
15
+ // FAIL CLOSED ON THE CURSOR. `{ reanchor: true }` is not an error and not an
16
+ // empty page — it is the hub saying "your cursor is not in this log". Reading
17
+ // it as "no changes" is exactly the shape that lets a stale peer believe it is
18
+ // current, which is the failure DDR-214's ordering amendment exists to prevent.
19
+
20
+ /** How long to wait for a journal page. Same figure as the manifest fetch. */
21
+ const JOURNAL_TIMEOUT_MS = 6000;
22
+
23
+ /**
24
+ * The hub's own published page ceiling, restated here.
25
+ *
26
+ * A receiver that trusts the sender to respect the sender's cap has no cap.
27
+ */
28
+ const MAX_JOURNAL_PAGE = 2000;
29
+
30
+ /** An epoch is a UUID-ish token; anything longer is not one. */
31
+ const MAX_EPOCH_LEN = 128;
32
+
33
+ /** How long to wait for `/health`. A capability probe must never hang a boot. */
34
+ const HEALTH_TIMEOUT_MS = 4000;
35
+
36
+ /** The shape a peer may act on. Anything else is dropped at parse time. */
37
+ export interface JournalEntry {
38
+ seq: number;
39
+ path: string;
40
+ sha256: string | null;
41
+ size: number | null;
42
+ /** DISPLAY ONLY. Never an overwrite authority anywhere (F4). */
43
+ mtimeMs: number | null;
44
+ /** The hub's claimed class — a reporting hint; receivers re-classify. */
45
+ class: string;
46
+ deleted: boolean;
47
+ }
48
+
49
+ export interface JournalPage {
50
+ epoch: string | null;
51
+ head: number;
52
+ entries: JournalEntry[];
53
+ truncated: boolean;
54
+ /** The cursor is not in this log — re-anchor against a full compaction. */
55
+ reanchor: boolean;
56
+ reason?: string;
57
+ /** The hub sent more than its own published ceiling; the rest was dropped. */
58
+ overflowed?: true;
59
+ }
60
+
61
+ /** A designRoot-relative path shape a peer will turn into a real file. */
62
+ const ENTRY_PATH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
63
+
64
+ function parseEntry(raw: unknown): JournalEntry | null {
65
+ if (!raw || typeof raw !== 'object') return null;
66
+ const e = raw as Record<string, unknown>;
67
+ const seq = e.seq;
68
+ const p = e.path;
69
+ if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return null;
70
+ if (typeof p !== 'string' || !ENTRY_PATH_RE.test(p) || p.split('/').includes('..')) return null;
71
+ const sha = typeof e.sha256 === 'string' && /^[0-9a-f]{64}$/.test(e.sha256) ? e.sha256 : null;
72
+ return {
73
+ seq,
74
+ path: p,
75
+ sha256: sha,
76
+ size: typeof e.size === 'number' && Number.isFinite(e.size) ? e.size : null,
77
+ mtimeMs: typeof e.mtimeMs === 'number' && Number.isFinite(e.mtimeMs) ? e.mtimeMs : null,
78
+ class: typeof e.class === 'string' ? e.class.slice(0, 32) : '',
79
+ deleted: e.deleted === true,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Fetch one journal page.
85
+ *
86
+ * Returns null when the hub is unreachable, refuses, or does not have the
87
+ * route — the `fetchRemoteListing` posture: sync continues either way and we
88
+ * ask again later. A null is never "nothing changed".
89
+ */
90
+ export async function fetchJournal(opts: {
91
+ hubUrl: string;
92
+ token: string;
93
+ since?: number;
94
+ /** Send the epoch a cursor belongs to so a mismatch fails closed hub-side. */
95
+ epoch?: string | null;
96
+ fetchImpl?: typeof fetch;
97
+ }): Promise<JournalPage | null> {
98
+ const fetchImpl = opts.fetchImpl ?? fetch;
99
+ const base = opts.hubUrl.replace(/\/+$/, '');
100
+ const params = new URLSearchParams();
101
+ params.set('since', String(Math.max(0, Math.trunc(opts.since ?? 0))));
102
+ if (opts.epoch) params.set('epoch', opts.epoch);
103
+ try {
104
+ const res = await fetchImpl(`${base}/api/journal?${params}`, {
105
+ headers: { authorization: `Bearer ${opts.token}` },
106
+ signal: AbortSignal.timeout(JOURNAL_TIMEOUT_MS),
107
+ });
108
+ if (!res.ok) return null;
109
+ const body = (await res.json()) as Record<string, unknown>;
110
+ // The head and the epoch are the anchor the whole cursor protocol rests
111
+ // on, and both arrive from a component DDR-054 calls untrusted. A
112
+ // fractional or negative head, or an unbounded epoch string, gets
113
+ // persisted into the ledger verbatim otherwise.
114
+ const rawHead = body?.head;
115
+ const head =
116
+ typeof rawHead === 'number' && Number.isInteger(rawHead) && rawHead >= 0 ? rawHead : 0;
117
+ const epoch =
118
+ typeof body?.epoch === 'string' && body.epoch.length > 0 && body.epoch.length <= MAX_EPOCH_LEN
119
+ ? body.epoch
120
+ : null;
121
+ if (body?.reanchor === true) {
122
+ return {
123
+ epoch,
124
+ head,
125
+ entries: [],
126
+ truncated: false,
127
+ reanchor: true,
128
+ ...(typeof body.reason === 'string' ? { reason: body.reason.slice(0, 120) } : {}),
129
+ };
130
+ }
131
+ // CAP THE PAGE at the hub's own published ceiling. An honest hub never
132
+ // exceeds it; a hostile one ignores its own cap, and every entry past this
133
+ // point becomes a ledger row on disk, a key in `_sync.json`, and a member
134
+ // of the union every future pass re-walks. One response should not be able
135
+ // to grow this machine's state without bound.
136
+ const rawEntries = Array.isArray(body?.entries) ? body.entries : [];
137
+ const overflowed = rawEntries.length > MAX_JOURNAL_PAGE;
138
+ const entries: JournalEntry[] = [];
139
+ for (const raw of rawEntries.slice(0, MAX_JOURNAL_PAGE)) {
140
+ const parsed = parseEntry(raw);
141
+ if (parsed !== null) entries.push(parsed);
142
+ }
143
+ return {
144
+ epoch,
145
+ head,
146
+ entries,
147
+ // An over-long page is treated as truncated, which is already the signal
148
+ // meaning "there is more; come back" — so the pass converges instead of
149
+ // silently believing it saw everything.
150
+ truncated: body?.truncated === true || overflowed,
151
+ reanchor: false,
152
+ ...(overflowed ? { overflowed: true } : {}),
153
+ };
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * What protocol features a hub advertises — the compat matrix's gate
161
+ * (DDR-226 §10, BINDING).
162
+ *
163
+ * Returns null when `/health` is unreachable or unparseable. A caller must
164
+ * treat BOTH null and a set without `ledger` as "this hub has no journal":
165
+ * attach no control channel, relax no polling, keep every legacy lane. The
166
+ * distinction between "an old hub that omits the field" and "a new hub with no
167
+ * checkout" matters for diagnostics, not for behaviour.
168
+ */
169
+ export async function hubCapabilities(opts: {
170
+ hubUrl: string;
171
+ fetchImpl?: typeof fetch;
172
+ /**
173
+ * Cancel the probe when the caller goes away.
174
+ *
175
+ * A boot-time probe that outlives its runtime is a timer holding a dying
176
+ * process open and a promise resolving into torn-down state. The caller
177
+ * aborts this in `stop()`; without it the 4 s timeout alone decides.
178
+ */
179
+ signal?: AbortSignal;
180
+ }): Promise<string[] | null> {
181
+ const fetchImpl = opts.fetchImpl ?? fetch;
182
+ const base = opts.hubUrl.replace(/\/+$/, '');
183
+ try {
184
+ const timeout = AbortSignal.timeout(HEALTH_TIMEOUT_MS);
185
+ const res = await fetchImpl(`${base}/health`, {
186
+ signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout,
187
+ });
188
+ if (!res.ok) return null;
189
+ const body = (await res.json()) as { capabilities?: unknown };
190
+ if (!Array.isArray(body?.capabilities)) return null;
191
+ return body.capabilities.filter((c): c is string => typeof c === 'string').slice(0, 32);
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /** Does this hub carry the journal file plane? */
198
+ export function hasLedger(capabilities: string[] | null): boolean {
199
+ return Array.isArray(capabilities) && capabilities.includes('ledger');
200
+ }
@@ -48,7 +48,9 @@ import {
48
48
  stampBodyEdit,
49
49
  Y_SYNC_TYPES,
50
50
  } from './codec.ts';
51
+ import type { ColdStartAction } from './cold-start.ts';
51
52
  import { decideAnnotationsColdStart, decideColdStart, unionCommentsById } from './cold-start.ts';
53
+ import { applyColdStart } from './cold-start-apply.ts';
52
54
  import { hashBytes } from './echo-guard.ts';
53
55
  import type { SyncJournal } from './journal.ts';
54
56
  import { ORIGINS } from './origins.ts';
@@ -73,6 +75,23 @@ export interface MigrateSeedOptions {
73
75
  journal?: SyncJournal;
74
76
  /** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
75
77
  snapshot?: (content: string, reason: 'pre-sync-local' | 'pre-sync-hub') => Promise<string | null>;
78
+ /**
79
+ * Does the HUB hold state for this slug, per its last document listing?
80
+ *
81
+ * `docIsEmpty` asks the local replica, and an empty replica has two
82
+ * completely different meanings: "the hub has never seen this canvas" (adopt
83
+ * — the local file is the only copy) and "the hub's state has not landed in
84
+ * this replica YET" (do nothing — it is on its way). Adopting in the second
85
+ * case is the DDR-102 F1 concurrent cold-seed collision: both peers
86
+ * clear-and-rebuild their own replica from a file with the same bytes, and
87
+ * because the two runs carry different client ids the CRDT merge
88
+ * CONCATENATES them — the canvas ends up with its body twice on every peer.
89
+ *
90
+ * The listing is the only enumeration a peer has, so it is also the only way
91
+ * to tell the two cases apart before writing. Absent ⇒ treated as "the hub
92
+ * does not have it", which is the pre-existing behaviour.
93
+ */
94
+ hubHasState?: (slug: string) => boolean;
76
95
  /** DDR-102 — divergence notification, same contract as the agent's. */
77
96
  onConflict?: (info: {
78
97
  slug: string;
@@ -90,9 +109,19 @@ export type MigrateSeedResult =
90
109
  | 'empty'
91
110
  /** DDR-102 — doc held state but no body; local body seeded up in-place. */
92
111
  | 'body-seed-up'
112
+ /** DDR-102 F1 — the hub body was our local body repeated N≥2 times (a
113
+ * concurrent cold-seed collision); local was re-applied so the codec's diff
114
+ * deletes the duplicate. Before Increment 0 this decision had NO case in
115
+ * this module's switch and fell through to `hub-wins`, keeping (and
116
+ * materializing) the doubled body. */
117
+ | 'recover-seed-dup'
93
118
  /** DDR-102 — divergence resolved newest-wins. */
94
119
  | 'conflict-local-wins'
95
- | 'conflict-hub-wins';
120
+ | 'conflict-hub-wins'
121
+ /** The replica is empty but the HUB is not — its state is still in flight.
122
+ * Seeding here is the F1 collision (see `hubHasState`), so this seed does
123
+ * nothing and lets the document arrive. */
124
+ | 'defer-hub-state';
96
125
 
97
126
  /** True when the shared doc holds no synced content for any of the five types. */
98
127
  export function docIsEmpty(doc: Y.Doc): boolean {
@@ -122,6 +151,26 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
122
151
  // a single MIGRATION transaction. The apply* codecs delete-then-insert, so
123
152
  // this is a clear+rebuild (re-running is a no-op once content matches).
124
153
  if (docIsEmpty(doc)) {
154
+ // AN EMPTY REPLICA IS NOT AN EMPTY HUB. See `hubHasState` — on a cell the
155
+ // hub's workspace agent writes every document onto the checkout, so the
156
+ // studio child scans up a file whose document the hub already owns. Seeding
157
+ // from it doubles the body on every peer.
158
+ if (opts.hubHasState?.(slug)) {
159
+ // SNAPSHOT BEFORE STANDING ASIDE. Deferring means the local body never
160
+ // enters the doc — so when the hub's state does arrive, the projection
161
+ // writes it over this file with no `pre-sync-local` copy behind it and no
162
+ // conflict recorded, because both live in the non-empty branch this
163
+ // return skips. The adopt path it replaced could not lose the local body:
164
+ // it was inside the merge. One snapshot buys back the recoverability.
165
+ if (localHtml && opts.snapshot) {
166
+ try {
167
+ await opts.snapshot(localHtml, 'pre-sync-local');
168
+ } catch {
169
+ /* best-effort — history is a safety net, never a gate on syncing */
170
+ }
171
+ }
172
+ return 'defer-hub-state';
173
+ }
125
174
  const hasLocal =
126
175
  !!localHtml || !!localComments || !!localAnnotations || !!localMeta || !!localCss;
127
176
  if (!hasLocal) return 'empty';
@@ -175,8 +224,6 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
175
224
  docBodyEditAtMs: bodyEditAtFromDoc(doc),
176
225
  });
177
226
 
178
- let result: MigrateSeedResult = 'hub-wins';
179
-
180
227
  /** Rebuild body (+ visually-coupled css) from local, in ONE MIGRATION
181
228
  * transaction — one source per type, never a two-history merge.
182
229
  * Annotations are deliberately NOT here any more: they resolve per-lane
@@ -194,69 +241,29 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
194
241
  });
195
242
  };
196
243
 
197
- switch (decision.action) {
198
- case 'noop':
199
- // Identical (or both empty) — checkpoint identity so the next boot
200
- // fast-forwards even if the hub then moves ahead.
201
- if (localHtml !== null && localHtml === docHtml && docHtml !== '') {
202
- opts.journal?.record(slug, { bodyHash: hashBytes(docHtml) });
203
- }
204
- break;
205
- case 'materialize-hub':
206
- case 'fast-forward-hub':
207
- // Keep the doc; the projection materializes it to disk (and records the
208
- // journal checkpoint on its write).
209
- break;
210
- case 'seed-local-up':
211
- // Doc holds state for OTHER types but no body — seed the local body up.
212
- rebuildBodyFromLocal();
213
- result = 'body-seed-up';
214
- break;
215
- case 'conflict': {
216
- const snapshots: { local?: string; hub?: string } = {};
217
- let snapshotAttempted = false;
218
- if (opts.snapshot) {
219
- snapshotAttempted = true;
220
- try {
221
- const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
222
- if (localTs) snapshots.local = localTs;
223
- const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
224
- if (hubTs) snapshots.hub = hubTs;
225
- } catch {
226
- /* swallowed below — the missing snapshot ref drives the fail-closed guard */
227
- }
228
- }
229
- // DDR-102 fail-closed (security F1): a hub-wins resolution keeps the doc
230
- // (hub) state, which projection.reconcile() then materializes to disk —
231
- // OVERWRITING local. If the local snapshot didn't land, rebuild the body
232
- // from local instead so the projection writes local back (a no-op on
233
- // disk) and nothing is lost. Gated on `snapshotAttempted` so a
234
- // snapshot-less standalone call keeps plain newest-wins.
235
- const localSnapshotMissing = snapshotAttempted && !snapshots.local;
236
- let winner = decision.winner;
237
- if (winner === 'hub' && localSnapshotMissing) {
238
- winner = 'local';
239
- console.error(
240
- `[sync/${slug}] shared-doc cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Rebuilding the doc from local; resolve the _history/ write failure to restore newest-wins.`
241
- );
242
- }
243
- if (winner === 'local') {
244
- rebuildBodyFromLocal();
245
- result = 'conflict-local-wins';
246
- } else {
247
- result = 'conflict-hub-wins';
248
- }
249
- console.warn(`[sync/${slug}] shared-doc cold-start divergence — ${decision.reason}`);
250
- opts.onConflict?.({
251
- slug,
252
- kind: 'cold-start-diverged',
253
- winner,
254
- ...(snapshots.local || snapshots.hub ? { snapshots } : {}),
255
- ...(localSnapshotMissing ? { snapshotFailed: true } : {}),
256
- });
257
- break;
258
- }
259
- }
244
+ // ONE application body, shared with agent.ts reconcile (DDR-226 Increment 0).
245
+ // `takeHub` is a genuine no-op here: under shared-doc the doc KEEPS its state
246
+ // and `projection.reconcile()` materializes it to disk (recording the journal
247
+ // checkpoint on its own write). Everything else — the dual snapshot, the
248
+ // fail-closed refusal, the conflict report, and crucially the
249
+ // `recover-seed-dup` row this switch used to be missing — now lives in
250
+ // exactly one place.
251
+ const applied = await applyColdStart({
252
+ slug,
253
+ decision,
254
+ localBody: localHtml,
255
+ docBody: docHtml,
256
+ takeHub: () => {
257
+ /* the projection materializes the doc; nothing to do here */
258
+ },
259
+ takeLocal: rebuildBodyFromLocal,
260
+ checkpointIdentity: (body) => opts.journal?.record(slug, { bodyHash: hashBytes(body) }),
261
+ logLabel: 'shared-doc',
262
+ ...(opts.snapshot ? { snapshot: opts.snapshot } : {}),
263
+ ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
264
+ });
265
+
266
+ const result: MigrateSeedResult = resultFor(applied.action, applied.bodyWinner);
260
267
 
261
268
  // ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix; the
262
269
  // same table as agent.ts reconcile). Under sharedDoc the collab room's
@@ -273,7 +280,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
273
280
  isEmpty: isEmptyAnnotationsSvg,
274
281
  localMtimeMs: localMtimeMs(paths.annotations),
275
282
  docEditAtMs: annotationsEditAtFromDoc(doc),
276
- bodyWinner: result === 'body-seed-up' || result === 'conflict-local-wins' ? 'local' : 'hub',
283
+ bodyWinner: applied.bodyWinner,
277
284
  });
278
285
  if (annDecision.winner === 'local' && localAnnotations !== null) {
279
286
  console.warn(`[sync/${slug}] shared-doc cold-start annotations: ${annDecision.reason}`);
@@ -312,6 +319,31 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
312
319
 
313
320
  /* ---------------------------------------------------------------- helpers */
314
321
 
322
+ /**
323
+ * Map the shared applier's verdict onto this module's public result string.
324
+ * Total over `ColdStartAction`, so a new row in the table surfaces here as a
325
+ * type error instead of silently reading as `hub-wins` (the exact shape of the
326
+ * `recover-seed-dup` bug this refactor fixed).
327
+ */
328
+ function resultFor(action: ColdStartAction, bodyWinner: 'local' | 'hub'): MigrateSeedResult {
329
+ switch (action) {
330
+ case 'noop':
331
+ case 'materialize-hub':
332
+ case 'fast-forward-hub':
333
+ return 'hub-wins';
334
+ case 'seed-local-up':
335
+ return 'body-seed-up';
336
+ case 'recover-seed-dup':
337
+ return 'recover-seed-dup';
338
+ case 'conflict':
339
+ return bodyWinner === 'local' ? 'conflict-local-wins' : 'conflict-hub-wins';
340
+ default: {
341
+ const never: never = action;
342
+ throw new Error(`migrate-seed: unhandled cold-start action ${String(never)}`);
343
+ }
344
+ }
345
+ }
346
+
315
347
  function snapshotLocal(opts: MigrateSeedOptions): void {
316
348
  if (!opts.historyDir) return;
317
349
  try {
@@ -0,0 +1,50 @@
1
+ // The poke frame's vocabulary, receiver side — Sync v2 Increment 2
2
+ // (DDR-226 §4).
3
+ //
4
+ // A DELIBERATE TWIN of `apps/hub/src/files-ctl.mjs`'s `parsePoke`, for the same
5
+ // reason `file-membership.ts` has a `.mjs` mirror: the hub image installs from
6
+ // its own frozen lockfile and cannot import out of `apps/studio`, so the two
7
+ // ends of a wire format live in two files. DDR-198's rule applies — a twin is
8
+ // allowed only with a drift test that imports BOTH and asserts they agree. That
9
+ // test is `apps/studio/test/sync-poke-parity.test.ts`; if you change the shape
10
+ // here, change it there, and the test is what makes forgetting a red build.
11
+ //
12
+ // WHY THE FRAME IS THIS SMALL. Everything on this channel arrives from the hub,
13
+ // which is untrusted to peers (DDR-054). A frame that carried a path would be a
14
+ // path the hub chose; a frame that carried a hash would be a hash the hub chose.
15
+ // So it carries neither. `head` is a HINT that something moved — the receiver
16
+ // then asks through an authenticated, scope-filtered route and believes the
17
+ // answer, not the doorbell.
18
+
19
+ /** Max frame size. A doorbell has no reason to be long. */
20
+ const MAX_POKE_BYTES = 512;
21
+
22
+ export interface Poke {
23
+ /** The hub's journal head at emit time. A hint, never an authority. */
24
+ head: number;
25
+ }
26
+
27
+ /**
28
+ * Parse a control frame. Returns null for anything that is not exactly
29
+ * `{ t: 'files', head: <non-negative integer> }`.
30
+ *
31
+ * Extra properties are DROPPED rather than refused: a future hub adding a field
32
+ * must not silence the channel for an older peer (the additive-field rule the
33
+ * documents listing already follows). What is never accepted is a missing or
34
+ * malformed `head`, because that is the one value the receiver acts on.
35
+ */
36
+ export function parsePoke(payload: unknown): Poke | null {
37
+ if (typeof payload !== 'string' || payload.length > MAX_POKE_BYTES) return null;
38
+ let parsed: unknown;
39
+ try {
40
+ parsed = JSON.parse(payload);
41
+ } catch {
42
+ return null;
43
+ }
44
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
45
+ const frame = parsed as { t?: unknown; head?: unknown };
46
+ if (frame.t !== 'files') return null;
47
+ const head = frame.head;
48
+ if (typeof head !== 'number' || !Number.isInteger(head) || head < 0) return null;
49
+ return { head };
50
+ }
@@ -39,6 +39,7 @@ import {
39
39
  htmlFromDoc,
40
40
  mergeSharedMetaIntoLocal,
41
41
  metaFromDoc,
42
+ movedToFromDoc,
42
43
  stampAnnotationsEdit,
43
44
  stampBodyEdit,
44
45
  } from './codec.ts';
@@ -259,6 +260,13 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
259
260
 
260
261
  async function flush(): Promise<void> {
261
262
  if (!dirty || stopped) return;
263
+ // A RETIRED document is write-inert — its canvas moved to a new path in a
264
+ // new document, and materialising this one is how a moved canvas
265
+ // resurrected itself at its old path (see codec stampMovedTo).
266
+ if (movedToFromDoc(doc) !== null) {
267
+ dirty = false;
268
+ return;
269
+ }
262
270
  dirty = false;
263
271
  if (flushTimer) {
264
272
  clearTimeout(flushTimer);
@@ -301,6 +309,9 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
301
309
 
302
310
  function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
303
311
  if (stopped) return false;
312
+ // Write-inert both ways — a local edit to a stale pre-move file must not
313
+ // revive the retired document (see codec stampMovedTo).
314
+ if (movedToFromDoc(doc) !== null) return false;
304
315
  // Echo of our own doc→file write — drop.
305
316
  if (opts.echoGuard?.consume(evt.path, evt.hash)) return false;
306
317
  // Circuit breaker — a file that won't parse can't spin the loop.
@@ -370,6 +381,8 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
370
381
 
371
382
  function reconcile(): void {
372
383
  if (stopped) return;
384
+ // A retired doc materialises NOTHING (see codec stampMovedTo).
385
+ if (movedToFromDoc(doc) !== null) return;
373
386
  // Materialize the converged doc to disk (html/css/meta). The *IfChanged
374
387
  // writers already guard against clobbering non-empty local with empty doc
375
388
  // values, so this is safe to run at cold start before the authoritative
@@ -0,0 +1,86 @@
1
+ // F6 — the aggregate byte budget on every downward lane (Sync v2 Increment 0,
2
+ // DDR-226 §9; the gap named in the Plane-B flip gate).
3
+ //
4
+ // Every pull lane already caps ONE file (`MAX_PULL_BYTES`) and the COUNT of
5
+ // files per pass (`MAX_PULLS_PER_PASS` / `MAX_FILES_PER_PASS`). Nothing capped
6
+ // the product. 200 files × 512 MB is 100 GB, and the hub decides both factors:
7
+ // a hostile or merely broken hub could fill a person's disk one "legitimate"
8
+ // pass at a time, and every individual transfer would pass every check.
9
+ //
10
+ // So the lanes share ONE budget object per pass. It is deliberately tiny and
11
+ // leaf (no imports): a counter, a ceiling, and a single loud line when it is
12
+ // reached — the "loud-cap convention" every other truncation in this codebase
13
+ // follows, because a silent stop reads as "sync is broken" with no cause.
14
+ //
15
+ // The per-pass ceiling is not a quota: the remainder is simply the next pass's
16
+ // work, exactly like the count caps. The CUMULATIVE per-hub accumulation quota
17
+ // (a real quota, which refuses rather than defers) is Increment 4's job.
18
+
19
+ /**
20
+ * How many bytes one downward pass may land, across all files in that pass.
21
+ *
22
+ * 2 GiB is far above any real project's per-poll delta (a converged project
23
+ * transfers zero) and far below "fills the disk while you are at lunch". A
24
+ * fresh link of a large project takes several passes — which is already how
25
+ * the count caps behave.
26
+ */
27
+ export const MAX_PULL_BYTES_PER_PASS = 2 * 1024 * 1024 * 1024;
28
+
29
+ export interface PullBudget {
30
+ /**
31
+ * Charge `bytes` against the budget.
32
+ *
33
+ * Returns false when this transfer would exceed the ceiling — the caller
34
+ * must NOT land it and should stop the pass (`exhausted()` is then true).
35
+ * A charge that fits is committed, so `take` is not a pure predicate.
36
+ */
37
+ take: (bytes: number) => boolean;
38
+ /** True once a `take` has been refused. */
39
+ exhausted: () => boolean;
40
+ /** Bytes committed so far this pass. */
41
+ spent: () => number;
42
+ /** Bytes still available. */
43
+ remaining: () => number;
44
+ }
45
+
46
+ export interface PullBudgetOptions {
47
+ /** Ceiling for this pass. Defaults to `MAX_PULL_BYTES_PER_PASS`. */
48
+ maxBytes?: number;
49
+ /** Log-line prefix — `sync/assets`, `sync/files`, … so the refusal names
50
+ * which lane hit the wall. */
51
+ label: string;
52
+ log?: Pick<Console, 'warn'>;
53
+ }
54
+
55
+ /**
56
+ * One budget per pass. Never throws; refusing is a boolean, and the caller
57
+ * reports it the way it reports every other cap.
58
+ */
59
+ export function createPullBudget(opts: PullBudgetOptions): PullBudget {
60
+ const max = opts.maxBytes ?? MAX_PULL_BYTES_PER_PASS;
61
+ const log = opts.log ?? console;
62
+ let spent = 0;
63
+ let hitTheWall = false;
64
+
65
+ return {
66
+ take(bytes: number): boolean {
67
+ // A negative/NaN size is a hub-supplied number: treat it as zero-cost
68
+ // rather than as a credit (it can never REFUND budget).
69
+ const cost = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
70
+ if (spent + cost > max) {
71
+ if (!hitTheWall) {
72
+ hitTheWall = true;
73
+ log.warn(
74
+ `[${opts.label}] pass byte budget reached — ${spent} B landed, refusing a further ${cost} B (ceiling ${max} B, DDR-226 F6). The rest is the next pass's work.`
75
+ );
76
+ }
77
+ return false;
78
+ }
79
+ spent += cost;
80
+ return true;
81
+ },
82
+ exhausted: () => hitTheWall,
83
+ spent: () => spent,
84
+ remaining: () => Math.max(0, max - spent),
85
+ };
86
+ }