@fabrika/canvas-kernel 0.1.44
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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/annotate.ts +94 -0
- package/daemon.ts +46 -0
- package/index.ts +84 -0
- package/package.json +74 -0
- package/src/client/AnnotationDraftFooter.tsx +76 -0
- package/src/client/AnnotationEditor.tsx +320 -0
- package/src/client/AnnotationList.tsx +190 -0
- package/src/client/AnnotationProvider.tsx +294 -0
- package/src/client/AnnotationSidebar.tsx +374 -0
- package/src/client/FileIcon.tsx +76 -0
- package/src/client/PlanRenderer.tsx +587 -0
- package/src/client/Popover.tsx +361 -0
- package/src/client/RenderErrorContext.ts +11 -0
- package/src/client/ResponsePreview.tsx +186 -0
- package/src/client/SessionContext.tsx +3 -0
- package/src/client/annotationContext.ts +130 -0
- package/src/client/annotationDom.ts +115 -0
- package/src/client/annotationDraft.ts +130 -0
- package/src/client/annotationMarkdown.ts +102 -0
- package/src/client/annotationTarget.ts +73 -0
- package/src/client/appContext.tsx +61 -0
- package/src/client/blockTarget.ts +127 -0
- package/src/client/components/Callout.tsx +27 -0
- package/src/client/components/Checklist.tsx +37 -0
- package/src/client/components/Choice.tsx +178 -0
- package/src/client/components/CodeBlock.tsx +35 -0
- package/src/client/components/Diff.tsx +36 -0
- package/src/client/components/FilePreview.tsx +88 -0
- package/src/client/components/Image.tsx +48 -0
- package/src/client/components/Markdown.tsx +44 -0
- package/src/client/components/Mermaid.tsx +576 -0
- package/src/client/components/Note.tsx +14 -0
- package/src/client/components/Priority.tsx +19 -0
- package/src/client/components/ResponseNote.tsx +19 -0
- package/src/client/components/Section.tsx +33 -0
- package/src/client/components/Table.tsx +35 -0
- package/src/client/components/Task.tsx +76 -0
- package/src/client/components/UserInput.tsx +176 -0
- package/src/client/components/index.ts +18 -0
- package/src/client/components/renderMarkdownChildren.tsx +100 -0
- package/src/client/diffLines.ts +50 -0
- package/src/client/generateMarkdown.ts +250 -0
- package/src/client/highlightRange.ts +262 -0
- package/src/client/hostContext.tsx +55 -0
- package/src/client/index.ts +128 -0
- package/src/client/popoverPosition.ts +46 -0
- package/src/client/preact-all.ts +4 -0
- package/src/client/regionTarget.ts +180 -0
- package/src/client/runtime.ts +149 -0
- package/src/client/styles.css +492 -0
- package/src/client/testing/dom.ts +58 -0
- package/src/client/textTarget.ts +31 -0
- package/src/client/theme.css +66 -0
- package/src/client/useRegionAnnotation.tsx +188 -0
- package/src/client/useTextAnnotation.tsx +194 -0
- package/src/client/utils.ts +33 -0
- package/src/engine/channel.ts +278 -0
- package/src/engine/compiler.ts +415 -0
- package/src/engine/http-utils.ts +14 -0
- package/src/engine/lifecycle.ts +302 -0
- package/src/engine/paths.ts +63 -0
- package/src/engine/router.ts +16 -0
- package/src/engine/session.ts +678 -0
- package/src/engine/waiter.ts +188 -0
- package/src/engine/watcher.ts +84 -0
- package/src/engine/websocket.ts +159 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync, copyFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface DiffStats {
|
|
5
|
+
added: number;
|
|
6
|
+
removed: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CanvasFileInfo {
|
|
10
|
+
filename: string;
|
|
11
|
+
diffStats?: DiffStats;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** How the caller selected the canvases in a revision. New revisions always
|
|
15
|
+
* carry this explicitly; its absence identifies legacy metadata. */
|
|
16
|
+
export type CanvasScope =
|
|
17
|
+
| { kind: "view"; filename: string }
|
|
18
|
+
| { kind: "directory" };
|
|
19
|
+
|
|
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;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A single piece of feedback received from a remote (shared) reviewer.
|
|
33
|
+
* Structured so it can be merged into the local annotation system alongside
|
|
34
|
+
* the author's own annotations. `annotations` is the structured list of
|
|
35
|
+
* highlighted-text comments; `generalNote` is the free-form markdown.
|
|
36
|
+
*/
|
|
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;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mirror of the client-side Annotation shape, minus fields that only make
|
|
51
|
+
* sense locally. Kept intentionally narrow so the wire format is stable.
|
|
52
|
+
*/
|
|
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 }[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A share is a snapshot of a specific revision that has been pushed to a
|
|
72
|
+
* remote endpoint (CF Worker) for external review. The `shareId` is the
|
|
73
|
+
* opaque capability token returned by the worker; the `url` is the full
|
|
74
|
+
* public URL the user shared with reviewers.
|
|
75
|
+
*/
|
|
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;
|
|
92
|
+
}
|
|
93
|
+
|
|
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[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type SecretResolution =
|
|
106
|
+
| { ok: true; values: Map<string, string> }
|
|
107
|
+
| { ok: false; missing: string[] };
|
|
108
|
+
|
|
109
|
+
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;
|
|
136
|
+
}
|
|
137
|
+
|
|
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 };
|
|
158
|
+
}
|
|
159
|
+
|
|
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
|
+
}
|
|
678
|
+
}
|