@fabrika/canvas-kernel 0.1.45 → 0.2.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 (66) hide show
  1. package/README.md +33 -67
  2. package/index.ts +19 -83
  3. package/package.json +68 -72
  4. package/src/client/AnnotationProvider.tsx +227 -268
  5. package/src/client/AnnotationSidebar.tsx +392 -338
  6. package/src/client/FileIcon.tsx +106 -65
  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 +98 -92
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +28 -28
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +26 -26
  17. package/src/client/components/Diff.tsx +17 -17
  18. package/src/client/components/FilePreview.tsx +99 -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 +592 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +11 -11
  24. package/src/client/components/ResponseNote.tsx +17 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +34 -29
  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 +34 -34
  34. package/src/client/index.ts +43 -125
  35. package/src/client/preact-all.ts +4 -4
  36. package/src/client/runtime.ts +58 -111
  37. package/src/client/styles.css +1 -74
  38. package/src/engine/compiler.ts +316 -313
  39. package/src/engine/paths.ts +44 -44
  40. package/src/engine/session.ts +623 -618
  41. package/src/engine/watcher.ts +63 -60
  42. package/src/engine/websocket.ts +123 -121
  43. package/annotate.ts +0 -94
  44. package/daemon.ts +0 -46
  45. package/src/client/AnnotationDraftFooter.tsx +0 -76
  46. package/src/client/AnnotationEditor.tsx +0 -320
  47. package/src/client/AnnotationList.tsx +0 -190
  48. package/src/client/Popover.tsx +0 -361
  49. package/src/client/SessionContext.tsx +0 -3
  50. package/src/client/annotationContext.ts +0 -130
  51. package/src/client/annotationMarkdown.ts +0 -102
  52. package/src/client/annotationTarget.ts +0 -73
  53. package/src/client/blockTarget.ts +0 -127
  54. package/src/client/highlightRange.ts +0 -262
  55. package/src/client/popoverPosition.ts +0 -46
  56. package/src/client/regionTarget.ts +0 -180
  57. package/src/client/testing/dom.ts +0 -74
  58. package/src/client/textTarget.ts +0 -31
  59. package/src/client/useRegionAnnotation.tsx +0 -188
  60. package/src/client/useTextAnnotation.tsx +0 -194
  61. package/src/client/utils.ts +0 -33
  62. package/src/engine/channel.ts +0 -278
  63. package/src/engine/http-utils.ts +0 -14
  64. package/src/engine/lifecycle.ts +0 -302
  65. package/src/engine/router.ts +0 -16
  66. package/src/engine/waiter.ts +0 -188
@@ -1,31 +1,31 @@
1
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync, copyFileSync } from "fs";
2
- import { join } from "path";
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
2
+ import { join } from 'path'
3
3
 
4
4
  export interface DiffStats {
5
- added: number;
6
- removed: number;
5
+ added: number
6
+ removed: number
7
7
  }
8
8
 
9
9
  export interface CanvasFileInfo {
10
- filename: string;
11
- diffStats?: DiffStats;
10
+ filename: string
11
+ diffStats?: DiffStats
12
12
  }
13
13
 
14
14
  /** How the caller selected the canvases in a revision. New revisions always
15
15
  * carry this explicitly; its absence identifies legacy metadata. */
16
16
  export type CanvasScope =
17
- | { kind: "view"; filename: string }
18
- | { kind: "directory" };
17
+ | { kind: 'view'; filename: string }
18
+ | { kind: 'directory' }
19
19
 
20
20
  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;
21
+ revision: number
22
+ label?: string
23
+ canvasFiles: CanvasFileInfo[]
24
+ createdAt: string
25
+ hasFeedback: boolean
26
+ feedbackConsumed: boolean
27
+ response?: string
28
+ canvasScope?: CanvasScope
29
29
  }
30
30
 
31
31
  /**
@@ -35,15 +35,15 @@ export interface RevisionInfo {
35
35
  * highlighted-text comments; `generalNote` is the free-form markdown.
36
36
  */
37
37
  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;
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
47
47
  }
48
48
 
49
49
  /**
@@ -51,20 +51,20 @@ export interface RemoteFeedbackEntry {
51
51
  * sense locally. Kept intentionally narrow so the wire format is stable.
52
52
  */
53
53
  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 }[];
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 }[]
68
68
  }
69
69
 
70
70
  /**
@@ -74,605 +74,610 @@ export interface RemoteAnnotation {
74
74
  * public URL the user shared with reviewers.
75
75
  */
76
76
  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;
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
92
92
  }
93
93
 
94
94
  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[];
95
+ id: string
96
+ projectRoot: string
97
+ canvasFiles: string[]
98
+ currentRevision: number
99
+ revisions: RevisionInfo[]
100
+ createdAt: string
101
+ updatedAt: string
102
+ shares?: ShareEntry[]
103
103
  }
104
104
 
105
105
  export type SecretResolution =
106
- | { ok: true; values: Map<string, string> }
107
- | { ok: false; missing: string[] };
106
+ | { ok: true; values: Map<string, string> }
107
+ | { ok: false; missing: string[] }
108
108
 
109
109
  interface SessionMeta {
110
- projectRoot: string;
111
- createdAt: string;
112
- updatedAt: string;
113
- currentRevision: number;
114
- revisions: RevisionInfo[];
115
- shares?: ShareEntry[];
110
+ projectRoot: string
111
+ createdAt: string
112
+ updatedAt: string
113
+ currentRevision: number
114
+ revisions: RevisionInfo[]
115
+ shares?: ShareEntry[]
116
116
  }
117
117
 
118
118
  // Legacy format (flat files, pre-revision)
119
119
  interface LegacyMeta {
120
- projectRoot: string;
121
- createdAt: string;
122
- updatedAt: string;
123
- version: number;
120
+ projectRoot: string
121
+ createdAt: string
122
+ updatedAt: string
123
+ version: number
124
124
  }
125
125
 
126
126
  // Previous format (single sourceFile per revision)
127
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;
128
+ revision: number
129
+ label?: string
130
+ sourceFile?: string
131
+ createdAt: string
132
+ hasFeedback: boolean
133
+ feedbackConsumed: boolean
134
+ response?: string
135
+ diffStats?: DiffStats
136
136
  }
137
137
 
138
138
  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 };
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 }
158
158
  }
159
159
 
160
160
  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
- }
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(
360
+ id: string,
361
+ canvasFiles: Map<string, string>,
362
+ projectRoot: string,
363
+ label?: string,
364
+ response?: string,
365
+ canvasScope?: CanvasScope,
366
+ ): SessionData {
367
+ const existing = this.sessions.get(id)
368
+ const now = new Date().toISOString()
369
+ // Stable session IDs must never recycle revision numbers used as browser draft keys.
370
+ const revision = existing ? existing.currentRevision + 1 : 1
371
+
372
+ // Compute per-file diffStats against the previous revision
373
+ const prevRev = existing ? existing.currentRevision : 0
374
+ const canvasFileInfos: CanvasFileInfo[] = []
375
+ for (const [filename, jsx] of canvasFiles) {
376
+ let diffStats: DiffStats | undefined
377
+ if (prevRev > 0) {
378
+ const prevJsx = this.readRevisionJsx(id, prevRev, filename)
379
+ if (prevJsx) {
380
+ diffStats = computeLineDiffStats(prevJsx, jsx)
381
+ }
382
+ }
383
+ canvasFileInfos.push({
384
+ filename,
385
+ ...(diffStats ? { diffStats } : {}),
386
+ })
387
+ }
388
+
389
+ const revInfo: RevisionInfo = {
390
+ revision,
391
+ canvasFiles: canvasFileInfos,
392
+ createdAt: now,
393
+ hasFeedback: false,
394
+ feedbackConsumed: false,
395
+ ...(label ? { label } : {}),
396
+ ...(response ? { response } : {}),
397
+ ...(canvasScope ? { canvasScope } : {}),
398
+ }
399
+ const revisions = existing ? [...existing.revisions, revInfo] : [revInfo]
400
+ // Order: files from previous revision first (preserving their order), then new files alphabetically
401
+ const prevOrder = existing?.canvasFiles ?? []
402
+ const currentNames = new Set(canvasFiles.keys())
403
+ const filenames = [
404
+ ...prevOrder.filter(f => currentNames.has(f)),
405
+ ...[...currentNames].filter(f => !prevOrder.includes(f)).sort(),
406
+ ]
407
+
408
+ const session: SessionData = {
409
+ id,
410
+ // A session belongs to the project it was created in. Callers send their
411
+ // cwd, so a later push from elsewhere must not repoint it — that would
412
+ // send project-relative reads (file previews, phase state) to the wrong
413
+ // tree.
414
+ projectRoot: existing?.projectRoot ?? projectRoot,
415
+ canvasFiles: filenames,
416
+ currentRevision: revision,
417
+ revisions,
418
+ createdAt: existing?.createdAt ?? now,
419
+ updatedAt: now,
420
+ }
421
+
422
+ // Secrets belong to one revision and must never carry into a new runbook.
423
+ this.sessionSecrets.delete(id)
424
+ this.sessions.set(id, session)
425
+ this.persistToDisk(session, canvasFiles)
426
+ return session
427
+ }
428
+
429
+ private persistMeta(session: SessionData) {
430
+ const meta: SessionMeta = {
431
+ projectRoot: session.projectRoot,
432
+ createdAt: session.createdAt,
433
+ updatedAt: session.updatedAt,
434
+ currentRevision: session.currentRevision,
435
+ revisions: session.revisions,
436
+ ...(session.shares?.length ? { shares: session.shares } : {}),
437
+ }
438
+ writeFileSync(join(this.sessionDir(session.id), 'meta.json'), JSON.stringify(meta, null, 2))
439
+ }
440
+
441
+ /** Record a new share for a session revision. */
442
+ addShare(id: string, entry: ShareEntry): void {
443
+ const session = this.sessions.get(id)
444
+ if (!session) return
445
+ session.shares = [...(session.shares ?? []), entry]
446
+ session.updatedAt = new Date().toISOString()
447
+ this.persistMeta(session)
448
+ }
449
+
450
+ getShares(id: string): ShareEntry[] {
451
+ return this.sessions.get(id)?.shares ?? []
452
+ }
453
+
454
+ removeShare(id: string, shareId: string): void {
455
+ const session = this.sessions.get(id)
456
+ if (!session?.shares) return
457
+ session.shares = session.shares.filter((s) => s.shareId !== shareId)
458
+ session.updatedAt = new Date().toISOString()
459
+ this.persistMeta(session)
460
+ }
461
+
462
+ updateShareLastFeedback(id: string, shareId: string, at: string): void {
463
+ const session = this.sessions.get(id)
464
+ if (!session?.shares) return
465
+ const entry = session.shares.find((s) => s.shareId === shareId)
466
+ if (!entry) return
467
+ entry.lastFeedbackAt = at
468
+ this.persistMeta(session)
469
+ }
470
+
471
+ /** List all sessions that have at least one share. Used by the remote feedback poller. */
472
+ listSessionsWithShares(): SessionData[] {
473
+ return Array.from(this.sessions.values()).filter((s) => (s.shares?.length ?? 0) > 0)
474
+ }
475
+
476
+ private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
477
+ const revDir = this.revisionDir(session.id, session.currentRevision)
478
+ mkdirSync(revDir, { recursive: true })
479
+ for (const [filename, jsx] of canvasFiles) {
480
+ writeFileSync(join(revDir, filename), jsx)
481
+ }
482
+ this.persistMeta(session)
483
+ }
484
+
485
+ get(id: string): SessionData | undefined {
486
+ return this.sessions.get(id)
487
+ }
488
+
489
+ list(): SessionData[] {
490
+ return Array.from(this.sessions.values())
491
+ }
492
+
493
+ remove(id: string) {
494
+ this.sessions.delete(id)
495
+ this.sessionSecrets.delete(id)
496
+ rmSync(this.sessionDir(id), { recursive: true, force: true })
497
+ }
498
+
499
+ setSecret(id: string, fieldId: string, value: string): boolean {
500
+ const session = this.sessions.get(id)
501
+ if (!session) return false
502
+
503
+ let secrets = this.sessionSecrets.get(id)
504
+ if (!secrets || secrets.revision !== session.currentRevision) {
505
+ secrets = { revision: session.currentRevision, values: new Map() }
506
+ this.sessionSecrets.set(id, secrets)
507
+ }
508
+ secrets.values.set(fieldId, value)
509
+ return true
510
+ }
511
+
512
+ hasSecret(id: string, fieldId: string): boolean {
513
+ const session = this.sessions.get(id)
514
+ const secrets = this.sessionSecrets.get(id)
515
+ return !!session
516
+ && secrets?.revision === session.currentRevision
517
+ && secrets.values.has(fieldId)
518
+ }
519
+
520
+ clearSecret(id: string, fieldId: string): boolean {
521
+ const session = this.sessions.get(id)
522
+ const secrets = this.sessionSecrets.get(id)
523
+ if (!session || secrets?.revision !== session.currentRevision) return false
524
+ return secrets.values.delete(fieldId)
525
+ }
526
+
527
+ resolveSecrets(id: string, fieldIds: string[]): SecretResolution | null {
528
+ const session = this.sessions.get(id)
529
+ if (!session) return null
530
+
531
+ const secrets = this.sessionSecrets.get(id)
532
+ const missing = fieldIds.filter((fieldId) => secrets?.revision !== session.currentRevision || !secrets.values.has(fieldId))
533
+ if (missing.length > 0) return { ok: false, missing }
534
+
535
+ const values = new Map<string, string>()
536
+ for (const fieldId of fieldIds) {
537
+ const value = secrets?.values.get(fieldId)
538
+ if (value !== undefined) values.set(fieldId, value)
539
+ }
540
+ return { ok: true, values }
541
+ }
542
+
543
+ saveCompiled(id: string, filename: string, js: string, rev?: number) {
544
+ const session = this.sessions.get(id)
545
+ const revision = rev ?? session?.currentRevision
546
+ if (!revision) return
547
+ const revDir = this.revisionDir(id, revision)
548
+ mkdirSync(revDir, { recursive: true })
549
+ const compiledName = filename.replace(/\.jsx$/, '.compiled.js')
550
+ writeFileSync(join(revDir, compiledName), js)
551
+ }
552
+
553
+ getCompiled(id: string, filename: string, rev?: number): string | null {
554
+ const session = this.sessions.get(id)
555
+ const revision = rev ?? session?.currentRevision
556
+ if (!revision) return null
557
+ const compiledName = filename.replace(/\.jsx$/, '.compiled.js')
558
+ try {
559
+ return readFileSync(join(this.revisionDir(id, revision), compiledName), 'utf-8')
560
+ } catch {
561
+ return null
562
+ }
563
+ }
564
+
565
+ saveFeedback(id: string, rev: number, markdown: string) {
566
+ const revDir = this.revisionDir(id, rev)
567
+ mkdirSync(revDir, { recursive: true })
568
+ writeFileSync(join(revDir, 'feedback.md'), markdown)
569
+
570
+ const session = this.sessions.get(id)
571
+ if (session) {
572
+ const ri = session.revisions.find((r) => r.revision === rev)
573
+ if (ri) {
574
+ ri.hasFeedback = true
575
+ ri.feedbackConsumed = false
576
+ }
577
+ // Feedback arriving is activity: it orders the session list, and it must
578
+ // not leave a session looking untouched while its feedback waits.
579
+ session.updatedAt = new Date().toISOString()
580
+ this.persistMeta(session)
581
+ }
582
+ }
583
+
584
+ getFeedback(id: string, rev: number): string | null {
585
+ try {
586
+ return readFileSync(join(this.revisionDir(id, rev), 'feedback.md'), 'utf-8')
587
+ } catch {
588
+ return null
589
+ }
590
+ }
591
+
592
+ consumeFeedback(id: string, rev: number) {
593
+ const session = this.sessions.get(id)
594
+ if (!session) return
595
+ const ri = session.revisions.find((r) => r.revision === rev)
596
+ if (!ri || !ri.hasFeedback || ri.feedbackConsumed) return
597
+ ri.feedbackConsumed = true
598
+ this.persistMeta(session)
599
+ }
600
+
601
+ getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
602
+ const session = this.sessions.get(id)
603
+ if (!session) return null
604
+ for (let i = session.revisions.length - 1; i >= 0; i--) {
605
+ const ri = session.revisions[i]
606
+ if (ri?.hasFeedback && !ri.feedbackConsumed) {
607
+ const feedback = this.getFeedback(id, ri.revision)
608
+ if (feedback) return { revision: ri.revision, feedback }
609
+ }
610
+ }
611
+ return null
612
+ }
613
+
614
+ getRevisionJsxPath(id: string, rev: number, filename: string): string {
615
+ return join(this.revisionDir(id, rev), filename)
616
+ }
617
+
618
+ /**
619
+ * Remote feedback from shared views. Stored as one JSON file per revision
620
+ * at `revisions/{rev}/remote_feedback.json`, containing an array of
621
+ * RemoteFeedbackEntry. Appended to (not replaced) as new feedback arrives.
622
+ */
623
+ appendRemoteFeedback(id: string, rev: number, entries: RemoteFeedbackEntry[]): void {
624
+ if (entries.length === 0) return
625
+ const revDir = this.revisionDir(id, rev)
626
+ mkdirSync(revDir, { recursive: true })
627
+ const file = join(revDir, 'remote_feedback.json')
628
+ let existing: RemoteFeedbackEntry[] = []
629
+ try {
630
+ existing = JSON.parse(readFileSync(file, 'utf-8'))
631
+ } catch {}
632
+ const seen = new Set(existing.map((e) => e.id))
633
+ const merged = [...existing]
634
+ for (const e of entries) if (!seen.has(e.id)) merged.push(e)
635
+ writeFileSync(file, JSON.stringify(merged, null, 2))
636
+
637
+ // Same reason as saveFeedback — a reviewer's comment is input for this
638
+ // session, not something that happens beside it.
639
+ const session = this.sessions.get(id)
640
+ if (session) {
641
+ session.updatedAt = new Date().toISOString()
642
+ this.persistMeta(session)
643
+ }
644
+ }
645
+
646
+ getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
647
+ try {
648
+ return JSON.parse(readFileSync(join(this.revisionDir(id, rev), 'remote_feedback.json'), 'utf-8'))
649
+ } catch {
650
+ return []
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Server-side backing store for an in-progress draft. Hosts that wire the
656
+ * AnnotationProvider's loadState/saveState to these endpoints get drafts that
657
+ * survive a reload and follow the author across browsers, instead of living
658
+ * in one browser's localStorage. Opaque payload — the shape is the client's.
659
+ */
660
+ saveResponses(id: string, rev: number, payload: unknown): void {
661
+ const revDir = this.revisionDir(id, rev)
662
+ mkdirSync(revDir, { recursive: true })
663
+ writeFileSync(join(revDir, 'responses.json'), JSON.stringify(payload))
664
+ }
665
+
666
+ getResponses(id: string, rev: number): unknown | null {
667
+ try {
668
+ return JSON.parse(readFileSync(join(this.revisionDir(id, rev), 'responses.json'), 'utf-8'))
669
+ } catch {
670
+ return null
671
+ }
672
+ }
673
+
674
+ /** Drop sessions untouched for longer than `maxAge` (default 24h). */
675
+ cleanupStale(maxAge = 24 * 60 * 60 * 1000) {
676
+ const now = Date.now()
677
+ for (const [id, session] of this.sessions) {
678
+ if (now - new Date(session.updatedAt).getTime() > maxAge) {
679
+ this.remove(id)
680
+ }
681
+ }
682
+ }
678
683
  }