agent-canvas 0.1.44 → 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.
- package/README.md +14 -0
- package/bin/lib/commands/daemon.ts +3 -6
- package/bin/lib/commands/push.ts +1 -1
- package/bin/lib/daemon-lifecycle.ts +65 -72
- package/bin/lib/helpers.test.ts +1 -0
- package/bin/lib/helpers.ts +30 -74
- package/daemon/build.ts +37 -16
- package/daemon/client/App.tsx +27 -61
- package/daemon/client/CompareView.tsx +4 -4
- package/daemon/client/FileBrowser.tsx +4 -4
- package/daemon/client/FileViewer.tsx +4 -4
- package/daemon/client/OverviewView.tsx +2 -2
- package/daemon/client/RevisionSelect.tsx +1 -1
- package/daemon/client/canvas-runtime.ts +1 -0
- package/daemon/client/clientApi.ts +18 -0
- package/daemon/client/components/index.ts +3 -16
- package/daemon/client/styles.css +4 -457
- package/daemon/dist/annotations-runtime.js +1 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +76 -76
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/index.html +1 -0
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/components.ts +13 -0
- package/daemon/src/handlers/api.ts +13 -9
- package/daemon/src/handlers/files.ts +2 -3
- package/daemon/src/handlers/static.ts +1 -1
- package/daemon/src/handlers/uploads.ts +2 -3
- package/daemon/src/paths.ts +10 -24
- package/daemon/src/remote-feedback.test.ts +1 -1
- package/daemon/src/remote-feedback.ts +1 -1
- package/daemon/src/secrets-api.test.ts +2 -2
- package/daemon/src/server.ts +11 -10
- package/daemon/src/share-e2e.test.ts +1 -1
- package/daemon/src/share.test.ts +1 -1
- package/daemon/src/share.ts +1 -1
- package/daemon/tsconfig.json +1 -3
- package/package.json +11 -4
- package/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/.github/workflows/ci.yml +0 -48
- package/.github/workflows/deploy-worker-prod.yml +0 -19
- package/.github/workflows/deploy-worker-stage.yml +0 -21
- package/.github/workflows/deploy-worker.yml +0 -72
- package/.github/workflows/release.yml +0 -36
- package/CLAUDE.md +0 -86
- package/bun.lock +0 -377
- package/daemon/client/AnnotationEditor.tsx +0 -315
- package/daemon/client/AnnotationProvider.tsx +0 -195
- package/daemon/client/AnnotationSidebar.tsx +0 -627
- package/daemon/client/FileIcon.tsx +0 -76
- package/daemon/client/PlanRenderer.tsx +0 -614
- package/daemon/client/Popover.tsx +0 -350
- package/daemon/client/RenderErrorContext.ts +0 -11
- package/daemon/client/ResponsePreview.tsx +0 -186
- package/daemon/client/SessionContext.tsx +0 -3
- package/daemon/client/annotationContext.ts +0 -114
- package/daemon/client/annotationDom.ts +0 -104
- package/daemon/client/annotationDraft.test.ts +0 -188
- package/daemon/client/annotationDraft.ts +0 -130
- package/daemon/client/compat/preact-all.ts +0 -4
- package/daemon/client/components/Callout.tsx +0 -26
- package/daemon/client/components/Checklist.tsx +0 -37
- package/daemon/client/components/Choice.tsx +0 -176
- package/daemon/client/components/CodeBlock.tsx +0 -35
- package/daemon/client/components/Diff.tsx +0 -65
- package/daemon/client/components/FilePreview.tsx +0 -88
- package/daemon/client/components/Image.tsx +0 -45
- package/daemon/client/components/Markdown.tsx +0 -44
- package/daemon/client/components/Mermaid.tsx +0 -565
- package/daemon/client/components/Note.tsx +0 -14
- package/daemon/client/components/Priority.tsx +0 -19
- package/daemon/client/components/ResponseNote.tsx +0 -19
- package/daemon/client/components/Section.tsx +0 -33
- package/daemon/client/components/Table.tsx +0 -35
- package/daemon/client/components/Task.tsx +0 -76
- package/daemon/client/components/UserInput.tsx +0 -174
- package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
- package/daemon/client/generateMarkdown.ts +0 -273
- package/daemon/client/highlightRange.ts +0 -224
- package/daemon/client/popoverPosition.ts +0 -31
- package/daemon/client/runtime.ts +0 -123
- package/daemon/client/theme.css +0 -66
- package/daemon/client/useTextAnnotation.tsx +0 -188
- package/daemon/client/utils.ts +0 -13
- package/daemon/src/compiler.test.ts +0 -57
- package/daemon/src/compiler.ts +0 -360
- package/daemon/src/handlers/utils.ts +0 -14
- package/daemon/src/router.ts +0 -16
- package/daemon/src/session-secrets.test.ts +0 -83
- package/daemon/src/session.ts +0 -612
- package/daemon/src/watcher.ts +0 -71
- package/daemon/src/websocket.test.ts +0 -99
- package/daemon/src/websocket.ts +0 -211
- package/tsconfig.json +0 -14
- package/workers/canvas-share/README.md +0 -159
- package/workers/canvas-share/oblaka.ts +0 -56
- package/workers/canvas-share/package.json +0 -25
- package/workers/canvas-share/public/assets/.gitkeep +0 -0
- package/workers/canvas-share/public/assets/client.css +0 -2
- package/workers/canvas-share/public/assets/client.js +0 -91
- package/workers/canvas-share/public/assets/components.js +0 -65
- package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
- package/workers/canvas-share/public/assets/preact-compat.js +0 -1
- package/workers/canvas-share/public/assets/runtime.js +0 -1
- package/workers/canvas-share/public/index.html +0 -385
- package/workers/canvas-share/public/screenshot-dark.png +0 -0
- package/workers/canvas-share/public/screenshot-light.png +0 -0
- package/workers/canvas-share/src/index.test.ts +0 -209
- package/workers/canvas-share/src/index.ts +0 -518
- package/workers/canvas-share/src/types.ts +0 -119
- package/workers/canvas-share/src/util.ts +0 -156
- package/workers/canvas-share/src/validation.ts +0 -160
- package/workers/canvas-share/tsconfig.json +0 -16
package/daemon/src/session.ts
DELETED
|
@@ -1,612 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync, copyFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { SESSIONS_DIR } from "./paths";
|
|
4
|
-
|
|
5
|
-
export interface DiffStats {
|
|
6
|
-
added: number;
|
|
7
|
-
removed: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface CanvasFileInfo {
|
|
11
|
-
filename: string;
|
|
12
|
-
diffStats?: DiffStats;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface RevisionInfo {
|
|
16
|
-
revision: number;
|
|
17
|
-
label?: string;
|
|
18
|
-
canvasFiles: CanvasFileInfo[];
|
|
19
|
-
createdAt: string;
|
|
20
|
-
hasFeedback: boolean;
|
|
21
|
-
feedbackConsumed: boolean;
|
|
22
|
-
response?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* A single piece of feedback received from a remote (shared) reviewer.
|
|
27
|
-
* Structured so it can be merged into the local annotation system alongside
|
|
28
|
-
* the author's own annotations. `annotations` is the structured list of
|
|
29
|
-
* highlighted-text comments; `generalNote` is the free-form markdown.
|
|
30
|
-
*/
|
|
31
|
-
export interface RemoteFeedbackEntry {
|
|
32
|
-
/** Stable unique id assigned by the worker; used to dedupe on re-poll */
|
|
33
|
-
id: string;
|
|
34
|
-
shareId: string;
|
|
35
|
-
revision: number;
|
|
36
|
-
/** ISO timestamp when the reviewer submitted */
|
|
37
|
-
submittedAt: string;
|
|
38
|
-
author: { id: string; name: string };
|
|
39
|
-
annotations: RemoteAnnotation[];
|
|
40
|
-
generalNote?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Mirror of the client-side Annotation shape, minus fields that only make
|
|
45
|
-
* sense locally. Kept intentionally narrow so the wire format is stable.
|
|
46
|
-
*/
|
|
47
|
-
export interface RemoteAnnotation {
|
|
48
|
-
id: string;
|
|
49
|
-
snippet: string;
|
|
50
|
-
note: string;
|
|
51
|
-
createdAt: string;
|
|
52
|
-
filePath?: string;
|
|
53
|
-
canvasFile?: string;
|
|
54
|
-
context?: {
|
|
55
|
-
before: string;
|
|
56
|
-
after: string;
|
|
57
|
-
hierarchy: string[];
|
|
58
|
-
lineStart?: number;
|
|
59
|
-
lineEnd?: number;
|
|
60
|
-
};
|
|
61
|
-
attachments?: { url: string; mime?: string }[];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* A share is a snapshot of a specific revision that has been pushed to a
|
|
66
|
-
* remote endpoint (CF Worker) for external review. The `shareId` is the
|
|
67
|
-
* opaque capability token returned by the worker; the `url` is the full
|
|
68
|
-
* public URL the user shared with reviewers.
|
|
69
|
-
*/
|
|
70
|
-
export interface ShareEntry {
|
|
71
|
-
shareId: string;
|
|
72
|
-
url: string;
|
|
73
|
-
revision: number;
|
|
74
|
-
createdAt: string;
|
|
75
|
-
/** Bearer token returned by the worker — proves ownership for revoke. */
|
|
76
|
-
ownerToken?: string;
|
|
77
|
-
/** ISO timestamp when the share will expire on the worker side. */
|
|
78
|
-
expiresAt?: string;
|
|
79
|
-
/** ISO timestamp of the most recent remote feedback we've seen */
|
|
80
|
-
lastFeedbackAt?: string;
|
|
81
|
-
/** Base64url-encoded AES-GCM key for end-to-end encrypted shares. The
|
|
82
|
-
* same key is also embedded in `url` as a `#k=...` fragment so reviewers
|
|
83
|
-
* can decrypt; the daemon keeps a copy here so it can decrypt feedback
|
|
84
|
-
* it polls back from the worker. Absent on legacy unencrypted shares. */
|
|
85
|
-
encryptionKey?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface SessionData {
|
|
89
|
-
id: string;
|
|
90
|
-
projectRoot: string;
|
|
91
|
-
canvasFiles: string[];
|
|
92
|
-
currentRevision: number;
|
|
93
|
-
revisions: RevisionInfo[];
|
|
94
|
-
createdAt: string;
|
|
95
|
-
updatedAt: string;
|
|
96
|
-
shares?: ShareEntry[];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export type SecretResolution =
|
|
100
|
-
| { ok: true; values: Map<string, string> }
|
|
101
|
-
| { ok: false; missing: string[] };
|
|
102
|
-
|
|
103
|
-
interface SessionMeta {
|
|
104
|
-
projectRoot: string;
|
|
105
|
-
createdAt: string;
|
|
106
|
-
updatedAt: string;
|
|
107
|
-
currentRevision: number;
|
|
108
|
-
revisions: RevisionInfo[];
|
|
109
|
-
shares?: ShareEntry[];
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Legacy format (flat files, pre-revision)
|
|
113
|
-
interface LegacyMeta {
|
|
114
|
-
projectRoot: string;
|
|
115
|
-
createdAt: string;
|
|
116
|
-
updatedAt: string;
|
|
117
|
-
version: number;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Previous format (single sourceFile per revision)
|
|
121
|
-
interface LegacyRevisionInfo {
|
|
122
|
-
revision: number;
|
|
123
|
-
label?: string;
|
|
124
|
-
sourceFile?: string;
|
|
125
|
-
createdAt: string;
|
|
126
|
-
hasFeedback: boolean;
|
|
127
|
-
feedbackConsumed: boolean;
|
|
128
|
-
response?: string;
|
|
129
|
-
diffStats?: DiffStats;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function computeLineDiffStats(oldText: string, newText: string): DiffStats {
|
|
133
|
-
const oldLines = oldText.split("\n");
|
|
134
|
-
const newLines = newText.split("\n");
|
|
135
|
-
const m = oldLines.length, n = newLines.length;
|
|
136
|
-
|
|
137
|
-
// LCS length via two-row DP (O(n) space)
|
|
138
|
-
let prev = new Uint32Array(n + 1);
|
|
139
|
-
let curr = new Uint32Array(n + 1);
|
|
140
|
-
for (let i = 1; i <= m; i++) {
|
|
141
|
-
for (let j = 1; j <= n; j++) {
|
|
142
|
-
curr[j] = oldLines[i - 1] === newLines[j - 1]
|
|
143
|
-
? prev[j - 1] + 1
|
|
144
|
-
: Math.max(prev[j], curr[j - 1]);
|
|
145
|
-
}
|
|
146
|
-
[prev, curr] = [curr, prev];
|
|
147
|
-
curr.fill(0);
|
|
148
|
-
}
|
|
149
|
-
const lcsLen = prev[n];
|
|
150
|
-
return { added: n - lcsLen, removed: m - lcsLen };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export class SessionManager {
|
|
154
|
-
private sessions = new Map<string, SessionData>();
|
|
155
|
-
private sessionSecrets = new Map<string, { revision: number; values: Map<string, string> }>();
|
|
156
|
-
private readonly sessionsDir: string;
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* @param sessionsDir Override the on-disk sessions root. Defaults to
|
|
160
|
-
* the global SESSIONS_DIR (`~/.claude/agent-canvas/sessions`).
|
|
161
|
-
* Tests pass an isolated temp directory so they don't interfere with
|
|
162
|
-
* the user's real sessions.
|
|
163
|
-
*/
|
|
164
|
-
constructor(sessionsDir?: string) {
|
|
165
|
-
this.sessionsDir = sessionsDir ?? SESSIONS_DIR;
|
|
166
|
-
mkdirSync(this.sessionsDir, { recursive: true });
|
|
167
|
-
this.loadFromDisk();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
private sessionDir(id: string): string {
|
|
171
|
-
return join(this.sessionsDir, id);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
private revisionDir(id: string, rev: number): string {
|
|
175
|
-
return join(this.sessionDir(id), "revisions", String(rev));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Compute file order from revision history: first-appearance order, alpha for ties */
|
|
179
|
-
private deriveFileOrder(revisions: RevisionInfo[], currentFiles: string[]): string[] {
|
|
180
|
-
const seen = new Set<string>();
|
|
181
|
-
const ordered: string[] = [];
|
|
182
|
-
for (const rev of revisions) {
|
|
183
|
-
for (const cf of rev.canvasFiles) {
|
|
184
|
-
if (!seen.has(cf.filename)) {
|
|
185
|
-
seen.add(cf.filename);
|
|
186
|
-
ordered.push(cf.filename);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// Keep only files that still exist, then append any unknown ones
|
|
191
|
-
const current = new Set(currentFiles);
|
|
192
|
-
const result = ordered.filter(f => current.has(f));
|
|
193
|
-
const remaining = currentFiles.filter(f => !seen.has(f)).sort();
|
|
194
|
-
return [...result, ...remaining];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** List *.jsx filenames in a revision directory */
|
|
198
|
-
getRevisionCanvasFiles(id: string, rev: number): string[] {
|
|
199
|
-
const dir = this.revisionDir(id, rev);
|
|
200
|
-
if (!existsSync(dir)) return [];
|
|
201
|
-
return readdirSync(dir).filter(f => f.endsWith(".jsx")).sort();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
private loadFromDisk() {
|
|
205
|
-
if (!existsSync(this.sessionsDir)) return;
|
|
206
|
-
for (const name of readdirSync(this.sessionsDir, { withFileTypes: true })) {
|
|
207
|
-
if (!name.isDirectory()) continue;
|
|
208
|
-
const dir = join(this.sessionsDir, name.name);
|
|
209
|
-
const metaPath = join(dir, "meta.json");
|
|
210
|
-
if (!existsSync(metaPath)) continue;
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
214
|
-
|
|
215
|
-
// Detect and migrate legacy flat-file format
|
|
216
|
-
if ("version" in raw && !("currentRevision" in raw)) {
|
|
217
|
-
this.migrateLegacy(name.name, raw as LegacyMeta);
|
|
218
|
-
const migrated: SessionMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
219
|
-
const canvasFiles = this.getRevisionCanvasFiles(name.name, migrated.currentRevision);
|
|
220
|
-
if (canvasFiles.length === 0) continue;
|
|
221
|
-
this.sessions.set(name.name, {
|
|
222
|
-
id: name.name,
|
|
223
|
-
projectRoot: migrated.projectRoot,
|
|
224
|
-
canvasFiles,
|
|
225
|
-
currentRevision: migrated.currentRevision,
|
|
226
|
-
revisions: migrated.revisions,
|
|
227
|
-
createdAt: migrated.createdAt,
|
|
228
|
-
updatedAt: migrated.updatedAt,
|
|
229
|
-
});
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
let meta = raw as SessionMeta;
|
|
234
|
-
|
|
235
|
-
// Migrate single-sourceFile revision format to canvasFiles
|
|
236
|
-
if (meta.revisions.length > 0 && !("canvasFiles" in meta.revisions[0])) {
|
|
237
|
-
meta = {
|
|
238
|
-
...meta,
|
|
239
|
-
revisions: (meta.revisions as unknown as LegacyRevisionInfo[]).map(r => ({
|
|
240
|
-
revision: r.revision,
|
|
241
|
-
label: r.label,
|
|
242
|
-
canvasFiles: [{ filename: r.sourceFile || "plan.jsx", diffStats: r.diffStats }],
|
|
243
|
-
createdAt: r.createdAt,
|
|
244
|
-
hasFeedback: r.hasFeedback,
|
|
245
|
-
feedbackConsumed: r.feedbackConsumed,
|
|
246
|
-
response: r.response,
|
|
247
|
-
})),
|
|
248
|
-
};
|
|
249
|
-
writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const diskFiles = this.getRevisionCanvasFiles(name.name, meta.currentRevision);
|
|
253
|
-
if (diskFiles.length === 0) continue;
|
|
254
|
-
// Derive file order from revision history (first-appearance order)
|
|
255
|
-
const canvasFiles = this.deriveFileOrder(meta.revisions, diskFiles);
|
|
256
|
-
|
|
257
|
-
this.sessions.set(name.name, {
|
|
258
|
-
id: name.name,
|
|
259
|
-
projectRoot: meta.projectRoot,
|
|
260
|
-
canvasFiles,
|
|
261
|
-
currentRevision: meta.currentRevision,
|
|
262
|
-
revisions: meta.revisions,
|
|
263
|
-
createdAt: meta.createdAt,
|
|
264
|
-
updatedAt: meta.updatedAt,
|
|
265
|
-
...(meta.shares ? { shares: meta.shares } : {}),
|
|
266
|
-
});
|
|
267
|
-
} catch {}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
private migrateLegacy(id: string, legacy: LegacyMeta) {
|
|
272
|
-
const dir = this.sessionDir(id);
|
|
273
|
-
const flatJsx = join(dir, "plan.jsx");
|
|
274
|
-
const flatCompiled = join(dir, "plan.compiled.js");
|
|
275
|
-
const historyDir = join(dir, "history");
|
|
276
|
-
|
|
277
|
-
const revisions: RevisionInfo[] = [];
|
|
278
|
-
|
|
279
|
-
// Migrate history files
|
|
280
|
-
if (existsSync(historyDir)) {
|
|
281
|
-
const files = readdirSync(historyDir).filter((f) => f.endsWith(".jsx")).sort();
|
|
282
|
-
for (const file of files) {
|
|
283
|
-
const num = parseInt(file.replace(".jsx", ""), 10);
|
|
284
|
-
if (isNaN(num)) continue;
|
|
285
|
-
const revDir = this.revisionDir(id, num);
|
|
286
|
-
mkdirSync(revDir, { recursive: true });
|
|
287
|
-
renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
|
|
288
|
-
revisions.push({
|
|
289
|
-
revision: num,
|
|
290
|
-
canvasFiles: [{ filename: "plan.jsx" }],
|
|
291
|
-
createdAt: legacy.createdAt,
|
|
292
|
-
hasFeedback: false,
|
|
293
|
-
feedbackConsumed: false,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
rmSync(historyDir, { recursive: true, force: true });
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Migrate current version
|
|
300
|
-
const currentRev = legacy.version;
|
|
301
|
-
const revDir = this.revisionDir(id, currentRev);
|
|
302
|
-
mkdirSync(revDir, { recursive: true });
|
|
303
|
-
if (existsSync(flatJsx)) {
|
|
304
|
-
renameSync(flatJsx, join(revDir, "plan.jsx"));
|
|
305
|
-
}
|
|
306
|
-
if (existsSync(flatCompiled)) {
|
|
307
|
-
renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
|
|
308
|
-
}
|
|
309
|
-
revisions.push({
|
|
310
|
-
revision: currentRev,
|
|
311
|
-
canvasFiles: [{ filename: "plan.jsx" }],
|
|
312
|
-
createdAt: legacy.updatedAt,
|
|
313
|
-
hasFeedback: false,
|
|
314
|
-
feedbackConsumed: false,
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
const meta: SessionMeta = {
|
|
318
|
-
projectRoot: legacy.projectRoot,
|
|
319
|
-
createdAt: legacy.createdAt,
|
|
320
|
-
updatedAt: legacy.updatedAt,
|
|
321
|
-
currentRevision: currentRev,
|
|
322
|
-
revisions,
|
|
323
|
-
};
|
|
324
|
-
writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
readRevisionJsx(id: string, rev: number, filename: string): string | null {
|
|
328
|
-
try {
|
|
329
|
-
return readFileSync(join(this.revisionDir(id, rev), filename), "utf-8");
|
|
330
|
-
} catch {
|
|
331
|
-
return null;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Create or update a session with a set of canvas files.
|
|
337
|
-
* @param canvasFiles Map of filename -> JSX content
|
|
338
|
-
*/
|
|
339
|
-
upsert(id: string, canvasFiles: Map<string, string>, projectRoot: string, label?: string, response?: string): SessionData {
|
|
340
|
-
const existing = this.sessions.get(id);
|
|
341
|
-
const now = new Date().toISOString();
|
|
342
|
-
// Stable session IDs must never recycle revision numbers used as browser draft keys.
|
|
343
|
-
const revision = existing ? existing.currentRevision + 1 : 1;
|
|
344
|
-
|
|
345
|
-
// Compute per-file diffStats against the previous revision
|
|
346
|
-
const prevRev = existing ? existing.currentRevision : 0;
|
|
347
|
-
const canvasFileInfos: CanvasFileInfo[] = [];
|
|
348
|
-
for (const [filename, jsx] of canvasFiles) {
|
|
349
|
-
let diffStats: DiffStats | undefined;
|
|
350
|
-
if (prevRev > 0) {
|
|
351
|
-
const prevJsx = this.readRevisionJsx(id, prevRev, filename);
|
|
352
|
-
if (prevJsx) {
|
|
353
|
-
diffStats = computeLineDiffStats(prevJsx, jsx);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
canvasFileInfos.push({
|
|
357
|
-
filename,
|
|
358
|
-
...(diffStats ? { diffStats } : {}),
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const revInfo: RevisionInfo = {
|
|
363
|
-
revision,
|
|
364
|
-
canvasFiles: canvasFileInfos,
|
|
365
|
-
createdAt: now,
|
|
366
|
-
hasFeedback: false,
|
|
367
|
-
feedbackConsumed: false,
|
|
368
|
-
...(label ? { label } : {}),
|
|
369
|
-
...(response ? { response } : {}),
|
|
370
|
-
};
|
|
371
|
-
const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
|
|
372
|
-
// Order: files from previous revision first (preserving their order), then new files alphabetically
|
|
373
|
-
const prevOrder = existing?.canvasFiles ?? [];
|
|
374
|
-
const currentNames = new Set(canvasFiles.keys());
|
|
375
|
-
const filenames = [
|
|
376
|
-
...prevOrder.filter(f => currentNames.has(f)),
|
|
377
|
-
...[...currentNames].filter(f => !prevOrder.includes(f)).sort(),
|
|
378
|
-
];
|
|
379
|
-
|
|
380
|
-
const session: SessionData = {
|
|
381
|
-
id,
|
|
382
|
-
projectRoot,
|
|
383
|
-
canvasFiles: filenames,
|
|
384
|
-
currentRevision: revision,
|
|
385
|
-
revisions,
|
|
386
|
-
createdAt: existing?.createdAt ?? now,
|
|
387
|
-
updatedAt: now,
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
// Secrets belong to one revision and must never carry into a new runbook.
|
|
391
|
-
this.sessionSecrets.delete(id);
|
|
392
|
-
this.sessions.set(id, session);
|
|
393
|
-
this.persistToDisk(session, canvasFiles);
|
|
394
|
-
return session;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
private persistMeta(session: SessionData) {
|
|
398
|
-
const meta: SessionMeta = {
|
|
399
|
-
projectRoot: session.projectRoot,
|
|
400
|
-
createdAt: session.createdAt,
|
|
401
|
-
updatedAt: session.updatedAt,
|
|
402
|
-
currentRevision: session.currentRevision,
|
|
403
|
-
revisions: session.revisions,
|
|
404
|
-
...(session.shares?.length ? { shares: session.shares } : {}),
|
|
405
|
-
};
|
|
406
|
-
writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** Record a new share for a session revision. */
|
|
410
|
-
addShare(id: string, entry: ShareEntry): void {
|
|
411
|
-
const session = this.sessions.get(id);
|
|
412
|
-
if (!session) return;
|
|
413
|
-
session.shares = [...(session.shares ?? []), entry];
|
|
414
|
-
session.updatedAt = new Date().toISOString();
|
|
415
|
-
this.persistMeta(session);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
getShares(id: string): ShareEntry[] {
|
|
419
|
-
return this.sessions.get(id)?.shares ?? [];
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
removeShare(id: string, shareId: string): void {
|
|
423
|
-
const session = this.sessions.get(id);
|
|
424
|
-
if (!session?.shares) return;
|
|
425
|
-
session.shares = session.shares.filter((s) => s.shareId !== shareId);
|
|
426
|
-
session.updatedAt = new Date().toISOString();
|
|
427
|
-
this.persistMeta(session);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
updateShareLastFeedback(id: string, shareId: string, at: string): void {
|
|
431
|
-
const session = this.sessions.get(id);
|
|
432
|
-
if (!session?.shares) return;
|
|
433
|
-
const entry = session.shares.find((s) => s.shareId === shareId);
|
|
434
|
-
if (!entry) return;
|
|
435
|
-
entry.lastFeedbackAt = at;
|
|
436
|
-
this.persistMeta(session);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** List all sessions that have at least one share. Used by the remote feedback poller. */
|
|
440
|
-
listSessionsWithShares(): SessionData[] {
|
|
441
|
-
return Array.from(this.sessions.values()).filter((s) => (s.shares?.length ?? 0) > 0);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
|
|
445
|
-
const revDir = this.revisionDir(session.id, session.currentRevision);
|
|
446
|
-
mkdirSync(revDir, { recursive: true });
|
|
447
|
-
for (const [filename, jsx] of canvasFiles) {
|
|
448
|
-
writeFileSync(join(revDir, filename), jsx);
|
|
449
|
-
}
|
|
450
|
-
this.persistMeta(session);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
get(id: string): SessionData | undefined {
|
|
454
|
-
return this.sessions.get(id);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
list(): SessionData[] {
|
|
458
|
-
return Array.from(this.sessions.values());
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
remove(id: string) {
|
|
462
|
-
this.sessions.delete(id);
|
|
463
|
-
this.sessionSecrets.delete(id);
|
|
464
|
-
rmSync(this.sessionDir(id), { recursive: true, force: true });
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
setSecret(id: string, fieldId: string, value: string): boolean {
|
|
468
|
-
const session = this.sessions.get(id);
|
|
469
|
-
if (!session) return false;
|
|
470
|
-
|
|
471
|
-
let secrets = this.sessionSecrets.get(id);
|
|
472
|
-
if (!secrets || secrets.revision !== session.currentRevision) {
|
|
473
|
-
secrets = { revision: session.currentRevision, values: new Map() };
|
|
474
|
-
this.sessionSecrets.set(id, secrets);
|
|
475
|
-
}
|
|
476
|
-
secrets.values.set(fieldId, value);
|
|
477
|
-
return true;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
hasSecret(id: string, fieldId: string): boolean {
|
|
481
|
-
const session = this.sessions.get(id);
|
|
482
|
-
const secrets = this.sessionSecrets.get(id);
|
|
483
|
-
return !!session
|
|
484
|
-
&& secrets?.revision === session.currentRevision
|
|
485
|
-
&& secrets.values.has(fieldId);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
clearSecret(id: string, fieldId: string): boolean {
|
|
489
|
-
const session = this.sessions.get(id);
|
|
490
|
-
const secrets = this.sessionSecrets.get(id);
|
|
491
|
-
if (!session || secrets?.revision !== session.currentRevision) return false;
|
|
492
|
-
return secrets.values.delete(fieldId);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
resolveSecrets(id: string, fieldIds: string[]): SecretResolution | null {
|
|
496
|
-
const session = this.sessions.get(id);
|
|
497
|
-
if (!session) return null;
|
|
498
|
-
|
|
499
|
-
const secrets = this.sessionSecrets.get(id);
|
|
500
|
-
const missing = fieldIds.filter((fieldId) =>
|
|
501
|
-
secrets?.revision !== session.currentRevision || !secrets.values.has(fieldId)
|
|
502
|
-
);
|
|
503
|
-
if (missing.length > 0) return { ok: false, missing };
|
|
504
|
-
|
|
505
|
-
const values = new Map<string, string>();
|
|
506
|
-
for (const fieldId of fieldIds) {
|
|
507
|
-
const value = secrets?.values.get(fieldId);
|
|
508
|
-
if (value !== undefined) values.set(fieldId, value);
|
|
509
|
-
}
|
|
510
|
-
return { ok: true, values };
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
saveCompiled(id: string, filename: string, js: string, rev?: number) {
|
|
514
|
-
const session = this.sessions.get(id);
|
|
515
|
-
const revision = rev ?? session?.currentRevision;
|
|
516
|
-
if (!revision) return;
|
|
517
|
-
const revDir = this.revisionDir(id, revision);
|
|
518
|
-
mkdirSync(revDir, { recursive: true });
|
|
519
|
-
const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
|
|
520
|
-
writeFileSync(join(revDir, compiledName), js);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
getCompiled(id: string, filename: string, rev?: number): string | null {
|
|
524
|
-
const session = this.sessions.get(id);
|
|
525
|
-
const revision = rev ?? session?.currentRevision;
|
|
526
|
-
if (!revision) return null;
|
|
527
|
-
const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
|
|
528
|
-
try {
|
|
529
|
-
return readFileSync(join(this.revisionDir(id, revision), compiledName), "utf-8");
|
|
530
|
-
} catch {
|
|
531
|
-
return null;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
saveFeedback(id: string, rev: number, markdown: string) {
|
|
536
|
-
const revDir = this.revisionDir(id, rev);
|
|
537
|
-
mkdirSync(revDir, { recursive: true });
|
|
538
|
-
writeFileSync(join(revDir, "feedback.md"), markdown);
|
|
539
|
-
|
|
540
|
-
const session = this.sessions.get(id);
|
|
541
|
-
if (session) {
|
|
542
|
-
const ri = session.revisions.find((r) => r.revision === rev);
|
|
543
|
-
if (ri) {
|
|
544
|
-
ri.hasFeedback = true;
|
|
545
|
-
ri.feedbackConsumed = false;
|
|
546
|
-
}
|
|
547
|
-
this.persistMeta(session);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
getFeedback(id: string, rev: number): string | null {
|
|
552
|
-
try {
|
|
553
|
-
return readFileSync(join(this.revisionDir(id, rev), "feedback.md"), "utf-8");
|
|
554
|
-
} catch {
|
|
555
|
-
return null;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
consumeFeedback(id: string, rev: number) {
|
|
560
|
-
const session = this.sessions.get(id);
|
|
561
|
-
if (!session) return;
|
|
562
|
-
const ri = session.revisions.find((r) => r.revision === rev);
|
|
563
|
-
if (!ri || !ri.hasFeedback || ri.feedbackConsumed) return;
|
|
564
|
-
ri.feedbackConsumed = true;
|
|
565
|
-
this.persistMeta(session);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
|
|
569
|
-
const session = this.sessions.get(id);
|
|
570
|
-
if (!session) return null;
|
|
571
|
-
for (let i = session.revisions.length - 1; i >= 0; i--) {
|
|
572
|
-
const ri = session.revisions[i];
|
|
573
|
-
if (ri.hasFeedback && !ri.feedbackConsumed) {
|
|
574
|
-
const feedback = this.getFeedback(id, ri.revision);
|
|
575
|
-
if (feedback) return { revision: ri.revision, feedback };
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
return null;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
getRevisionJsxPath(id: string, rev: number, filename: string): string {
|
|
582
|
-
return join(this.revisionDir(id, rev), filename);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Remote feedback from shared views. Stored as one JSON file per revision
|
|
587
|
-
* at `revisions/{rev}/remote_feedback.json`, containing an array of
|
|
588
|
-
* RemoteFeedbackEntry. Appended to (not replaced) as new feedback arrives.
|
|
589
|
-
*/
|
|
590
|
-
appendRemoteFeedback(id: string, rev: number, entries: RemoteFeedbackEntry[]): void {
|
|
591
|
-
if (entries.length === 0) return;
|
|
592
|
-
const revDir = this.revisionDir(id, rev);
|
|
593
|
-
mkdirSync(revDir, { recursive: true });
|
|
594
|
-
const file = join(revDir, "remote_feedback.json");
|
|
595
|
-
let existing: RemoteFeedbackEntry[] = [];
|
|
596
|
-
try {
|
|
597
|
-
existing = JSON.parse(readFileSync(file, "utf-8"));
|
|
598
|
-
} catch {}
|
|
599
|
-
const seen = new Set(existing.map((e) => e.id));
|
|
600
|
-
const merged = [...existing];
|
|
601
|
-
for (const e of entries) if (!seen.has(e.id)) merged.push(e);
|
|
602
|
-
writeFileSync(file, JSON.stringify(merged, null, 2));
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
|
|
606
|
-
try {
|
|
607
|
-
return JSON.parse(readFileSync(join(this.revisionDir(id, rev), "remote_feedback.json"), "utf-8"));
|
|
608
|
-
} catch {
|
|
609
|
-
return [];
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
}
|
package/daemon/src/watcher.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { watch, existsSync, readFileSync, readdirSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { compilePlan } from "./compiler";
|
|
4
|
-
import type { SessionManager } from "./session";
|
|
5
|
-
|
|
6
|
-
type BroadcastFn = (sessionId: string) => void;
|
|
7
|
-
|
|
8
|
-
const watchers = new Map<string, ReturnType<typeof watch>[]>();
|
|
9
|
-
|
|
10
|
-
export function watchSession(
|
|
11
|
-
sessionId: string,
|
|
12
|
-
sessionManager: SessionManager,
|
|
13
|
-
broadcast: BroadcastFn,
|
|
14
|
-
) {
|
|
15
|
-
// Close existing watchers for this session
|
|
16
|
-
unwatchSession(sessionId);
|
|
17
|
-
|
|
18
|
-
const session = sessionManager.get(sessionId);
|
|
19
|
-
if (!session) return;
|
|
20
|
-
|
|
21
|
-
const rev = session.currentRevision;
|
|
22
|
-
const canvasFiles = sessionManager.getRevisionCanvasFiles(sessionId, rev);
|
|
23
|
-
if (canvasFiles.length === 0) return;
|
|
24
|
-
|
|
25
|
-
const fileWatchers: ReturnType<typeof watch>[] = [];
|
|
26
|
-
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
27
|
-
|
|
28
|
-
for (const filename of canvasFiles) {
|
|
29
|
-
const filePath = sessionManager.getRevisionJsxPath(sessionId, rev, filename);
|
|
30
|
-
if (!existsSync(filePath)) continue;
|
|
31
|
-
|
|
32
|
-
const watcher = watch(filePath, async (event) => {
|
|
33
|
-
if (event !== "change") return;
|
|
34
|
-
|
|
35
|
-
// Debounce per-file
|
|
36
|
-
const existing = debounceTimers.get(filename);
|
|
37
|
-
if (existing) clearTimeout(existing);
|
|
38
|
-
|
|
39
|
-
debounceTimers.set(filename, setTimeout(async () => {
|
|
40
|
-
debounceTimers.delete(filename);
|
|
41
|
-
try {
|
|
42
|
-
const jsx = readFileSync(filePath, "utf-8");
|
|
43
|
-
const result = await compilePlan(jsx, session.projectRoot);
|
|
44
|
-
if (result.ok) {
|
|
45
|
-
sessionManager.saveCompiled(sessionId, filename, result.js, rev);
|
|
46
|
-
broadcast(sessionId);
|
|
47
|
-
}
|
|
48
|
-
} catch {}
|
|
49
|
-
}, 200));
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
fileWatchers.push(watcher);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
watchers.set(sessionId, fileWatchers);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function unwatchSession(sessionId: string) {
|
|
59
|
-
const sessionWatchers = watchers.get(sessionId);
|
|
60
|
-
if (sessionWatchers) {
|
|
61
|
-
for (const w of sessionWatchers) w.close();
|
|
62
|
-
watchers.delete(sessionId);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function unwatchAll() {
|
|
67
|
-
for (const [, sessionWatchers] of watchers) {
|
|
68
|
-
for (const w of sessionWatchers) w.close();
|
|
69
|
-
}
|
|
70
|
-
watchers.clear();
|
|
71
|
-
}
|