@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
@@ -0,0 +1,292 @@
1
+ /** Shared shell/canvas notifications. Sonner owns layout, gestures and announcements;
2
+ * our clock also pauses for app overlays, hidden queue entries and Save dialogs. */
3
+ import {
4
+ type CSSProperties,
5
+ type ReactNode,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ } from 'react';
11
+ import { Toaster, toast } from 'sonner';
12
+
13
+ export type NoticeKind = 'info' | 'success' | 'error' | 'warning' | 'undo';
14
+ export interface Notice {
15
+ id?: string;
16
+ title: string;
17
+ description?: string;
18
+ content?: ReactNode;
19
+ kind?: NoticeKind;
20
+ group?: string;
21
+ duration?: number;
22
+ /** Change only for a new lifecycle phase, never for a progress update. */
23
+ timerKey?: string;
24
+ paused?: boolean;
25
+ action?: { label: string; onClick: () => void };
26
+ onDismiss?: () => void;
27
+ }
28
+ type Entry = Notice & { id: string };
29
+ let entries: Entry[] = [];
30
+ let sequence = 0;
31
+ const listeners = new Set<() => void>();
32
+ const publish = () => {
33
+ for (const listener of listeners) listener();
34
+ };
35
+ const subscribe = (listener: () => void) => {
36
+ listeners.add(listener);
37
+ return () => {
38
+ listeners.delete(listener);
39
+ };
40
+ };
41
+ const snapshot = () => entries;
42
+
43
+ export function notify(notice: Notice): string {
44
+ const id = notice.id ?? `notice-${++sequence}`;
45
+ const existing = entries.findIndex((entry) => entry.id === id);
46
+ if (existing < 0) entries = [...entries, { ...notice, id }];
47
+ else entries = entries.map((entry) => (entry.id === id ? { ...notice, id } : entry));
48
+ publish();
49
+ return id;
50
+ }
51
+
52
+ let canvasSlot = 0;
53
+ let canvasWindowStart = -Infinity;
54
+ let canvasWindowCount = 0;
55
+ /** Untrusted canvas traffic gets two reusable slots, leaving room for shell notices.
56
+ * Limit publications as well as retention; editing can emit dozens per second. */
57
+ export function notifyCanvasText(title: string, kind: NoticeKind = 'info'): string | null {
58
+ const now = Date.now();
59
+ if (now < canvasWindowStart || now - canvasWindowStart >= 1000) {
60
+ canvasWindowStart = now;
61
+ canvasWindowCount = 0;
62
+ }
63
+ if (canvasWindowCount >= 5) return null;
64
+ canvasWindowCount++;
65
+ const previousUndo =
66
+ kind === 'undo'
67
+ ? entries.find((entry) => entry.group === 'canvas' && entry.kind === 'undo')
68
+ : undefined;
69
+ const id = previousUndo?.id ?? `canvas-notice-${canvasSlot++ % 2}`;
70
+ return notify({
71
+ id,
72
+ title: title.slice(0, 4000),
73
+ kind,
74
+ group: 'canvas',
75
+ timerKey: `${++sequence}`,
76
+ });
77
+ }
78
+
79
+ /** Programmatic removal (unmount/navigation) does not acknowledge the notice. */
80
+ export function dismissNotice(id: string): void {
81
+ entries = entries.filter((entry) => entry.id !== id);
82
+ publish();
83
+ }
84
+ function acknowledge(id: string) {
85
+ const entry = entries.find((item) => item.id === id);
86
+ dismissNotice(id);
87
+ entry?.onDismiss?.();
88
+ }
89
+
90
+ export function noticeDuration(entry: Notice): number {
91
+ return (
92
+ entry.duration ??
93
+ (entry.action || ['error', 'warning', 'undo'].includes(entry.kind ?? '') ? 10_000 : 5_000)
94
+ );
95
+ }
96
+
97
+ /** Keep full diagnostics in their owning panel, including for screen readers. */
98
+ export function summarizeNoticeText(text: string): string {
99
+ const compact = text.trim().replace(/\s+/g, ' ');
100
+ return compact.length > 280 ? `${compact.slice(0, 279)}…` : compact;
101
+ }
102
+
103
+ function NoticeCard({ entry }: { entry: Entry }) {
104
+ return (
105
+ <div
106
+ className="maude-notice"
107
+ data-kind={entry.kind ?? 'info'}
108
+ data-testid={`notice-${entry.id}`}
109
+ >
110
+ <button
111
+ type="button"
112
+ className="maude-notice-close"
113
+ aria-label="Dismiss notification"
114
+ onClick={(event) => {
115
+ event.currentTarget.blur();
116
+ acknowledge(entry.id);
117
+ }}
118
+ >
119
+ ×
120
+ </button>
121
+ <div className="maude-notice-title">{summarizeNoticeText(entry.title)}</div>
122
+ {entry.description && (
123
+ <div className="maude-notice-summary">{summarizeNoticeText(entry.description)}</div>
124
+ )}
125
+ {entry.content}
126
+ {entry.action && (
127
+ <button
128
+ type="button"
129
+ className="maude-notice-action"
130
+ onClick={(event) => {
131
+ event.currentTarget.blur();
132
+ entry.action?.onClick();
133
+ acknowledge(entry.id);
134
+ }}
135
+ >
136
+ {entry.action.label}
137
+ </button>
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ function MountedNotice({
144
+ entry,
145
+ visible,
146
+ paused,
147
+ }: {
148
+ entry: Entry;
149
+ visible: boolean;
150
+ paused: boolean;
151
+ }) {
152
+ const duration = noticeDuration(entry);
153
+ const presentationId = useRef<string | null>(null);
154
+ const remaining = useRef(duration);
155
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a new lifecycle phase gets a fresh duration.
156
+ useEffect(() => {
157
+ remaining.current = duration;
158
+ }, [duration, entry.timerKey]);
159
+ // Keep the clock outside Sonner's card: hiding a group must preserve its time.
160
+ // biome-ignore lint/correctness/useExhaustiveDependencies: restart the clock when its lifecycle phase changes.
161
+ useEffect(() => {
162
+ if (!visible || paused || entry.paused || duration === Infinity) return;
163
+ const started = Date.now();
164
+ const timer = setTimeout(() => acknowledge(entry.id), Math.max(0, remaining.current));
165
+ return () => {
166
+ clearTimeout(timer);
167
+ remaining.current = Math.max(0, remaining.current - (Date.now() - started));
168
+ };
169
+ }, [visible, paused, entry.paused, duration, entry.timerKey, entry.id]);
170
+ // Sonner invokes onDismiss even for programmatic removal. Invalidate the
171
+ // presentation before hiding so opening history never acknowledges a job.
172
+ useEffect(() => {
173
+ if (visible) {
174
+ const token = presentationId.current ?? `${entry.id}:view-${++sequence}`;
175
+ presentationId.current = token;
176
+ toast.custom(() => <NoticeCard entry={entry} />, {
177
+ id: token,
178
+ duration: Infinity,
179
+ onDismiss: () => {
180
+ if (presentationId.current === token) acknowledge(entry.id);
181
+ },
182
+ });
183
+ } else if (presentationId.current) {
184
+ const token = presentationId.current;
185
+ presentationId.current = null;
186
+ toast.dismiss(token);
187
+ }
188
+ }, [entry, visible]);
189
+ useEffect(
190
+ () => () => {
191
+ const token = presentationId.current;
192
+ presentationId.current = null;
193
+ if (token) toast.dismiss(token);
194
+ },
195
+ []
196
+ );
197
+ return null;
198
+ }
199
+
200
+ const NOTICE_CSS = `
201
+ .maude-notifications [data-sonner-toaster] { --width: min(360px, calc(100vw - 32px)); z-index: 8000; }
202
+ .maude-notifications [data-sonner-toast] { width: var(--width); }
203
+ .maude-notifications [data-sonner-toast][data-expanded=false][data-front=false] {
204
+ overflow: hidden; border-radius: var(--radius-md, 8px);
205
+ }
206
+ .maude-notifications[data-paused=true] { visibility: hidden; pointer-events: none; }
207
+ .maude-notice { box-sizing: border-box; position: relative; width: 100%; padding: 16px;
208
+ border: 1px solid var(--border-default, #45454c); border-left: 3px solid var(--accent, #d63b1f);
209
+ border-radius: var(--radius-md, 8px); background: var(--bg-2, #26262b); color: var(--fg-0, #fafafa);
210
+ box-shadow: 0 8px 28px #0004; font: 13px/1.5 var(--font-sans, system-ui, sans-serif);
211
+ display: flex; flex-direction: column; gap: 8px; overflow-wrap: anywhere; }
212
+ .maude-notice[data-kind=error], .maude-notice[data-kind=warning] { border-left-color: var(--status-error, #d65b4d); }
213
+ .maude-notice-title { font-weight: 600; padding-right: 20px; }
214
+ .maude-notice-summary, .maude-notice-title, .maude-notice .st-export-degraded {
215
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
216
+ overflow-wrap: anywhere; }
217
+ .maude-notice-summary { color: var(--fg-1, #ddd); }
218
+ .maude-notice-close { position: absolute; right: 8px; top: 8px; border: 0; border-radius: 4px;
219
+ background: transparent; color: inherit; cursor: pointer; font-size: 18px; padding: 0 6px; }
220
+ .maude-notice-action { align-self: flex-start; border: 0; background: transparent; color: inherit;
221
+ font: inherit; text-decoration: underline; cursor: pointer; padding: 2px 0; }
222
+ .maude-notice button:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: 2px; }
223
+ .maude-notice .st-toast-actions { flex-wrap: wrap; }
224
+ @media (prefers-reduced-motion: reduce) {
225
+ .maude-notifications [data-sonner-toast], .maude-notifications [data-sonner-toaster] {
226
+ animation: none !important; transition: none !important;
227
+ }
228
+ }`;
229
+
230
+ const NO_GROUPS: string[] = [];
231
+ export function NotificationHost({
232
+ paused = false,
233
+ hiddenGroups = NO_GROUPS,
234
+ }: {
235
+ paused?: boolean;
236
+ hiddenGroups?: string[];
237
+ }) {
238
+ const notices = useSyncExternalStore(subscribe, snapshot, snapshot);
239
+ const hostRef = useRef<HTMLDivElement>(null);
240
+ const [hovered, setHovered] = useState(false);
241
+ const [focused, setFocused] = useState(false);
242
+ const [hidden, setHidden] = useState(() => typeof document !== 'undefined' && document.hidden);
243
+ useEffect(() => {
244
+ const change = () => setHidden(document.hidden);
245
+ document.addEventListener('visibilitychange', change);
246
+ return () => document.removeEventListener('visibilitychange', change);
247
+ }, []);
248
+ useEffect(() => {
249
+ if (!notices.length) {
250
+ setHovered(false);
251
+ setFocused(false);
252
+ }
253
+ // Removing a focused DOM node does not reliably emit blur in browsers.
254
+ if (focused && !hostRef.current?.contains(document.activeElement)) setFocused(false);
255
+ }, [notices, focused]);
256
+ const visible = notices.filter((entry) => !hiddenGroups.includes(entry.group ?? ''));
257
+ // Publish only the latest three; older entries keep their clocks while waiting.
258
+ // This also keeps visibility and clocks aligned when a hidden group returns.
259
+ const timedIds = new Set(visible.slice(-3).map((entry) => entry.id));
260
+ return (
261
+ <div
262
+ ref={hostRef}
263
+ className="maude-notifications"
264
+ data-paused={paused}
265
+ onPointerEnter={() => setHovered(true)}
266
+ onPointerLeave={() => setHovered(false)}
267
+ onFocusCapture={() => setFocused(true)}
268
+ onBlurCapture={(event) => {
269
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
270
+ }}
271
+ >
272
+ <style>{NOTICE_CSS}</style>
273
+ <Toaster
274
+ position="bottom-left"
275
+ offset={{ left: 16, bottom: 84 }}
276
+ mobileOffset={{ left: 16, right: 16, bottom: 84 }}
277
+ visibleToasts={3}
278
+ expand={focused}
279
+ style={{ '--width': 'min(360px, calc(100vw - 32px))' } as CSSProperties}
280
+ containerAriaLabel="Notifications"
281
+ />
282
+ {notices.map((entry) => (
283
+ <MountedNotice
284
+ key={entry.id}
285
+ entry={entry}
286
+ visible={timedIds.has(entry.id)}
287
+ paused={paused || hovered || focused || hidden || !timedIds.has(entry.id)}
288
+ />
289
+ ))}
290
+ </div>
291
+ );
292
+ }
@@ -47,6 +47,8 @@ export const RUNTIME_PACKAGES = [
47
47
  'react-dom/client',
48
48
  'react/jsx-runtime',
49
49
  'react/jsx-dev-runtime',
50
+ // Toasts in standalone canvases resolve without node_modules in packaged installs.
51
+ 'sonner',
50
52
  // Pixi.js v8 — per-iframe runtime bundle for the non-destructive photo editor
51
53
  // (feature-photo-editor). The canvas-lib `<PhotoLayer>` reaches the WebGL
52
54
  // compositor (`photo/pipeline.ts`) through a LAZY runtime `import('pixi.js')`,
@@ -14,6 +14,7 @@
14
14
  // else binds to a port. The orchestrator (slash commands) reads _server.json
15
15
  // to detect a live instance and avoid duplicate boots.
16
16
 
17
+ import path from 'node:path';
17
18
  import { createAcp } from './acp/index.ts';
18
19
  import { cancelInstall, cancelSignin } from './acp/login-state.ts';
19
20
  import { createActivity } from './activity.ts';
@@ -78,7 +79,23 @@ let collab: ReturnType<typeof createCollab> | null = null;
78
79
  let inspectHandle: ReturnType<typeof createInspectRegistry> | null = null;
79
80
 
80
81
  const api = createApi(ctx, {
81
- onCommentsChanged: (file, comments) => {
82
+ onCommentsChanged: (file, comments, base) => {
83
+ // Accepted-revisions mode (DDR-241): the change is PROPOSED; the room's
84
+ // document changes when the project publishes it. Fire-and-forget — the
85
+ // proposal is durable in the outbox before this returns, and the sidebar
86
+ // below already shows the author their own comment.
87
+ const runtime = ctx.syncControl?.current?.();
88
+ const proposed = runtime?.proposeLane?.(
89
+ api.fileSlug(file),
90
+ 'comments',
91
+ JSON.stringify(comments),
92
+ base ? { baseText: JSON.stringify(base) } : {}
93
+ );
94
+ if (proposed) {
95
+ void proposed.catch(() => {});
96
+ ctx.bus.emit('comments', { file, comments });
97
+ return;
98
+ }
82
99
  // Phase 8 Task 3 — bridge into the live Y.Array so collab peers see the
83
100
  // change without waiting for cold-open re-seeding. No-op when no room is
84
101
  // live for this canvas slug.
@@ -98,9 +115,18 @@ const api = createApi(ctx, {
98
115
  },
99
116
  // Phase 8 Task 5 — same bridge for annotations. PUT /_api/annotations writes
100
117
  // the SVG blob to disk; we mirror it into the live Y.Map for collab peers.
101
- onAnnotationsChanged: (file, svg) => {
118
+ onAnnotationsChanged: (file, svg, writeId, base) => {
119
+ const runtime = ctx.syncControl?.current?.();
120
+ const proposed = runtime?.proposeLane?.(api.fileSlug(file), 'annotations', svg, {
121
+ ...(writeId ? { writeId } : {}),
122
+ ...(base !== undefined ? { baseText: base } : {}),
123
+ });
124
+ if (proposed) {
125
+ void proposed.catch(() => {});
126
+ return;
127
+ }
102
128
  if (collab) {
103
- collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg);
129
+ collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg, writeId);
104
130
  }
105
131
  },
106
132
  // feature-file-tree-drag-drop-folders (Task 3) — moveCanvas's collab guard
@@ -120,6 +146,13 @@ const api = createApi(ctx, {
120
146
  return false;
121
147
  }
122
148
  },
149
+ proposeFolder: (op) => ctx.syncControl?.current?.()?.proposeFolder?.(op) ?? null,
150
+ onMetaChanged: (file, text, baseText) => {
151
+ const proposed = ctx.syncControl
152
+ ?.current?.()
153
+ ?.proposeLane?.(api.fileSlug(file), 'meta', text, baseText ? { baseText } : {});
154
+ if (proposed) void proposed.catch(() => {});
155
+ },
123
156
  flushAndDropRoom: async (slug) => {
124
157
  if (collab) await collab.registry.forceDrop(slug);
125
158
  },
@@ -136,6 +169,19 @@ inspectHandle = inspects;
136
169
  // arrives without a vouched session.
137
170
  await inspects.for('').load();
138
171
 
172
+ // Semantic structural notifications include remote sync operations as well as
173
+ // API actions. Keep every session's agent/inspector context on the live path.
174
+ ctx.bus.on('canvas-list-update', (change) => {
175
+ if (!change?.rel) return;
176
+ const file = path.posix.join(ctx.paths.designRel, change.rel);
177
+ if (change.action === 'moved' && change.fromRel) {
178
+ const fromFile = path.posix.join(ctx.paths.designRel, change.fromRel);
179
+ for (const inspect of inspects.all()) inspect.retarget(fromFile, file);
180
+ } else if (change.action === 'removed') {
181
+ for (const inspect of inspects.all()) inspect.remove(file);
182
+ }
183
+ });
184
+
139
185
  collab = createCollab(ctx, api);
140
186
  const aiActivity = createAiActivity(ctx);
141
187
 
@@ -152,6 +198,16 @@ ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | nul
152
198
  payload.entry ? { name: payload.entry.author, since: payload.entry.startedAt } : null
153
199
  );
154
200
  });
201
+ // T16 — an AI edit whose heartbeat went silent (the driver crashed) did not
202
+ // finish: whatever it wrote stays unpublished for the person's decision. An
203
+ // explicit /end already closed its action, so this is a no-op then.
204
+ ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | null }) => {
205
+ if (payload.entry) return;
206
+ void ctx.syncControl
207
+ ?.current?.()
208
+ ?.endAiAction?.(`edit:${payload.file}`, 'failed')
209
+ ?.catch(() => {});
210
+ });
155
211
  const gitLifecycle = createGitLifecycle(ctx, collab.registry);
156
212
  // Phase 13 / DDR-029 — fs-watch-driven canvas activity overlay. Subscribes to
157
213
  // `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.
@@ -0,0 +1,225 @@
1
+ // Cold start under accepted revisions — DDR-241 §3, plan T13/T14.
2
+ //
3
+ // The legacy cold start (`migrate-seed.ts`) had to CHOOSE a source and write it
4
+ // into the shared document, because the document was writable and whatever a
5
+ // peer put there was the project. Here the document is a read-only replica of
6
+ // the accepted state, so cold start only ever DECIDES, per lane:
7
+ //
8
+ // agreed — disk already holds the accepted value;
9
+ // materialize — disk is older than the project (or absent): the projection
10
+ // writes the accepted value, keeping local bytes in recovery;
11
+ // propose — disk carries a change made on top of a known base: it is
12
+ // proposed, and the hub merges it three-way from that base;
13
+ // hold — disk differs and nothing proves what it was derived from:
14
+ // both are kept, the lane stays blocked with a visible
15
+ // conflict, and the next save resolves it (T2's rule — never a
16
+ // silent overwrite of either side).
17
+ //
18
+ // A canvas the project does not know yet is proposed as `doc.create` with all
19
+ // of its lanes, so it arrives on every peer as ONE action.
20
+ //
21
+ // Comments and annotations are never re-proposed from disk here: every change
22
+ // the studio made to them went through the durable outbox (drained before any
23
+ // cold start), so a difference on disk is an older accepted state the room had
24
+ // not re-projected — the accepted value wins, and the local file is kept in a
25
+ // recovery slot in case a raw edit made while the studio was down lived there.
26
+
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+ import type * as Y from 'yjs';
29
+
30
+ import { cssFromDoc, htmlFromDoc, laneValueFromFile, readLaneFromDoc } from './codec.ts';
31
+ import { hashBytes } from './echo-guard.ts';
32
+ import type { SyncJournal } from './journal.ts';
33
+ import type { DocProjection, ProjectionPaths, ProposalLane } from './projection.ts';
34
+ import { readRecoveryBody, saveRecoveryBody } from './source-recovery.ts';
35
+ import { sourceError } from './source-validation.ts';
36
+
37
+ export type LaneDecision = 'agreed' | 'materialize' | 'propose' | 'hold';
38
+
39
+ export interface LaneVerdict {
40
+ lane: ProposalLane;
41
+ decision: LaneDecision;
42
+ /** For `propose`: the value the local edit was derived from. For `hold`: the accepted value adopted as the base. */
43
+ base?: string;
44
+ local?: string;
45
+ }
46
+
47
+ function readText(p: string | undefined): string | null {
48
+ if (!p || !existsSync(p)) return null;
49
+ try {
50
+ return readFileSync(p, 'utf8');
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Pure decision for the source lanes. `knownBase` is the last value disk and
58
+ * the accepted replica agreed on (recovery slot), `baseHash` the journal's
59
+ * hash of it when the bytes are gone.
60
+ */
61
+ export function decideSourceLane(input: {
62
+ local: string | null;
63
+ accepted: string;
64
+ knownBase: string | null;
65
+ baseHash?: string | null;
66
+ }): { decision: LaneDecision; base?: string } {
67
+ const { local, accepted } = input;
68
+ if (local === null) return { decision: 'materialize' };
69
+ if (local === accepted) return { decision: 'agreed' };
70
+ let base = input.knownBase;
71
+ if (base === null && input.baseHash) {
72
+ if (hashBytes(accepted) === input.baseHash) base = accepted;
73
+ else if (hashBytes(local) === input.baseHash) base = local;
74
+ }
75
+ if (base === null) {
76
+ // The project never had this lane: nothing of anybody's can be lost.
77
+ if (accepted === '') return { decision: 'propose', base: '' };
78
+ return { decision: 'hold', base: accepted };
79
+ }
80
+ if (base === local) return { decision: 'materialize' }; // an older accepted state
81
+ return { decision: 'propose', base };
82
+ }
83
+
84
+ export interface AcceptedColdStartInput {
85
+ slug: string;
86
+ doc: Y.Doc;
87
+ paths: ProjectionPaths;
88
+ /** Design-root-relative body path (`ui/home.tsx`). */
89
+ rel: string;
90
+ /** Is this canvas a live document of the project's accepted state? */
91
+ inProject: boolean;
92
+ projection: DocProjection;
93
+ historyDir?: string;
94
+ journal?: SyncJournal;
95
+ createDoc: (lanes: Partial<Record<ProposalLane, string>>) => Promise<{
96
+ status: 'accepted' | 'rejected';
97
+ code?: string;
98
+ }>;
99
+ log?: Pick<Console, 'log' | 'warn'>;
100
+ }
101
+
102
+ export type AcceptedColdStartResult =
103
+ | { kind: 'created' }
104
+ | { kind: 'create-refused'; code?: string }
105
+ | { kind: 'reconciled'; verdicts: LaneVerdict[] };
106
+
107
+ export async function acceptedColdStart(
108
+ i: AcceptedColdStartInput
109
+ ): Promise<AcceptedColdStartResult> {
110
+ const log = i.log ?? console;
111
+ const localHtml = readText(i.paths.html);
112
+
113
+ if (!i.inProject) {
114
+ if (localHtml === null) return { kind: 'reconciled', verdicts: [] };
115
+ const lanes: Partial<Record<ProposalLane, string>> = { html: localHtml };
116
+ const css = readText(i.paths.css);
117
+ if (css) lanes.css = css;
118
+ const metaText = readText(i.paths.meta);
119
+ const meta = metaText === null ? null : laneValueFromFile('meta', metaText);
120
+ if (meta && meta !== '{}') lanes.meta = meta;
121
+ const ann = readText(i.paths.annotations);
122
+ if (ann) lanes.annotations = ann;
123
+ const commentsText = readText(i.paths.comments);
124
+ const comments = commentsText === null ? null : laneValueFromFile('comments', commentsText);
125
+ if (comments) lanes.comments = comments;
126
+ const r = await i.createDoc(lanes);
127
+ if (r.status === 'accepted') {
128
+ // What the project now holds IS the local file: the next save is based
129
+ // on it, even if it lands before the publication reaches this replica.
130
+ i.projection.adoptBase(localHtml);
131
+ log.log(`[sync/${i.slug}] added to the project (accepted).`);
132
+ return { kind: 'created' };
133
+ }
134
+ log.warn(
135
+ `[sync/${i.slug}] the project did not accept this canvas (${r.code ?? 'rejected'}) — it stays local.`
136
+ );
137
+ return { kind: 'create-refused', code: r.code };
138
+ }
139
+
140
+ const verdicts: LaneVerdict[] = [];
141
+
142
+ // ---- html — the canvas source
143
+ const acceptedHtml = htmlFromDoc(i.doc);
144
+ if (localHtml !== null && localHtml !== acceptedHtml && sourceError(i.paths.html, localHtml)) {
145
+ // An invalid local body cannot be proposed; the projection keeps its bytes
146
+ // in recovery and reports it when it materializes the accepted source.
147
+ verdicts.push({ lane: 'html', decision: 'materialize', local: localHtml });
148
+ } else {
149
+ const d = decideSourceLane({
150
+ local: localHtml,
151
+ accepted: acceptedHtml,
152
+ knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null,
153
+ baseHash: i.journal?.get(i.slug)?.bodyHash ?? null,
154
+ });
155
+ verdicts.push({ lane: 'html', ...d, ...(localHtml !== null ? { local: localHtml } : {}) });
156
+ }
157
+
158
+ // ---- css — opaque text, journal-checkpointed
159
+ if (i.paths.css) {
160
+ const localCss = readText(i.paths.css);
161
+ const d = decideSourceLane({
162
+ local: localCss,
163
+ accepted: cssFromDoc(i.doc) ?? '',
164
+ knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.css, 'base') : null,
165
+ baseHash: i.journal?.get(i.slug)?.cssHash ?? null,
166
+ });
167
+ verdicts.push({ lane: 'css', ...d, ...(localCss !== null ? { local: localCss } : {}) });
168
+ }
169
+
170
+ // ---- meta — the shared layout keys (viewport never travels)
171
+ if (i.paths.meta) {
172
+ const metaText = readText(i.paths.meta);
173
+ const local = metaText === null ? null : laneValueFromFile('meta', metaText);
174
+ const accepted = readLaneFromDoc(i.doc, 'meta');
175
+ if (local === null || local === accepted || (local === '{}' && accepted === '')) {
176
+ verdicts.push({ lane: 'meta', decision: local === null ? 'materialize' : 'agreed' });
177
+ } else if (accepted === '') {
178
+ verdicts.push({ lane: 'meta', decision: 'propose', base: '', local });
179
+ } else {
180
+ // No base is recorded for layout: the project's arrangement wins and the
181
+ // projection merges it into the local file's private keys.
182
+ verdicts.push({ lane: 'meta', decision: 'materialize', local });
183
+ }
184
+ }
185
+
186
+ // ---- comments / annotations — the accepted value wins (see header)
187
+ for (const lane of ['comments', 'annotations'] as const) {
188
+ const p = lane === 'comments' ? i.paths.comments : i.paths.annotations;
189
+ const text = readText(p);
190
+ const local = text === null ? null : laneValueFromFile(lane, text);
191
+ const accepted = readLaneFromDoc(i.doc, lane);
192
+ if (local === null || local === accepted) {
193
+ verdicts.push({ lane, decision: local === null ? 'materialize' : 'agreed' });
194
+ continue;
195
+ }
196
+ if (i.historyDir && text) {
197
+ try {
198
+ saveRecoveryBody(i.historyDir, p, 'local', text);
199
+ } catch {
200
+ /* recovery is best-effort for these lanes */
201
+ }
202
+ }
203
+ verdicts.push({ lane, decision: 'materialize', local: text ?? undefined });
204
+ }
205
+
206
+ // ---- act
207
+ for (const v of verdicts) {
208
+ if (v.decision === 'propose' && v.local !== undefined) {
209
+ const value = v.lane === 'meta' ? v.local : (laneValueFromFile(v.lane, v.local) ?? v.local);
210
+ if (v.lane === 'html') i.projection.adoptBase(v.base ?? '');
211
+ // Stageable: a restored unfinished AI action (T16) keeps its canvases'
212
+ // differences for the person's decision instead of publishing them.
213
+ void i.projection.proposeLane(v.lane, value, { baseContent: v.base ?? '', stageable: true });
214
+ } else if (v.decision === 'hold' && v.local !== undefined) {
215
+ i.projection.hold(v.lane, v.base ?? '', v.local);
216
+ }
217
+ }
218
+ const moved = verdicts.filter((v) => v.decision === 'propose' || v.decision === 'hold');
219
+ if (moved.length) {
220
+ log.log(
221
+ `[sync/${i.slug}] cold start: ${moved.map((v) => `${v.lane}=${v.decision}`).join(', ')}`
222
+ );
223
+ }
224
+ return { kind: 'reconciled', verdicts };
225
+ }