@fabrika/canvas-kernel 0.1.45 → 0.3.1

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 (70) hide show
  1. package/README.md +33 -67
  2. package/index.ts +20 -83
  3. package/package.json +69 -72
  4. package/src/client/AnnotationProvider.tsx +218 -255
  5. package/src/client/AnnotationSidebar.tsx +393 -338
  6. package/src/client/FileIcon.tsx +105 -66
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +106 -96
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +27 -29
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +27 -26
  17. package/src/client/components/Diff.tsx +16 -17
  18. package/src/client/components/FilePreview.tsx +102 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +593 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +10 -12
  24. package/src/client/components/ResponseNote.tsx +16 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +33 -30
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +39 -34
  34. package/src/client/index.ts +44 -125
  35. package/src/client/pageGlobals.ts +25 -0
  36. package/src/client/persistenceSchemas.ts +41 -0
  37. package/src/client/preact-all.ts +4 -4
  38. package/src/client/runtime.ts +58 -111
  39. package/src/client/styles.css +1 -74
  40. package/src/engine/compiler.ts +411 -323
  41. package/src/engine/file-admission.ts +25 -0
  42. package/src/engine/paths.ts +44 -44
  43. package/src/engine/session.ts +515 -626
  44. package/src/engine/sessionSchemas.ts +71 -0
  45. package/src/engine/watcher.ts +70 -60
  46. package/src/engine/websocket.ts +134 -132
  47. package/annotate.ts +0 -94
  48. package/daemon.ts +0 -46
  49. package/src/client/AnnotationDraftFooter.tsx +0 -76
  50. package/src/client/AnnotationEditor.tsx +0 -320
  51. package/src/client/AnnotationList.tsx +0 -190
  52. package/src/client/Popover.tsx +0 -361
  53. package/src/client/SessionContext.tsx +0 -3
  54. package/src/client/annotationContext.ts +0 -130
  55. package/src/client/annotationMarkdown.ts +0 -102
  56. package/src/client/annotationTarget.ts +0 -73
  57. package/src/client/blockTarget.ts +0 -127
  58. package/src/client/highlightRange.ts +0 -262
  59. package/src/client/popoverPosition.ts +0 -46
  60. package/src/client/regionTarget.ts +0 -180
  61. package/src/client/testing/dom.ts +0 -74
  62. package/src/client/textTarget.ts +0 -31
  63. package/src/client/useRegionAnnotation.tsx +0 -188
  64. package/src/client/useTextAnnotation.tsx +0 -194
  65. package/src/client/utils.ts +0 -33
  66. package/src/engine/channel.ts +0 -278
  67. package/src/engine/http-utils.ts +0 -14
  68. package/src/engine/lifecycle.ts +0 -302
  69. package/src/engine/router.ts +0 -16
  70. package/src/engine/waiter.ts +0 -188
@@ -1,31 +1,32 @@
1
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync, copyFileSync } from "fs";
2
- import { join } from "path";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { remoteFeedbackSchema, savedSessionMetadataSchema } from './sessionSchemas'
3
4
 
4
5
  export interface DiffStats {
5
- added: number;
6
- removed: number;
6
+ added: number
7
+ removed: number
7
8
  }
8
9
 
9
10
  export interface CanvasFileInfo {
10
- filename: string;
11
- diffStats?: DiffStats;
11
+ filename: string
12
+ diffStats?: DiffStats
12
13
  }
13
14
 
14
15
  /** How the caller selected the canvases in a revision. New revisions always
15
16
  * carry this explicitly; its absence identifies legacy metadata. */
16
17
  export type CanvasScope =
17
- | { kind: "view"; filename: string }
18
- | { kind: "directory" };
18
+ | { kind: 'view'; filename: string }
19
+ | { kind: 'directory' }
19
20
 
20
21
  export interface RevisionInfo {
21
- revision: number;
22
- label?: string;
23
- canvasFiles: CanvasFileInfo[];
24
- createdAt: string;
25
- hasFeedback: boolean;
26
- feedbackConsumed: boolean;
27
- response?: string;
28
- canvasScope?: CanvasScope;
22
+ revision: number
23
+ label?: string
24
+ canvasFiles: CanvasFileInfo[]
25
+ createdAt: string
26
+ hasFeedback: boolean
27
+ feedbackConsumed: boolean
28
+ response?: string
29
+ canvasScope?: CanvasScope
29
30
  }
30
31
 
31
32
  /**
@@ -35,15 +36,15 @@ export interface RevisionInfo {
35
36
  * highlighted-text comments; `generalNote` is the free-form markdown.
36
37
  */
37
38
  export interface RemoteFeedbackEntry {
38
- /** Stable unique id assigned by the worker; used to dedupe on re-poll */
39
- id: string;
40
- shareId: string;
41
- revision: number;
42
- /** ISO timestamp when the reviewer submitted */
43
- submittedAt: string;
44
- author: { id: string; name: string };
45
- annotations: RemoteAnnotation[];
46
- generalNote?: string;
39
+ /** Stable unique id assigned by the worker; used to dedupe on re-poll */
40
+ id: string
41
+ shareId: string
42
+ revision: number
43
+ /** ISO timestamp when the reviewer submitted */
44
+ submittedAt: string
45
+ author: { id: string; name: string }
46
+ annotations: RemoteAnnotation[]
47
+ generalNote?: string
47
48
  }
48
49
 
49
50
  /**
@@ -51,20 +52,20 @@ export interface RemoteFeedbackEntry {
51
52
  * sense locally. Kept intentionally narrow so the wire format is stable.
52
53
  */
53
54
  export interface RemoteAnnotation {
54
- id: string;
55
- snippet: string;
56
- note: string;
57
- createdAt: string;
58
- filePath?: string;
59
- canvasFile?: string;
60
- context?: {
61
- before: string;
62
- after: string;
63
- hierarchy: string[];
64
- lineStart?: number;
65
- lineEnd?: number;
66
- };
67
- attachments?: { url: string; mime?: string }[];
55
+ id: string
56
+ snippet: string
57
+ note: string
58
+ createdAt: string
59
+ filePath?: string
60
+ canvasFile?: string
61
+ context?: {
62
+ before: string
63
+ after: string
64
+ hierarchy: string[]
65
+ lineStart?: number
66
+ lineEnd?: number
67
+ }
68
+ attachments?: { url: string; mime?: string }[]
68
69
  }
69
70
 
70
71
  /**
@@ -74,605 +75,493 @@ export interface RemoteAnnotation {
74
75
  * public URL the user shared with reviewers.
75
76
  */
76
77
  export interface ShareEntry {
77
- shareId: string;
78
- url: string;
79
- revision: number;
80
- createdAt: string;
81
- /** Bearer token returned by the worker — proves ownership for revoke. */
82
- ownerToken?: string;
83
- /** ISO timestamp when the share will expire on the worker side. */
84
- expiresAt?: string;
85
- /** ISO timestamp of the most recent remote feedback we've seen */
86
- lastFeedbackAt?: string;
87
- /** Base64url-encoded AES-GCM key for end-to-end encrypted shares. The
88
- * same key is also embedded in `url` as a `#k=...` fragment so reviewers
89
- * can decrypt; the daemon keeps a copy here so it can decrypt feedback
90
- * it polls back from the worker. Absent on legacy unencrypted shares. */
91
- encryptionKey?: string;
78
+ shareId: string
79
+ url: string
80
+ revision: number
81
+ createdAt: string
82
+ /** Bearer token returned by the worker — proves ownership for revoke. */
83
+ ownerToken?: string
84
+ /** ISO timestamp when the share will expire on the worker side. */
85
+ expiresAt?: string
86
+ /** ISO timestamp of the most recent remote feedback we've seen */
87
+ lastFeedbackAt?: string
88
+ /** Base64url-encoded AES-GCM key for end-to-end encrypted shares. The
89
+ * same key is also embedded in `url` as a `#k=...` fragment so reviewers
90
+ * can decrypt; the daemon keeps a copy here so it can decrypt feedback
91
+ * it polls back from the worker. Absent on legacy unencrypted shares. */
92
+ encryptionKey?: string
92
93
  }
93
94
 
94
95
  export interface SessionData {
95
- id: string;
96
- projectRoot: string;
97
- canvasFiles: string[];
98
- currentRevision: number;
99
- revisions: RevisionInfo[];
100
- createdAt: string;
101
- updatedAt: string;
102
- shares?: ShareEntry[];
96
+ id: string
97
+ projectRoot: string
98
+ canvasFiles: string[]
99
+ currentRevision: number
100
+ revisions: RevisionInfo[]
101
+ createdAt: string
102
+ updatedAt: string
103
+ shares?: ShareEntry[]
103
104
  }
104
105
 
105
106
  export type SecretResolution =
106
- | { ok: true; values: Map<string, string> }
107
- | { ok: false; missing: string[] };
107
+ | { ok: true; values: Map<string, string> }
108
+ | { ok: false; missing: string[] }
108
109
 
109
110
  interface SessionMeta {
110
- projectRoot: string;
111
- createdAt: string;
112
- updatedAt: string;
113
- currentRevision: number;
114
- revisions: RevisionInfo[];
115
- shares?: ShareEntry[];
116
- }
117
-
118
- // Legacy format (flat files, pre-revision)
119
- interface LegacyMeta {
120
- projectRoot: string;
121
- createdAt: string;
122
- updatedAt: string;
123
- version: number;
124
- }
125
-
126
- // Previous format (single sourceFile per revision)
127
- interface LegacyRevisionInfo {
128
- revision: number;
129
- label?: string;
130
- sourceFile?: string;
131
- createdAt: string;
132
- hasFeedback: boolean;
133
- feedbackConsumed: boolean;
134
- response?: string;
135
- diffStats?: DiffStats;
111
+ projectRoot: string
112
+ createdAt: string
113
+ updatedAt: string
114
+ currentRevision: number
115
+ revisions: RevisionInfo[]
116
+ shares?: ShareEntry[]
136
117
  }
137
118
 
138
119
  function computeLineDiffStats(oldText: string, newText: string): DiffStats {
139
- const oldLines = oldText.split("\n");
140
- const newLines = newText.split("\n");
141
- const m = oldLines.length, n = newLines.length;
142
-
143
- // LCS length via two-row DP (O(n) space)
144
- let prev = new Uint32Array(n + 1);
145
- let curr = new Uint32Array(n + 1);
146
- for (let i = 1; i <= m; i++) {
147
- for (let j = 1; j <= n; j++) {
148
- // The `?? 0` fallbacks are for the checker only — both rows are n+1 long.
149
- curr[j] = oldLines[i - 1] === newLines[j - 1]
150
- ? (prev[j - 1] ?? 0) + 1
151
- : Math.max(prev[j] ?? 0, curr[j - 1] ?? 0);
152
- }
153
- [prev, curr] = [curr, prev];
154
- curr.fill(0);
155
- }
156
- const lcsLen = prev[n] ?? 0;
157
- return { added: n - lcsLen, removed: m - lcsLen };
120
+ const oldLines = oldText.split('\n')
121
+ const newLines = newText.split('\n')
122
+ const m = oldLines.length, n = newLines.length
123
+
124
+ // LCS length via two-row DP (O(n) space)
125
+ let prev = new Uint32Array(n + 1)
126
+ let curr = new Uint32Array(n + 1)
127
+ for (let i = 1; i <= m; i++) {
128
+ for (let j = 1; j <= n; j++) {
129
+ // The `?? 0` fallbacks are for the checker only — both rows are n+1 long.
130
+ curr[j] = oldLines[i - 1] === newLines[j - 1]
131
+ ? (prev[j - 1] ?? 0) + 1
132
+ : Math.max(prev[j] ?? 0, curr[j - 1] ?? 0)
133
+ }
134
+ ;[prev, curr] = [curr, prev]
135
+ curr.fill(0)
136
+ }
137
+ const lcsLen = prev[n] ?? 0
138
+ return { added: n - lcsLen, removed: m - lcsLen }
158
139
  }
159
140
 
160
141
  export class SessionManager {
161
- private sessions = new Map<string, SessionData>();
162
- private sessionSecrets = new Map<string, { revision: number; values: Map<string, string> }>();
163
- private readonly sessionsDir: string;
164
-
165
- /**
166
- * @param sessionsDir On-disk sessions root, from `createCanvasPaths()`.
167
- * Tests pass an isolated temp directory so they don't interfere with
168
- * the user's real sessions.
169
- */
170
- constructor(sessionsDir: string) {
171
- this.sessionsDir = sessionsDir;
172
- mkdirSync(this.sessionsDir, { recursive: true });
173
- this.loadFromDisk();
174
- }
175
-
176
- private sessionDir(id: string): string {
177
- return join(this.sessionsDir, id);
178
- }
179
-
180
- private revisionDir(id: string, rev: number): string {
181
- return join(this.sessionDir(id), "revisions", String(rev));
182
- }
183
-
184
- /** Compute file order from revision history: first-appearance order, alpha for ties */
185
- private deriveFileOrder(revisions: RevisionInfo[], currentFiles: string[]): string[] {
186
- const seen = new Set<string>();
187
- const ordered: string[] = [];
188
- for (const rev of revisions) {
189
- for (const cf of rev.canvasFiles) {
190
- if (!seen.has(cf.filename)) {
191
- seen.add(cf.filename);
192
- ordered.push(cf.filename);
193
- }
194
- }
195
- }
196
- // Keep only files that still exist, then append any unknown ones
197
- const current = new Set(currentFiles);
198
- const result = ordered.filter(f => current.has(f));
199
- const remaining = currentFiles.filter(f => !seen.has(f)).sort();
200
- return [...result, ...remaining];
201
- }
202
-
203
- /** List *.jsx filenames in a revision directory */
204
- getRevisionCanvasFiles(id: string, rev: number): string[] {
205
- const dir = this.revisionDir(id, rev);
206
- if (!existsSync(dir)) return [];
207
- return readdirSync(dir).filter(f => f.endsWith(".jsx")).sort();
208
- }
209
-
210
- private loadFromDisk() {
211
- if (!existsSync(this.sessionsDir)) return;
212
- for (const name of readdirSync(this.sessionsDir, { withFileTypes: true })) {
213
- if (!name.isDirectory()) continue;
214
- const dir = join(this.sessionsDir, name.name);
215
- const metaPath = join(dir, "meta.json");
216
- if (!existsSync(metaPath)) continue;
217
-
218
- try {
219
- const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
220
-
221
- // Detect and migrate legacy flat-file format
222
- if ("version" in raw && !("currentRevision" in raw)) {
223
- this.migrateLegacy(name.name, raw as LegacyMeta);
224
- const migrated: SessionMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
225
- const canvasFiles = this.getRevisionCanvasFiles(name.name, migrated.currentRevision);
226
- if (canvasFiles.length === 0) continue;
227
- this.sessions.set(name.name, {
228
- id: name.name,
229
- projectRoot: migrated.projectRoot,
230
- canvasFiles,
231
- currentRevision: migrated.currentRevision,
232
- revisions: migrated.revisions,
233
- createdAt: migrated.createdAt,
234
- updatedAt: migrated.updatedAt,
235
- });
236
- continue;
237
- }
238
-
239
- let meta = raw as SessionMeta;
240
-
241
- // Migrate single-sourceFile revision format to canvasFiles
242
- const [firstRevision] = meta.revisions;
243
- if (firstRevision && !("canvasFiles" in firstRevision)) {
244
- meta = {
245
- ...meta,
246
- revisions: (meta.revisions as unknown as LegacyRevisionInfo[]).map(r => ({
247
- revision: r.revision,
248
- label: r.label,
249
- canvasFiles: [{ filename: r.sourceFile || "plan.jsx", diffStats: r.diffStats }],
250
- createdAt: r.createdAt,
251
- hasFeedback: r.hasFeedback,
252
- feedbackConsumed: r.feedbackConsumed,
253
- response: r.response,
254
- })),
255
- };
256
- writeFileSync(metaPath, JSON.stringify(meta, null, 2));
257
- }
258
-
259
- const diskFiles = this.getRevisionCanvasFiles(name.name, meta.currentRevision);
260
- if (diskFiles.length === 0) continue;
261
- // Derive file order from revision history (first-appearance order)
262
- const canvasFiles = this.deriveFileOrder(meta.revisions, diskFiles);
263
-
264
- this.sessions.set(name.name, {
265
- id: name.name,
266
- projectRoot: meta.projectRoot,
267
- canvasFiles,
268
- currentRevision: meta.currentRevision,
269
- revisions: meta.revisions,
270
- createdAt: meta.createdAt,
271
- updatedAt: meta.updatedAt,
272
- ...(meta.shares ? { shares: meta.shares } : {}),
273
- });
274
- } catch (cause) {
275
- // A session that fails here vanishes from the list, and migration moves
276
- // the user's files before it can fail — so staying silent loses work
277
- // without saying so. Other sessions still load.
278
- const reason = cause instanceof Error ? cause.message : String(cause);
279
- console.warn(`[sessions] ${name.name}: could not be loaded (${reason})`);
280
- }
281
- }
282
- }
283
-
284
- private migrateLegacy(id: string, legacy: LegacyMeta) {
285
- const dir = this.sessionDir(id);
286
- const flatJsx = join(dir, "plan.jsx");
287
- const flatCompiled = join(dir, "plan.compiled.js");
288
- const historyDir = join(dir, "history");
289
-
290
- const revisions: RevisionInfo[] = [];
291
-
292
- // Migrate history files
293
- if (existsSync(historyDir)) {
294
- // By revision number, not by name — sorting as text puts 10 before 2, and
295
- // the order of this array is the revision order everything else reads.
296
- const files = readdirSync(historyDir)
297
- .filter((f) => f.endsWith(".jsx"))
298
- .map((file) => ({ file, num: parseInt(file.replace(".jsx", ""), 10) }))
299
- .filter(({ num }) => !isNaN(num))
300
- .sort((a, b) => a.num - b.num);
301
- for (const { file, num } of files) {
302
- const revDir = this.revisionDir(id, num);
303
- mkdirSync(revDir, { recursive: true });
304
- renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
305
- revisions.push({
306
- revision: num,
307
- canvasFiles: [{ filename: "plan.jsx" }],
308
- createdAt: legacy.createdAt,
309
- hasFeedback: false,
310
- feedbackConsumed: false,
311
- });
312
- }
313
- }
314
-
315
- // Migrate current version
316
- const currentRev = legacy.version;
317
- const revDir = this.revisionDir(id, currentRev);
318
- mkdirSync(revDir, { recursive: true });
319
- if (existsSync(flatJsx)) {
320
- renameSync(flatJsx, join(revDir, "plan.jsx"));
321
- }
322
- if (existsSync(flatCompiled)) {
323
- renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
324
- }
325
- revisions.push({
326
- revision: currentRev,
327
- canvasFiles: [{ filename: "plan.jsx" }],
328
- createdAt: legacy.updatedAt,
329
- hasFeedback: false,
330
- feedbackConsumed: false,
331
- });
332
-
333
- const meta: SessionMeta = {
334
- projectRoot: legacy.projectRoot,
335
- createdAt: legacy.createdAt,
336
- updatedAt: legacy.updatedAt,
337
- currentRevision: currentRev,
338
- revisions,
339
- };
340
- writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
341
-
342
- // Only once the new layout is on disk. Anything that fails above leaves the
343
- // old directory in place rather than half-migrated and unrecorded.
344
- rmSync(historyDir, { recursive: true, force: true });
345
- }
346
-
347
- readRevisionJsx(id: string, rev: number, filename: string): string | null {
348
- try {
349
- return readFileSync(join(this.revisionDir(id, rev), filename), "utf-8");
350
- } catch {
351
- return null;
352
- }
353
- }
354
-
355
- /**
356
- * Create or update a session with a set of canvas files.
357
- * @param canvasFiles Map of filename -> JSX content
358
- */
359
- upsert(id: string, canvasFiles: Map<string, string>, projectRoot: string, label?: string, response?: string, canvasScope?: CanvasScope): SessionData {
360
- const existing = this.sessions.get(id);
361
- const now = new Date().toISOString();
362
- // Stable session IDs must never recycle revision numbers used as browser draft keys.
363
- const revision = existing ? existing.currentRevision + 1 : 1;
364
-
365
- // Compute per-file diffStats against the previous revision
366
- const prevRev = existing ? existing.currentRevision : 0;
367
- const canvasFileInfos: CanvasFileInfo[] = [];
368
- for (const [filename, jsx] of canvasFiles) {
369
- let diffStats: DiffStats | undefined;
370
- if (prevRev > 0) {
371
- const prevJsx = this.readRevisionJsx(id, prevRev, filename);
372
- if (prevJsx) {
373
- diffStats = computeLineDiffStats(prevJsx, jsx);
374
- }
375
- }
376
- canvasFileInfos.push({
377
- filename,
378
- ...(diffStats ? { diffStats } : {}),
379
- });
380
- }
381
-
382
- const revInfo: RevisionInfo = {
383
- revision,
384
- canvasFiles: canvasFileInfos,
385
- createdAt: now,
386
- hasFeedback: false,
387
- feedbackConsumed: false,
388
- ...(label ? { label } : {}),
389
- ...(response ? { response } : {}),
390
- ...(canvasScope ? { canvasScope } : {}),
391
- };
392
- const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
393
- // Order: files from previous revision first (preserving their order), then new files alphabetically
394
- const prevOrder = existing?.canvasFiles ?? [];
395
- const currentNames = new Set(canvasFiles.keys());
396
- const filenames = [
397
- ...prevOrder.filter(f => currentNames.has(f)),
398
- ...[...currentNames].filter(f => !prevOrder.includes(f)).sort(),
399
- ];
400
-
401
- const session: SessionData = {
402
- id,
403
- // A session belongs to the project it was created in. Callers send their
404
- // cwd, so a later push from elsewhere must not repoint it — that would
405
- // send project-relative reads (file previews, phase state) to the wrong
406
- // tree.
407
- projectRoot: existing?.projectRoot ?? projectRoot,
408
- canvasFiles: filenames,
409
- currentRevision: revision,
410
- revisions,
411
- createdAt: existing?.createdAt ?? now,
412
- updatedAt: now,
413
- };
414
-
415
- // Secrets belong to one revision and must never carry into a new runbook.
416
- this.sessionSecrets.delete(id);
417
- this.sessions.set(id, session);
418
- this.persistToDisk(session, canvasFiles);
419
- return session;
420
- }
421
-
422
- private persistMeta(session: SessionData) {
423
- const meta: SessionMeta = {
424
- projectRoot: session.projectRoot,
425
- createdAt: session.createdAt,
426
- updatedAt: session.updatedAt,
427
- currentRevision: session.currentRevision,
428
- revisions: session.revisions,
429
- ...(session.shares?.length ? { shares: session.shares } : {}),
430
- };
431
- writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
432
- }
433
-
434
- /** Record a new share for a session revision. */
435
- addShare(id: string, entry: ShareEntry): void {
436
- const session = this.sessions.get(id);
437
- if (!session) return;
438
- session.shares = [...(session.shares ?? []), entry];
439
- session.updatedAt = new Date().toISOString();
440
- this.persistMeta(session);
441
- }
442
-
443
- getShares(id: string): ShareEntry[] {
444
- return this.sessions.get(id)?.shares ?? [];
445
- }
446
-
447
- removeShare(id: string, shareId: string): void {
448
- const session = this.sessions.get(id);
449
- if (!session?.shares) return;
450
- session.shares = session.shares.filter((s) => s.shareId !== shareId);
451
- session.updatedAt = new Date().toISOString();
452
- this.persistMeta(session);
453
- }
454
-
455
- updateShareLastFeedback(id: string, shareId: string, at: string): void {
456
- const session = this.sessions.get(id);
457
- if (!session?.shares) return;
458
- const entry = session.shares.find((s) => s.shareId === shareId);
459
- if (!entry) return;
460
- entry.lastFeedbackAt = at;
461
- this.persistMeta(session);
462
- }
463
-
464
- /** List all sessions that have at least one share. Used by the remote feedback poller. */
465
- listSessionsWithShares(): SessionData[] {
466
- return Array.from(this.sessions.values()).filter((s) => (s.shares?.length ?? 0) > 0);
467
- }
468
-
469
- private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
470
- const revDir = this.revisionDir(session.id, session.currentRevision);
471
- mkdirSync(revDir, { recursive: true });
472
- for (const [filename, jsx] of canvasFiles) {
473
- writeFileSync(join(revDir, filename), jsx);
474
- }
475
- this.persistMeta(session);
476
- }
477
-
478
- get(id: string): SessionData | undefined {
479
- return this.sessions.get(id);
480
- }
481
-
482
- list(): SessionData[] {
483
- return Array.from(this.sessions.values());
484
- }
485
-
486
- remove(id: string) {
487
- this.sessions.delete(id);
488
- this.sessionSecrets.delete(id);
489
- rmSync(this.sessionDir(id), { recursive: true, force: true });
490
- }
491
-
492
- setSecret(id: string, fieldId: string, value: string): boolean {
493
- const session = this.sessions.get(id);
494
- if (!session) return false;
495
-
496
- let secrets = this.sessionSecrets.get(id);
497
- if (!secrets || secrets.revision !== session.currentRevision) {
498
- secrets = { revision: session.currentRevision, values: new Map() };
499
- this.sessionSecrets.set(id, secrets);
500
- }
501
- secrets.values.set(fieldId, value);
502
- return true;
503
- }
504
-
505
- hasSecret(id: string, fieldId: string): boolean {
506
- const session = this.sessions.get(id);
507
- const secrets = this.sessionSecrets.get(id);
508
- return !!session
509
- && secrets?.revision === session.currentRevision
510
- && secrets.values.has(fieldId);
511
- }
512
-
513
- clearSecret(id: string, fieldId: string): boolean {
514
- const session = this.sessions.get(id);
515
- const secrets = this.sessionSecrets.get(id);
516
- if (!session || secrets?.revision !== session.currentRevision) return false;
517
- return secrets.values.delete(fieldId);
518
- }
519
-
520
- resolveSecrets(id: string, fieldIds: string[]): SecretResolution | null {
521
- const session = this.sessions.get(id);
522
- if (!session) return null;
523
-
524
- const secrets = this.sessionSecrets.get(id);
525
- const missing = fieldIds.filter((fieldId) =>
526
- secrets?.revision !== session.currentRevision || !secrets.values.has(fieldId)
527
- );
528
- if (missing.length > 0) return { ok: false, missing };
529
-
530
- const values = new Map<string, string>();
531
- for (const fieldId of fieldIds) {
532
- const value = secrets?.values.get(fieldId);
533
- if (value !== undefined) values.set(fieldId, value);
534
- }
535
- return { ok: true, values };
536
- }
537
-
538
- saveCompiled(id: string, filename: string, js: string, rev?: number) {
539
- const session = this.sessions.get(id);
540
- const revision = rev ?? session?.currentRevision;
541
- if (!revision) return;
542
- const revDir = this.revisionDir(id, revision);
543
- mkdirSync(revDir, { recursive: true });
544
- const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
545
- writeFileSync(join(revDir, compiledName), js);
546
- }
547
-
548
- getCompiled(id: string, filename: string, rev?: number): string | null {
549
- const session = this.sessions.get(id);
550
- const revision = rev ?? session?.currentRevision;
551
- if (!revision) return null;
552
- const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
553
- try {
554
- return readFileSync(join(this.revisionDir(id, revision), compiledName), "utf-8");
555
- } catch {
556
- return null;
557
- }
558
- }
559
-
560
- saveFeedback(id: string, rev: number, markdown: string) {
561
- const revDir = this.revisionDir(id, rev);
562
- mkdirSync(revDir, { recursive: true });
563
- writeFileSync(join(revDir, "feedback.md"), markdown);
564
-
565
- const session = this.sessions.get(id);
566
- if (session) {
567
- const ri = session.revisions.find((r) => r.revision === rev);
568
- if (ri) {
569
- ri.hasFeedback = true;
570
- ri.feedbackConsumed = false;
571
- }
572
- // Feedback arriving is activity: it orders the session list, and it must
573
- // not leave a session looking untouched while its feedback waits.
574
- session.updatedAt = new Date().toISOString();
575
- this.persistMeta(session);
576
- }
577
- }
578
-
579
- getFeedback(id: string, rev: number): string | null {
580
- try {
581
- return readFileSync(join(this.revisionDir(id, rev), "feedback.md"), "utf-8");
582
- } catch {
583
- return null;
584
- }
585
- }
586
-
587
- consumeFeedback(id: string, rev: number) {
588
- const session = this.sessions.get(id);
589
- if (!session) return;
590
- const ri = session.revisions.find((r) => r.revision === rev);
591
- if (!ri || !ri.hasFeedback || ri.feedbackConsumed) return;
592
- ri.feedbackConsumed = true;
593
- this.persistMeta(session);
594
- }
595
-
596
- getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
597
- const session = this.sessions.get(id);
598
- if (!session) return null;
599
- for (let i = session.revisions.length - 1; i >= 0; i--) {
600
- const ri = session.revisions[i];
601
- if (ri?.hasFeedback && !ri.feedbackConsumed) {
602
- const feedback = this.getFeedback(id, ri.revision);
603
- if (feedback) return { revision: ri.revision, feedback };
604
- }
605
- }
606
- return null;
607
- }
608
-
609
- getRevisionJsxPath(id: string, rev: number, filename: string): string {
610
- return join(this.revisionDir(id, rev), filename);
611
- }
612
-
613
- /**
614
- * Remote feedback from shared views. Stored as one JSON file per revision
615
- * at `revisions/{rev}/remote_feedback.json`, containing an array of
616
- * RemoteFeedbackEntry. Appended to (not replaced) as new feedback arrives.
617
- */
618
- appendRemoteFeedback(id: string, rev: number, entries: RemoteFeedbackEntry[]): void {
619
- if (entries.length === 0) return;
620
- const revDir = this.revisionDir(id, rev);
621
- mkdirSync(revDir, { recursive: true });
622
- const file = join(revDir, "remote_feedback.json");
623
- let existing: RemoteFeedbackEntry[] = [];
624
- try {
625
- existing = JSON.parse(readFileSync(file, "utf-8"));
626
- } catch {}
627
- const seen = new Set(existing.map((e) => e.id));
628
- const merged = [...existing];
629
- for (const e of entries) if (!seen.has(e.id)) merged.push(e);
630
- writeFileSync(file, JSON.stringify(merged, null, 2));
631
-
632
- // Same reason as saveFeedback — a reviewer's comment is input for this
633
- // session, not something that happens beside it.
634
- const session = this.sessions.get(id);
635
- if (session) {
636
- session.updatedAt = new Date().toISOString();
637
- this.persistMeta(session);
638
- }
639
- }
640
-
641
- getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
642
- try {
643
- return JSON.parse(readFileSync(join(this.revisionDir(id, rev), "remote_feedback.json"), "utf-8"));
644
- } catch {
645
- return [];
646
- }
647
- }
648
-
649
- /**
650
- * Server-side backing store for an in-progress draft. Hosts that wire the
651
- * AnnotationProvider's loadState/saveState to these endpoints get drafts that
652
- * survive a reload and follow the author across browsers, instead of living
653
- * in one browser's localStorage. Opaque payload — the shape is the client's.
654
- */
655
- saveResponses(id: string, rev: number, payload: unknown): void {
656
- const revDir = this.revisionDir(id, rev);
657
- mkdirSync(revDir, { recursive: true });
658
- writeFileSync(join(revDir, "responses.json"), JSON.stringify(payload));
659
- }
660
-
661
- getResponses(id: string, rev: number): unknown | null {
662
- try {
663
- return JSON.parse(readFileSync(join(this.revisionDir(id, rev), "responses.json"), "utf-8"));
664
- } catch {
665
- return null;
666
- }
667
- }
668
-
669
- /** Drop sessions untouched for longer than `maxAge` (default 24h). */
670
- cleanupStale(maxAge = 24 * 60 * 60 * 1000) {
671
- const now = Date.now();
672
- for (const [id, session] of this.sessions) {
673
- if (now - new Date(session.updatedAt).getTime() > maxAge) {
674
- this.remove(id);
675
- }
676
- }
677
- }
142
+ private sessions = new Map<string, SessionData>()
143
+ private sessionSecrets = new Map<string, { revision: number; values: Map<string, string> }>()
144
+ private readonly sessionsDir: string
145
+
146
+ /**
147
+ * @param sessionsDir On-disk sessions root, from `createCanvasPaths()`.
148
+ * Tests pass an isolated temp directory so they don't interfere with
149
+ * the user's real sessions.
150
+ */
151
+ constructor(sessionsDir: string) {
152
+ this.sessionsDir = sessionsDir
153
+ mkdirSync(this.sessionsDir, { recursive: true })
154
+ this.loadFromDisk()
155
+ }
156
+
157
+ private sessionDir(id: string): string {
158
+ return join(this.sessionsDir, id)
159
+ }
160
+
161
+ private revisionDir(id: string, rev: number): string {
162
+ return join(this.sessionDir(id), 'revisions', String(rev))
163
+ }
164
+
165
+ /** Compute file order from revision history: first-appearance order, alpha for ties */
166
+ private deriveFileOrder(revisions: RevisionInfo[], currentFiles: string[]): string[] {
167
+ const seen = new Set<string>()
168
+ const ordered: string[] = []
169
+ for (const rev of revisions) {
170
+ for (const cf of rev.canvasFiles) {
171
+ if (!seen.has(cf.filename)) {
172
+ seen.add(cf.filename)
173
+ ordered.push(cf.filename)
174
+ }
175
+ }
176
+ }
177
+ // Keep only files that still exist, then append any unknown ones
178
+ const current = new Set(currentFiles)
179
+ const result = ordered.filter(f => current.has(f))
180
+ const remaining = currentFiles.filter(f => !seen.has(f)).sort()
181
+ return [...result, ...remaining]
182
+ }
183
+
184
+ /** List *.jsx filenames in a revision directory */
185
+ getRevisionCanvasFiles(id: string, rev: number): string[] {
186
+ const dir = this.revisionDir(id, rev)
187
+ if (!existsSync(dir)) return []
188
+ return readdirSync(dir).filter(f => f.endsWith('.jsx')).sort()
189
+ }
190
+
191
+ private loadFromDisk() {
192
+ if (!existsSync(this.sessionsDir)) return
193
+ for (const name of readdirSync(this.sessionsDir, { withFileTypes: true })) {
194
+ if (!name.isDirectory()) continue
195
+ const dir = join(this.sessionsDir, name.name)
196
+ const metaPath = join(dir, 'meta.json')
197
+ if (!existsSync(metaPath)) continue
198
+
199
+ try {
200
+ const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8'))
201
+ const { meta, migrated } = savedSessionMetadataSchema.parse(raw)
202
+ if (migrated) {
203
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2))
204
+ }
205
+
206
+ const diskFiles = this.getRevisionCanvasFiles(name.name, meta.currentRevision)
207
+ if (diskFiles.length === 0) continue
208
+ // Derive file order from revision history (first-appearance order)
209
+ const canvasFiles = this.deriveFileOrder(meta.revisions, diskFiles)
210
+
211
+ this.sessions.set(name.name, {
212
+ ...meta,
213
+ id: name.name,
214
+ projectRoot: meta.projectRoot,
215
+ canvasFiles,
216
+ currentRevision: meta.currentRevision,
217
+ revisions: meta.revisions,
218
+ createdAt: meta.createdAt,
219
+ updatedAt: meta.updatedAt,
220
+ ...(meta.shares ? { shares: meta.shares } : {}),
221
+ })
222
+ } catch (cause) {
223
+ // A session that fails here vanishes from the list, so staying silent
224
+ // loses work without saying so. Other sessions still load.
225
+ const reason = cause instanceof Error ? cause.message : String(cause)
226
+ console.warn(`[sessions] ${name.name}: could not be loaded (${reason})`)
227
+ }
228
+ }
229
+ }
230
+
231
+ readRevisionJsx(id: string, rev: number, filename: string): string | null {
232
+ try {
233
+ return readFileSync(join(this.revisionDir(id, rev), filename), 'utf-8')
234
+ } catch {
235
+ return null
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Create or update a session with a set of canvas files.
241
+ * @param canvasFiles Map of filename -> JSX content
242
+ */
243
+ upsert(
244
+ id: string,
245
+ canvasFiles: Map<string, string>,
246
+ projectRoot: string,
247
+ label?: string,
248
+ response?: string,
249
+ canvasScope?: CanvasScope,
250
+ ): SessionData {
251
+ const existing = this.sessions.get(id)
252
+ const now = new Date().toISOString()
253
+ // Stable session IDs must never recycle revision numbers used as browser draft keys.
254
+ const revision = existing ? existing.currentRevision + 1 : 1
255
+
256
+ // Compute per-file diffStats against the previous revision
257
+ const prevRev = existing ? existing.currentRevision : 0
258
+ const canvasFileInfos: CanvasFileInfo[] = []
259
+ for (const [filename, jsx] of canvasFiles) {
260
+ let diffStats: DiffStats | undefined
261
+ if (prevRev > 0) {
262
+ const prevJsx = this.readRevisionJsx(id, prevRev, filename)
263
+ if (prevJsx) {
264
+ diffStats = computeLineDiffStats(prevJsx, jsx)
265
+ }
266
+ }
267
+ canvasFileInfos.push({
268
+ filename,
269
+ ...(diffStats ? { diffStats } : {}),
270
+ })
271
+ }
272
+
273
+ const revInfo: RevisionInfo = {
274
+ revision,
275
+ canvasFiles: canvasFileInfos,
276
+ createdAt: now,
277
+ hasFeedback: false,
278
+ feedbackConsumed: false,
279
+ ...(label ? { label } : {}),
280
+ ...(response ? { response } : {}),
281
+ ...(canvasScope ? { canvasScope } : {}),
282
+ }
283
+ const revisions = existing ? [...existing.revisions, revInfo] : [revInfo]
284
+ // Order: files from previous revision first (preserving their order), then new files alphabetically
285
+ const prevOrder = existing?.canvasFiles ?? []
286
+ const currentNames = new Set(canvasFiles.keys())
287
+ const filenames = [
288
+ ...prevOrder.filter(f => currentNames.has(f)),
289
+ ...[...currentNames].filter(f => !prevOrder.includes(f)).sort(),
290
+ ]
291
+
292
+ const session: SessionData = {
293
+ ...existing,
294
+ id,
295
+ // A session belongs to the project it was created in. Callers send their
296
+ // cwd, so a later push from elsewhere must not repoint it — that would
297
+ // send project-relative reads (file previews, phase state) to the wrong
298
+ // tree.
299
+ projectRoot: existing?.projectRoot ?? projectRoot,
300
+ canvasFiles: filenames,
301
+ currentRevision: revision,
302
+ revisions,
303
+ createdAt: existing?.createdAt ?? now,
304
+ updatedAt: now,
305
+ }
306
+
307
+ // Secrets belong to one revision and must never carry into a new runbook.
308
+ this.sessionSecrets.delete(id)
309
+ this.sessions.set(id, session)
310
+ this.persistToDisk(session, canvasFiles)
311
+ return session
312
+ }
313
+
314
+ private persistMeta(session: SessionData) {
315
+ const { id: _id, canvasFiles: _canvasFiles, ...fields } = session
316
+ const meta: SessionMeta = {
317
+ ...fields,
318
+ projectRoot: session.projectRoot,
319
+ createdAt: session.createdAt,
320
+ updatedAt: session.updatedAt,
321
+ currentRevision: session.currentRevision,
322
+ revisions: session.revisions,
323
+ ...(session.shares?.length ? { shares: session.shares } : {}),
324
+ }
325
+ writeFileSync(join(this.sessionDir(session.id), 'meta.json'), JSON.stringify(meta, null, 2))
326
+ }
327
+
328
+ /** Record a new share for a session revision. */
329
+ addShare(id: string, entry: ShareEntry): void {
330
+ const session = this.sessions.get(id)
331
+ if (!session) return
332
+ session.shares = [...(session.shares ?? []), entry]
333
+ session.updatedAt = new Date().toISOString()
334
+ this.persistMeta(session)
335
+ }
336
+
337
+ getShares(id: string): ShareEntry[] {
338
+ return this.sessions.get(id)?.shares ?? []
339
+ }
340
+
341
+ removeShare(id: string, shareId: string): void {
342
+ const session = this.sessions.get(id)
343
+ if (!session?.shares) return
344
+ session.shares = session.shares.filter((s) => s.shareId !== shareId)
345
+ session.updatedAt = new Date().toISOString()
346
+ this.persistMeta(session)
347
+ }
348
+
349
+ updateShareLastFeedback(id: string, shareId: string, at: string): void {
350
+ const session = this.sessions.get(id)
351
+ if (!session?.shares) return
352
+ const entry = session.shares.find((s) => s.shareId === shareId)
353
+ if (!entry) return
354
+ entry.lastFeedbackAt = at
355
+ this.persistMeta(session)
356
+ }
357
+
358
+ /** List all sessions that have at least one share. Used by the remote feedback poller. */
359
+ listSessionsWithShares(): SessionData[] {
360
+ return Array.from(this.sessions.values()).filter((s) => (s.shares?.length ?? 0) > 0)
361
+ }
362
+
363
+ private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
364
+ const revDir = this.revisionDir(session.id, session.currentRevision)
365
+ mkdirSync(revDir, { recursive: true })
366
+ for (const [filename, jsx] of canvasFiles) {
367
+ writeFileSync(join(revDir, filename), jsx)
368
+ }
369
+ this.persistMeta(session)
370
+ }
371
+
372
+ get(id: string): SessionData | undefined {
373
+ return this.sessions.get(id)
374
+ }
375
+
376
+ list(): SessionData[] {
377
+ return Array.from(this.sessions.values())
378
+ }
379
+
380
+ remove(id: string) {
381
+ this.sessions.delete(id)
382
+ this.sessionSecrets.delete(id)
383
+ rmSync(this.sessionDir(id), { recursive: true, force: true })
384
+ }
385
+
386
+ setSecret(id: string, fieldId: string, value: string): boolean {
387
+ const session = this.sessions.get(id)
388
+ if (!session) return false
389
+
390
+ let secrets = this.sessionSecrets.get(id)
391
+ if (!secrets || secrets.revision !== session.currentRevision) {
392
+ secrets = { revision: session.currentRevision, values: new Map() }
393
+ this.sessionSecrets.set(id, secrets)
394
+ }
395
+ secrets.values.set(fieldId, value)
396
+ return true
397
+ }
398
+
399
+ hasSecret(id: string, fieldId: string): boolean {
400
+ const session = this.sessions.get(id)
401
+ const secrets = this.sessionSecrets.get(id)
402
+ return !!session
403
+ && secrets?.revision === session.currentRevision
404
+ && secrets.values.has(fieldId)
405
+ }
406
+
407
+ clearSecret(id: string, fieldId: string): boolean {
408
+ const session = this.sessions.get(id)
409
+ const secrets = this.sessionSecrets.get(id)
410
+ if (!session || secrets?.revision !== session.currentRevision) return false
411
+ return secrets.values.delete(fieldId)
412
+ }
413
+
414
+ resolveSecrets(id: string, fieldIds: string[]): SecretResolution | null {
415
+ const session = this.sessions.get(id)
416
+ if (!session) return null
417
+
418
+ const secrets = this.sessionSecrets.get(id)
419
+ const missing = fieldIds.filter((fieldId) => secrets?.revision !== session.currentRevision || !secrets.values.has(fieldId))
420
+ if (missing.length > 0) return { ok: false, missing }
421
+
422
+ const values = new Map<string, string>()
423
+ for (const fieldId of fieldIds) {
424
+ const value = secrets?.values.get(fieldId)
425
+ if (value !== undefined) values.set(fieldId, value)
426
+ }
427
+ return { ok: true, values }
428
+ }
429
+
430
+ saveCompiled(id: string, filename: string, js: string, rev?: number) {
431
+ const session = this.sessions.get(id)
432
+ const revision = rev ?? session?.currentRevision
433
+ if (!revision) return
434
+ const revDir = this.revisionDir(id, revision)
435
+ mkdirSync(revDir, { recursive: true })
436
+ const compiledName = filename.replace(/\.jsx$/, '.compiled.js')
437
+ writeFileSync(join(revDir, compiledName), js)
438
+ }
439
+
440
+ getCompiled(id: string, filename: string, rev?: number): string | null {
441
+ const session = this.sessions.get(id)
442
+ const revision = rev ?? session?.currentRevision
443
+ if (!revision) return null
444
+ const compiledName = filename.replace(/\.jsx$/, '.compiled.js')
445
+ try {
446
+ return readFileSync(join(this.revisionDir(id, revision), compiledName), 'utf-8')
447
+ } catch {
448
+ return null
449
+ }
450
+ }
451
+
452
+ saveFeedback(id: string, rev: number, markdown: string) {
453
+ const revDir = this.revisionDir(id, rev)
454
+ mkdirSync(revDir, { recursive: true })
455
+ writeFileSync(join(revDir, 'feedback.md'), markdown)
456
+
457
+ const session = this.sessions.get(id)
458
+ if (session) {
459
+ const ri = session.revisions.find((r) => r.revision === rev)
460
+ if (ri) {
461
+ ri.hasFeedback = true
462
+ ri.feedbackConsumed = false
463
+ }
464
+ // Feedback arriving is activity: it orders the session list, and it must
465
+ // not leave a session looking untouched while its feedback waits.
466
+ session.updatedAt = new Date().toISOString()
467
+ this.persistMeta(session)
468
+ }
469
+ }
470
+
471
+ getFeedback(id: string, rev: number): string | null {
472
+ try {
473
+ return readFileSync(join(this.revisionDir(id, rev), 'feedback.md'), 'utf-8')
474
+ } catch {
475
+ return null
476
+ }
477
+ }
478
+
479
+ consumeFeedback(id: string, rev: number) {
480
+ const session = this.sessions.get(id)
481
+ if (!session) return
482
+ const ri = session.revisions.find((r) => r.revision === rev)
483
+ if (!ri?.hasFeedback || ri.feedbackConsumed) return
484
+ ri.feedbackConsumed = true
485
+ this.persistMeta(session)
486
+ }
487
+
488
+ getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
489
+ const session = this.sessions.get(id)
490
+ if (!session) return null
491
+ for (let i = session.revisions.length - 1; i >= 0; i--) {
492
+ const ri = session.revisions[i]
493
+ if (ri?.hasFeedback && !ri.feedbackConsumed) {
494
+ const feedback = this.getFeedback(id, ri.revision)
495
+ if (feedback) return { revision: ri.revision, feedback }
496
+ }
497
+ }
498
+ return null
499
+ }
500
+
501
+ getRevisionJsxPath(id: string, rev: number, filename: string): string {
502
+ return join(this.revisionDir(id, rev), filename)
503
+ }
504
+
505
+ /**
506
+ * Remote feedback from shared views. Stored as one JSON file per revision
507
+ * at `revisions/{rev}/remote_feedback.json`, containing an array of
508
+ * RemoteFeedbackEntry. Appended to (not replaced) as new feedback arrives.
509
+ */
510
+ appendRemoteFeedback(id: string, rev: number, entries: RemoteFeedbackEntry[]): void {
511
+ if (entries.length === 0) return
512
+ const revDir = this.revisionDir(id, rev)
513
+ mkdirSync(revDir, { recursive: true })
514
+ const file = join(revDir, 'remote_feedback.json')
515
+ const existing = this.getRemoteFeedback(id, rev)
516
+ const seen = new Set(existing.map((e) => e.id))
517
+ const merged = [...existing]
518
+ for (const e of entries) if (!seen.has(e.id)) merged.push(e)
519
+ writeFileSync(file, JSON.stringify(merged, null, 2))
520
+
521
+ // Same reason as saveFeedback — a reviewer's comment is input for this
522
+ // session, not something that happens beside it.
523
+ const session = this.sessions.get(id)
524
+ if (session) {
525
+ session.updatedAt = new Date().toISOString()
526
+ this.persistMeta(session)
527
+ }
528
+ }
529
+
530
+ getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
531
+ try {
532
+ return remoteFeedbackSchema.parse(JSON.parse(readFileSync(join(this.revisionDir(id, rev), 'remote_feedback.json'), 'utf-8')))
533
+ } catch {
534
+ return []
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Server-side backing store for an in-progress draft. Hosts that wire the
540
+ * AnnotationProvider's loadState/saveState to these endpoints get drafts that
541
+ * survive a reload and follow the author across browsers, instead of living
542
+ * in one browser's localStorage. Opaque payload — the shape is the client's.
543
+ */
544
+ saveResponses(id: string, rev: number, payload: unknown): void {
545
+ const revDir = this.revisionDir(id, rev)
546
+ mkdirSync(revDir, { recursive: true })
547
+ writeFileSync(join(revDir, 'responses.json'), JSON.stringify(payload))
548
+ }
549
+
550
+ getResponses(id: string, rev: number): unknown | null {
551
+ try {
552
+ return JSON.parse(readFileSync(join(this.revisionDir(id, rev), 'responses.json'), 'utf-8'))
553
+ } catch {
554
+ return null
555
+ }
556
+ }
557
+
558
+ /** Drop sessions untouched for longer than `maxAge` (default 24h). */
559
+ cleanupStale(maxAge = 24 * 60 * 60 * 1000) {
560
+ const now = Date.now()
561
+ for (const [id, session] of this.sessions) {
562
+ if (now - new Date(session.updatedAt).getTime() > maxAge) {
563
+ this.remove(id)
564
+ }
565
+ }
566
+ }
678
567
  }