@1agh/maude 0.55.0 → 0.57.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 (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -0,0 +1,273 @@
1
+ // One rule for "what is the hub link doing", shared by every surface.
2
+ //
3
+ // There were two answers to that question and they disagreed. The status bar
4
+ // keyed off `state` alone — it referenced `docs` zero times, so a link whose
5
+ // every document the hub had refused still showed a green dot and the word
6
+ // "synced". The connect note in the cloud rail keyed off the ATTACH RESPONSE,
7
+ // a value that means "the runtime started", and never updated again. A user
8
+ // who distrusted one was sent to the other, which was wrong in a different way.
9
+ //
10
+ // So the rule lives here, once, as a pure function over the payload both
11
+ // surfaces already receive. Agreement between them is then structural rather
12
+ // than a thing two files have to remember.
13
+ //
14
+ // DDR-054 — document names and project names originate at the hub. Counts come
15
+ // first in every sentence so a hostile name cannot dominate it, names are
16
+ // capped, and nothing here produces markup.
17
+
18
+ import type { SyncStatusSnapshot } from './connection-state.ts';
19
+
20
+ /** Where a link is, in the terms a person cares about. */
21
+ export type SyncPhase =
22
+ | 'connecting'
23
+ | 'syncing'
24
+ | 'synced'
25
+ | 'refused'
26
+ | 'offline'
27
+ | 'nothing-syncable';
28
+
29
+ export interface SyncPresentation {
30
+ phase: SyncPhase;
31
+ /** Green dot — reserved for a link that is genuinely carrying edits. */
32
+ online: boolean;
33
+ /** Status-bar slot text. Terse; never contains a hub-supplied name. */
34
+ label: string;
35
+ /** Full sentence — the rail note and the status-bar hover. */
36
+ title: string;
37
+ /**
38
+ * What the person should do now, or null when the honest answer is "nothing,
39
+ * it is working". Every non-null phase names one — being told a state without
40
+ * being told the move is the complaint this whole change exists for.
41
+ */
42
+ next: string | null;
43
+ /** Up to 3 hub-supplied names relevant to the phase (refused / pulled). */
44
+ names: string[];
45
+ }
46
+
47
+ /**
48
+ * The `/_sync-status` payload, in its three historical shapes:
49
+ * - `{ linked: false }` — solo project, no link
50
+ * - `{ notSyncable, tsxCount, reason }` — DDR-060, linked but 0 syncable
51
+ * - the connection-state snapshot + url/canvases (the common case)
52
+ *
53
+ * Every field is optional on purpose: pre-DDR-102 payloads have no `docs`, and
54
+ * the browser and the CLI read the same JSON.
55
+ */
56
+ export interface SyncStatusLike extends Partial<SyncStatusSnapshot> {
57
+ linked?: boolean;
58
+ notSyncable?: boolean;
59
+ tsxCount?: number;
60
+ reason?: string;
61
+ canvases?: number;
62
+ }
63
+
64
+ /** Hub-supplied text that reaches a UI. Bounded, never markup. */
65
+ const MAX_NAME_LEN = 60;
66
+ const SHOWN_NAMES = 3;
67
+
68
+ export function safeName(raw: unknown, fallback: string): string {
69
+ // Length alone was not enough. A name is hub-supplied (cell mode sets it from
70
+ // `MAUDE_PROJECT_NAME`; `.design/config.json` is a committed file anyone with
71
+ // repo access can author), and it lands in trusted app chrome — including a
72
+ // `title=` tooltip, where a newline RENDERS and can push the true clause out
73
+ // of view. `All 75 canvases synced.\n\n\n` fits inside 60 characters and
74
+ // reads as a reassuring sentence of ours.
75
+ //
76
+ // So: strip control and format characters (which covers the bidi overrides
77
+ // U+202A–U+202E / U+2066–U+2069 — a name that reverses the rest of the line
78
+ // is the same attack by a different mechanism), then collapse whitespace to
79
+ // single spaces so no name can ever contain a line break.
80
+ const s = String(raw ?? '')
81
+ .replace(/[\p{Cc}\p{Cf}]/gu, '')
82
+ .replace(/\s+/g, ' ')
83
+ .trim();
84
+ if (!s) return fallback;
85
+ return s.length > MAX_NAME_LEN ? `${s.slice(0, MAX_NAME_LEN)}…` : s;
86
+ }
87
+
88
+ function shownNames(list: readonly string[] | undefined): string[] {
89
+ return (list ?? []).slice(0, SHOWN_NAMES).map((n) => safeName(n, '(unnamed)'));
90
+ }
91
+
92
+ /**
93
+ * The per-document counts, or null if they cannot be trusted.
94
+ *
95
+ * FAIL CLOSED. `/_sync-status` returns `JSON.parse` of `_sync.json` with no
96
+ * schema, so this function's input is whatever is on disk — a partial write, an
97
+ * older producer, a newer one. `synced` was the FALL-THROUGH branch, which made
98
+ * the reassuring answer the only reachable one for any shape not recognised:
99
+ * `docs: {}` gave `total === NaN`, failed both `> 0` and `=== 0`, and rendered
100
+ * "Synced — all NaN canvases", green. For a module whose entire premise is that
101
+ * it does not overstate, the unreadable case must land in `connecting`, never
102
+ * in `synced`.
103
+ */
104
+ function readCounts(
105
+ docs: SyncStatusLike['docs']
106
+ ): { synced: number; pending: number; rejected: number } | null {
107
+ if (!docs) return null;
108
+ const ok = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0;
109
+ if (!ok(docs.synced) || !ok(docs.pending) || !ok(docs.rejected)) return null;
110
+ return { synced: docs.synced, pending: docs.pending, rejected: docs.rejected };
111
+ }
112
+
113
+ /**
114
+ * Read a sync payload the way a person would.
115
+ *
116
+ * Returns null when there is nothing to say — an unlinked project has no hub
117
+ * status, and inventing one would put a slot on screen that can only ever be
118
+ * empty.
119
+ */
120
+ export function syncPresentation(
121
+ status: SyncStatusLike | null | undefined,
122
+ opts: { project?: string | null } = {}
123
+ ): SyncPresentation | null {
124
+ if (!status || status.linked === false) return null;
125
+ const project = safeName(opts.project, 'the workspace');
126
+
127
+ if (status.notSyncable) {
128
+ const tsx = status.tsxCount ?? 0;
129
+ return {
130
+ phase: 'nothing-syncable',
131
+ online: false,
132
+ label: `0 syncable${tsx > 0 ? ` · ${tsx} tsx` : ''}`,
133
+ title: status.reason
134
+ ? safeName(status.reason, 'No canvases in this project are syncable yet.')
135
+ : `Connected to ${project} — no canvases here are syncable yet.`,
136
+ next: 'Create a canvas — it will start syncing on its own.',
137
+ names: [],
138
+ };
139
+ }
140
+
141
+ const queued = status.queuedOps ?? 0;
142
+ const queueNote = queued > 0 ? ` ${queued} local edit${queued === 1 ? '' : 's'} are queued.` : '';
143
+ const unreachable = status.state === 'offline' || status.state === 'offline-long';
144
+ // Validated, not taken on trust — see `readCounts`. `null` means "unreadable",
145
+ // which is deliberately NOT the same as "absent" (an old payload, handled
146
+ // below) and must never reach the synced branch.
147
+ const docs = readCounts(status.docs);
148
+ const unreadable = status.docs !== undefined && docs === null;
149
+
150
+ // A REFUSAL OUTRANKS EVERYTHING, including an unreachable hub.
151
+ //
152
+ // The offline branch used to sit above this one, and that was the bug from
153
+ // one branch up: a hub can refuse auth on the documents it wants silenced and
154
+ // then drop the sockets, and the user would be told "your edits are safe and
155
+ // queued — nothing to do". For an auth-rejected document that is false and
156
+ // never self-heals. Rejections are deliberately sticky (a dropped socket does
157
+ // not launder them), so if one is on record it is still true while offline —
158
+ // and it is the only part of the picture that needs a person.
159
+ if (docs && docs.rejected > 0) {
160
+ const total = docs.synced + docs.pending + docs.rejected;
161
+ return {
162
+ phase: 'refused',
163
+ online: false,
164
+ label: `${docs.rejected} refused`,
165
+ title:
166
+ `${docs.rejected} of ${total} canvas${total === 1 ? '' : 'es'} were refused by ${project} — their edits are not syncing.` +
167
+ (unreachable ? ' The hub is also unreachable right now.' : ''),
168
+ next: 'Reconnect the workspace — the credential may have been rotated.',
169
+ names: shownNames(status.rejectedSlugs),
170
+ };
171
+ }
172
+
173
+ // Otherwise an unreachable hub outranks every count. Whatever the documents
174
+ // last said, nothing is moving — and "72 synced" over a dead socket is the
175
+ // exact shape of lie this module exists to stop.
176
+ //
177
+ // Count first, name second: `project` is hub-supplied, and a leading name is
178
+ // a name that gets to set the tone of our own sentence.
179
+ if (unreachable) {
180
+ return {
181
+ phase: 'offline',
182
+ online: false,
183
+ label: queued > 0 ? `offline · ${queued} ↑` : 'offline',
184
+ title: `Not reachable: ${project}. Your edits are safe on this machine and queued.${queueNote}`,
185
+ next: 'Nothing to do — syncing resumes by itself when the hub is back.',
186
+ names: [],
187
+ };
188
+ }
189
+
190
+ if (unreadable) {
191
+ // A payload we cannot read is not a payload that says everything is fine.
192
+ return {
193
+ phase: 'connecting',
194
+ online: false,
195
+ label: 'status unreadable',
196
+ title: `Cannot read the sync status for ${project} — the counts on disk are malformed.`,
197
+ next: 'Reconnect the workspace; if it persists, restart Maude.',
198
+ names: [],
199
+ };
200
+ }
201
+
202
+ if (!docs) {
203
+ // Pre-DDR-102 payload: `state` is all there is. Report it as the weaker
204
+ // evidence it is, rather than promoting it to a document-level claim.
205
+ const online = status.state === 'online' || status.flash === 'synced';
206
+ return {
207
+ phase: online ? 'synced' : 'connecting',
208
+ online,
209
+ label: online ? (queued > 0 ? `${queued} ↑` : 'synced') : 'connecting…',
210
+ title: online ? `Syncing with ${project}.${queueNote}` : `Connecting to ${project}…`,
211
+ next: online ? null : 'Nothing to do — this usually takes a moment.',
212
+ names: [],
213
+ };
214
+ }
215
+
216
+ // Refusals were already handled above — they outrank an unreachable hub, so
217
+ // that branch has to come first. Everything from here on has `rejected === 0`.
218
+ const total = docs.synced + docs.pending + docs.rejected;
219
+
220
+ if (total === 0) {
221
+ return {
222
+ phase: 'connecting',
223
+ online: false,
224
+ label: 'connecting…',
225
+ title: `Connecting to ${project}…`,
226
+ next: 'Nothing to do — this usually takes a moment.',
227
+ names: [],
228
+ };
229
+ }
230
+
231
+ if (docs.pending > 0) {
232
+ // Zero settled yet is a different fact from some settled: one is a
233
+ // handshake in flight, the other is visible progress.
234
+ const started = docs.synced > 0;
235
+ return {
236
+ phase: started ? 'syncing' : 'connecting',
237
+ online: false,
238
+ label: started ? `${docs.synced}/${total}` : 'connecting…',
239
+ title: started
240
+ ? `Syncing with ${project} — ${docs.synced} of ${total} canvas${total === 1 ? '' : 'es'} so far.`
241
+ : `Connecting to ${project}…`,
242
+ next: 'Nothing to do — this usually takes a moment.',
243
+ names: [],
244
+ };
245
+ }
246
+
247
+ // Clamped and validated for the same reason as the doc counts: `count` is
248
+ // read off disk, and "1000000000 came down from the project" is not a
249
+ // sentence this module should be capable of producing. The ceiling is `total`
250
+ // — a pulled document becomes one of the canvases being counted, so more
251
+ // pulled than exist is not a big number, it is a broken payload.
252
+ const rawPulled = status.pulled?.count;
253
+ const pulledCount =
254
+ typeof rawPulled === 'number' && Number.isInteger(rawPulled) && rawPulled >= 0
255
+ ? Math.min(rawPulled, total)
256
+ : 0;
257
+ return {
258
+ phase: 'synced',
259
+ online: true,
260
+ label: queued > 0 ? `${queued} ↑` : 'synced',
261
+ title:
262
+ `Synced with ${project} — all ${total} canvas${total === 1 ? '' : 'es'}.` +
263
+ (pulledCount > 0 ? ` ${pulledCount} came down from the project on this connect.` : '') +
264
+ queueNote,
265
+ // Deliberately NOT "open one of the canvases that just arrived". A pulled
266
+ // canvas is hub-authored TSX (DDR-054, and the DDR-079 residual it carries),
267
+ // and an imperative in our own green success sentence is the strongest
268
+ // possible endorsement of content we do not vouch for. State the fact; the
269
+ // decision to open stays the person's.
270
+ next: pulledCount > 0 ? 'They are new to this machine — look them over before editing.' : null,
271
+ names: pulledCount > 0 ? shownNames(status.pulled?.names) : [],
272
+ };
273
+ }
@@ -37,15 +37,13 @@ import {
37
37
  applyMetaToDoc,
38
38
  cssFromDoc,
39
39
  htmlFromDoc,
40
- MAX_CSS_BYTES,
41
- MAX_HTML_BYTES,
42
- MAX_META_BYTES,
43
40
  mergeSharedMetaIntoLocal,
44
41
  metaFromDoc,
45
42
  stampBodyEdit,
46
43
  } from './codec.ts';
47
44
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
48
45
  import type { SyncJournal } from './journal.ts';
46
+ import { MAX_CSS_BYTES, MAX_HTML_BYTES, MAX_META_BYTES, withinByteCap } from './limits.ts';
49
47
  import { ORIGINS } from './origins.ts';
50
48
 
51
49
  export const PROJECT_FLUSH_MS = 800;
@@ -76,6 +74,20 @@ export interface DocProjectionOptions {
76
74
  echoGuard?: EchoGuard;
77
75
  /** Injected for tests — defaults to atomicWrite. */
78
76
  writer?: (path: string, bytes: string | Uint8Array) => void;
77
+ /**
78
+ * Called with the absolute path after a doc→file write LANDS.
79
+ *
80
+ * Exists for one environment: a cell, where the container's recursive
81
+ * `fs.watch` does not see our atomic tmp+rename writes. Locally the watcher
82
+ * fires and the runtime leaves this unset — the same "the watcher owes us this
83
+ * event" reasoning (and the same workspace-mode gate) as
84
+ * `createContainerWriteBridge`, which covers the API write path and cannot
85
+ * cover this one because the projector never arms `activity:suppress`.
86
+ *
87
+ * Without it a peer's edit reaches the doc, reaches disk, and the browser's
88
+ * canvas iframe still shows the old render until somebody reloads by hand.
89
+ */
90
+ onWrote?: (absPath: string) => void;
79
91
  /** Override the 800 ms debounce. Tests use 0 to flush on the next microtask. */
80
92
  flushMs?: number;
81
93
  /** Circuit-breaker threshold (consecutive parse failures per path). */
@@ -153,18 +165,41 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
153
165
  opts.echoGuard?.record(path, hashBytes(value));
154
166
  }
155
167
 
168
+ /**
169
+ * Write + announce. Every doc→file write goes through here rather than calling
170
+ * `writer` directly, so a future fourth projected type cannot silently skip
171
+ * the announcement — the bug class this whole hook exists to close.
172
+ *
173
+ * NO-OPS WHEN THE FILE ALREADY HOLDS THESE BYTES. The `last*` guards above
174
+ * compare against what THIS projector wrote, which is null on the first pass —
175
+ * so the cold-start `reconcile()` re-wrote every canvas with content identical
176
+ * to what was already there. Harmless while nobody was listening; once the
177
+ * write announces itself (below) it became a spurious reload of every open
178
+ * canvas at boot. A write that changes nothing should cost nothing, including
179
+ * downstream.
180
+ *
181
+ * `onWrote` is best-effort: it feeds a reload, and a reload that failed to
182
+ * fire must never cost the write that already succeeded.
183
+ */
184
+ function writeAndAnnounce(path: string, value: string): void {
185
+ if (readLocal(path) === value) return;
186
+ writer(path, value);
187
+ try {
188
+ opts.onWrote?.(path);
189
+ } catch (err) {
190
+ console.error(`[projection/${slug}] onWrote(${path}) failed:`, err);
191
+ }
192
+ }
193
+
156
194
  // Security re-audit (Phase D, finding A2 / DDR-054 §2d): the codec's
157
195
  // MAX_*_BYTES caps guard the file→doc *import* lane, but hub-pushed content
158
196
  // arrives as raw Yjs updates through the provider (NOT via applyXToDoc), so it
159
197
  // bypasses those caps. This doc→file lane is the consumer's guard on that
160
198
  // direction — refuse to materialize an oversized hub-pushed body to disk
161
- // (disk-fill DoS). Returns true when the write is allowed.
199
+ // (disk-fill DoS). Returns true when the write is allowed. Shared with
200
+ // `collab/persistence.ts`'s equivalent guard via `limits.ts`.
162
201
  function withinCap(path: string, value: string, max: number): boolean {
163
- if (Buffer.byteLength(value, 'utf8') <= max) return true;
164
- console.warn(
165
- `[projection/${slug}] refusing doc→file write of ${path} > ${max} bytes (hub-pushed oversize). DDR-054 §2d.`
166
- );
167
- return false;
202
+ return withinByteCap(`projection/${slug}`, path, Buffer.byteLength(value, 'utf8'), max);
168
203
  }
169
204
 
170
205
  // ----- doc → file (html / css / meta only; room owns comments/annotations)
@@ -180,7 +215,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
180
215
  }
181
216
  if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return;
182
217
  recordEcho(paths.html, next);
183
- writer(paths.html, next);
218
+ writeAndAnnounce(paths.html, next);
184
219
  lastHtml = next;
185
220
  opts.journal?.record(slug, { bodyHash: hashBytes(next) }); // DDR-102 checkpoint
186
221
  }
@@ -193,7 +228,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
193
228
  if (next === null) return; // doc carries no css yet — nothing to write
194
229
  if (!withinCap(paths.css, next, MAX_CSS_BYTES)) return;
195
230
  recordEcho(paths.css, next);
196
- writer(paths.css, next);
231
+ writeAndAnnounce(paths.css, next);
197
232
  opts.journal?.record(slug, { cssHash: hashBytes(next) }); // DDR-102 checkpoint
198
233
  }
199
234
 
@@ -208,7 +243,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
208
243
  if (merged === null || merged === local) return; // unparseable / disk matches
209
244
  if (!withinCap(paths.meta, merged, MAX_META_BYTES)) return;
210
245
  recordEcho(paths.meta, merged);
211
- writer(paths.meta, merged);
246
+ writeAndAnnounce(paths.meta, merged);
212
247
  }
213
248
 
214
249
  async function flush(): Promise<void> {
@@ -0,0 +1,191 @@
1
+ // What the PROJECT holds, versus what this machine happens to carry.
2
+ //
3
+ // The sync runtime opens one provider per canvas found on LOCAL disk, and Yjs
4
+ // has no enumeration — so a document that exists only on the hub is invisible
5
+ // to a peer by construction. A desktop with 72 of a project's 75 canvases syncs
6
+ // 72, reports "72/72 synced", and is accurate about the wrong universe. The
7
+ // user-visible form of that was "I pressed Open in Maude and nothing happened":
8
+ // three real canvases (welcome, how-to-use, how-to-make-video) lived only in
9
+ // the cloud and could never arrive.
10
+ //
11
+ // This module asks the hub what it has (`GET /api/documents`, scope-bound to
12
+ // the same token the sync uses) and diffs it against the local set, so the
13
+ // runtime can say which side is missing what.
14
+ //
15
+ // SYNC IS BIDIRECTIONAL AND COMPLETE. A project you have been granted access to
16
+ // is a project you get — all of it, both directions. Local canvases go up (they
17
+ // always did); hub-only documents now come DOWN, materialised as real files.
18
+ //
19
+ // What that trades, stated plainly: a hub can create files in your design root,
20
+ // where before it could only update canvases you already had (DDR-054 treats
21
+ // hub-pushed content as untrusted). That is accepted deliberately — the hub is
22
+ // the project, the caller is authenticated, and a partial project is not a
23
+ // project. The one guard kept is containment: a document NAME can never place a
24
+ // file outside the design root. That is not policy filtering, it is the
25
+ // difference between writing your project and writing your filesystem.
26
+
27
+ /** One document as the hub reports it. */
28
+ export interface RemoteDoc {
29
+ name: string;
30
+ bytes: number;
31
+ }
32
+
33
+ export interface RemoteDocDiff {
34
+ /** Documents on both sides — the ones actually syncing. */
35
+ shared: string[];
36
+ /** On the hub, absent here. The project has them; this machine cannot get them. */
37
+ hubOnly: RemoteDoc[];
38
+ /** Here, not on the hub yet — normal for a canvas this peer just created. */
39
+ localOnly: string[];
40
+ /** Null when the hub could not be asked (old hub, offline, refused). */
41
+ reachable: boolean;
42
+ }
43
+
44
+ /** How long to wait for the listing. Never blocks a sync — see `fetchRemoteDocs`. */
45
+ const LIST_TIMEOUT_MS = 6000;
46
+
47
+ /**
48
+ * Ask the hub which documents this token may open.
49
+ *
50
+ * Returns null on ANY failure — an old hub without the route, a refused token,
51
+ * a network blip. A peer that cannot get the listing must still sync: this is a
52
+ * reporting improvement, and making it load-bearing would trade a real feature
53
+ * for a nicer message.
54
+ */
55
+ export async function fetchRemoteDocs(
56
+ hubUrl: string,
57
+ token: string,
58
+ fetchImpl: typeof fetch = fetch
59
+ ): Promise<RemoteDoc[] | null> {
60
+ try {
61
+ const base = hubUrl.replace(/\/+$/, '');
62
+ const res = await fetchImpl(`${base}/api/documents`, {
63
+ headers: { authorization: `Bearer ${token}` },
64
+ signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
65
+ });
66
+ if (!res.ok) return null;
67
+ const body = (await res.json()) as { documents?: unknown };
68
+ if (!Array.isArray(body?.documents)) return null;
69
+ return body.documents
70
+ .filter(
71
+ (d): d is RemoteDoc =>
72
+ !!d && typeof (d as RemoteDoc).name === 'string' && (d as RemoteDoc).name.length > 0
73
+ )
74
+ .map((d) => ({ name: d.name, bytes: Number(d.bytes) || 0 }));
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Diff the hub's documents against the ones this peer syncs.
82
+ *
83
+ * `localDocNames` must already be in WIRE form (`docNameFor(slug)`), not raw
84
+ * slugs — comparing a namespaced hub against flat local names would report
85
+ * every document as missing on both sides, which is the exact failure the
86
+ * namespace exists to prevent, arrived at from the reporting side.
87
+ */
88
+ export function diffRemoteDocs(
89
+ localDocNames: readonly string[],
90
+ remote: RemoteDoc[] | null
91
+ ): RemoteDocDiff {
92
+ if (remote === null) {
93
+ return { shared: [], hubOnly: [], localOnly: [...localDocNames], reachable: false };
94
+ }
95
+ const local = new Set(localDocNames);
96
+ const remoteNames = new Set(remote.map((d) => d.name));
97
+ return {
98
+ shared: [...local].filter((n) => remoteNames.has(n)).sort(),
99
+ hubOnly: remote.filter((d) => !local.has(d.name)).sort((a, b) => a.name.localeCompare(b.name)),
100
+ localOnly: [...local].filter((n) => !remoteNames.has(n)).sort(),
101
+ reachable: true,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * One line a person can act on.
107
+ *
108
+ * Deliberately names the documents being PULLED rather than only what already
109
+ * matched: "72 synced" was true and useless, because the number is drawn from
110
+ * the local file set and can never express what the project holds beyond it.
111
+ */
112
+ export function describeRemoteDiff(diff: RemoteDocDiff): string | null {
113
+ if (!diff.reachable) return null;
114
+ if (diff.hubOnly.length === 0) return null;
115
+ const n = diff.hubOnly.length;
116
+ const names = diff.hubOnly
117
+ .slice(0, 3)
118
+ .map((d) => d.name)
119
+ .join(', ');
120
+ return `pulling ${n} canvas${n === 1 ? '' : 'es'} down from the project (${names}${n > 3 ? ', …' : ''}).`;
121
+ }
122
+
123
+ /**
124
+ * The local slug a hub document name maps to.
125
+ *
126
+ * Namespaced names (`ws/<workspace>/<branch>/<slug>` — DDR-192 §5) carry the
127
+ * slug in the last segment; a legacy flat name IS the slug. Returns null for
128
+ * anything that does not survive the charset the hub itself enforces on
129
+ * `documentName`, so a crafted name cannot become a path component.
130
+ */
131
+ export function slugFromDocName(docName: string): string | null {
132
+ const raw = String(docName ?? '');
133
+ // Validate the WHOLE name against the two shapes a hub may legitimately use,
134
+ // never just its last segment. Taking the tail of an arbitrary string would
135
+ // accept `../../etc/passwd` as `passwd`: contained by the check below, and
136
+ // still a file this project never asked for, created from a name that should
137
+ // have been refused outright. A component is `[A-Za-z0-9_-]` — no dots (so no
138
+ // traversal and no extension smuggling), no spaces.
139
+ const COMPONENT = '[A-Za-z0-9_-]{1,120}';
140
+ const FLAT = new RegExp(`^${COMPONENT}$`);
141
+ const NAMESPACED = new RegExp(`^ws/${COMPONENT}/${COMPONENT}/(${COMPONENT})$`);
142
+ if (FLAT.test(raw)) return raw.toLowerCase();
143
+ const ns = NAMESPACED.exec(raw);
144
+ return ns ? ns[1].toLowerCase() : null;
145
+ }
146
+
147
+ export interface PullTarget {
148
+ slug: string;
149
+ docName: string;
150
+ /** Absolute path the body will be written to, flat under the design root. */
151
+ bodyAbs: string;
152
+ }
153
+
154
+ /**
155
+ * Where a hub-only document lands on disk.
156
+ *
157
+ * FLAT, directly under the design root — the same convention the hub applies in
158
+ * the mirror direction (`workspace-files.mjs defaultBodyPath`) and for the same
159
+ * reason: a slug is lossy, `ui-card` cannot be un-flattened into `ui/Card.tsx`
160
+ * without guessing, and guessing wrong scatters files into directories the user
161
+ * never made. A flat file is trivially moved; an invented tree is not. Moving it
162
+ * later does not break sync — both paths slug to the same document.
163
+ *
164
+ * Returns only targets that resolve INSIDE the design root. A document name is
165
+ * hub-controlled input, and this is the last point before a create.
166
+ */
167
+ export function pullTargets(
168
+ hubOnly: readonly RemoteDoc[],
169
+ designRoot: string,
170
+ join: (...parts: string[]) => string,
171
+ resolve: (p: string) => string,
172
+ sep: string
173
+ ): PullTarget[] {
174
+ const rootResolved = resolve(designRoot);
175
+ const out: PullTarget[] = [];
176
+ // One slug, one target. `slugFromDocName` lowercases, so a hub advertising
177
+ // `Foo`, `foo` and `ws/w/main/foo` yields three targets for ONE file — three
178
+ // providers on the same path, and a `pulled` count that overstates what
179
+ // arrived by a factor the hub chooses.
180
+ const seen = new Set<string>();
181
+ for (const doc of hubOnly) {
182
+ const slug = slugFromDocName(doc.name);
183
+ if (!slug || seen.has(slug)) continue;
184
+ seen.add(slug);
185
+ const bodyAbs = join(designRoot, `${slug}.tsx`);
186
+ const target = resolve(bodyAbs);
187
+ if (target !== rootResolved && !target.startsWith(rootResolved + sep)) continue;
188
+ out.push({ slug, docName: doc.name, bodyAbs });
189
+ }
190
+ return out;
191
+ }
@@ -79,7 +79,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
79
79
  const conflicts: SyncConflict[] = [];
80
80
 
81
81
  let snapshot: SyncStatusSnapshot = {
82
- state: 'online',
82
+ // Same reason the monitor is seeded `connecting` (see connection-state.ts):
83
+ // this is the payload a reader gets BEFORE the first monitor snapshot lands,
84
+ // so seeding it `online` re-introduced the born-connected lie one layer out.
85
+ state: 'connecting',
83
86
  queuedOps: 0,
84
87
  lastSyncAt: null,
85
88
  offlineSince: null,