agent-canvas 0.2.1 → 0.7.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 +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -106
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +5 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/files-api.test.ts +0 -78
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
|
@@ -1,343 +1,345 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const SECRET_FIELD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
13
|
-
const MAX_SECRET_BYTES = 64 * 1024;
|
|
14
|
-
const MAX_RESOLVED_SECRETS = 32;
|
|
1
|
+
import { compileJsx, type SessionManager, watchSession } from '@fabrika/canvas-kernel/server'
|
|
2
|
+
import { jsonResponse, type Route } from '@fabrika/daemon-kit'
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
4
|
+
import { errorMessage } from '../../client/errorMessage'
|
|
5
|
+
import { HOST_COMPONENTS } from '../components'
|
|
6
|
+
import { COMPILE_TEMP_DIR } from '../paths'
|
|
7
|
+
import { loadShareConfig, revokeShare, shareRevision } from '../share'
|
|
8
|
+
|
|
9
|
+
const SECRET_FIELD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
10
|
+
const MAX_SECRET_BYTES = 64 * 1024
|
|
11
|
+
const MAX_RESOLVED_SECRETS = 32
|
|
15
12
|
|
|
16
13
|
function secretJsonResponse(data: unknown, status = 200): Response {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
const response = jsonResponse(data, status)
|
|
15
|
+
response.headers.set('Cache-Control', 'no-store')
|
|
16
|
+
return response
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
-
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
function isValidSecretField(fieldId: string): boolean {
|
|
27
|
-
|
|
24
|
+
return SECRET_FIELD_RE.test(fieldId)
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
export interface ApiContext {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
sessionManager: SessionManager
|
|
29
|
+
broadcastPlanUpdate: (id: string) => void
|
|
30
|
+
broadcastRevisionUpdate: (id: string) => void
|
|
31
|
+
port: number
|
|
32
|
+
version: string
|
|
33
|
+
cliAuthToken: string
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
37
|
+
const { sessionManager, broadcastPlanUpdate, broadcastRevisionUpdate, port } = ctx
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read *.jsx canvas files from a directory.
|
|
41
|
+
* Returns a Map of filename -> JSX content.
|
|
42
|
+
*/
|
|
43
|
+
function resolveCanvasFiles(directory: string): Map<string, string> | null {
|
|
44
|
+
if (!existsSync(directory)) return null
|
|
45
|
+
const files = readdirSync(directory).filter(f => f.endsWith('.jsx')).sort()
|
|
46
|
+
if (files.length === 0) return null
|
|
47
|
+
const map = new Map<string, string>()
|
|
48
|
+
for (const f of files) {
|
|
49
|
+
map.set(f, readFileSync(`${directory}/${f}`, 'utf-8'))
|
|
50
|
+
}
|
|
51
|
+
return map
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
55
|
+
const sessionId = match.pathname.groups.id!
|
|
56
|
+
try {
|
|
57
|
+
const body = await req.json()
|
|
58
|
+
|
|
59
|
+
if (!body.directory) {
|
|
60
|
+
return jsonResponse({ error: 'Missing directory' }, 400)
|
|
61
|
+
}
|
|
62
|
+
const canvasFiles = resolveCanvasFiles(body.directory)
|
|
63
|
+
if (!canvasFiles) {
|
|
64
|
+
return jsonResponse({ error: 'No .jsx files found in directory' }, 400)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const unconsumed = sessionManager.getLatestUnconsumedFeedback(sessionId)
|
|
68
|
+
if (unconsumed) {
|
|
69
|
+
sessionManager.consumeFeedback(sessionId, unconsumed.revision)
|
|
70
|
+
return jsonResponse({
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `Unconsumed feedback from revision ${unconsumed.revision}. Address the feedback before pushing a new canvas.`,
|
|
73
|
+
unconsumedFeedback: unconsumed.feedback,
|
|
74
|
+
unconsumedRevision: unconsumed.revision,
|
|
75
|
+
}, 409)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const projectRoot = body.projectRoot || process.cwd()
|
|
79
|
+
const isNew = !sessionManager.get(sessionId)
|
|
80
|
+
|
|
81
|
+
// Compile all canvas files — fail if any file fails
|
|
82
|
+
const compiled = new Map<string, string>()
|
|
83
|
+
const errors: Record<string, string> = {}
|
|
84
|
+
await Promise.all(
|
|
85
|
+
[...canvasFiles.entries()].map(async ([filename, jsx]) => {
|
|
86
|
+
const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR, components: HOST_COMPONENTS })
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
compiled.set(filename, result.js)
|
|
89
|
+
} else {
|
|
90
|
+
errors[filename] = result.error
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (Object.keys(errors).length > 0) {
|
|
96
|
+
return jsonResponse({
|
|
97
|
+
ok: false,
|
|
98
|
+
error: 'Canvas compilation failed',
|
|
99
|
+
errors,
|
|
100
|
+
}, 400)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const session = sessionManager.upsert(sessionId, canvasFiles, projectRoot, body.label, body.response)
|
|
104
|
+
for (const [filename, js] of compiled) {
|
|
105
|
+
sessionManager.saveCompiled(sessionId, filename, js, session.currentRevision)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
broadcastPlanUpdate(sessionId)
|
|
109
|
+
watchSession(sessionId, sessionManager, broadcastPlanUpdate, { tempDir: COMPILE_TEMP_DIR, components: HOST_COMPONENTS })
|
|
110
|
+
|
|
111
|
+
const browserUrl = `http://localhost:${port}/s/${sessionId}`
|
|
112
|
+
return jsonResponse({
|
|
113
|
+
ok: true,
|
|
114
|
+
browserUrl,
|
|
115
|
+
isNew,
|
|
116
|
+
revision: session.currentRevision,
|
|
117
|
+
sessionId,
|
|
118
|
+
canvasFiles: [...canvasFiles.keys()],
|
|
119
|
+
})
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return jsonResponse({ error: errorMessage(e) }, 500)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function handleCanvasJs(_req: Request, url: URL, match: URLPatternResult): Promise<Response> {
|
|
126
|
+
const sessionId = match.pathname.groups.id!
|
|
127
|
+
const jsFilename = match.pathname.groups.filename!
|
|
128
|
+
const jsxFilename = jsFilename.replace(/\.js$/, '.jsx')
|
|
129
|
+
const revParam = url.searchParams.get('rev')
|
|
130
|
+
const rev = revParam ? parseInt(revParam, 10) : undefined
|
|
131
|
+
|
|
132
|
+
let compiled = sessionManager.getCompiled(sessionId, jsxFilename, rev)
|
|
133
|
+
|
|
134
|
+
if (!compiled && rev) {
|
|
135
|
+
const session = sessionManager.get(sessionId)
|
|
136
|
+
if (session) {
|
|
137
|
+
const jsx = sessionManager.readRevisionJsx(sessionId, rev, jsxFilename)
|
|
138
|
+
if (jsx) {
|
|
139
|
+
const result = await compileJsx(jsx, { projectRoot: session.projectRoot, tempDir: COMPILE_TEMP_DIR, components: HOST_COMPONENTS })
|
|
140
|
+
if (result.ok) {
|
|
141
|
+
sessionManager.saveCompiled(sessionId, jsxFilename, result.js, rev)
|
|
142
|
+
compiled = result.js
|
|
143
|
+
} else {
|
|
144
|
+
console.warn(`[canvas.js] compilation failed for ${sessionId}/${jsxFilename} rev ${rev}: ${result.error}`)
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
console.warn(`[canvas.js] no JSX found for ${sessionId}/${jsxFilename} rev ${rev}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!compiled) return jsonResponse({ error: 'No compiled canvas' }, 404)
|
|
153
|
+
return new Response(compiled, {
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/javascript',
|
|
156
|
+
'Cache-Control': 'no-cache',
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function handleHealth(): Response {
|
|
162
|
+
const sessions = sessionManager.list().map((s) => s.id)
|
|
163
|
+
// The pid is what the CLI signals to stop us — a pid file it cannot corroborate is not.
|
|
164
|
+
return jsonResponse({ ok: true, sessions, version: ctx.version, pid: process.pid })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function handleMeta(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
168
|
+
const sessionId = match.pathname.groups.id!
|
|
169
|
+
const session = sessionManager.get(sessionId)
|
|
170
|
+
if (!session) return jsonResponse({ error: 'Session not found' }, 404)
|
|
171
|
+
return jsonResponse({
|
|
172
|
+
projectRoot: session.projectRoot,
|
|
173
|
+
currentRevision: session.currentRevision,
|
|
174
|
+
revisions: session.revisions,
|
|
175
|
+
canvasFiles: session.canvasFiles,
|
|
176
|
+
createdAt: session.createdAt,
|
|
177
|
+
updatedAt: session.updatedAt,
|
|
178
|
+
shares: session.shares ?? [],
|
|
179
|
+
shareEnabled: !!loadShareConfig(ctx.version),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function handleShareRevision(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
184
|
+
const sessionId = match.pathname.groups.id!
|
|
185
|
+
const rev = parseInt(match.pathname.groups.rev!, 10)
|
|
186
|
+
|
|
187
|
+
const session = sessionManager.get(sessionId)
|
|
188
|
+
if (!session) return jsonResponse({ error: 'Session not found' }, 404)
|
|
189
|
+
if (!session.revisions.find((r) => r.revision === rev)) {
|
|
190
|
+
return jsonResponse({ error: `Revision ${rev} not found` }, 404)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const config = loadShareConfig(ctx.version)
|
|
194
|
+
if (!config) {
|
|
195
|
+
return jsonResponse({
|
|
196
|
+
error: 'Sharing disabled. Set CANVAS_SHARE_ENDPOINT to the deployed canvas-share worker URL.',
|
|
197
|
+
}, 501)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const entry = await shareRevision(sessionManager, sessionId, rev, config)
|
|
202
|
+
broadcastRevisionUpdate(sessionId)
|
|
203
|
+
return jsonResponse({ ok: true, share: entry })
|
|
204
|
+
} catch (e) {
|
|
205
|
+
return jsonResponse({ error: errorMessage(e) }, 502)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function handleShareRevoke(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
210
|
+
const sessionId = match.pathname.groups.id!
|
|
211
|
+
const shareId = match.pathname.groups.shareId!
|
|
212
|
+
if (!sessionManager.get(sessionId)) return jsonResponse({ error: 'Session not found' }, 404)
|
|
213
|
+
const config = loadShareConfig(ctx.version)
|
|
214
|
+
if (!config) return jsonResponse({ error: 'Sharing disabled' }, 501)
|
|
215
|
+
try {
|
|
216
|
+
await revokeShare(sessionManager, sessionId, shareId, config)
|
|
217
|
+
broadcastRevisionUpdate(sessionId)
|
|
218
|
+
return jsonResponse({ ok: true })
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return jsonResponse({ error: errorMessage(e) }, 502)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function handleSharesList(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
225
|
+
const sessionId = match.pathname.groups.id!
|
|
226
|
+
const session = sessionManager.get(sessionId)
|
|
227
|
+
if (!session) return jsonResponse({ error: 'Session not found' }, 404)
|
|
228
|
+
return jsonResponse({ shares: session.shares ?? [] })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function handleRemoteFeedbackGet(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
232
|
+
const sessionId = match.pathname.groups.id!
|
|
233
|
+
const rev = parseInt(match.pathname.groups.rev!, 10)
|
|
234
|
+
if (!sessionManager.get(sessionId)) return jsonResponse({ error: 'Session not found' }, 404)
|
|
235
|
+
const entries = sessionManager.getRemoteFeedback(sessionId, rev)
|
|
236
|
+
return jsonResponse({ entries })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function handleSessions(): Response {
|
|
240
|
+
return jsonResponse(
|
|
241
|
+
sessionManager.list().map((s) => ({
|
|
242
|
+
id: s.id,
|
|
243
|
+
projectRoot: s.projectRoot,
|
|
244
|
+
currentRevision: s.currentRevision,
|
|
245
|
+
updatedAt: s.updatedAt,
|
|
246
|
+
})),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function handleFeedbackGet(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
251
|
+
const sessionId = match.pathname.groups.id!
|
|
252
|
+
const rev = parseInt(match.pathname.groups.rev!, 10)
|
|
253
|
+
const feedback = sessionManager.getFeedback(sessionId, rev)
|
|
254
|
+
if (feedback === null) return jsonResponse({ error: 'No feedback' }, 404)
|
|
255
|
+
return jsonResponse({ feedback })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function handleFeedbackConsume(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
259
|
+
const sessionId = match.pathname.groups.id!
|
|
260
|
+
const result = sessionManager.getLatestUnconsumedFeedback(sessionId)
|
|
261
|
+
if (!result) return jsonResponse({ found: false })
|
|
262
|
+
sessionManager.consumeFeedback(sessionId, result.revision)
|
|
263
|
+
broadcastRevisionUpdate(sessionId)
|
|
264
|
+
return jsonResponse({ found: true, revision: result.revision, feedback: result.feedback })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function handleSecretStore(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
268
|
+
const sessionId = match.pathname.groups.id!
|
|
269
|
+
const fieldId = match.pathname.groups.field!
|
|
270
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: 'Session not found' }, 404)
|
|
271
|
+
if (!isValidSecretField(fieldId)) return secretJsonResponse({ error: 'Invalid secret field ID' }, 400)
|
|
272
|
+
|
|
273
|
+
const raw: unknown = await req.json()
|
|
274
|
+
if (!isRecord(raw) || typeof raw.value !== 'string' || raw.value.length === 0) {
|
|
275
|
+
return secretJsonResponse({ error: 'Secret value must be a non-empty string' }, 400)
|
|
276
|
+
}
|
|
277
|
+
if (new TextEncoder().encode(raw.value).byteLength > MAX_SECRET_BYTES) {
|
|
278
|
+
return secretJsonResponse({ error: `Secret exceeds ${MAX_SECRET_BYTES} bytes` }, 413)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
sessionManager.setSecret(sessionId, fieldId, raw.value)
|
|
282
|
+
return secretJsonResponse({ ok: true, ready: true })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function handleSecretStatus(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
286
|
+
const sessionId = match.pathname.groups.id!
|
|
287
|
+
const fieldId = match.pathname.groups.field!
|
|
288
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: 'Session not found' }, 404)
|
|
289
|
+
if (!isValidSecretField(fieldId)) return secretJsonResponse({ error: 'Invalid secret field ID' }, 400)
|
|
290
|
+
return secretJsonResponse({ ready: sessionManager.hasSecret(sessionId, fieldId) })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function handleSecretClear(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
294
|
+
const sessionId = match.pathname.groups.id!
|
|
295
|
+
const fieldId = match.pathname.groups.field!
|
|
296
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: 'Session not found' }, 404)
|
|
297
|
+
if (!isValidSecretField(fieldId)) return secretJsonResponse({ error: 'Invalid secret field ID' }, 400)
|
|
298
|
+
sessionManager.clearSecret(sessionId, fieldId)
|
|
299
|
+
return secretJsonResponse({ ok: true, ready: false })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function handleSecretResolve(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
303
|
+
if (req.headers.get('X-Agent-Canvas-CLI-Token') !== ctx.cliAuthToken) {
|
|
304
|
+
return secretJsonResponse({ error: 'Secret resolution is available only to the local CLI' }, 403)
|
|
305
|
+
}
|
|
306
|
+
const sessionId = match.pathname.groups.id!
|
|
307
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: 'Session not found' }, 404)
|
|
308
|
+
|
|
309
|
+
const raw: unknown = await req.json()
|
|
310
|
+
const fields = isRecord(raw) && Array.isArray(raw.fields) ? raw.fields : null
|
|
311
|
+
if (
|
|
312
|
+
!fields || fields.length === 0 || fields.length > MAX_RESOLVED_SECRETS
|
|
313
|
+
|| !fields.every((field) => typeof field === 'string' && isValidSecretField(field))
|
|
314
|
+
) {
|
|
315
|
+
return secretJsonResponse({ error: `fields must contain 1-${MAX_RESOLVED_SECRETS} valid secret field IDs` }, 400)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const uniqueFields = [...new Set(fields)]
|
|
319
|
+
const result = sessionManager.resolveSecrets(sessionId, uniqueFields)
|
|
320
|
+
if (!result) return secretJsonResponse({ error: 'Session not found' }, 404)
|
|
321
|
+
if (!result.ok) return secretJsonResponse({ error: 'Required secrets are not ready', missing: result.missing }, 409)
|
|
322
|
+
|
|
323
|
+
return secretJsonResponse({
|
|
324
|
+
values: [...result.values].map(([id, value]) => ({ id, value })),
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return [
|
|
329
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/health' }), handler: handleHealth },
|
|
330
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/plan' }), handler: handlePlanPost },
|
|
331
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/canvas/:filename' }), handler: handleCanvasJs },
|
|
332
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/meta' }), handler: handleMeta },
|
|
333
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/feedback' }), handler: handleFeedbackGet },
|
|
334
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/feedback/consume' }), handler: handleFeedbackConsume },
|
|
335
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/secrets/resolve' }), handler: handleSecretResolve },
|
|
336
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/secrets/:field/value' }), handler: handleSecretStore },
|
|
337
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/secrets/:field/status' }), handler: handleSecretStatus },
|
|
338
|
+
{ method: 'DELETE', pattern: new URLPattern({ pathname: '/api/session/:id/secrets/:field/value' }), handler: handleSecretClear },
|
|
339
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/share' }), handler: handleShareRevision },
|
|
340
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/shares/:shareId/revoke' }), handler: handleShareRevoke },
|
|
341
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/shares' }), handler: handleSharesList },
|
|
342
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/remote-feedback' }), handler: handleRemoteFeedbackGet },
|
|
343
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/sessions' }), handler: handleSessions },
|
|
344
|
+
]
|
|
343
345
|
}
|