@1agh/maude 0.60.7 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -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,211 @@
1
+ // The ONE application body for the cold-start decision tables — Sync v2
2
+ // Increment 0 (DDR-226), closing the drift class DDR-102's own text warned
3
+ // about ("the decision table is a pure module consumed by BOTH sync paths" —
4
+ // the TABLE was shared, the APPLICATION was not).
5
+ //
6
+ // Before this module there were two appliers:
7
+ //
8
+ // 1. `agent.ts reconcile()` — the desktop two-doc path
9
+ // 2. `migrate-seed.ts migrateSeed()` — the shared-doc / cell path
10
+ //
11
+ // Same table, two switches, and they had already drifted THREE times:
12
+ //
13
+ // - the DDR-076 empty-hub guard had to be written twice,
14
+ // - the DDR-223 annotations eraser had to be FIXED twice,
15
+ // - and `migrateSeed`'s switch was missing a `recover-seed-dup` case *and*
16
+ // a default, so that decision fell through to "hub-wins" and a duplicated
17
+ // body was kept and materialized to disk. That one was LIVE when this
18
+ // module landed.
19
+ //
20
+ // The fix is structural, not vigilance: this switch is exhaustive over
21
+ // `ColdStartAction` with a compile-time `never` default, so adding a row to
22
+ // the table without handling it here is a TYPE ERROR, not a silent
23
+ // fallthrough. Both callers import this function; `test/cold-start-apply.test.ts`
24
+ // pins that they do.
25
+ //
26
+ // What stays with the callers is only what genuinely differs between the two
27
+ // substrates, injected as three effects:
28
+ //
29
+ // - `takeHub()` — agent: write the doc body to disk + checkpoint.
30
+ // migrate-seed: nothing (the projection materializes).
31
+ // - `takeLocal()` — agent: seed the local body up into the doc.
32
+ // migrate-seed: rebuild body (+ css) inside ONE MIGRATION
33
+ // transaction.
34
+ // - `checkpointIdentity()` — journal write for identical non-empty sides.
35
+ //
36
+ // Everything else — the order of the rows, the dual snapshot, the DDR-102
37
+ // fail-closed refusal, the conflict report, the resulting body winner — lives
38
+ // here exactly once.
39
+
40
+ import type { ColdStartAction, ColdStartDecision } from './cold-start.ts';
41
+
42
+ export type ColdStartSnapshotReason = 'pre-sync-local' | 'pre-sync-hub';
43
+
44
+ export interface ColdStartConflictInfo {
45
+ slug: string;
46
+ kind: 'cold-start-diverged';
47
+ winner?: 'local' | 'hub';
48
+ snapshots?: { local?: string; hub?: string };
49
+ /** DDR-102 fail-closed (F1) — local snapshot didn't land; hub-wins refused. */
50
+ snapshotFailed?: boolean;
51
+ }
52
+
53
+ export interface ColdStartApplyInput {
54
+ slug: string;
55
+ /** The verdict from `decideColdStart`. */
56
+ decision: ColdStartDecision;
57
+ /** Local body file content, or null when absent. */
58
+ localBody: string | null;
59
+ /** Body currently held by the doc. */
60
+ docBody: string;
61
+
62
+ /** Make the HUB body the winner (materialize / fast-forward / conflict-hub). */
63
+ takeHub: () => void | Promise<void>;
64
+ /** Make LOCAL the winner — seed it up so the hub converges on our bytes. */
65
+ takeLocal: (localBody: string) => void | Promise<void>;
66
+ /** Both sides already identical: record the journal checkpoint so the NEXT
67
+ * boot fast-forwards instead of re-entering the conflict path. */
68
+ checkpointIdentity?: (body: string) => void;
69
+
70
+ /** DDR-102 dual snapshot. Absent ⇒ plain newest-wins (standalone/test wiring). */
71
+ snapshot?: (content: string, reason: ColdStartSnapshotReason) => Promise<string | null>;
72
+ onConflict?: (info: ColdStartConflictInfo) => void;
73
+
74
+ /** Log prefix, so the two callers keep their distinguishable lines
75
+ * (`[sync/<slug>]` vs `[sync/<slug>] shared-doc`). */
76
+ logLabel?: string;
77
+ log?: { warn: (msg: string) => void; error: (msg: string) => void };
78
+ }
79
+
80
+ export interface ColdStartApplyResult {
81
+ /** The action that was actually applied (echo of `decision.action`). */
82
+ action: ColdStartAction;
83
+ /** Which side owns the visually-coupled lanes (css; annotations' fallback). */
84
+ bodyWinner: 'local' | 'hub';
85
+ /** Set only when `action === 'conflict'`. */
86
+ conflict?: {
87
+ /** The winner AFTER the fail-closed guard — may differ from `decision.winner`. */
88
+ winner: 'local' | 'hub';
89
+ snapshots: { local?: string; hub?: string };
90
+ /** True when a hub-wins verdict was REFUSED because the local snapshot
91
+ * didn't land (DDR-102 fail-closed). */
92
+ snapshotFailed: boolean;
93
+ };
94
+ }
95
+
96
+ const defaultLog = {
97
+ warn: (msg: string) => console.warn(msg),
98
+ error: (msg: string) => console.error(msg),
99
+ };
100
+
101
+ /**
102
+ * Apply a cold-start body decision. Total over `ColdStartAction`.
103
+ *
104
+ * Returns the resolved `bodyWinner` so callers can drive the per-lane
105
+ * annotations table (DDR-223) and the visually-coupled css lane from one
106
+ * answer instead of re-deriving it from a result string.
107
+ */
108
+ export async function applyColdStart(input: ColdStartApplyInput): Promise<ColdStartApplyResult> {
109
+ const { slug, decision, localBody, docBody, takeHub, takeLocal } = input;
110
+ const log = input.log ?? defaultLog;
111
+ const prefix = input.logLabel ? `[sync/${slug}] ${input.logLabel}` : `[sync/${slug}]`;
112
+
113
+ switch (decision.action) {
114
+ case 'noop': {
115
+ // Identical non-empty sides: checkpoint identity so the next boot
116
+ // fast-forwards even if the hub then moves ahead. (Both-empty falls in
117
+ // here too and carries nothing to checkpoint.)
118
+ if (localBody !== null && localBody === docBody && docBody !== '') {
119
+ input.checkpointIdentity?.(docBody);
120
+ }
121
+ return { action: decision.action, bodyWinner: 'hub' };
122
+ }
123
+
124
+ case 'materialize-hub':
125
+ case 'fast-forward-hub': {
126
+ await takeHub();
127
+ return { action: decision.action, bodyWinner: 'hub' };
128
+ }
129
+
130
+ case 'seed-local-up': {
131
+ // The DDR-064/076 empty-hub guard as a named row: an empty hub doc means
132
+ // the hub holds no body for this slug YET — never an authoritative blank.
133
+ await takeLocal(localBody as string);
134
+ return { action: decision.action, bodyWinner: 'local' };
135
+ }
136
+
137
+ case 'recover-seed-dup': {
138
+ // Concurrent cold-seed collision: the hub body is our local body repeated
139
+ // N≥2 times. Re-applying local makes the codec's diff delete the trailing
140
+ // duplicate; the content equals local, so the coupled lanes follow local.
141
+ // Idempotent across peers — concurrent recoveries converge.
142
+ //
143
+ // This case is why the module exists: migrate-seed used to have no row
144
+ // for it and no default, so the duplicated body was kept.
145
+ await takeLocal(localBody as string);
146
+ return { action: decision.action, bodyWinner: 'local' };
147
+ }
148
+
149
+ case 'conflict': {
150
+ // Divergence: snapshot BOTH versions BEFORE any write, then apply the
151
+ // newest-wins winner. Even a wrong pick then costs one /design:rollback.
152
+ const snapshots: { local?: string; hub?: string } = {};
153
+ let snapshotAttempted = false;
154
+ if (input.snapshot) {
155
+ snapshotAttempted = true;
156
+ try {
157
+ const localTs = await input.snapshot(localBody as string, 'pre-sync-local');
158
+ if (localTs) snapshots.local = localTs;
159
+ const hubTs = await input.snapshot(docBody, 'pre-sync-hub');
160
+ if (hubTs) snapshots.hub = hubTs;
161
+ } catch {
162
+ /* swallowed — the missing snapshot ref drives the fail-closed guard */
163
+ }
164
+ }
165
+
166
+ // DDR-102 fail-closed (security F1): the whole guarantee is "the loser is
167
+ // recoverable from _history/". A hub-wins resolution overwrites local — so
168
+ // if we ASKED for a snapshot and the local one did not land (full disk,
169
+ // read-only `_history/`, a write error), refuse the destructive overwrite:
170
+ // keep local and seed it up instead. Nothing is lost on either side.
171
+ // `snapshotAttempted` gates this to production wiring, so a snapshot-less
172
+ // standalone/test caller keeps plain newest-wins.
173
+ const localSnapshotMissing = snapshotAttempted && !snapshots.local;
174
+ let winner: 'local' | 'hub' = decision.winner ?? 'hub';
175
+ if (winner === 'hub' && localSnapshotMissing) {
176
+ winner = 'local';
177
+ log.error(
178
+ `${prefix} cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Keeping local + pushing it up; resolve the _history/ write failure (disk full / read-only?) to restore newest-wins.`
179
+ );
180
+ }
181
+
182
+ if (winner === 'local') {
183
+ await takeLocal(localBody as string);
184
+ } else {
185
+ await takeHub();
186
+ }
187
+
188
+ log.warn(`${prefix} cold-start divergence — ${decision.reason}`);
189
+ input.onConflict?.({
190
+ slug,
191
+ kind: 'cold-start-diverged',
192
+ winner,
193
+ ...(snapshots.local || snapshots.hub ? { snapshots } : {}),
194
+ ...(localSnapshotMissing ? { snapshotFailed: true } : {}),
195
+ });
196
+
197
+ return {
198
+ action: decision.action,
199
+ bodyWinner: winner,
200
+ conflict: { winner, snapshots, snapshotFailed: localSnapshotMissing },
201
+ };
202
+ }
203
+
204
+ default: {
205
+ // Exhaustiveness: adding a `ColdStartAction` without a row above is a
206
+ // compile error here, never a silent "hub keeps whatever it had".
207
+ const never: never = decision.action;
208
+ throw new Error(`cold-start: unhandled action ${String(never)}`);
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,253 @@
1
+ // Poke → `fs:any` → the existing HMR heal — Sync v2 Increment 2 (DDR-226 §6).
2
+ //
3
+ // This is the last hop of the fix, and it deliberately adds no new UI path: the
4
+ // studio already knows how to repoint a broken `<img>` when a media file lands
5
+ // (`canvas-hmr {mode:'asset'}`, DDR-224) and how to hot-swap a stylesheet. All
6
+ // that was ever missing in a container was the EVENT — `fs.watch` does not fire
7
+ // for the hub process's atomic tmp+rename writes, so the child never learned.
8
+ //
9
+ // So: the hub pokes, this asks the journal WHICH paths moved, and emits the
10
+ // `fs:any` the watcher owed us. Everything downstream is unchanged.
11
+ //
12
+ // WHY IT ASKS INSTEAD OF BEING TOLD. The poke carries a head and nothing else,
13
+ // on purpose (DDR-054 — a frame carrying a path would be a path the hub chose).
14
+ // The journal read is authenticated and scope-filtered, its rows are re-shaped
15
+ // on arrival (`journal-client.ts`), and this only ever emits a bus event for a
16
+ // path — it materializes nothing. A hostile hub's best case here is making the
17
+ // child re-read files it already has.
18
+ //
19
+ // A LOST POKE COSTS LATENCY, NEVER CORRECTNESS. The cursor only ever moves
20
+ // forward on a page we actually parsed, and the 20 s reconciler poll is still
21
+ // underneath. If the channel is down for an hour, the heal is late by an hour;
22
+ // nothing diverges.
23
+
24
+ import { fetchJournal } from './journal-client.ts';
25
+
26
+ /** Coalesce a burst of pokes into one journal read. */
27
+ const READ_DEBOUNCE_MS = 150;
28
+
29
+ export interface CtlHealerOptions {
30
+ hubUrl: string;
31
+ token: string;
32
+ /** Emit the `fs:any` the container's watcher failed to (one per path). */
33
+ emit: (rel: string) => void;
34
+ fetchImpl?: typeof fetch;
35
+ log?: Pick<Console, 'log' | 'warn'>;
36
+ debounceMs?: number;
37
+ setTimeoutImpl?: typeof setTimeout;
38
+ clearTimeoutImpl?: typeof clearTimeout;
39
+ }
40
+
41
+ export interface CtlHealer {
42
+ /** A poke arrived. `head` is the hub's hint; the journal is the answer. */
43
+ onPoke(head: number): void;
44
+ /**
45
+ * Set the baseline from the hub's CURRENT head, replaying nothing.
46
+ *
47
+ * Without this the cursor is adopted from the FIRST POKE — and the hub pokes
48
+ * only when the journal appends, so that first poke IS a change this child
49
+ * has not seen. Adopting its head as the baseline therefore swallowed exactly
50
+ * one change per boot: the first asset a peer delivered after a cell started
51
+ * never healed an open canvas, and looked like the channel was dead. Called
52
+ * once at attach; a failure is harmless (the first poke still anchors, as
53
+ * before).
54
+ */
55
+ anchor(): Promise<void>;
56
+ /** Read now (tests; boot). Resolves once the pass is done. */
57
+ drain(): Promise<void>;
58
+ stop(): void;
59
+ /** Paths announced so far — the receiver half of the honesty counters. */
60
+ healed(): number;
61
+ /** Pokes whose head was at or below the cursor: pure noise, and expected. */
62
+ ignored(): number;
63
+ }
64
+
65
+ /**
66
+ * Turn pokes into heal events.
67
+ *
68
+ * The cursor starts at the FIRST head we are told about rather than at 0. A
69
+ * child that has just booted has already read the checkout from disk — every
70
+ * row before now describes a file it can already see, so replaying them would
71
+ * be a reload storm on every cell wake for zero benefit. What matters is
72
+ * everything AFTER the child started looking.
73
+ */
74
+ export function createCtlHealer(opts: CtlHealerOptions): CtlHealer {
75
+ const log = opts.log ?? console;
76
+ const debounceMs = opts.debounceMs ?? READ_DEBOUNCE_MS;
77
+ const setTimeoutImpl = opts.setTimeoutImpl ?? setTimeout;
78
+ const clearTimeoutImpl = opts.clearTimeoutImpl ?? clearTimeout;
79
+
80
+ let cursor: number | null = null;
81
+ let epoch: string | null = null;
82
+ let timer: ReturnType<typeof setTimeout> | null = null;
83
+ let inFlight: Promise<void> | null = null;
84
+ let again = false;
85
+ let stopped = false;
86
+ let healed = 0;
87
+ let ignored = 0;
88
+ /**
89
+ * Is there anything to read?
90
+ *
91
+ * Without this, `drain()` would fire a request every time it is called even
92
+ * when nothing has moved — which on a quiet project is a poll we did not ask
93
+ * for, against a route that is rate-limited. Only `schedule()` sets it, and
94
+ * only a completed read clears it.
95
+ */
96
+ let dirty = false;
97
+
98
+ async function readOnce(): Promise<void> {
99
+ if (stopped || cursor === null || !dirty) return;
100
+ dirty = false;
101
+ const page = await fetchJournal({
102
+ hubUrl: opts.hubUrl,
103
+ token: opts.token,
104
+ since: cursor,
105
+ epoch,
106
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
107
+ });
108
+ // Unreachable / refused / unparseable — ask again on the next poke or the
109
+ // next poll. Never advance the cursor on a page we did not read, and stay
110
+ // dirty so the retry actually retries rather than short-circuiting.
111
+ if (page === null) {
112
+ dirty = true;
113
+ return;
114
+ }
115
+
116
+ if (page.reanchor) {
117
+ // The log no longer contains our cursor (an epoch rotation, or a
118
+ // compaction past it). For the HEAL path specifically there is nothing to
119
+ // replay — the child re-reads the tree from disk on demand anyway — so
120
+ // the honest move is to jump to the new head and say so, rather than
121
+ // pretend a page arrived.
122
+ log.warn?.(
123
+ `[sync/ctl] the hub asked us to re-anchor (${page.reason ?? 'cursor not in this log'}); heal cursor moves to ${page.head}.`
124
+ );
125
+ cursor = page.head;
126
+ epoch = page.epoch;
127
+ return;
128
+ }
129
+
130
+ epoch = page.epoch;
131
+ // WHAT THIS PASS ACTUALLY ANNOUNCED. The counters exist but nothing reads
132
+ // them, so "the poke arrived but the canvas never repainted" was a question
133
+ // with no evidence on either side of it. One line per non-empty pass, named
134
+ // paths, capped — the receiving half of the "N poke(s) folded" line the
135
+ // sender already prints.
136
+ const announced: string[] = [];
137
+ for (const entry of page.entries) {
138
+ // A tombstone is not a heal — Increment 6 owns deletion, and emitting
139
+ // `fs:any` for a vanished path would make the canvas layer look for a
140
+ // file that is deliberately gone.
141
+ if (entry.deleted) continue;
142
+ try {
143
+ opts.emit(entry.path);
144
+ healed += 1;
145
+ announced.push(entry.path);
146
+ } catch (err) {
147
+ log.warn?.(`[sync/ctl] heal emit failed for ${entry.path}: ${(err as Error).message}`);
148
+ }
149
+ }
150
+ if (announced.length > 0) {
151
+ const shown = announced.slice(0, 5).join(', ');
152
+ log.log?.(
153
+ `[sync/ctl] healed ${announced.length} path(s) from the journal: ${shown}${
154
+ announced.length > 5 ? `, +${announced.length - 5} more` : ''
155
+ }`
156
+ );
157
+ }
158
+ // Advance only over what we actually consumed. `truncated` means the next
159
+ // pass has more, and the poke that follows (or the next drain) takes it.
160
+ const last = page.entries.at(-1);
161
+ cursor = last ? last.seq : page.head;
162
+ if (page.truncated) schedule();
163
+ }
164
+
165
+ function schedule(): void {
166
+ if (stopped) return;
167
+ dirty = true;
168
+ if (timer !== null) return;
169
+ timer = setTimeoutImpl(() => {
170
+ timer = null;
171
+ void drain();
172
+ }, debounceMs);
173
+ timer.unref?.();
174
+ }
175
+
176
+ async function drain(): Promise<void> {
177
+ if (inFlight) {
178
+ again = true;
179
+ return inFlight;
180
+ }
181
+ inFlight = (async () => {
182
+ try {
183
+ await readOnce();
184
+ } finally {
185
+ inFlight = null;
186
+ if (again) {
187
+ again = false;
188
+ void drain();
189
+ }
190
+ }
191
+ })();
192
+ return inFlight;
193
+ }
194
+
195
+ return {
196
+ async anchor(): Promise<void> {
197
+ if (stopped || cursor !== null) return;
198
+ const page = await fetchJournal({
199
+ hubUrl: opts.hubUrl,
200
+ token: opts.token,
201
+ since: 0,
202
+ epoch: null,
203
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
204
+ });
205
+ // Unreachable → stay unanchored; `onPoke` still has the old fallback.
206
+ if (page === null || cursor !== null) return;
207
+ cursor = page.head;
208
+ epoch = page.epoch;
209
+ },
210
+ onPoke(head: number): void {
211
+ if (stopped) return;
212
+ // A HEAD BELOW THE CURSOR IS A QUESTION FOR THE JOURNAL, NOT NOISE.
213
+ //
214
+ // `reanchor` recovers from an epoch rotation, a compaction, or a
215
+ // restore-from-backup — and every one of those moves the head BACKWARD,
216
+ // which the "at or below the cursor is noise" rule below then swallowed.
217
+ // The recovery path was therefore unreachable from precisely the states it
218
+ // was written for, and a cursor parked above the log (an over-large head,
219
+ // honest or not) left the healer permanently deaf.
220
+ //
221
+ // We do NOT move the cursor here — a coalesced or reordered frame can
222
+ // carry a stale head, and trusting it would rewind a healthy cursor on the
223
+ // hub's say-so. We only ASK: `GET /api/journal` answers `reanchor` when
224
+ // `since > head` (its own rule), and the branch in `readOnce` then takes
225
+ // the new head and epoch from a page we actually read.
226
+ if (cursor !== null && Number.isFinite(head) && head < cursor) {
227
+ schedule();
228
+ return;
229
+ }
230
+ if (cursor === null) {
231
+ // First contact: adopt the hub's head as the baseline. Everything
232
+ // before it is already on disk and already rendered.
233
+ cursor = head;
234
+ return;
235
+ }
236
+ if (head <= cursor) {
237
+ ignored += 1;
238
+ return;
239
+ }
240
+ schedule();
241
+ },
242
+ drain,
243
+ stop(): void {
244
+ stopped = true;
245
+ if (timer !== null) {
246
+ clearTimeoutImpl(timer);
247
+ timer = null;
248
+ }
249
+ },
250
+ healed: () => healed,
251
+ ignored: () => ignored,
252
+ };
253
+ }
@@ -0,0 +1,217 @@
1
+ // The file-plane control channel, receiver side — Sync v2 Increment 2
2
+ // (DDR-226 §4).
3
+ //
4
+ // ── The bug, and why the fix has to live OUTSIDE the pairing gate ───────────
5
+ //
6
+ // A hub-process write (a desktop asset PUT, a bucket→checkout refill) lands in
7
+ // the checkout via tmp+rename. In a container, recursive `fs.watch` does not
8
+ // fire for that — verified live in this repo, three separate times, and the
9
+ // reason `announceWrite` exists for projection writes. So the studio child
10
+ // never learns, no `fs:any` is synthesized, no `canvas-hmr {mode:'asset'}` heal
11
+ // goes out, and an open cloud tab keeps its broken-image glyph until somebody
12
+ // reloads by hand. Bytes delivered; nothing visible.
13
+ //
14
+ // The obvious place to receive the hub's poke is the child's EXISTING loopback
15
+ // Hocuspocus provider. That provider only exists under cell pairing, and
16
+ // pairing is a one-tenant pilot allowlist (`CELL_LIVE_PAIRING`) — a breaker
17
+ // caught the claim "the child hears it on its existing provider" as false
18
+ // fleet-wide. So the control attach is its OWN thing, gated only on what it
19
+ // actually needs:
20
+ //
21
+ // workspace mode + a loopback hub URL + a token.
22
+ //
23
+ // All three are already injected into every cell's studio child (the hub mints
24
+ // the loopback token whenever it supervises a child at all). Pairing's five
25
+ // preconditions govern SHARED-DOC CONTENT — two writers over one working tree,
26
+ // one committer, one Y.Doc per canvas. None of them is about a read-only
27
+ // stateless channel that carries `{t:'files', head}` and cannot write anything.
28
+ // Applying them here would buy no safety and would leave the bug alive on every
29
+ // cell but one.
30
+ //
31
+ // ── What this channel is allowed to do ─────────────────────────────────────
32
+ //
33
+ // Receive a number. That is the whole vocabulary. `head` is a HINT — the
34
+ // receiver re-reads `GET /api/journal` (authenticated, scope-filtered,
35
+ // fail-closed) or simply re-runs its existing missing-only pull, and believes
36
+ // THAT. A lost poke costs latency and never correctness, because the 20 s
37
+ // reconciler poll is still underneath it. That is why this file has no
38
+ // retry-until-delivered machinery and no ordering guarantees: it is a doorbell,
39
+ // not a delivery.
40
+
41
+ import { parsePoke } from './poke.ts';
42
+
43
+ /** The reserved control document. Must match the hub's `files-ctl.mjs`. */
44
+ export const FILES_CTL_DOC = 'maude.files';
45
+
46
+ export interface CellCtlTarget {
47
+ url: string;
48
+ token: string;
49
+ }
50
+
51
+ function isLoopbackHost(host: string): boolean {
52
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '');
53
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.startsWith('127.');
54
+ }
55
+
56
+ /**
57
+ * Resolve the cell child's control target from the environment.
58
+ *
59
+ * Deliberately NOT `resolveCellPairing`: this needs three facts, not eight, and
60
+ * conflating them is what would keep the watcher-gap bug alive on every
61
+ * unpaired cell. The loopback assertion is kept verbatim from the pairing
62
+ * resolver, because "a cell talks to ITSELF or to nothing" (DDR-209) is about
63
+ * egress and applies to every socket the child opens, control or not.
64
+ */
65
+ export function resolveCellCtl(
66
+ env: Record<string, string | undefined> = process.env
67
+ ): CellCtlTarget | null {
68
+ if (env.MAUDE_WORKSPACE_MODE !== '1') return null;
69
+ // The one opt-out, and it is a CONFIG-shaped kill switch rather than a
70
+ // feature flag: `linkedHub.fileEvents:false` is the documented rollback, and
71
+ // this env var is its cell-side twin for the operator runbook.
72
+ if (env.MAUDE_FILE_EVENTS === '0') return null;
73
+ const url = (env.MAUDE_LOOPBACK_SYNC_URL ?? '').trim();
74
+ const token = (env.MAUDE_LOOPBACK_SYNC_TOKEN ?? '').trim();
75
+ if (!url || !token) return null;
76
+ let parsed: URL;
77
+ try {
78
+ parsed = new URL(url);
79
+ } catch {
80
+ return null;
81
+ }
82
+ if (!isLoopbackHost(parsed.hostname)) return null;
83
+ return { url, token };
84
+ }
85
+
86
+ /** The minimal provider surface this needs — injectable, so tests need no WS. */
87
+ export interface CtlProviderLike {
88
+ on(event: 'stateless', cb: (data: { payload: string }) => void): void;
89
+ on(event: 'status', cb: (data: { status?: string }) => void): void;
90
+ destroy(): void;
91
+ }
92
+
93
+ export interface CtlProviderOptions {
94
+ url: string;
95
+ token: string;
96
+ /** Fired with the hub's head on every well-formed poke. */
97
+ onPoke: (head: number) => void;
98
+ documentName?: string;
99
+ log?: Pick<Console, 'log' | 'warn' | 'error'>;
100
+ /** Injected in tests; production builds one from `@hocuspocus/provider`. */
101
+ connect?: (args: {
102
+ wsUrl: string;
103
+ token: string;
104
+ documentName: string;
105
+ }) => CtlProviderLike | Promise<CtlProviderLike>;
106
+ }
107
+
108
+ export interface CtlProvider {
109
+ stop(): void;
110
+ connected(): boolean;
111
+ /** Well-formed pokes received — the honesty counter's receiver half. */
112
+ received(): number;
113
+ /** Frames that arrived and were refused. Non-zero means someone is lying. */
114
+ malformed(): number;
115
+ }
116
+
117
+ /** `http(s)://host` → `ws(s)://host` — the same mapping the sync runtime uses. */
118
+ export function toWsUrl(url: string): string {
119
+ return url
120
+ .replace(/^http:/i, 'ws:')
121
+ .replace(/^https:/i, 'wss:')
122
+ .replace(/\/+$/, '');
123
+ }
124
+
125
+ /**
126
+ * Attach the control channel.
127
+ *
128
+ * Never throws: a control channel that cannot open is a latency regression to
129
+ * exactly today's behaviour, and taking a studio server down over a doorbell
130
+ * would be a far worse trade.
131
+ */
132
+ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
133
+ const log = opts.log ?? console;
134
+ const documentName = opts.documentName ?? FILES_CTL_DOC;
135
+ let provider: CtlProviderLike | null = null;
136
+ let status: string = 'connecting';
137
+ let received = 0;
138
+ let malformed = 0;
139
+
140
+ const connect =
141
+ opts.connect ??
142
+ (async ({ wsUrl, token, documentName: name }) => {
143
+ // Loaded lazily and defensively: the provider package is a runtime dep of
144
+ // the studio, and a failure to load it must degrade to "no control
145
+ // channel", never to "the studio did not start". Dynamic `import` rather
146
+ // than `require` — this is an ES module, exactly like the sync runtime's
147
+ // own provider factory.
148
+ // biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
149
+ const mod: any = await import('@hocuspocus/provider');
150
+ // Its OWN socket, not the sync runtime's multiplexed one: in a cell the
151
+ // sync runtime frequently does not exist at all (unpaired), which is the
152
+ // whole point of this file.
153
+ return new mod.HocuspocusProvider({ url: wsUrl, name, token }) as CtlProviderLike;
154
+ });
155
+
156
+ const wire = (p: CtlProviderLike): void => {
157
+ p.on('status', (data: { status?: string }) => {
158
+ if (typeof data?.status === 'string') status = data.status;
159
+ });
160
+ p.on('stateless', (data: { payload: string }) => {
161
+ const poke = parsePoke(data?.payload);
162
+ if (poke === null) {
163
+ malformed += 1;
164
+ // One line, not a flood: a hub sending garbage on this channel is
165
+ // worth seeing exactly once, and the counter carries the rest.
166
+ if (malformed === 1) {
167
+ log.warn?.(
168
+ '[sync/ctl] refused a malformed control frame — the channel carries {t:"files", head} and nothing else.'
169
+ );
170
+ }
171
+ return;
172
+ }
173
+ received += 1;
174
+ try {
175
+ opts.onPoke(poke.head);
176
+ } catch (err) {
177
+ log.error?.(`[sync/ctl] poke handler threw: ${(err as Error).message}`);
178
+ }
179
+ });
180
+ };
181
+
182
+ // Attach without making every caller await: a doorbell that is one tick late
183
+ // is a doorbell. `stopped` covers the race where the caller tears the runtime
184
+ // down while the module is still resolving.
185
+ let stopped = false;
186
+ void (async () => {
187
+ try {
188
+ const p = await connect({ wsUrl: toWsUrl(opts.url), token: opts.token, documentName });
189
+ if (stopped) {
190
+ p.destroy();
191
+ return;
192
+ }
193
+ provider = p;
194
+ wire(p);
195
+ } catch (err) {
196
+ log.warn?.(
197
+ `[sync/ctl] control channel unavailable (${(err as Error).message}) — falling back to the reconciler poll.`
198
+ );
199
+ provider = null;
200
+ }
201
+ })();
202
+
203
+ return {
204
+ stop() {
205
+ stopped = true;
206
+ try {
207
+ provider?.destroy();
208
+ } catch {
209
+ /* best-effort */
210
+ }
211
+ provider = null;
212
+ },
213
+ connected: () => status === 'connected',
214
+ received: () => received,
215
+ malformed: () => malformed,
216
+ };
217
+ }