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,6 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { RemoteAnnotation, RemoteFeedbackEntry, SessionManager } from '@fabrika/canvas-kernel/server'
|
|
2
|
+
import { errorMessage } from '../client/errorMessage'
|
|
3
|
+
import { decryptString, importShareKey } from '../client/shareCrypto'
|
|
4
|
+
import { loadShareConfig } from './share'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Background polling loop for remote feedback.
|
|
@@ -17,212 +18,211 @@ import { importShareKey, decryptString } from "../client/shareCrypto";
|
|
|
17
18
|
* as soon as the worker comes back.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
|
-
const POLL_INTERVAL_MS = 5_000
|
|
21
|
-
const MAX_BACKOFF_MS = 60_000
|
|
21
|
+
const POLL_INTERVAL_MS = 5_000
|
|
22
|
+
const MAX_BACKOFF_MS = 60_000
|
|
22
23
|
|
|
23
24
|
/** Wire-format feedback entry as returned by the worker. When the
|
|
24
25
|
* reviewer submitted from an encrypted share their browser ciphertexted
|
|
25
26
|
* the secret fields and stamped this with `encryption`; the daemon
|
|
26
27
|
* decrypts using the key stashed on the local ShareEntry. */
|
|
27
28
|
interface WireFeedbackEntry {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
id: string
|
|
30
|
+
shareId: string
|
|
31
|
+
revision: number
|
|
32
|
+
submittedAt: string
|
|
33
|
+
author: { id: string; name: string }
|
|
34
|
+
annotations: Array<{
|
|
35
|
+
id: string
|
|
36
|
+
snippet: string
|
|
37
|
+
note: string
|
|
38
|
+
createdAt: string
|
|
39
|
+
filePath?: string
|
|
40
|
+
canvasFile?: string
|
|
41
|
+
context?: unknown
|
|
42
|
+
attachments?: Array<{ url: string; mime?: string }>
|
|
43
|
+
}>
|
|
44
|
+
generalNote?: string
|
|
45
|
+
encryption?: { alg: string; v: number }
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
interface WorkerFeedbackResponse {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
entries: WireFeedbackEntry[]
|
|
50
|
+
/** Server-reported high water mark — echo back in the next `since` param */
|
|
51
|
+
latestAt?: string
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/** Decrypt an encrypted-wire feedback entry into the plaintext shape used
|
|
54
55
|
* throughout the rest of the daemon. Throws if any field fails to
|
|
55
56
|
* decrypt — caller treats that as a transient error so we re-try later. */
|
|
56
57
|
async function decryptFeedbackEntry(entry: WireFeedbackEntry, key: CryptoKey): Promise<RemoteFeedbackEntry> {
|
|
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
|
-
|
|
58
|
+
const annotations: RemoteAnnotation[] = await Promise.all(
|
|
59
|
+
entry.annotations.map(async (a) => {
|
|
60
|
+
const out: RemoteAnnotation = {
|
|
61
|
+
id: a.id,
|
|
62
|
+
snippet: await decryptString(key, a.snippet),
|
|
63
|
+
note: await decryptString(key, a.note),
|
|
64
|
+
createdAt: a.createdAt,
|
|
65
|
+
}
|
|
66
|
+
if (a.filePath) out.filePath = await decryptString(key, a.filePath)
|
|
67
|
+
if (a.canvasFile) out.canvasFile = await decryptString(key, a.canvasFile)
|
|
68
|
+
if (typeof a.context === 'string' && a.context.length > 0) {
|
|
69
|
+
out.context = JSON.parse(await decryptString(key, a.context))
|
|
70
|
+
}
|
|
71
|
+
if (a.attachments) out.attachments = a.attachments
|
|
72
|
+
return out
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id: entry.id,
|
|
78
|
+
shareId: entry.shareId,
|
|
79
|
+
revision: entry.revision,
|
|
80
|
+
submittedAt: entry.submittedAt,
|
|
81
|
+
author: {
|
|
82
|
+
id: entry.author.id,
|
|
83
|
+
name: await decryptString(key, entry.author.name),
|
|
84
|
+
},
|
|
85
|
+
annotations,
|
|
86
|
+
...(entry.generalNote ? { generalNote: await decryptString(key, entry.generalNote) } : {}),
|
|
87
|
+
}
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export function startRemoteFeedbackPoller(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
sessionManager: SessionManager,
|
|
92
|
+
broadcastRemoteFeedback: (sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) => void,
|
|
93
|
+
version: string,
|
|
93
94
|
): { stop: () => void } {
|
|
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
|
-
};
|
|
95
|
+
let stopped = false
|
|
96
|
+
|
|
97
|
+
// Per-share CryptoKey cache — `importKey` is async and pure given the
|
|
98
|
+
// same encoded key, so we memoize it for the lifetime of the poller.
|
|
99
|
+
const keyCache = new Map<string, Promise<CryptoKey>>()
|
|
100
|
+
function getKey(shareId: string, encoded: string): Promise<CryptoKey> {
|
|
101
|
+
let cached = keyCache.get(shareId)
|
|
102
|
+
if (!cached) {
|
|
103
|
+
cached = importShareKey(encoded)
|
|
104
|
+
keyCache.set(shareId, cached)
|
|
105
|
+
}
|
|
106
|
+
return cached
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Per-share consecutive error count → exponential backoff so a dead
|
|
110
|
+
// worker doesn't generate a fire-hose of failed requests.
|
|
111
|
+
const errorCounts = new Map<string, number>()
|
|
112
|
+
function backoffFor(shareId: string): number {
|
|
113
|
+
const errors = errorCounts.get(shareId) || 0
|
|
114
|
+
if (errors === 0) return 0
|
|
115
|
+
return Math.min(MAX_BACKOFF_MS, POLL_INTERVAL_MS * Math.pow(2, errors - 1))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function pollShare(
|
|
119
|
+
sessionId: string,
|
|
120
|
+
share: { shareId: string; lastFeedbackAt?: string; encryptionKey?: string },
|
|
121
|
+
endpoint: string,
|
|
122
|
+
) {
|
|
123
|
+
try {
|
|
124
|
+
const since = share.lastFeedbackAt ?? ''
|
|
125
|
+
const url = `${endpoint}/shares/${share.shareId}/feedback${since ? `?since=${encodeURIComponent(since)}` : ''}`
|
|
126
|
+
const res = await fetch(url)
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
if (res.status === 404) {
|
|
129
|
+
// Share no longer exists on the worker — likely revoked or expired.
|
|
130
|
+
// Stop trying so we don't hammer it forever.
|
|
131
|
+
errorCounts.set(share.shareId, 99)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`HTTP ${res.status}`)
|
|
135
|
+
}
|
|
136
|
+
const data = (await res.json()) as WorkerFeedbackResponse
|
|
137
|
+
errorCounts.delete(share.shareId)
|
|
138
|
+
|
|
139
|
+
if (!data.entries || data.entries.length === 0) return
|
|
140
|
+
|
|
141
|
+
// Decrypt any entries that came in encrypted. Plaintext entries
|
|
142
|
+
// (legacy submissions from non-encrypted shares) pass through.
|
|
143
|
+
const decrypted: RemoteFeedbackEntry[] = []
|
|
144
|
+
for (const e of data.entries) {
|
|
145
|
+
if (e.encryption) {
|
|
146
|
+
if (!share.encryptionKey) {
|
|
147
|
+
console.warn(`[remote-feedback] ${share.shareId}: received encrypted feedback but no local key — skipping entry ${e.id}`)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const key = await getKey(share.shareId, share.encryptionKey)
|
|
152
|
+
decrypted.push(await decryptFeedbackEntry(e, key))
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.warn(`[remote-feedback] ${share.shareId}: failed to decrypt entry ${e.id}: ${errorMessage(err)}`)
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
decrypted.push(e as RemoteFeedbackEntry)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (decrypted.length === 0) return
|
|
162
|
+
|
|
163
|
+
const byRev = new Map<number, RemoteFeedbackEntry[]>()
|
|
164
|
+
for (const e of decrypted) {
|
|
165
|
+
if (!byRev.has(e.revision)) byRev.set(e.revision, [])
|
|
166
|
+
byRev.get(e.revision)!.push(e)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [rev, entries] of byRev) {
|
|
170
|
+
sessionManager.appendRemoteFeedback(sessionId, rev, entries)
|
|
171
|
+
broadcastRemoteFeedback(sessionId, rev, entries)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const latest = data.latestAt
|
|
175
|
+
?? data.entries.reduce((acc, e) => (e.submittedAt > acc ? e.submittedAt : acc), share.lastFeedbackAt ?? '')
|
|
176
|
+
if (latest) {
|
|
177
|
+
sessionManager.updateShareLastFeedback(sessionId, share.shareId, latest)
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
errorCounts.set(share.shareId, (errorCounts.get(share.shareId) || 0) + 1)
|
|
181
|
+
console.warn(`[remote-feedback] ${share.shareId}: ${errorMessage(e)} (will retry with backoff)`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Per-share next-poll timestamps so backoffs are independent.
|
|
186
|
+
const nextPollAt = new Map<string, number>()
|
|
187
|
+
|
|
188
|
+
async function pollOnce() {
|
|
189
|
+
const config = loadShareConfig(version)
|
|
190
|
+
if (!config) return
|
|
191
|
+
|
|
192
|
+
const sessions = sessionManager.listSessionsWithShares()
|
|
193
|
+
if (sessions.length === 0) return
|
|
194
|
+
|
|
195
|
+
const now = Date.now()
|
|
196
|
+
const tasks: Promise<void>[] = []
|
|
197
|
+
for (const session of sessions) {
|
|
198
|
+
for (const share of session.shares ?? []) {
|
|
199
|
+
const next = nextPollAt.get(share.shareId) || 0
|
|
200
|
+
if (now < next) continue
|
|
201
|
+
// Skip "permanently dead" shares (after a 404).
|
|
202
|
+
if ((errorCounts.get(share.shareId) || 0) >= 99) continue
|
|
203
|
+
nextPollAt.set(share.shareId, now + Math.max(POLL_INTERVAL_MS, backoffFor(share.shareId)))
|
|
204
|
+
tasks.push(pollShare(session.id, share, config.endpoint))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
await Promise.all(tasks)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function loop() {
|
|
211
|
+
while (!stopped) {
|
|
212
|
+
try {
|
|
213
|
+
await pollOnce()
|
|
214
|
+
} catch (e) {
|
|
215
|
+
console.warn(`[remote-feedback] loop error: ${errorMessage(e)}`)
|
|
216
|
+
}
|
|
217
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
loop()
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
stop: () => {
|
|
225
|
+
stopped = true
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
228
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReviewAnchor, ReviewThread } from './model'
|
|
2
|
+
|
|
3
|
+
function fence(language: string, text: string): string {
|
|
4
|
+
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
|
|
5
|
+
const marker = '`'.repeat(longest + 1)
|
|
6
|
+
return `${marker}${language}\n${text}\n${marker}`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function describeAnchor(anchor: ReviewAnchor, screenshot: string | undefined): string[] {
|
|
10
|
+
const lines = [`- Page: ${anchor.url}`]
|
|
11
|
+
if (anchor.context !== undefined) lines.push(`- Where: ${anchor.context}`)
|
|
12
|
+
const accessible = [anchor.role, anchor.name === undefined ? undefined : `"${anchor.name}"`].filter((part) => part !== undefined).join(' ')
|
|
13
|
+
if (accessible !== '') lines.push(`- Element: ${accessible}`)
|
|
14
|
+
if (anchor.selector !== '') lines.push(`- Selector: \`${anchor.selector}\``)
|
|
15
|
+
if (anchor.components !== undefined && anchor.components.length > 0) lines.push(`- Components: ${anchor.components.join(' › ')}`)
|
|
16
|
+
if (anchor.sources !== undefined && anchor.sources.length > 0) {
|
|
17
|
+
lines.push('- Source, innermost first:')
|
|
18
|
+
for (const source of anchor.sources) lines.push(` - ${source.label}: \`${source.file}:${source.line}:${source.column}\``)
|
|
19
|
+
}
|
|
20
|
+
if (anchor.quote !== undefined) {
|
|
21
|
+
lines.push(`- Selected text: …${anchor.quote.before}**${anchor.quote.exact}**${anchor.quote.after}…`)
|
|
22
|
+
} else if (anchor.text !== undefined && anchor.text !== '') {
|
|
23
|
+
lines.push(`- Text: "${anchor.text}"`)
|
|
24
|
+
}
|
|
25
|
+
if (screenshot !== undefined) lines.push(`- Screenshot, the element outlined in red: \`${screenshot}\` (read the file to see it)`)
|
|
26
|
+
if (anchor.html !== undefined) lines.push('- Markup, trimmed:', '', fence('html', anchor.html))
|
|
27
|
+
return lines
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function quoteBody(body: string): string {
|
|
31
|
+
return body.split('\n').map((line) => `> ${line}`).join('\n')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The requests a waiting agent receives: each thread with its place in the page, its conversation and how to answer. */
|
|
35
|
+
export function renderReviewRequests(project: string, threads: readonly ReviewThread[]): string {
|
|
36
|
+
const sections = threads.map((thread, index) => {
|
|
37
|
+
const conversation = thread.comments.map((comment) => `**${comment.author === 'user' ? 'Reviewer' : 'You'}:**\n${quoteBody(comment.body)}`)
|
|
38
|
+
return [`## ${index + 1}. Thread ${thread.id}`, '', ...describeAnchor(thread.anchor, thread.screenshot), '', ...conversation].join('\n')
|
|
39
|
+
})
|
|
40
|
+
return [
|
|
41
|
+
`# Review requests for ${project}`,
|
|
42
|
+
'',
|
|
43
|
+
`The reviewer sent ${threads.length === 1 ? 'one thread' : `${threads.length} threads`} from the running application.`,
|
|
44
|
+
'Make the change each thread asks for, then answer it so the reviewer sees what happened:',
|
|
45
|
+
'',
|
|
46
|
+
` bunx agent-canvas review reply <thread-id> "<what you changed>" --resolve --project ${project}`,
|
|
47
|
+
'',
|
|
48
|
+
'Leave out `--resolve` when you ask a question back instead.',
|
|
49
|
+
'',
|
|
50
|
+
...sections.flatMap((section) => [section, '']),
|
|
51
|
+
].join('\n')
|
|
52
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Review threads: comments a person leaves on a running application. A thread is a note until the person sends it
|
|
5
|
+
* to the agent; the agent picks it up, answers and may resolve it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PROJECT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loopback origins only: a page on any other origin must not be able to put a request in front of the agent.
|
|
12
|
+
* The application under review runs on its own dev server, so its port is not the daemon's.
|
|
13
|
+
*/
|
|
14
|
+
export function isLoopbackOrigin(origin: string): boolean {
|
|
15
|
+
let url: URL
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(origin)
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
|
22
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname.endsWith('.localhost')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const shortText = z.string().max(500)
|
|
26
|
+
|
|
27
|
+
export const MAX_HTML = 4000
|
|
28
|
+
|
|
29
|
+
/** A position in the application's source, resolved from a development stack through the page's source maps. */
|
|
30
|
+
export const reviewSourceSchema = z.object({
|
|
31
|
+
/** What was created there, e.g. `<a> in ProductListCell` or `<ProductListCell> in DataGrid`. */
|
|
32
|
+
label: shortText,
|
|
33
|
+
file: z.string().max(1000),
|
|
34
|
+
line: z.number().int().positive(),
|
|
35
|
+
column: z.number().int().nonnegative(),
|
|
36
|
+
})
|
|
37
|
+
export type ReviewSource = z.infer<typeof reviewSourceSchema>
|
|
38
|
+
|
|
39
|
+
/** Where in the page a thread points. Every field is best effort; the agent reads them, the widget re-finds the element. */
|
|
40
|
+
export const reviewAnchorSchema = z.object({
|
|
41
|
+
/** The page's URL when the thread was created, hash routes included. */
|
|
42
|
+
url: z.string().min(1).max(2000),
|
|
43
|
+
/** CSS path from the nearest element with an id, or from `body`. */
|
|
44
|
+
selector: z.string().max(2000),
|
|
45
|
+
/** ARIA role and accessible name, as the agent would target the element in a browser. */
|
|
46
|
+
role: shortText.optional(),
|
|
47
|
+
name: shortText.optional(),
|
|
48
|
+
/** The element's visible text, shortened. */
|
|
49
|
+
text: shortText.optional(),
|
|
50
|
+
/** Owner components from the outermost to the element, read off React's development fibers. */
|
|
51
|
+
components: z.array(z.string().max(200)).max(20).optional(),
|
|
52
|
+
/** Source positions of the element and its nearest owner components, innermost first. */
|
|
53
|
+
sources: z.array(reviewSourceSchema).max(10).optional(),
|
|
54
|
+
/** The landmarks around the element: page heading, section, dialog. */
|
|
55
|
+
context: shortText.optional(),
|
|
56
|
+
/** The element's markup, trimmed: deep subtrees, long lists and long texts are cut. */
|
|
57
|
+
html: z.string().max(MAX_HTML).optional(),
|
|
58
|
+
/** A text selection inside the element, with its surroundings. */
|
|
59
|
+
quote: z.object({ exact: z.string().min(1).max(2000), before: shortText, after: shortText }).optional(),
|
|
60
|
+
})
|
|
61
|
+
export type ReviewAnchor = z.infer<typeof reviewAnchorSchema>
|
|
62
|
+
|
|
63
|
+
export const reviewAuthorSchema = z.enum(['user', 'agent'])
|
|
64
|
+
export type ReviewAuthor = z.infer<typeof reviewAuthorSchema>
|
|
65
|
+
|
|
66
|
+
export const reviewCommentSchema = z.object({
|
|
67
|
+
id: z.string(),
|
|
68
|
+
author: reviewAuthorSchema,
|
|
69
|
+
body: z.string().min(1).max(20_000),
|
|
70
|
+
createdAt: z.string(),
|
|
71
|
+
})
|
|
72
|
+
export type ReviewComment = z.infer<typeof reviewCommentSchema>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* - `open`: a note, or an answered thread waiting on the person.
|
|
76
|
+
* - `queued`: sent to the agent, not picked up yet.
|
|
77
|
+
* - `working`: delivered to the agent.
|
|
78
|
+
* - `resolved`: the agent or the person closed it.
|
|
79
|
+
*/
|
|
80
|
+
export const reviewThreadStateSchema = z.enum(['open', 'queued', 'working', 'resolved'])
|
|
81
|
+
export type ReviewThreadState = z.infer<typeof reviewThreadStateSchema>
|
|
82
|
+
|
|
83
|
+
export const reviewThreadSchema = z.object({
|
|
84
|
+
id: z.string(),
|
|
85
|
+
anchor: reviewAnchorSchema,
|
|
86
|
+
state: reviewThreadStateSchema,
|
|
87
|
+
comments: z.array(reviewCommentSchema).min(1),
|
|
88
|
+
/** Absolute path of a PNG of the element the reviewer attached, stored by the daemon. */
|
|
89
|
+
screenshot: z.string().optional(),
|
|
90
|
+
createdAt: z.string(),
|
|
91
|
+
updatedAt: z.string(),
|
|
92
|
+
})
|
|
93
|
+
export type ReviewThread = z.infer<typeof reviewThreadSchema>
|
|
94
|
+
|
|
95
|
+
export const reviewThreadsFileSchema = z.object({ threads: z.array(reviewThreadSchema) })
|
|
96
|
+
|
|
97
|
+
const commentBody = z.string().trim().min(1).max(20_000)
|
|
98
|
+
|
|
99
|
+
const PNG_DATA_URL = 'data:image/png;base64,'
|
|
100
|
+
|
|
101
|
+
/** A PNG the widget rendered from the element, as a data URL; at most about 6 MB decoded. */
|
|
102
|
+
export const screenshotDataUrlSchema = z.string().startsWith(PNG_DATA_URL).max(8_000_000)
|
|
103
|
+
.transform((url) => Uint8Array.from(atob(url.slice(PNG_DATA_URL.length)), (char) => char.charCodeAt(0)))
|
|
104
|
+
|
|
105
|
+
export const createThreadBodySchema = z.object({
|
|
106
|
+
anchor: reviewAnchorSchema,
|
|
107
|
+
body: commentBody,
|
|
108
|
+
send: z.boolean(),
|
|
109
|
+
screenshot: screenshotDataUrlSchema.optional(),
|
|
110
|
+
})
|
|
111
|
+
export const addCommentBodySchema = z.object({ body: commentBody, send: z.boolean() })
|
|
112
|
+
export const agentReplyBodySchema = z.object({ body: commentBody, resolve: z.boolean() })
|
|
113
|
+
|
|
114
|
+
/** An application on this machine: the review proxy forwards to its origin and refuses any other host. */
|
|
115
|
+
const loopbackUrlSchema = z.string().url().refine((url) => isLoopbackOrigin(url), 'must be an http(s) URL on localhost')
|
|
116
|
+
|
|
117
|
+
/** The CLI's request to open an application for review through the daemon's proxy. */
|
|
118
|
+
export const openProxyBodySchema = z.object({ target: loopbackUrlSchema })
|
|
119
|
+
|
|
120
|
+
/** A project's review proxy, kept so a restarted daemon serves it on the same port: the reviewer's tab keeps working. */
|
|
121
|
+
export const reviewProxySchema = z.object({ target: loopbackUrlSchema, port: z.number().int().min(1).max(65_535) })
|
|
122
|
+
export type ReviewProxy = z.infer<typeof reviewProxySchema>
|
|
123
|
+
|
|
124
|
+
/** Frames the daemon sends to browsers on a project's topic. */
|
|
125
|
+
export type ReviewBrowserFrame =
|
|
126
|
+
| { type: 'threads'; threads: readonly ReviewThread[] }
|
|
127
|
+
| { type: 'agent-status'; watching: boolean }
|
|
128
|
+
|
|
129
|
+
/** The frame a waiting CLI receives when the person sends threads; it reads them back through the claim endpoint. */
|
|
130
|
+
export interface ReviewWaitFrame {
|
|
131
|
+
type: 'queued'
|
|
132
|
+
}
|