@1agh/maude 0.56.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 (48) 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/build.ts +28 -1
  7. package/apps/studio/client/app.jsx +47 -30
  8. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  9. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  10. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  11. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  12. package/apps/studio/client/panels/acp-runtime.js +96 -5
  13. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  14. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  15. package/apps/studio/dist/client.bundle.js +1278 -1278
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/http.ts +51 -2
  18. package/apps/studio/server.ts +11 -0
  19. package/apps/studio/sync/connection-state.ts +116 -6
  20. package/apps/studio/sync/index.ts +50 -1
  21. package/apps/studio/sync/presentation.ts +273 -0
  22. package/apps/studio/sync/remote-docs.ts +191 -0
  23. package/apps/studio/sync/status.ts +4 -1
  24. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  25. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  26. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  27. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  28. package/apps/studio/test/acp-commands.test.ts +5 -0
  29. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  30. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  31. package/apps/studio/test/acp-permission.test.ts +18 -2
  32. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  33. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  35. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  36. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  37. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  38. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  40. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  41. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  42. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  43. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  44. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  45. package/apps/studio/test/sync-presentation.test.ts +185 -0
  46. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  47. package/apps/studio/whats-new.json +18 -0
  48. package/package.json +8 -8
@@ -17,7 +17,14 @@ import {
17
17
  startSignin,
18
18
  } from './acp/login-state.ts';
19
19
  import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
20
- import { deleteChat, listChats, readChatMessages, writeChatMeta } from './acp/transcript.ts';
20
+ import { activitySnapshot, runningChats } from './acp/running.ts';
21
+ import {
22
+ chatTranscriptSeq,
23
+ deleteChat,
24
+ listChats,
25
+ readChatMessages,
26
+ writeChatMeta,
27
+ } from './acp/transcript.ts';
21
28
  import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
22
29
  import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
23
30
  import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
@@ -1319,6 +1326,40 @@ export function createHttp(
1319
1326
  return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
1320
1327
  },
1321
1328
 
1329
+ // Addendum Task 9 — "is a chat mid-turn right now?", asked by
1330
+ // RepoBranchSwitcher.jsx before any of its three `window.location.reload()`
1331
+ // call sites. A `git checkout` moves the worktree under a running agent
1332
+ // (read on `draft-a`, written back after the checkout to `main` — a silent
1333
+ // cross-branch clobber that `_history/`, being per-canvas-slug with no
1334
+ // branch awareness, cannot undo), so this gates a confirm rather than
1335
+ // reloading silently. MAIN-ORIGIN ONLY, read-only, no chat CONTENT — just
1336
+ // how many are busy, so it says nothing the canvas origin could want.
1337
+ '/_api/acp/running': (req: Request) => {
1338
+ if (!isTrustedRequestHost(req))
1339
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1340
+ const running = runningChats();
1341
+ return Response.json(
1342
+ { running: running.length, chats: running },
1343
+ { headers: { 'Cache-Control': 'no-store' } }
1344
+ );
1345
+ },
1346
+
1347
+ // feature-acp-turn-notifications Task 3 — the native shell's poller reads
1348
+ // this to decide whether to fire an OS notification for a NON-visible
1349
+ // project (Decision C). A sibling of `/_api/acp/running` above rather than
1350
+ // an extension of it — that route stays exactly as-is because the reaper's
1351
+ // `has_running_chat` (sidecar.rs) already depends on its `{running,chats}`
1352
+ // shape and has no reason to grow one. MAIN-ORIGIN ONLY, same gate, and —
1353
+ // load-bearing (Decision D) — NEVER a chat title, message text, or any
1354
+ // transcript content: just chat ids (opaque, already exposed by `running`
1355
+ // above) and a three-value state. Anyone adding a field here should ask
1356
+ // what it would look like rendered on a lock screen.
1357
+ '/_api/acp/activity': (req: Request) => {
1358
+ if (!isTrustedRequestHost(req))
1359
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1360
+ return Response.json(activitySnapshot(), { headers: { 'Cache-Control': 'no-store' } });
1361
+ },
1362
+
1322
1363
  // Phase 31 — repo-level chat list + history (for the chat switcher +
1323
1364
  // hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
1324
1365
  '/_api/acp/chats': () =>
@@ -1365,8 +1406,16 @@ export function createHttp(
1365
1406
  return Response.json(meta, { headers: { 'Cache-Control': 'no-store' } });
1366
1407
  }
1367
1408
  if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
1409
+ // Addendum Task 8 — the re-attach seam. Read the sequence marker BEFORE
1410
+ // the messages: a line appended between the two reads then shows up in
1411
+ // neither, and the client's `attach` will replay it. Reading it after
1412
+ // would risk the opposite (a line counted but not returned), which the
1413
+ // client would silently drop as "already hydrated" — a hole in the feed.
1414
+ // Shipped as a header rather than a body field so the response SHAPE
1415
+ // stays the plain message array every existing consumer expects.
1416
+ const seq = chatTranscriptSeq(ctx.paths.designRoot, id);
1368
1417
  return Response.json(readChatMessages(ctx.paths.designRoot, id), {
1369
- headers: { 'Cache-Control': 'no-store' },
1418
+ headers: { 'Cache-Control': 'no-store', 'X-Maude-Chat-Seq': String(seq) },
1370
1419
  });
1371
1420
  },
1372
1421
 
@@ -735,6 +735,17 @@ async function shutdown() {
735
735
  // previously-unanswered "how does app-quit reach the grandchild" question.
736
736
  cancelSignin();
737
737
  cancelInstall();
738
+ // Addendum Task 8 — bridges now outlive their WebSocket, so socket-close is
739
+ // no longer the reaper it used to be. App quit / dev-server shutdown is the
740
+ // ONE lifetime boundary the detached model deliberately does NOT survive
741
+ // (DDR-166's SIGTERM-first path): extending a session across a project or
742
+ // branch *switch* is the point; extending it across a quit is not, and a
743
+ // surviving `claude` subprocess after quit would be an orphan.
744
+ try {
745
+ acp.stopAll();
746
+ } catch {
747
+ /* best-effort — the process is exiting either way */
748
+ }
738
749
  fsWatch.stop();
739
750
  try {
740
751
  gitWatch.stop();
@@ -47,6 +47,22 @@ export interface SyncStatusSnapshot {
47
47
  /** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
48
48
  * for the true count). Treat as text, never HTML. */
49
49
  rejectedSlugs?: string[];
50
+ /**
51
+ * Canvases this run brought DOWN from the project — documents that existed
52
+ * only on the hub and are now real local files.
53
+ *
54
+ * This field was briefly `remoteGap`, "what the project has that this machine
55
+ * does not". That name stopped being true the moment the pull landed: the
56
+ * diff is computed before providers are built and recorded after, so it
57
+ * enumerated exactly the documents that had just arrived. Observed live —
58
+ * `_sync.json` naming two canvases that were sitting on disk.
59
+ *
60
+ * "What arrived this run" is both true and the more useful fact: it is what
61
+ * the Synced state tells the user to go and open. Absent when the hub could
62
+ * not be asked (old hub, offline) or when nothing was pulled. Names are
63
+ * hub-controlled — treat as text, never HTML, and see the cap in `notePulled`.
64
+ */
65
+ pulled?: { names: string[]; count: number };
50
66
  }
51
67
 
52
68
  type TimerHandle = ReturnType<typeof setTimeout>;
@@ -75,6 +91,8 @@ export interface ConnectionMonitor {
75
91
  /** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
76
92
  * applied): bumps `lastSyncAt` to now. */
77
93
  noteSyncActivity(slug: string): void;
94
+ /** Record the canvases this run pulled down from the project (see `pulled`). */
95
+ notePulled(slugs: readonly string[]): void;
78
96
  /** Current snapshot (defensive copy). */
79
97
  snapshot(): SyncStatusSnapshot;
80
98
  /** Tear down timers. */
@@ -100,7 +118,14 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
100
118
  // DDR-102 — per-doc states (pending/connected/auth-rejected).
101
119
  const docStates = new Map<string, DocSyncState>();
102
120
 
103
- let state: SyncState = 'online';
121
+ // NOT born connected. The monitor used to start `online`, so from the instant
122
+ // a link was created — before a socket, before a token was accepted, before a
123
+ // byte moved — every surface reading `state` reported success. On a healthy
124
+ // fast connect that was harmless (the truth arrived milliseconds later); on a
125
+ // refused or unreachable one it was the whole bug: the user was shown the
126
+ // intention and read it as the result. `connecting` is the honest seed, and
127
+ // it is the state a link genuinely occupies until a provider says otherwise.
128
+ let state: SyncState = 'connecting';
104
129
  let queuedOps = 0;
105
130
  let lastSyncAt: number | null = null;
106
131
  let offlineSince: number | null = null;
@@ -111,6 +136,25 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
111
136
  let flashTimer: TimerHandle | null = null;
112
137
  let stopped = false;
113
138
 
139
+ /** Canvases pulled down from the project this run. */
140
+ let pulled: SyncStatusSnapshot['pulled'];
141
+
142
+ /**
143
+ * Start the clock at construction, not at the first provider event.
144
+ *
145
+ * `enterGrace` is reachable ONLY from `noteProviderStatus`, so a link where
146
+ * no provider ever reports — every provider rejected during boot, a hub that
147
+ * never completes an upgrade — would have sat in `connecting` forever and
148
+ * never reached the offline banner. Seeding `state` honestly is not enough on
149
+ * its own; something has to be counting from the moment the link exists.
150
+ */
151
+ function armInitialGrace(): void {
152
+ graceTimer = setTimer(() => {
153
+ graceTimer = null;
154
+ goOffline();
155
+ }, graceMs);
156
+ }
157
+
114
158
  function snapshot(): SyncStatusSnapshot {
115
159
  const docs = { synced: 0, pending: 0, rejected: 0 };
116
160
  const rejectedSlugs: string[] = [];
@@ -130,9 +174,31 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
130
174
  updatedAt: now(),
131
175
  docs,
132
176
  rejectedSlugs,
177
+ ...(pulled ? { pulled } : {}),
133
178
  };
134
179
  }
135
180
 
181
+ /**
182
+ * Record the canvases this run pulled down from the project.
183
+ *
184
+ * `names` is capped like `rejectedSlugs` — this reaches a UI and a JSON file,
185
+ * and an unbounded list of hub-chosen names is a hub-controlled payload size.
186
+ * `count` keeps the true total, so the cap never falsifies the number the
187
+ * user is shown. An empty list clears the field rather than recording a zero:
188
+ * a run that pulled nothing has nothing to say.
189
+ */
190
+ function notePulled(slugs: readonly string[]): void {
191
+ // Every other mutator carries this guard; without it a late call could
192
+ // write `_sync.json` and broadcast for a monitor that has been torn down.
193
+ // Unreachable today (the sole caller checks `stopped` too) — kept so the
194
+ // invariant does not depend on the caller remembering it.
195
+ if (stopped) return;
196
+ pulled = slugs.length
197
+ ? { names: slugs.slice(0, MAX_REJECTED_SLUGS), count: slugs.length }
198
+ : undefined;
199
+ emit();
200
+ }
201
+
136
202
  function emit(): void {
137
203
  onChange?.(snapshot());
138
204
  }
@@ -181,15 +247,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
181
247
  }
182
248
 
183
249
  function enterGrace(): void {
184
- // Already counting down or already offline — don't restart the clock.
185
- if (state !== 'online') return;
250
+ // Already offline — stay there until a provider reports 'connected'.
251
+ if (state === 'offline' || state === 'offline-long') return;
252
+ // Already counting down — don't restart the clock. The guard used to be
253
+ // `state !== 'online'`; the timer, not the state, is what says whether a
254
+ // countdown is running, and with the `connecting` seed those are no longer
255
+ // the same question.
256
+ if (graceTimer !== null) return;
186
257
  state = 'connecting';
187
- if (graceTimer !== null) clearTimer(graceTimer);
188
258
  graceTimer = setTimer(() => {
189
259
  graceTimer = null;
190
260
  goOffline();
191
261
  }, graceMs);
192
- emit();
262
+ // No emit here — the sole caller emits once for the whole update, so a
263
+ // demotion that does not change `state` still reaches the readers.
193
264
  }
194
265
 
195
266
  function goOffline(): void {
@@ -204,19 +275,56 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
204
275
  emit();
205
276
  }
206
277
 
278
+ armInitialGrace();
279
+
207
280
  return {
208
281
  noteProviderStatus(providerId, status) {
209
282
  if (stopped) return;
283
+
284
+ // EMIT ONLY ON A REAL CHANGE.
285
+ //
286
+ // Every emit is a synchronous `_sync.json` write plus a WS broadcast to
287
+ // every open tab. Provider status is driven by the socket lifecycle,
288
+ // which the hub controls, and there is one provider per canvas — 75+ in
289
+ // the reported project. An unconditional emit here turns a flapping (or
290
+ // deliberately hostile) hub into a sustained disk-write and fan-out loop
291
+ // on the dev-server's main thread. The pre-existing code emitted only on
292
+ // a transition; adding the demotion below must not cost that property.
293
+ const prevStatus = providerStatuses.get(providerId);
210
294
  providerStatuses.set(providerId, status);
295
+ let changed = prevStatus !== status;
296
+
297
+ // A document whose socket is gone is not a synced document.
298
+ //
299
+ // `docs.synced` used to only ever RISE — nothing demoted a `connected`
300
+ // doc, so a hub that died left the last count frozen on screen, which is
301
+ // the most convincing form of the lie: it was true a moment ago. The
302
+ // provider id IS the slug (`index.ts` passes `canvas.slug`), so the
303
+ // monitor already knows which document just lost its socket.
304
+ //
305
+ // `auth-rejected` is deliberately NOT demoted. The hub gave an answer;
306
+ // losing the socket afterwards does not turn that answer back into
307
+ // "still trying", and letting it would hide a rotated credential behind
308
+ // a spinner.
309
+ if (status !== 'connected' && docStates.get(providerId) === 'connected') {
310
+ docStates.set(providerId, 'pending');
311
+ changed = true;
312
+ }
313
+
211
314
  const agg = aggregate();
212
315
  if (agg === 'connected') {
316
+ // goOnline() emits for the transition; otherwise only a real change
317
+ // (this provider's status moved, or a document was demoted) is news.
213
318
  if (state !== 'online') goOnline();
319
+ else if (changed) emit();
214
320
  return;
215
321
  }
216
322
  // Aggregate is connecting or disconnected → start (or continue) the
217
323
  // grace countdown. Once offline/offline-long we stay there until a
218
324
  // provider reports 'connected' again.
219
- if (state === 'online') enterGrace();
325
+ const before = state;
326
+ enterGrace();
327
+ if (changed || state !== before) emit();
220
328
  },
221
329
 
222
330
  noteLocalEdit() {
@@ -245,6 +353,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
245
353
  emit();
246
354
  },
247
355
 
356
+ notePulled,
357
+
248
358
  snapshot,
249
359
 
250
360
  stop() {
@@ -45,6 +45,7 @@ import { loadJournal, type SyncJournal } from './journal.ts';
45
45
  import { isLoopbackHost } from './loopback.ts';
46
46
  import { migrateSeed } from './migrate-seed.ts';
47
47
  import { createDocProjection, type DocProjection } from './projection.ts';
48
+ import { describeRemoteDiff, diffRemoteDocs, fetchRemoteDocs, pullTargets } from './remote-docs.ts';
48
49
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
49
50
  import { writeUntrustedMarkers } from './untrusted.ts';
50
51
 
@@ -424,7 +425,46 @@ export function createSyncRuntime(
424
425
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
425
426
  // DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
426
427
  // the pinned set must be bounded. See `admitCanvases`.
427
- const canvases = admitCanvases(scan.canvases, useSharedDoc);
428
+ const localCanvases = admitCanvases(scan.canvases, useSharedDoc);
429
+
430
+ // PULL THE REST OF THE PROJECT DOWN.
431
+ //
432
+ // The scan above only sees this machine's disk, and Yjs cannot enumerate —
433
+ // so before this, a document that existed only on the hub was invisible to
434
+ // a peer forever. A desktop carrying 72 of a project's 75 canvases synced
435
+ // 72, reported "72/72 synced", and was accurate about the wrong universe.
436
+ // That is what "Open in Maude does nothing" was.
437
+ //
438
+ // A project you have access to is a project you get, in full and in both
439
+ // directions. Hub-only documents become real local files (flat under the
440
+ // design root — see `pullTargets` for why flat); local-only canvases go up
441
+ // as they always did. Best-effort: an older hub without the listing route,
442
+ // or an unreachable one, syncs exactly as before.
443
+ const remoteDocs = await fetchRemoteDocs(linkedHub.url, resolvedToken);
444
+ const remoteDiff = diffRemoteDocs(
445
+ localCanvases.map((c) => docNameFor(c.slug)),
446
+ remoteDocs
447
+ );
448
+ const pulled = pullTargets(
449
+ remoteDiff.hubOnly,
450
+ ctx.paths.designRoot,
451
+ path.join,
452
+ path.resolve,
453
+ path.sep
454
+ );
455
+ const pullNote = describeRemoteDiff(remoteDiff);
456
+ if (pullNote) console.log(`[sync] ${pullNote}`);
457
+ const canvases = [
458
+ ...localCanvases,
459
+ ...pulled.map((t) => ({
460
+ slug: t.slug,
461
+ html: t.bodyAbs,
462
+ comments: path.join(ctx.paths.commentsDir, `${t.slug}.json`),
463
+ annotations: path.join(ctx.paths.designRoot, `${t.slug}.annotations.svg`),
464
+ meta: t.bodyAbs.replace(/\.tsx$/i, '.meta.json'),
465
+ css: t.bodyAbs.replace(/\.tsx$/i, '.css'),
466
+ })),
467
+ ];
428
468
  // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
429
469
  // content, so the whole set is untrusted Claude-context. Mark it (writes
430
470
  // `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
@@ -981,6 +1021,15 @@ export function createSyncRuntime(
981
1021
  console.log(
982
1022
  `[sync] ${linkedHub.url}: ${parts.join(' · ')} · shared-doc:${useSharedDoc ? 'on' : 'off'}`
983
1023
  );
1024
+ // What this run brought DOWN, recorded for `_sync.json` and the UI.
1025
+ //
1026
+ // This used to record `remoteDiff` under the name `remoteGap` — "what the
1027
+ // project has that this machine does not". By the time it ran, that was
1028
+ // false: the diff is taken BEFORE providers are built and recorded after
1029
+ // the pull, so it named exactly the canvases that had just arrived and
1030
+ // were sitting on disk. `pulled` is the same list under the name that is
1031
+ // true, and it is the fact the user is told to act on.
1032
+ mon.notePulled(pulled.map((t) => t.slug));
984
1033
  });
985
1034
  }
986
1035
 
@@ -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
+ }