agent-canvas 0.2.1 → 0.6.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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { ReviewStore } from './store'
|
|
2
|
+
|
|
3
|
+
/** One browser socket proxied to the application, e.g. its dev server's hot reload. */
|
|
4
|
+
interface ProxySocketData {
|
|
5
|
+
upstream: WebSocket
|
|
6
|
+
/** Frames the application sent before the browser's socket opened. */
|
|
7
|
+
early: Array<string | ArrayBuffer>
|
|
8
|
+
browser: Bun.ServerWebSocket<ProxySocketData> | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type ProxyServer = Bun.Server<ProxySocketData>
|
|
12
|
+
|
|
13
|
+
interface RunningProxy {
|
|
14
|
+
target: string
|
|
15
|
+
server: ProxyServer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Puts the widget's tag before `</body>`, or at the end of a document without one. */
|
|
19
|
+
export function withWidget(html: string, tag: string): string {
|
|
20
|
+
const bodyEnd = html.toLowerCase().lastIndexOf('</body>')
|
|
21
|
+
return bodyEnd === -1 ? `${html}${tag}` : `${html.slice(0, bodyEnd)}${tag}${html.slice(bodyEnd)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function connectUpstream(url: URL, protocols: string[]): Promise<WebSocket> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const socket = new WebSocket(url, protocols)
|
|
27
|
+
socket.binaryType = 'arraybuffer'
|
|
28
|
+
socket.onopen = () => resolve(socket)
|
|
29
|
+
socket.onerror = () => reject(new Error(`WebSocket ${url} failed`))
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Review proxies: each project's application, served on a port of its own with the review widget added to every HTML
|
|
35
|
+
* page, so a reviewer opens a URL instead of editing the application. The proxy's origin is a loopback one, which the
|
|
36
|
+
* review routes accept; it forwards to applications on this machine only.
|
|
37
|
+
*/
|
|
38
|
+
export class ReviewProxies {
|
|
39
|
+
private readonly running = new Map<string, RunningProxy>()
|
|
40
|
+
|
|
41
|
+
constructor(private readonly store: ReviewStore, private readonly widgetScript: string) {}
|
|
42
|
+
|
|
43
|
+
/** Serves the project's application at `target` and returns where the reviewer opens it, `target`'s path kept. */
|
|
44
|
+
open(project: string, target: string): string {
|
|
45
|
+
const targetUrl = new URL(target)
|
|
46
|
+
const current = this.running.get(project)
|
|
47
|
+
if (current === undefined || current.target !== targetUrl.origin) {
|
|
48
|
+
const port = current?.server.port ?? this.store.proxyOf(project)?.port
|
|
49
|
+
current?.server.stop(true)
|
|
50
|
+
this.listen(project, targetUrl.origin, port)
|
|
51
|
+
}
|
|
52
|
+
const proxy = this.running.get(project)
|
|
53
|
+
if (proxy === undefined) throw new Error(`Review proxy for ${project} did not start`)
|
|
54
|
+
return `http://localhost:${proxy.server.port}${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Serves every proxy a previous daemon served, on the same ports. */
|
|
58
|
+
restore(): void {
|
|
59
|
+
for (const project of this.store.proxiedProjects()) {
|
|
60
|
+
try {
|
|
61
|
+
const proxy = this.store.proxyOf(project)
|
|
62
|
+
if (proxy !== undefined) this.listen(project, proxy.target, proxy.port)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`Review proxy for ${project} not restored: ${error instanceof Error ? error.message : String(error)}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
stop(): void {
|
|
70
|
+
for (const proxy of this.running.values()) proxy.server.stop(true)
|
|
71
|
+
this.running.clear()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private listen(project: string, target: string, port: number | undefined): void {
|
|
75
|
+
let server: ProxyServer
|
|
76
|
+
try {
|
|
77
|
+
server = this.serve(project, target, port ?? 0)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (port === undefined) throw error
|
|
80
|
+
// The previous port is taken; the reviewer opens the new URL the CLI prints.
|
|
81
|
+
server = this.serve(project, target, 0)
|
|
82
|
+
}
|
|
83
|
+
this.running.set(project, { target, server })
|
|
84
|
+
this.store.saveProxy(project, { target, port: server.port ?? 0 })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private serve(project: string, target: string, port: number): ProxyServer {
|
|
88
|
+
const widgetTag = `<script src="${this.widgetScript}" data-project="${project}"></script>`
|
|
89
|
+
return Bun.serve<ProxySocketData>({
|
|
90
|
+
port,
|
|
91
|
+
hostname: 'localhost',
|
|
92
|
+
fetch: async (req, server) => {
|
|
93
|
+
const url = new URL(req.url)
|
|
94
|
+
const upstreamUrl = new URL(`${url.pathname}${url.search}`, target)
|
|
95
|
+
if (req.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
|
|
96
|
+
upstreamUrl.protocol = upstreamUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
97
|
+
return await this.upgrade(req, server, upstreamUrl)
|
|
98
|
+
}
|
|
99
|
+
return await forward(req, upstreamUrl, url.origin, widgetTag)
|
|
100
|
+
},
|
|
101
|
+
websocket: {
|
|
102
|
+
open(socket) {
|
|
103
|
+
socket.data.browser = socket
|
|
104
|
+
for (const frame of socket.data.early) socket.send(frame)
|
|
105
|
+
socket.data.early = []
|
|
106
|
+
if (socket.data.upstream.readyState !== WebSocket.OPEN) socket.close()
|
|
107
|
+
},
|
|
108
|
+
message(socket, message) {
|
|
109
|
+
if (socket.data.upstream.readyState === WebSocket.OPEN) socket.data.upstream.send(message)
|
|
110
|
+
},
|
|
111
|
+
close(socket) {
|
|
112
|
+
socket.data.upstream.close()
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Connects to the application first, so the browser gets the subprotocol the application chose. */
|
|
119
|
+
private async upgrade(req: Request, server: ProxyServer, upstreamUrl: URL): Promise<Response | undefined> {
|
|
120
|
+
const protocols = (req.headers.get('Sec-WebSocket-Protocol') ?? '').split(',').map((protocol) => protocol.trim()).filter((protocol) =>
|
|
121
|
+
protocol !== ''
|
|
122
|
+
)
|
|
123
|
+
let upstream: WebSocket
|
|
124
|
+
try {
|
|
125
|
+
upstream = await connectUpstream(upstreamUrl, protocols)
|
|
126
|
+
} catch {
|
|
127
|
+
return new Response('agent-canvas review: the application refused the WebSocket', { status: 502 })
|
|
128
|
+
}
|
|
129
|
+
const data: ProxySocketData = { upstream, early: [], browser: null }
|
|
130
|
+
upstream.onmessage = (event) => {
|
|
131
|
+
if (typeof event.data !== 'string' && !(event.data instanceof ArrayBuffer)) return
|
|
132
|
+
if (data.browser === null) data.early.push(event.data)
|
|
133
|
+
else data.browser.send(event.data)
|
|
134
|
+
}
|
|
135
|
+
upstream.onclose = () => data.browser?.close()
|
|
136
|
+
// Bun rejects an empty `headers` object, so a socket without a subprotocol (Bun's own hot reload) passes none.
|
|
137
|
+
const upgraded = upstream.protocol === ''
|
|
138
|
+
? server.upgrade(req, { data })
|
|
139
|
+
: server.upgrade(req, { data, headers: { 'Sec-WebSocket-Protocol': upstream.protocol } })
|
|
140
|
+
if (upgraded) return undefined
|
|
141
|
+
upstream.close()
|
|
142
|
+
return new Response('WebSocket upgrade failed', { status: 400 })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function forward(req: Request, upstreamUrl: URL, proxyOrigin: string, widgetTag: string): Promise<Response> {
|
|
147
|
+
const headers = new Headers(req.headers)
|
|
148
|
+
headers.delete('Host')
|
|
149
|
+
// Uncompressed, so an HTML page can take the widget's tag.
|
|
150
|
+
headers.set('Accept-Encoding', 'identity')
|
|
151
|
+
if (headers.get('Origin') === proxyOrigin) headers.set('Origin', upstreamUrl.origin)
|
|
152
|
+
let upstream: Response
|
|
153
|
+
try {
|
|
154
|
+
upstream = await fetch(upstreamUrl, {
|
|
155
|
+
method: req.method,
|
|
156
|
+
headers,
|
|
157
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? null : req.body,
|
|
158
|
+
redirect: 'manual',
|
|
159
|
+
})
|
|
160
|
+
} catch {
|
|
161
|
+
return new Response(`agent-canvas review: ${upstreamUrl.origin} does not answer. Is the application running?`, {
|
|
162
|
+
status: 502,
|
|
163
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const responseHeaders = new Headers(upstream.headers)
|
|
168
|
+
// fetch hands over a decoded body, whatever the application sent.
|
|
169
|
+
responseHeaders.delete('Content-Encoding')
|
|
170
|
+
responseHeaders.delete('Content-Length')
|
|
171
|
+
const location = responseHeaders.get('Location')
|
|
172
|
+
if (location?.startsWith(upstreamUrl.origin)) responseHeaders.set('Location', `${proxyOrigin}${location.slice(upstreamUrl.origin.length)}`)
|
|
173
|
+
|
|
174
|
+
const init = { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders }
|
|
175
|
+
if (req.method === 'HEAD' || !(responseHeaders.get('Content-Type') ?? '').startsWith('text/html')) return new Response(upstream.body, init)
|
|
176
|
+
return new Response(withWidget(await upstream.text(), widgetTag), init)
|
|
177
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { type ChannelSocket, createNotificationChannel, dispatch, jsonResponse, type Route } from '@fabrika/daemon-kit'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
import { renderReviewRequests } from './markdown'
|
|
4
|
+
import {
|
|
5
|
+
addCommentBodySchema,
|
|
6
|
+
agentReplyBodySchema,
|
|
7
|
+
createThreadBodySchema,
|
|
8
|
+
isLoopbackOrigin,
|
|
9
|
+
openProxyBodySchema,
|
|
10
|
+
PROJECT_RE,
|
|
11
|
+
type ReviewBrowserFrame,
|
|
12
|
+
type ReviewWaitFrame,
|
|
13
|
+
} from './model'
|
|
14
|
+
import type { ReviewProxies } from './proxy'
|
|
15
|
+
import { ReviewError, type ReviewStore } from './store'
|
|
16
|
+
|
|
17
|
+
export interface ReviewSocketData {
|
|
18
|
+
type: 'review'
|
|
19
|
+
role: 'browser' | 'wait'
|
|
20
|
+
project: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReviewSocket extends ChannelSocket {
|
|
24
|
+
data: ReviewSocketData
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function corsHeaders(origin: string): Record<string, string> {
|
|
28
|
+
return {
|
|
29
|
+
'Access-Control-Allow-Origin': origin,
|
|
30
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
31
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
32
|
+
Vary: 'Origin',
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function parseBody<T extends z.ZodTypeAny>(req: Request, schema: T): Promise<z.infer<T>> {
|
|
37
|
+
let body: unknown
|
|
38
|
+
try {
|
|
39
|
+
body = await req.json()
|
|
40
|
+
} catch {
|
|
41
|
+
throw new ReviewError('Body must be JSON', 400)
|
|
42
|
+
}
|
|
43
|
+
const parsed = schema.safeParse(body)
|
|
44
|
+
if (!parsed.success) throw new ReviewError(parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '), 400)
|
|
45
|
+
return parsed.data
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function projectOf(match: URLPatternResult): string {
|
|
49
|
+
const project = match.pathname.groups['project'] ?? ''
|
|
50
|
+
if (!PROJECT_RE.test(project)) throw new ReviewError(`Invalid project name ${project}`, 400)
|
|
51
|
+
return project
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function threadIdOf(match: URLPatternResult): string {
|
|
55
|
+
return match.pathname.groups['thread'] ?? ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Audience = 'browser' | 'agent'
|
|
59
|
+
|
|
60
|
+
/** Review mode: threads a person leaves on a running application through the injected widget, and their delivery to the agent. */
|
|
61
|
+
export function createReviewServer(store: ReviewStore, proxies: ReviewProxies) {
|
|
62
|
+
const channel = createNotificationChannel<ReviewSocket>({
|
|
63
|
+
roleOf: (socket) => socket.data.role,
|
|
64
|
+
topicOf: (socket) => socket.data.project,
|
|
65
|
+
presenceFrame: (watching): ReviewBrowserFrame => ({ type: 'agent-status', watching }),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function publish(project: string): void {
|
|
69
|
+
const frame: ReviewBrowserFrame = { type: 'threads', threads: store.list(project) }
|
|
70
|
+
channel.broadcast(project, frame)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function notifyAgent(project: string): void {
|
|
74
|
+
const frame: ReviewWaitFrame = { type: 'queued' }
|
|
75
|
+
channel.relayToWaiters(project, frame)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Browser routes answer only loopback origins. Agent routes refuse any request a browser makes: browsers send
|
|
80
|
+
* `Origin` on every cross-origin request, the CLI sends none.
|
|
81
|
+
*/
|
|
82
|
+
function route(
|
|
83
|
+
method: Route['method'],
|
|
84
|
+
pathname: string,
|
|
85
|
+
audience: Audience,
|
|
86
|
+
handle: (req: Request, match: URLPatternResult) => Promise<unknown>,
|
|
87
|
+
): Route {
|
|
88
|
+
return {
|
|
89
|
+
method,
|
|
90
|
+
pattern: new URLPattern({ pathname }),
|
|
91
|
+
handler: async (req, _url, match) => {
|
|
92
|
+
const origin = req.headers.get('Origin')
|
|
93
|
+
if (audience === 'agent' && origin !== null) return jsonResponse({ error: 'Agent endpoints do not accept browser requests' }, 403)
|
|
94
|
+
if (origin !== null && !isLoopbackOrigin(origin)) return jsonResponse({ error: 'Origin not allowed' }, 403)
|
|
95
|
+
let response: Response
|
|
96
|
+
try {
|
|
97
|
+
response = jsonResponse(await handle(req, match))
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof ReviewError)) throw error
|
|
100
|
+
response = jsonResponse({ error: error.message }, error.status)
|
|
101
|
+
}
|
|
102
|
+
if (origin !== null) { for (const [name, value] of Object.entries(corsHeaders(origin))) response.headers.set(name, value) }
|
|
103
|
+
return response
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const routes: Route[] = [
|
|
109
|
+
route('GET', '/api/review/:project/threads', 'browser', async (_req, match) => ({ threads: store.list(projectOf(match)) })),
|
|
110
|
+
route('POST', '/api/review/:project/threads', 'browser', async (req, match) => {
|
|
111
|
+
const project = projectOf(match)
|
|
112
|
+
const body = await parseBody(req, createThreadBodySchema)
|
|
113
|
+
const thread = store.create(project, body.anchor, body.body, body.send, body.screenshot)
|
|
114
|
+
publish(project)
|
|
115
|
+
if (body.send) notifyAgent(project)
|
|
116
|
+
return { thread }
|
|
117
|
+
}),
|
|
118
|
+
route('POST', '/api/review/:project/threads/:thread/comments', 'browser', async (req, match) => {
|
|
119
|
+
const project = projectOf(match)
|
|
120
|
+
const body = await parseBody(req, addCommentBodySchema)
|
|
121
|
+
const thread = store.addUserComment(project, threadIdOf(match), body.body, body.send)
|
|
122
|
+
publish(project)
|
|
123
|
+
if (body.send) notifyAgent(project)
|
|
124
|
+
return { thread }
|
|
125
|
+
}),
|
|
126
|
+
route('POST', '/api/review/:project/threads/:thread/send', 'browser', async (_req, match) => {
|
|
127
|
+
const project = projectOf(match)
|
|
128
|
+
const thread = store.send(project, threadIdOf(match))
|
|
129
|
+
publish(project)
|
|
130
|
+
notifyAgent(project)
|
|
131
|
+
return { thread }
|
|
132
|
+
}),
|
|
133
|
+
route('POST', '/api/review/:project/threads/:thread/resolve', 'browser', async (_req, match) => {
|
|
134
|
+
const project = projectOf(match)
|
|
135
|
+
const thread = store.setResolved(project, threadIdOf(match), true)
|
|
136
|
+
publish(project)
|
|
137
|
+
return { thread }
|
|
138
|
+
}),
|
|
139
|
+
route('POST', '/api/review/:project/threads/:thread/reopen', 'browser', async (_req, match) => {
|
|
140
|
+
const project = projectOf(match)
|
|
141
|
+
const thread = store.setResolved(project, threadIdOf(match), false)
|
|
142
|
+
publish(project)
|
|
143
|
+
return { thread }
|
|
144
|
+
}),
|
|
145
|
+
route('DELETE', '/api/review/:project/threads/:thread', 'browser', async (_req, match) => {
|
|
146
|
+
const project = projectOf(match)
|
|
147
|
+
store.delete(project, threadIdOf(match))
|
|
148
|
+
publish(project)
|
|
149
|
+
return { ok: true }
|
|
150
|
+
}),
|
|
151
|
+
route('POST', '/api/review/:project/claim', 'agent', async (_req, match) => {
|
|
152
|
+
const project = projectOf(match)
|
|
153
|
+
const threads = store.claimQueued(project)
|
|
154
|
+
if (threads.length === 0) return { found: false }
|
|
155
|
+
publish(project)
|
|
156
|
+
return { found: true, markdown: renderReviewRequests(project, threads) }
|
|
157
|
+
}),
|
|
158
|
+
route('POST', '/api/review/:project/proxy', 'agent', async (req, match) => {
|
|
159
|
+
const body = await parseBody(req, openProxyBodySchema)
|
|
160
|
+
return { url: proxies.open(projectOf(match), body.target) }
|
|
161
|
+
}),
|
|
162
|
+
route('POST', '/api/review/:project/threads/:thread/reply', 'agent', async (req, match) => {
|
|
163
|
+
const project = projectOf(match)
|
|
164
|
+
const body = await parseBody(req, agentReplyBodySchema)
|
|
165
|
+
const thread = store.agentReply(project, threadIdOf(match), body.body, body.resolve)
|
|
166
|
+
publish(project)
|
|
167
|
+
return { thread }
|
|
168
|
+
}),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
channel,
|
|
173
|
+
/** Answers the request when it is review mode's: CORS preflight, a route, or a WebSocket upgrade. */
|
|
174
|
+
fetch(
|
|
175
|
+
req: Request,
|
|
176
|
+
url: URL,
|
|
177
|
+
server: { upgrade(req: Request, options: { data: ReviewSocketData }): boolean },
|
|
178
|
+
): Response | Promise<Response> | 'upgraded' | null {
|
|
179
|
+
const socketMatch = url.pathname.match(/^\/ws\/review(-wait)?\/([^/]+)$/)
|
|
180
|
+
if (socketMatch !== null) {
|
|
181
|
+
const origin = req.headers.get('Origin')
|
|
182
|
+
const project = socketMatch[2] ?? ''
|
|
183
|
+
const role = socketMatch[1] === undefined ? 'browser' : 'wait'
|
|
184
|
+
if (!PROJECT_RE.test(project)) return jsonResponse({ error: 'Invalid project name' }, 400)
|
|
185
|
+
if (role === 'browser' ? origin === null || !isLoopbackOrigin(origin) : origin !== null) return jsonResponse({ error: 'Origin not allowed' }, 403)
|
|
186
|
+
return server.upgrade(req, { data: { type: 'review', role, project } }) ? 'upgraded' : new Response('WebSocket upgrade failed', { status: 400 })
|
|
187
|
+
}
|
|
188
|
+
if (!url.pathname.startsWith('/api/review/')) return null
|
|
189
|
+
if (req.method === 'OPTIONS') {
|
|
190
|
+
const origin = req.headers.get('Origin')
|
|
191
|
+
if (origin === null || !isLoopbackOrigin(origin)) return new Response(null, { status: 403 })
|
|
192
|
+
return new Response(null, { status: 204, headers: corsHeaders(origin) })
|
|
193
|
+
}
|
|
194
|
+
return dispatch(routes, req, url)
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type ReviewServer = ReturnType<typeof createReviewServer>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import {
|
|
4
|
+
PROJECT_RE,
|
|
5
|
+
type ReviewAnchor,
|
|
6
|
+
type ReviewAuthor,
|
|
7
|
+
type ReviewProxy,
|
|
8
|
+
reviewProxySchema,
|
|
9
|
+
type ReviewThread,
|
|
10
|
+
reviewThreadsFileSchema,
|
|
11
|
+
type ReviewThreadState,
|
|
12
|
+
} from './model'
|
|
13
|
+
|
|
14
|
+
export class ReviewError extends Error {
|
|
15
|
+
constructor(message: string, readonly status: 400 | 404 | 409) {
|
|
16
|
+
super(message)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Review threads of each project: `<directory>/<project>/threads.json`, attached screenshots beside it, and the
|
|
22
|
+
* project's review proxy in `proxy.json`.
|
|
23
|
+
*/
|
|
24
|
+
export class ReviewStore {
|
|
25
|
+
constructor(private readonly directory: string, private readonly now: () => Date = () => new Date()) {}
|
|
26
|
+
|
|
27
|
+
list(project: string): readonly ReviewThread[] {
|
|
28
|
+
const path = join(this.projectDirectory(project), 'threads.json')
|
|
29
|
+
if (!existsSync(path)) return []
|
|
30
|
+
return reviewThreadsFileSchema.parse(JSON.parse(readFileSync(path, 'utf-8'))).threads
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
create(project: string, anchor: ReviewAnchor, body: string, send: boolean, screenshot?: Uint8Array): ReviewThread {
|
|
34
|
+
const createdAt = this.timestamp()
|
|
35
|
+
const id = Bun.randomUUIDv7()
|
|
36
|
+
const thread: ReviewThread = {
|
|
37
|
+
id,
|
|
38
|
+
anchor,
|
|
39
|
+
state: send ? 'queued' : 'open',
|
|
40
|
+
comments: [{ id: Bun.randomUUIDv7(), author: 'user', body, createdAt }],
|
|
41
|
+
...(screenshot === undefined ? {} : { screenshot: this.writeScreenshot(project, id, screenshot) }),
|
|
42
|
+
createdAt,
|
|
43
|
+
updatedAt: createdAt,
|
|
44
|
+
}
|
|
45
|
+
this.save(project, [...this.list(project), thread])
|
|
46
|
+
return thread
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A comment from the person reopens a resolved thread; with `send` it goes to the agent. */
|
|
50
|
+
addUserComment(project: string, threadId: string, body: string, send: boolean): ReviewThread {
|
|
51
|
+
return this.update(project, threadId, (thread) => {
|
|
52
|
+
const state: ReviewThreadState = send ? 'queued' : thread.state === 'resolved' ? 'open' : thread.state
|
|
53
|
+
return { ...this.withComment(thread, 'user', body), state }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
send(project: string, threadId: string): ReviewThread {
|
|
58
|
+
return this.update(project, threadId, (thread) => {
|
|
59
|
+
if (thread.state === 'working') throw new ReviewError('The agent is already working on this thread', 409)
|
|
60
|
+
return { ...thread, state: 'queued', updatedAt: this.timestamp() }
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setResolved(project: string, threadId: string, resolved: boolean): ReviewThread {
|
|
65
|
+
return this.update(project, threadId, (thread) => ({ ...thread, state: resolved ? 'resolved' : 'open', updatedAt: this.timestamp() }))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
delete(project: string, threadId: string): void {
|
|
69
|
+
const threads = this.list(project)
|
|
70
|
+
const thread = threads.find((candidate) => candidate.id === threadId)
|
|
71
|
+
if (thread === undefined) throw new ReviewError(`No thread ${threadId}`, 404)
|
|
72
|
+
this.save(project, threads.filter((candidate) => candidate.id !== threadId))
|
|
73
|
+
if (thread.screenshot !== undefined) rmSync(thread.screenshot, { force: true })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Hands every queued thread to the agent: they become `working` and are returned in creation order. */
|
|
77
|
+
claimQueued(project: string): readonly ReviewThread[] {
|
|
78
|
+
const threads = this.list(project)
|
|
79
|
+
const claimed = threads.filter((thread) => thread.state === 'queued')
|
|
80
|
+
if (claimed.length === 0) return []
|
|
81
|
+
const updatedAt = this.timestamp()
|
|
82
|
+
const claimedIds = new Set(claimed.map((thread) => thread.id))
|
|
83
|
+
this.save(project, threads.map((thread) => (claimedIds.has(thread.id) ? { ...thread, state: 'working', updatedAt } : thread)))
|
|
84
|
+
return claimed.map((thread) => ({ ...thread, state: 'working', updatedAt }))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The agent's answer. Without `resolve` the thread goes back to the person as `open`. */
|
|
88
|
+
agentReply(project: string, threadId: string, body: string, resolve: boolean): ReviewThread {
|
|
89
|
+
return this.update(project, threadId, (thread) => ({ ...this.withComment(thread, 'agent', body), state: resolve ? 'resolved' : 'open' }))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
proxyOf(project: string): ReviewProxy | undefined {
|
|
93
|
+
const path = join(this.projectDirectory(project), 'proxy.json')
|
|
94
|
+
if (!existsSync(path)) return undefined
|
|
95
|
+
return reviewProxySchema.parse(JSON.parse(readFileSync(path, 'utf-8')))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
saveProxy(project: string, proxy: ReviewProxy): void {
|
|
99
|
+
this.writeJson(project, 'proxy.json', proxy)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Projects that have a review proxy, for a starting daemon to serve again. */
|
|
103
|
+
proxiedProjects(): string[] {
|
|
104
|
+
if (!existsSync(this.directory)) return []
|
|
105
|
+
return readdirSync(this.directory).filter((name) => PROJECT_RE.test(name) && existsSync(join(this.directory, name, 'proxy.json')))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private update(project: string, threadId: string, change: (thread: ReviewThread) => ReviewThread): ReviewThread {
|
|
109
|
+
const threads = this.list(project)
|
|
110
|
+
const current = threads.find((thread) => thread.id === threadId)
|
|
111
|
+
if (current === undefined) throw new ReviewError(`No thread ${threadId}`, 404)
|
|
112
|
+
const next = change(current)
|
|
113
|
+
this.save(project, threads.map((thread) => (thread.id === threadId ? next : thread)))
|
|
114
|
+
return next
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private withComment(thread: ReviewThread, author: ReviewAuthor, body: string): ReviewThread {
|
|
118
|
+
const createdAt = this.timestamp()
|
|
119
|
+
return { ...thread, comments: [...thread.comments, { id: Bun.randomUUIDv7(), author, body, createdAt }], updatedAt: createdAt }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private writeScreenshot(project: string, threadId: string, png: Uint8Array): string {
|
|
123
|
+
const directory = join(this.projectDirectory(project), 'screenshots')
|
|
124
|
+
mkdirSync(directory, { recursive: true })
|
|
125
|
+
const path = join(directory, `${threadId}.png`)
|
|
126
|
+
writeFileSync(path, png)
|
|
127
|
+
return path
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private save(project: string, threads: readonly ReviewThread[]): void {
|
|
131
|
+
this.writeJson(project, 'threads.json', { threads })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private writeJson(project: string, file: string, value: unknown): void {
|
|
135
|
+
const directory = this.projectDirectory(project)
|
|
136
|
+
mkdirSync(directory, { recursive: true })
|
|
137
|
+
const path = join(directory, file)
|
|
138
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
139
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, '\t')}\n`)
|
|
140
|
+
renameSync(temporary, path)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private projectDirectory(project: string): string {
|
|
144
|
+
if (!PROJECT_RE.test(project)) throw new ReviewError(`Invalid project name ${project}`, 400)
|
|
145
|
+
return join(this.directory, project)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private timestamp(): string {
|
|
149
|
+
return this.now().toISOString()
|
|
150
|
+
}
|
|
151
|
+
}
|