@1agh/maude 1.2.0 → 1.3.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 (83) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. package/plugins/flow/dependencies.json +3 -3
@@ -35,6 +35,7 @@ import path from 'node:path';
35
35
  import type * as Y from 'yjs';
36
36
 
37
37
  import { Y_TYPES } from '../collab/persistence.ts';
38
+ import { atomicWrite } from './atomic-write.ts';
38
39
  import {
39
40
  annotationsEditAtFromDoc,
40
41
  applyAnnotationsToDoc,
@@ -53,12 +54,18 @@ import {
53
54
  decideAnnotationsColdStart,
54
55
  decideColdStart,
55
56
  decideCssColdStart,
57
+ isExactRepeat,
56
58
  unionCommentsById,
57
59
  } from './cold-start.ts';
58
60
  import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
59
61
  import { hashBytes } from './echo-guard.ts';
60
62
  import type { SyncJournal } from './journal.ts';
61
63
  import { ORIGINS } from './origins.ts';
64
+ import { collapseRepeatedModule, collapseRepeatedText } from './repeated-module.ts';
65
+ import { rememberSeed } from './seed-repair.ts';
66
+ import { mergeSource } from './source-merge.ts';
67
+ import { lastValidSource, readRecoveryBody, saveRecoveryBody } from './source-recovery.ts';
68
+ import { sourceError } from './source-validation.ts';
62
69
 
63
70
  export interface MigrateSeedPaths {
64
71
  html: string;
@@ -97,6 +104,13 @@ export interface MigrateSeedOptions {
97
104
  * does not have it", which is the pre-existing behaviour.
98
105
  */
99
106
  hubHasState?: (slug: string) => boolean;
107
+ /**
108
+ * A local candidate diverged from the hub body and could not be merged from
109
+ * their shared base. Neither side is overwritten: the caller hands `base` to
110
+ * the projection (`adoptBase`) so its write stays blocked with a visible
111
+ * conflict until a later save resolves it (audit 2026-09-13 P0 #1 / T2).
112
+ */
113
+ onHold?: (base: string) => void;
100
114
  /** DDR-102 — divergence notification, same contract as the agent's. */
101
115
  onConflict?: (info: {
102
116
  slug: string;
@@ -123,6 +137,10 @@ export type MigrateSeedResult =
123
137
  /** DDR-102 — divergence resolved newest-wins. */
124
138
  | 'conflict-local-wins'
125
139
  | 'conflict-hub-wins'
140
+ /** T2 — divergence resolved by a clean three-way merge from the saved base. */
141
+ | 'conflict-merged'
142
+ /** T2 — divergence that no merge could prove independent; both kept, held. */
143
+ | 'conflict-held'
126
144
  /** The replica is empty but the HUB is not — its state is still in flight.
127
145
  * Seeding here is the F1 collision (see `hubHasState`), so this seed does
128
146
  * nothing and lets the document arrive. */
@@ -146,11 +164,17 @@ export function docIsEmpty(doc: Y.Doc): boolean {
146
164
  export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeedResult> {
147
165
  const { slug, doc, paths } = opts;
148
166
 
149
- const localHtml = readLocal(paths.html);
167
+ // A file already holding its module k× over (an earlier seed race) enters
168
+ // as ONE copy — adopting it as-is would plant the repeat in the project.
169
+ const readHtml = readLocal(paths.html);
170
+ const diskHtml = readHtml === null ? null : (collapseRepeatedModule(readHtml)?.unit ?? readHtml);
171
+ const localHtml =
172
+ diskHtml !== null && sourceError(paths.html, diskHtml) === null ? diskHtml : null;
150
173
  const localComments = readLocal(paths.comments);
151
174
  const localAnnotations = readLocal(paths.annotations);
152
175
  const localMeta = paths.meta ? readLocal(paths.meta) : null;
153
- const localCss = paths.css ? readLocal(paths.css) : null;
176
+ const readCss = paths.css ? readLocal(paths.css) : null;
177
+ const localCss = readCss === null ? null : (collapseRepeatedText(readCss)?.unit ?? readCss);
154
178
 
155
179
  // Hub was empty → adopt local. Build the doc from the local files ONCE, inside
156
180
  // a single MIGRATION transaction. The apply* codecs delete-then-insert, so
@@ -182,6 +206,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
182
206
 
183
207
  doc.transact(() => {
184
208
  if (localHtml) {
209
+ rememberSeed(doc, localHtml, localCss, ORIGINS.MIGRATION);
185
210
  if (applyHtmlToDoc(doc, localHtml, ORIGINS.MIGRATION)) {
186
211
  stampBodyEdit(doc, ORIGINS.MIGRATION);
187
212
  }
@@ -219,6 +244,16 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
219
244
  // boot — not spam) so non-body files keep their pre-cutover backup too.
220
245
  snapshotLocal(opts);
221
246
 
247
+ // Invalid hub source must reach the projector's quarantine unchanged. Never
248
+ // checkpoint it, elect it by timestamp, or merge a healthy local file into it
249
+ // merely because the connection restarted. A deliberate file edit can repair it.
250
+ if (
251
+ sourceError(paths.html, doc.getText(Y_SYNC_TYPES.html).toString()) !== null &&
252
+ !(localHtml && isExactRepeat(doc.getText(Y_SYNC_TYPES.html).toString(), localHtml))
253
+ ) {
254
+ return 'hub-wins';
255
+ }
256
+
222
257
  // Body resolution via the DDR-102 decision table.
223
258
  const docHtml = doc.getText(Y_SYNC_TYPES.html).toString();
224
259
  const decision = decideColdStart({
@@ -235,6 +270,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
235
270
  * below (decideAnnotationsColdStart), independent of the body winner. */
236
271
  const rebuildBodyFromLocal = (): void => {
237
272
  doc.transact(() => {
273
+ if (docHtml === '' && localHtml) rememberSeed(doc, localHtml, localCss, ORIGINS.MIGRATION);
238
274
  if (applyHtmlToDoc(doc, localHtml as string, ORIGINS.MIGRATION)) {
239
275
  stampBodyEdit(doc, ORIGINS.MIGRATION);
240
276
  }
@@ -253,22 +289,61 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
253
289
  // fail-closed refusal, the conflict report, and crucially the
254
290
  // `recover-seed-dup` row this switch used to be missing — now lives in
255
291
  // exactly one place.
256
- const applied = await applyColdStart({
257
- slug,
258
- decision,
259
- localBody: localHtml,
260
- docBody: docHtml,
261
- takeHub: () => {
262
- /* the projection materializes the doc; nothing to do here */
263
- },
264
- takeLocal: rebuildBodyFromLocal,
265
- checkpointIdentity: (body) => opts.journal?.record(slug, { bodyHash: hashBytes(body) }),
266
- logLabel: 'shared-doc',
267
- ...(opts.snapshot ? { snapshot: opts.snapshot } : {}),
268
- ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
269
- });
270
-
271
- const result: MigrateSeedResult = resultFor(applied.action, applied.bodyWinner);
292
+ // A DIVERGENCE WITH A KNOWN BASE IS NOT A COIN TOSS. Newest-wins keeps one
293
+ // side and snapshots the other — recoverable, but the peer's edit is gone
294
+ // from the canvas. When the projection saved the body both sides last agreed
295
+ // on (and the journal proves it is that body), merge from it exactly as a
296
+ // live save does; if the edits touch, hold both and let the projection keep
297
+ // the conflict visible. No base (older state) keeps DDR-102's table.
298
+ const journalHash = opts.journal?.get(slug)?.bodyHash ?? null;
299
+ const savedBase =
300
+ decision.action === 'conflict' && opts.historyDir && journalHash !== null
301
+ ? readRecoveryBody(opts.historyDir, paths.html, 'base')
302
+ : null;
303
+ const base = savedBase !== null && hashBytes(savedBase) === journalHash ? savedBase : null;
304
+ let applied: { action: ColdStartAction; bodyWinner: 'local' | 'hub' };
305
+ let result: MigrateSeedResult;
306
+ if (base !== null && localHtml !== null) {
307
+ const merged = mergeSource(base, localHtml, docHtml);
308
+ if (opts.snapshot) {
309
+ try {
310
+ await opts.snapshot(localHtml, 'pre-sync-local');
311
+ await opts.snapshot(docHtml, 'pre-sync-hub');
312
+ } catch {
313
+ /* best-effort — neither branch below overwrites a side it did not merge */
314
+ }
315
+ }
316
+ if (merged.ok && sourceError(paths.html, merged.merged) === null) {
317
+ doc.transact(() => {
318
+ if (applyHtmlToDoc(doc, merged.merged, ORIGINS.MIGRATION)) {
319
+ stampBodyEdit(doc, ORIGINS.MIGRATION);
320
+ }
321
+ }, ORIGINS.MIGRATION);
322
+ // The projection writes the merged body and checkpoints it.
323
+ result = 'conflict-merged';
324
+ } else {
325
+ opts.onHold?.(base);
326
+ result = 'conflict-held';
327
+ }
328
+ // The coupled lanes resolve per-lane against the hub, as on a hub-wins boot.
329
+ applied = { action: 'conflict', bodyWinner: 'hub' };
330
+ } else {
331
+ applied = await applyColdStart({
332
+ slug,
333
+ decision,
334
+ localBody: localHtml,
335
+ docBody: docHtml,
336
+ takeHub: () => {
337
+ /* the projection materializes the doc; nothing to do here */
338
+ },
339
+ takeLocal: rebuildBodyFromLocal,
340
+ checkpointIdentity: (body) => opts.journal?.record(slug, { bodyHash: hashBytes(body) }),
341
+ logLabel: 'shared-doc',
342
+ ...(opts.snapshot ? { snapshot: opts.snapshot } : {}),
343
+ ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
344
+ });
345
+ result = resultFor(applied.action, applied.bodyWinner);
346
+ }
272
347
 
273
348
  // ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix; the
274
349
  // same table as agent.ts reconcile). Under sharedDoc the collab room's
@@ -394,7 +469,23 @@ function snapshotLocal(opts: MigrateSeedOptions): void {
394
469
  opts.paths.css,
395
470
  ]) {
396
471
  if (p && existsSync(p)) {
397
- copyFileSync(p, path.join(dir, path.basename(p)));
472
+ const target = path.join(dir, path.basename(p));
473
+ if (p === opts.paths.html) {
474
+ const current = readLocal(p);
475
+ // A body holding the same module twice over parses, but it is the
476
+ // corruption this snapshot exists to survive — never its "valid"
477
+ // copy, and never a reason to replace a healthy earlier snapshot.
478
+ const healthy = (body: string | null): body is string =>
479
+ !!body?.trim() &&
480
+ sourceError(p, body) === null &&
481
+ collapseRepeatedModule(body) === null;
482
+ const valid = healthy(current) ? current : lastValidSource(opts.historyDir, p);
483
+ if (valid && healthy(valid)) {
484
+ saveRecoveryBody(opts.historyDir, p, 'last-valid', valid);
485
+ const prior = readLocal(target);
486
+ if (!healthy(prior)) atomicWrite(target, valid);
487
+ }
488
+ } else copyFileSync(p, target);
398
489
  }
399
490
  }
400
491
  } catch {
@@ -22,6 +22,8 @@ const MAX_POKE_BYTES = 512;
22
22
  export interface Poke {
23
23
  /** The hub's journal head at emit time. A hint, never an authority. */
24
24
  head: number;
25
+ /** Only document membership changed; do not scan or transfer the file plane. */
26
+ documents?: true;
25
27
  }
26
28
 
27
29
  /**
@@ -42,9 +44,9 @@ export function parsePoke(payload: unknown): Poke | null {
42
44
  return null;
43
45
  }
44
46
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
45
- const frame = parsed as { t?: unknown; head?: unknown };
47
+ const frame = parsed as { t?: unknown; head?: unknown; documents?: unknown };
46
48
  if (frame.t !== 'files') return null;
47
49
  const head = frame.head;
48
50
  if (typeof head !== 'number' || !Number.isInteger(head) || head < 0) return null;
49
- return { head };
51
+ return frame.documents === true ? { head, documents: true } : { head };
50
52
  }
@@ -25,7 +25,9 @@ export type SyncPhase =
25
25
  | 'stalled'
26
26
  | 'refused'
27
27
  | 'offline'
28
- | 'nothing-syncable';
28
+ | 'nothing-syncable'
29
+ /** The link works, but part of the project did not get through. */
30
+ | 'attention';
29
31
 
30
32
  /**
31
33
  * How long `connecting…` may honestly stay on screen with ZERO documents
@@ -71,6 +73,18 @@ export interface SyncStatusLike extends Partial<SyncStatusSnapshot> {
71
73
  tsxCount?: number;
72
74
  reason?: string;
73
75
  canvases?: number;
76
+ /**
77
+ * The other lanes `_sync.json` carries (`status.ts`): source-sync conflicts,
78
+ * the file plane, the asset push. Typed `unknown` on purpose — they are read
79
+ * off disk with no schema and validated below, never trusted.
80
+ */
81
+ conflicts?: unknown;
82
+ files?: unknown;
83
+ assets?: unknown;
84
+ /** Accepted-revisions save counters (plan T29) — validated below. */
85
+ accepted?: unknown;
86
+ /** An AI action open or held (plan T16) — validated below. */
87
+ aiAction?: unknown;
74
88
  }
75
89
 
76
90
  /** Hub-supplied text that reaches a UI. Bounded, never markup. */
@@ -143,6 +157,118 @@ function readCounts(
143
157
  return { synced: docs.synced, pending: docs.pending, rejected: docs.rejected };
144
158
  }
145
159
 
160
+ const isCount = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0;
161
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
162
+ v !== null && typeof v === 'object' && !Array.isArray(v);
163
+
164
+ /** What the non-document lanes say, validated. */
165
+ interface LaneFacts {
166
+ /** A payload field we cannot read. Fail closed: never "synced". */
167
+ unreadable: boolean;
168
+ /** Canvases whose source change was refused and is waiting (#121 / T2). */
169
+ sourceSlugs: string[];
170
+ failedFiles: number;
171
+ tooLarge: number;
172
+ blockedOther: number;
173
+ /** Changes a breaker is holding for a person's decision. */
174
+ held: number;
175
+ failedMedia: number;
176
+ /** Files or media still moving on their own. */
177
+ moving:
178
+ | { delivered: number; tracked: number }
179
+ | { media: { done: number; total: number } }
180
+ | null;
181
+ paused: boolean;
182
+ }
183
+
184
+ /**
185
+ * Audit 2026-09-13 P0 #3 — "synced" is a claim about the whole project, not
186
+ * about the document sockets. The payload already carried refused source
187
+ * changes, failed files, a blocked seed and failed uploads; the summary read
188
+ * none of them and said "Synced … all 91 canvases" over all four.
189
+ */
190
+ function readLanes(status: SyncStatusLike): LaneFacts {
191
+ const facts: LaneFacts = {
192
+ unreadable: false,
193
+ sourceSlugs: [],
194
+ failedFiles: 0,
195
+ tooLarge: 0,
196
+ blockedOther: 0,
197
+ held: 0,
198
+ failedMedia: 0,
199
+ moving: null,
200
+ paused: false,
201
+ };
202
+ const { conflicts, files, assets } = status;
203
+ if (conflicts !== undefined) {
204
+ if (!Array.isArray(conflicts)) facts.unreadable = true;
205
+ else {
206
+ for (const c of conflicts) {
207
+ // `body-rejected` is the only kind that stays until it is resolved
208
+ // (`clearSourceConflict`); cold-start notes record a decision made.
209
+ if (isRecord(c) && c.kind === 'body-rejected') {
210
+ facts.sourceSlugs.push(typeof c.slug === 'string' ? c.slug : '(unnamed)');
211
+ }
212
+ }
213
+ }
214
+ }
215
+ if (files !== undefined) {
216
+ if (!isRecord(files)) facts.unreadable = true;
217
+ else {
218
+ for (const key of ['synced', 'pulled', 'conflicts', 'pushed', 'failed'] as const) {
219
+ if (files[key] !== undefined && !isCount(files[key])) facts.unreadable = true;
220
+ }
221
+ if (isCount(files.failed)) facts.failedFiles = files.failed;
222
+ if (Array.isArray(files.held)) {
223
+ for (const h of files.held) facts.held += isRecord(h) && isCount(h.count) ? h.count : 1;
224
+ }
225
+ if (isRecord(files.rateLimited)) facts.paused = true;
226
+ const progress = files.progress;
227
+ if (isRecord(progress)) {
228
+ if (Array.isArray(progress.blocked)) {
229
+ for (const b of progress.blocked) {
230
+ if (!isRecord(b) || !isCount(b.count)) continue;
231
+ if (b.class === 'too-large') facts.tooLarge += b.count;
232
+ else facts.blockedOther += b.count;
233
+ }
234
+ }
235
+ if (progress.phase === 'paused') facts.paused = true;
236
+ if (
237
+ progress.phase === 'scanning' ||
238
+ progress.phase === 'seeding' ||
239
+ progress.phase === 'paused'
240
+ ) {
241
+ facts.moving =
242
+ isCount(progress.delivered) && isCount(progress.tracked)
243
+ ? {
244
+ delivered: Math.min(progress.delivered, progress.tracked),
245
+ tracked: progress.tracked,
246
+ }
247
+ : { delivered: 0, tracked: 0 };
248
+ }
249
+ } else if (progress !== undefined) facts.unreadable = true;
250
+ }
251
+ }
252
+ if (assets !== undefined) {
253
+ if (!isRecord(assets)) facts.unreadable = true;
254
+ else {
255
+ if (isCount(assets.failedCount)) facts.failedMedia = assets.failedCount;
256
+ else if (assets.failedCount !== undefined) facts.unreadable = true;
257
+ if (assets.finished === false && !facts.moving) {
258
+ facts.moving = {
259
+ media: {
260
+ done: isCount(assets.done) ? assets.done : 0,
261
+ total: isCount(assets.total) ? assets.total : 0,
262
+ },
263
+ };
264
+ }
265
+ }
266
+ }
267
+ return facts;
268
+ }
269
+
270
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
271
+
146
272
  /**
147
273
  * Read a sync payload the way a person would.
148
274
  *
@@ -224,6 +350,54 @@ export function syncPresentation(
224
350
  const queued = status.queuedOps ?? 0;
225
351
  const queueNote = queued > 0 ? ` ${queued} local edit${queued === 1 ? '' : 's'} are queued.` : '';
226
352
  const unreachable = status.state === 'offline' || status.state === 'offline-long';
353
+ const lanes = readLanes(status);
354
+
355
+ /**
356
+ * Part of the project did not get through. `sourceOnly` is the ranking
357
+ * split: a refused source change never heals on its own, so it outranks an
358
+ * unreachable hub; a failed transfer may be the outage itself, so file and
359
+ * media problems rank below it.
360
+ */
361
+ const attention = (sourceOnly: boolean): SyncPresentation | null => {
362
+ const parts: string[] = [];
363
+ let items = 0;
364
+ const add = (n: number, text: string) => {
365
+ if (n <= 0) return;
366
+ parts.push(text);
367
+ items += n;
368
+ };
369
+ const src = lanes.sourceSlugs.length;
370
+ add(src, `${plural(src, 'source change')} ${src === 1 ? 'was' : 'were'} not applied`);
371
+ if (!sourceOnly) {
372
+ add(lanes.failedFiles, `${plural(lanes.failedFiles, 'file')} could not be delivered`);
373
+ add(
374
+ lanes.tooLarge,
375
+ `${plural(lanes.tooLarge, 'file')} ${lanes.tooLarge === 1 ? 'is' : 'are'} too large to sync`
376
+ );
377
+ add(
378
+ lanes.blockedOther,
379
+ `${plural(lanes.blockedOther, 'file')} ${lanes.blockedOther === 1 ? 'is' : 'are'} blocked`
380
+ );
381
+ add(
382
+ lanes.held,
383
+ `${plural(lanes.held, 'change')} ${lanes.held === 1 ? 'is' : 'are'} held for your decision`
384
+ );
385
+ add(lanes.failedMedia, `${plural(lanes.failedMedia, 'media upload')} failed`);
386
+ }
387
+ if (items === 0) return null;
388
+ return {
389
+ phase: 'attention',
390
+ online: false,
391
+ label: `${items} to review`,
392
+ title:
393
+ `${items === 1 ? 'A change needs' : `${items} changes need`} your attention in ${project}: ` +
394
+ `${parts.join('; ')}.` +
395
+ (unreachable ? ' The hub is also unreachable right now.' : '') +
396
+ queueNote,
397
+ next: 'Open the Sync panel to see what is waiting and why.',
398
+ names: shownNames(lanes.sourceSlugs),
399
+ };
400
+ };
227
401
  // Validated, not taken on trust — see `readCounts`. `null` means "unreadable",
228
402
  // which is deliberately NOT the same as "absent" (an old payload, handled
229
403
  // below) and must never reach the synced branch.
@@ -253,6 +427,14 @@ export function syncPresentation(
253
427
  };
254
428
  }
255
429
 
430
+ // A refused SOURCE change outranks an unreachable hub for the same reason a
431
+ // refusal does: it is sticky, and it is the part that needs a person.
432
+ // (With the hub reachable, the same sentence lists every other lane too.)
433
+ if (lanes.sourceSlugs.length > 0) {
434
+ const sourceAttention = attention(unreachable);
435
+ if (sourceAttention) return sourceAttention;
436
+ }
437
+
256
438
  // Otherwise an unreachable hub outranks every count. Whatever the documents
257
439
  // last said, nothing is moving — and "72 synced" over a dead socket is the
258
440
  // exact shape of lie this module exists to stop.
@@ -270,7 +452,7 @@ export function syncPresentation(
270
452
  };
271
453
  }
272
454
 
273
- if (unreadable) {
455
+ if (unreadable || lanes.unreadable) {
274
456
  // A payload we cannot read is not a payload that says everything is fine.
275
457
  return {
276
458
  phase: 'connecting',
@@ -289,6 +471,8 @@ export function syncPresentation(
289
471
  // Pre-DDR-102 payload — no per-document counts, so a refusal cannot be
290
472
  // ruled out and the credential hedge stays.
291
473
  if (!online && isStalled) return stalled(true);
474
+ const laneAttention = online ? attention(false) : null;
475
+ if (laneAttention) return laneAttention;
292
476
  return {
293
477
  phase: online ? 'synced' : 'connecting',
294
478
  online,
@@ -315,6 +499,25 @@ export function syncPresentation(
315
499
  };
316
500
  }
317
501
 
502
+ // Needs a person, so it outranks work that will finish by itself.
503
+ const laneAttention = attention(false);
504
+ if (laneAttention) return laneAttention;
505
+
506
+ // Plan T16 — an AI edit that did not finish is kept on this device, not
507
+ // shared. It is the person's decision, so it reads as attention.
508
+ const ai = readAiAction(status.aiAction);
509
+ if (ai?.state === 'held') {
510
+ const n = ai.canvases;
511
+ return {
512
+ phase: 'attention',
513
+ online: true,
514
+ label: 'AI edit held',
515
+ title: `An unfinished AI edit to ${n} canvas${n === 1 ? '' : 'es'} is kept on this device — it is not shared with ${project} yet.`,
516
+ next: 'Open the Sync panel to publish it as it stands or discard it.',
517
+ names: [],
518
+ };
519
+ }
520
+
318
521
  if (docs.pending > 0) {
319
522
  // Zero settled yet is a different fact from some settled: one is a
320
523
  // handshake in flight, the other is visible progress.
@@ -332,6 +535,61 @@ export function syncPresentation(
332
535
  };
333
536
  }
334
537
 
538
+ // Every canvas is in step, but files or media are still moving: that is
539
+ // syncing, not synced. The denominator is the ledger's (seed-progress.ts).
540
+ if (lanes.moving) {
541
+ const m = lanes.moving;
542
+ const detail =
543
+ 'media' in m
544
+ ? `uploading media (${m.media.done} of ${m.media.total})`
545
+ : m.tracked > 0
546
+ ? `files ${m.delivered} of ${m.tracked} delivered`
547
+ : 'checking project files';
548
+ return {
549
+ phase: 'syncing',
550
+ online: true,
551
+ label:
552
+ 'media' in m ? 'media ↑' : m.tracked > 0 ? `${m.delivered}/${m.tracked} files` : 'files…',
553
+ title:
554
+ `Syncing with ${project} — all ${total} canvas${total === 1 ? '' : 'es'} in step, ${detail}.` +
555
+ (lanes.paused ? ' Paused while the hub asks us to wait.' : '') +
556
+ queueNote,
557
+ next: 'Nothing to do — this continues on its own.',
558
+ names: [],
559
+ };
560
+ }
561
+
562
+ // Plan T29 — accepted revisions: "synced" documents only mean the replica is
563
+ // current. A change still waiting for the project's durable answer is saved
564
+ // on this device, not yet shared — so it is Saving, never Saved.
565
+ if (ai?.state === 'open') {
566
+ return {
567
+ phase: 'syncing',
568
+ online: true,
569
+ label: 'AI editing',
570
+ title: `An AI edit is in progress — its changes are shared with ${project} together when it finishes.`,
571
+ next: null,
572
+ names: [],
573
+ };
574
+ }
575
+ const acceptedPending = readAcceptedPending(status.accepted);
576
+ if (acceptedPending) {
577
+ const ageS = acceptedPending.oldestPendingAt
578
+ ? Math.max(0, Math.round((Date.now() - acceptedPending.oldestPendingAt) / 1000))
579
+ : 0;
580
+ return {
581
+ phase: 'syncing',
582
+ online: true,
583
+ label: `saving ${acceptedPending.pending}`,
584
+ title:
585
+ `Saving ${acceptedPending.pending} change${acceptedPending.pending === 1 ? '' : 's'} to ${project}` +
586
+ (ageS >= 10 ? ` — the oldest has waited ${ageS} s.` : '.') +
587
+ ' They are kept on this device until the project confirms them.',
588
+ next: ageS >= 60 ? 'Maude keeps trying. If this persists, check your connection.' : null,
589
+ names: [],
590
+ };
591
+ }
592
+
335
593
  // Clamped and validated for the same reason as the doc counts: `count` is
336
594
  // read off disk, and "1000000000 came down from the project" is not a
337
595
  // sentence this module should be capable of producing. The ceiling is `total`
@@ -359,3 +617,28 @@ export function syncPresentation(
359
617
  names: pulledCount > 0 ? shownNames(status.pulled?.names) : [],
360
618
  };
361
619
  }
620
+
621
+ /** `status.accepted`, validated — it is read off disk like every other count. */
622
+ function readAcceptedPending(
623
+ raw: unknown
624
+ ): { pending: number; oldestPendingAt: number | null } | null {
625
+ if (!raw || typeof raw !== 'object') return null;
626
+ const r = raw as { pending?: unknown; oldestPendingAt?: unknown };
627
+ const pending = r.pending;
628
+ if (typeof pending !== 'number' || !Number.isInteger(pending) || pending <= 0 || pending > 1e6)
629
+ return null;
630
+ const at = r.oldestPendingAt;
631
+ return {
632
+ pending,
633
+ oldestPendingAt: typeof at === 'number' && Number.isFinite(at) && at > 0 ? at : null,
634
+ };
635
+ }
636
+
637
+ /** `status.aiAction`, validated — never trusted off disk. */
638
+ function readAiAction(raw: unknown): { state: 'open' | 'held'; canvases: number } | null {
639
+ if (!raw || typeof raw !== 'object') return null;
640
+ const r = raw as { state?: unknown; canvases?: unknown };
641
+ if (r.state !== 'open' && r.state !== 'held') return null;
642
+ const canvases = Array.isArray(r.canvases) ? Math.min(r.canvases.length, 10_000) : 0;
643
+ return { state: r.state, canvases };
644
+ }