opencode-codeops 2.2.0 → 2.3.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/CHANGELOG.md +45 -0
- package/README.md +53 -0
- package/_shared/discovery.md +92 -0
- package/_shared/zero-ambiguity-gate.md +4 -0
- package/bin/lib/discovery-rules.d.mts +228 -0
- package/bin/lib/discovery-rules.mjs +1213 -0
- package/bin/lib/discovery-server.d.mts +62 -0
- package/bin/lib/discovery-server.mjs +755 -0
- package/bin/lib/discovery-store.d.mts +224 -0
- package/bin/lib/discovery-store.mjs +1189 -0
- package/bin/lib/discovery-tools.d.mts +66 -0
- package/bin/lib/discovery-tools.mjs +540 -0
- package/discovery-ui/dist/assets/app.js +107 -0
- package/discovery-ui/dist/index.html +17 -0
- package/package.json +16 -3
- package/plugin/index.ts +45 -4
- package/plugin/tui.js +113 -0
- package/schemas/codeops-config.schema.json +8 -0
- package/scripts/__pycache__/codeops_plan.cpython-314.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-314.pyc +0 -0
- package/scripts/build-discovery-ui.mjs +59 -0
- package/scripts/build-tui.mjs +73 -0
- package/skills/analyze-project/SKILL.md +19 -0
- package/skills/make-plan/SKILL.md +7 -1
- package/skills/preflight/SKILL.md +4 -0
- package/skills/preflight/report-format.md +4 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery listener and HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* One `node:http` listener per OpenCode process is shared by every opted-in
|
|
5
|
+
* project. It serves the browser app, the JSON API, live events, and the
|
|
6
|
+
* delivery hook. The runtime lives in a `globalThis` slot so every plugin
|
|
7
|
+
* instance in the process reaches the same listener.
|
|
8
|
+
*
|
|
9
|
+
* Security is enforced before routing: only a loopback `Host` is accepted,
|
|
10
|
+
* writes must be JSON and same-origin, route slugs are validated before any
|
|
11
|
+
* store call, bodies are capped, and every response carries `nosniff`,
|
|
12
|
+
* `no-referrer`, and a nonce-based CSP. Error bodies never contain paths,
|
|
13
|
+
* stack traces, or stored content.
|
|
14
|
+
*
|
|
15
|
+
* @module discovery-server
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes, randomUUID } from "node:crypto"
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"
|
|
20
|
+
import { createServer } from "node:http"
|
|
21
|
+
import { extname, join, resolve, sep } from "node:path"
|
|
22
|
+
|
|
23
|
+
import { KEY_PATTERN, SLUG_PATTERN } from "./discovery-rules.mjs"
|
|
24
|
+
import { DiscoveryDataError } from "./discovery-store.mjs"
|
|
25
|
+
|
|
26
|
+
/** The process-wide runtime slot. */
|
|
27
|
+
const RUNTIME_KEY = Symbol.for("opencode-codeops.discovery")
|
|
28
|
+
|
|
29
|
+
/** Request body cap in bytes. */
|
|
30
|
+
const BODY_LIMIT = 256 * 1024
|
|
31
|
+
|
|
32
|
+
/** The default port when the environment names none. */
|
|
33
|
+
const DEFAULT_PORT = 4747
|
|
34
|
+
|
|
35
|
+
/** The service name reported by the health endpoint. */
|
|
36
|
+
const SERVICE_NAME = "codeops-discovery"
|
|
37
|
+
|
|
38
|
+
/** MIME types served from the asset directory. */
|
|
39
|
+
const MIME_TYPES = {
|
|
40
|
+
".js": "text/javascript; charset=utf-8",
|
|
41
|
+
".css": "text/css; charset=utf-8",
|
|
42
|
+
".svg": "image/svg+xml",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The static page shown when the UI bundle has not been built yet. */
|
|
46
|
+
const NOT_BUILT_HTML = `<!doctype html>
|
|
47
|
+
<html><head><meta charset="utf-8"><title>Discovery</title></head>
|
|
48
|
+
<body><h1>Discovery UI is not built</h1>
|
|
49
|
+
<p>Run <code>npm run build:discovery</code> in the CodeOps checkout.</p></body></html>`
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the listener port from the environment.
|
|
53
|
+
*
|
|
54
|
+
* `CODEOPS_DISCOVERY_PORT` must be a plain integer in 1–65535; anything else
|
|
55
|
+
* falls back to 4747.
|
|
56
|
+
*
|
|
57
|
+
* @param {Record<string, string | undefined>} [env] - Environment variables.
|
|
58
|
+
* @returns {number} The configured port, or 4747.
|
|
59
|
+
*/
|
|
60
|
+
export function resolvePort(env = process.env) {
|
|
61
|
+
const raw = String(env?.CODEOPS_DISCOVERY_PORT ?? "").trim()
|
|
62
|
+
if (!/^\d+$/.test(raw)) return DEFAULT_PORT
|
|
63
|
+
const port = Number(raw)
|
|
64
|
+
return port >= 1 && port <= 65535 ? port : DEFAULT_PORT
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Canonicalize a checkout directory so registration and lookup agree. */
|
|
68
|
+
function canonicalDirectory(directory) {
|
|
69
|
+
const resolved = resolve(String(directory))
|
|
70
|
+
try {
|
|
71
|
+
return realpathSync(resolved)
|
|
72
|
+
} catch {
|
|
73
|
+
return resolved
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A fresh base64 CSP nonce. */
|
|
78
|
+
function newNonce() {
|
|
79
|
+
return randomBytes(16).toString("base64")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The CSP header for one nonce. */
|
|
83
|
+
function csp(nonce) {
|
|
84
|
+
return (
|
|
85
|
+
"default-src 'none'; script-src 'self'; " +
|
|
86
|
+
`style-src 'self' 'nonce-${nonce}'; ` +
|
|
87
|
+
"connect-src 'self'; img-src 'self' data:; base-uri 'none'; " +
|
|
88
|
+
"form-action 'none'; frame-ancestors 'none'"
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Base security headers shared by every response. */
|
|
93
|
+
function securityHeaders(nonce) {
|
|
94
|
+
return {
|
|
95
|
+
"X-Content-Type-Options": "nosniff",
|
|
96
|
+
"Referrer-Policy": "no-referrer",
|
|
97
|
+
"Content-Security-Policy": csp(nonce),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build the runtime object for one process. */
|
|
102
|
+
function buildRuntime(options) {
|
|
103
|
+
const store = options.store
|
|
104
|
+
const assetsDir = options.assetsDir
|
|
105
|
+
const version = String(options.version ?? "0.0.0")
|
|
106
|
+
const log = typeof options.log === "function" ? options.log : () => {}
|
|
107
|
+
const requestedPort = options.port === undefined ? resolvePort(process.env) : options.port
|
|
108
|
+
|
|
109
|
+
/** Registered projects, keyed by checkout directory. */
|
|
110
|
+
const projects = new Map()
|
|
111
|
+
/** Open event streams, keyed by "<project>/<plan>". */
|
|
112
|
+
const viewers = new Map()
|
|
113
|
+
/** Live sockets, used for a prompt shutdown. */
|
|
114
|
+
const sockets = new Set()
|
|
115
|
+
|
|
116
|
+
let server = null
|
|
117
|
+
let listening = null
|
|
118
|
+
let boundPort = 0
|
|
119
|
+
let fallback = false
|
|
120
|
+
let instanceId = null
|
|
121
|
+
let indexTemplate = null
|
|
122
|
+
let assets = new Map()
|
|
123
|
+
|
|
124
|
+
/** Send one JSON response with the shared security headers. */
|
|
125
|
+
function sendJson(response, status, payload) {
|
|
126
|
+
if (response.writableEnded) return
|
|
127
|
+
const nonce = newNonce()
|
|
128
|
+
response.writeHead(status, {
|
|
129
|
+
...securityHeaders(nonce),
|
|
130
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
131
|
+
"Cache-Control": "no-store",
|
|
132
|
+
})
|
|
133
|
+
response.end(JSON.stringify(payload))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Send one error envelope. */
|
|
137
|
+
function sendError(response, status, code, message = "") {
|
|
138
|
+
sendJson(response, status, { error: code, message })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Send one HTML response whose CSP nonce matches its body. */
|
|
142
|
+
function sendHtml(response, status, template) {
|
|
143
|
+
if (response.writableEnded) return
|
|
144
|
+
const nonce = newNonce()
|
|
145
|
+
response.writeHead(status, {
|
|
146
|
+
...securityHeaders(nonce),
|
|
147
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
148
|
+
})
|
|
149
|
+
response.end(template.split("__CSP_NONCE__").join(nonce))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Read the index template and the asset allow-list from disk. */
|
|
153
|
+
function refreshAssets() {
|
|
154
|
+
if (!existsSync(assetsDir)) return false
|
|
155
|
+
const indexPath = join(assetsDir, "index.html")
|
|
156
|
+
if (!existsSync(indexPath)) return false
|
|
157
|
+
try {
|
|
158
|
+
indexTemplate = readFileSync(indexPath, "utf8")
|
|
159
|
+
assets = new Map()
|
|
160
|
+
const directory = join(assetsDir, "assets")
|
|
161
|
+
if (existsSync(directory)) {
|
|
162
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
163
|
+
if (!entry.isFile()) continue
|
|
164
|
+
const file = join(directory, entry.name)
|
|
165
|
+
if (!file.startsWith(directory + sep)) continue
|
|
166
|
+
assets.set(entry.name, file)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return true
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Decode and validate a project or plan slug. */
|
|
176
|
+
function slugParameter(value) {
|
|
177
|
+
try {
|
|
178
|
+
const decoded = decodeURIComponent(value)
|
|
179
|
+
return SLUG_PATTERN.test(decoded) ? decoded : null
|
|
180
|
+
} catch {
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Decode and validate a question key. */
|
|
186
|
+
function keyParameter(value) {
|
|
187
|
+
try {
|
|
188
|
+
const decoded = decodeURIComponent(value)
|
|
189
|
+
return KEY_PATTERN.test(decoded) ? decoded : null
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Read a JSON body within the cap, or send the matching error. */
|
|
196
|
+
async function readBody(request, response) {
|
|
197
|
+
const chunks = []
|
|
198
|
+
let size = 0
|
|
199
|
+
let tooLarge = false
|
|
200
|
+
for await (const chunk of request) {
|
|
201
|
+
size += chunk.length
|
|
202
|
+
if (size > BODY_LIMIT) {
|
|
203
|
+
tooLarge = true
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
chunks.push(chunk)
|
|
207
|
+
}
|
|
208
|
+
if (tooLarge) {
|
|
209
|
+
sendError(response, 413, "too-large")
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
|
214
|
+
} catch {
|
|
215
|
+
sendError(response, 400, "bad-request")
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** True when the Host header names this listener. */
|
|
221
|
+
function hostAllowed(host) {
|
|
222
|
+
return host === `127.0.0.1:${boundPort}` || host === `localhost:${boundPort}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** True when the Origin header, when present, is this listener. */
|
|
226
|
+
function originAllowed(origin) {
|
|
227
|
+
if (origin === undefined) return true
|
|
228
|
+
return origin === `http://127.0.0.1:${boundPort}` || origin === `http://localhost:${boundPort}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Serve the single-page app or the not-built page. */
|
|
232
|
+
function serveIndex(response) {
|
|
233
|
+
if (indexTemplate === null || assets.size === 0) refreshAssets()
|
|
234
|
+
if (indexTemplate === null) {
|
|
235
|
+
sendHtml(response, 200, NOT_BUILT_HTML)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
sendHtml(response, 200, indexTemplate)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Serve one allow-listed asset. */
|
|
242
|
+
function serveAsset(response, name) {
|
|
243
|
+
if (indexTemplate === null) refreshAssets()
|
|
244
|
+
const file = assets.get(name)
|
|
245
|
+
if (!file) {
|
|
246
|
+
sendError(response, 404, "not-found")
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const extension = extname(name)
|
|
250
|
+
const type = MIME_TYPES[extension]
|
|
251
|
+
if (!type) {
|
|
252
|
+
sendError(response, 404, "not-found")
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const content = readFileSync(file)
|
|
257
|
+
const nonce = newNonce()
|
|
258
|
+
response.writeHead(200, {
|
|
259
|
+
...securityHeaders(nonce),
|
|
260
|
+
"Content-Type": type,
|
|
261
|
+
})
|
|
262
|
+
response.end(content)
|
|
263
|
+
} catch {
|
|
264
|
+
sendError(response, 404, "not-found")
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Summarize a plan's submissions for the read endpoint. */
|
|
269
|
+
function summarize(plan) {
|
|
270
|
+
return {
|
|
271
|
+
...plan,
|
|
272
|
+
submissions: (plan.submissions ?? []).map((submission) => ({
|
|
273
|
+
id: submission.id,
|
|
274
|
+
at: submission.at,
|
|
275
|
+
groups: submission.groups,
|
|
276
|
+
deliveredAt: submission.deliveredAt,
|
|
277
|
+
deliveredTo: submission.deliveredTo,
|
|
278
|
+
})),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Map a store refusal to an HTTP response. */
|
|
283
|
+
function storeFailure(response, reason, detail) {
|
|
284
|
+
if (reason === "not-found") {
|
|
285
|
+
sendError(response, 404, "not-found")
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (reason === "invalid") {
|
|
289
|
+
sendError(response, 400, "invalid", detail?.message ?? "")
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
sendError(response, 409, reason, detail?.message ?? "")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Broadcast one live event to the streams of a plan. */
|
|
296
|
+
function notify(projectSlug, planSlug, event, data) {
|
|
297
|
+
const streams = viewers.get(`${projectSlug}/${planSlug}`)
|
|
298
|
+
if (!streams) return
|
|
299
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
300
|
+
for (const response of streams) {
|
|
301
|
+
if (response.writableEnded) continue
|
|
302
|
+
try {
|
|
303
|
+
response.write(payload)
|
|
304
|
+
} catch {
|
|
305
|
+
// A broken stream is removed by its own close handler.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The request handler. */
|
|
311
|
+
async function handle(request, response) {
|
|
312
|
+
const host = request.headers.host ?? ""
|
|
313
|
+
if (!hostAllowed(host)) {
|
|
314
|
+
sendError(response, 421, "forbidden-host")
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const method = request.method ?? "GET"
|
|
318
|
+
const path = new URL(request.url ?? "/", `http://${host}`).pathname
|
|
319
|
+
|
|
320
|
+
if (method === "GET" && path.startsWith("/assets/")) {
|
|
321
|
+
let name = ""
|
|
322
|
+
try {
|
|
323
|
+
name = decodeURIComponent(path.slice("/assets/".length))
|
|
324
|
+
} catch {
|
|
325
|
+
sendError(response, 404, "not-found")
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if (name.length === 0 || name.includes("/") || name.includes("\\") || name.includes("..")) {
|
|
329
|
+
sendError(response, 404, "not-found")
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
serveAsset(response, name)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
if (method === "GET" && path === "/") {
|
|
336
|
+
serveIndex(response)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Route parameters are validated before the write checks and before any
|
|
341
|
+
// store call, so a malformed slug can never reach a file path.
|
|
342
|
+
const segments = path.split("/")
|
|
343
|
+
if (segments[1] === "api" && segments[2] === "projects") {
|
|
344
|
+
if (segments[3] !== undefined && slugParameter(segments[3]) === null) {
|
|
345
|
+
sendError(response, 404, "not-found")
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
if (segments[5] !== undefined && slugParameter(segments[5]) === null) {
|
|
349
|
+
sendError(response, 404, "not-found")
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
if (segments[7] !== undefined && keyParameter(segments[7]) === null) {
|
|
353
|
+
sendError(response, 404, "not-found")
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const isWrite = method === "PUT" || method === "POST" || method === "DELETE"
|
|
359
|
+
let writeBody
|
|
360
|
+
if (isWrite) {
|
|
361
|
+
if (!String(request.headers["content-type"] ?? "").startsWith("application/json")) {
|
|
362
|
+
sendError(response, 415, "unsupported-media-type")
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
if (!originAllowed(request.headers.origin)) {
|
|
366
|
+
sendError(response, 403, "forbidden-origin")
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
// The body is validated before routing, so an oversized or malformed
|
|
370
|
+
// body never reaches a store call.
|
|
371
|
+
writeBody = await readBody(request, response)
|
|
372
|
+
if (writeBody === undefined) return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (method === "GET" && path === "/api/health") {
|
|
376
|
+
sendJson(response, 200, { name: SERVICE_NAME, instanceId, version })
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (method === "GET" && path === "/api/projects") {
|
|
380
|
+
const summaries = await store.listProjects()
|
|
381
|
+
const repositories = new Map()
|
|
382
|
+
for (const project of summaries) {
|
|
383
|
+
const canonical = project.repository?.canonical ?? project.directory
|
|
384
|
+
if (!repositories.has(canonical)) {
|
|
385
|
+
repositories.set(canonical, {
|
|
386
|
+
name: project.repository?.name ?? canonical,
|
|
387
|
+
canonical,
|
|
388
|
+
projects: [],
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
repositories.get(canonical).projects.push({
|
|
392
|
+
slug: project.slug,
|
|
393
|
+
name: project.name,
|
|
394
|
+
branch: project.branch,
|
|
395
|
+
directory: project.directory,
|
|
396
|
+
counts: project.counts,
|
|
397
|
+
live: projects.has(project.directory),
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
sendJson(response, 200, { repositories: [...repositories.values()] })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let match = /^\/api\/projects\/([^/]+)$/.exec(path)
|
|
405
|
+
if (method === "GET" && match) {
|
|
406
|
+
const projectSlug = slugParameter(match[1])
|
|
407
|
+
if (!projectSlug) {
|
|
408
|
+
sendError(response, 404, "not-found")
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
const project = await store.getProject(projectSlug)
|
|
412
|
+
if (!project) {
|
|
413
|
+
sendError(response, 404, "not-found")
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
const plans = await store.listPlans(projectSlug)
|
|
417
|
+
sendJson(response, 200, { project, plans })
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/questions\/([^/]+)$/.exec(path)
|
|
422
|
+
if (method === "PUT" && match) {
|
|
423
|
+
const projectSlug = slugParameter(match[1])
|
|
424
|
+
const planSlug = slugParameter(match[2])
|
|
425
|
+
const key = keyParameter(match[3])
|
|
426
|
+
if (!projectSlug || !planSlug || !key) {
|
|
427
|
+
sendError(response, 404, "not-found")
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
const body = writeBody
|
|
431
|
+
if (!body || typeof body.baseRevision !== "number" || typeof body.patch !== "object") {
|
|
432
|
+
sendError(response, 400, "invalid", "expected baseRevision and patch")
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
const result = await store.saveAnswer(projectSlug, planSlug, key, body.patch, body.baseRevision)
|
|
436
|
+
if (!result.ok) {
|
|
437
|
+
storeFailure(response, result.reason, result)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
notify(projectSlug, planSlug, "changed", { revision: result.plan.revision })
|
|
441
|
+
sendJson(response, 200, { plan: summarize(result.plan) })
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/submit$/.exec(path)
|
|
446
|
+
if (method === "POST" && match) {
|
|
447
|
+
const projectSlug = slugParameter(match[1])
|
|
448
|
+
const planSlug = slugParameter(match[2])
|
|
449
|
+
if (!projectSlug || !planSlug) {
|
|
450
|
+
sendError(response, 404, "not-found")
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const project = await store.getProject(projectSlug)
|
|
454
|
+
if (!project) {
|
|
455
|
+
sendError(response, 404, "not-found")
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
const body = writeBody
|
|
459
|
+
const group = body?.group ?? null
|
|
460
|
+
const baseRevision = body?.baseRevision ?? null
|
|
461
|
+
if (group !== null && typeof group !== "string") {
|
|
462
|
+
sendError(response, 400, "invalid", "group must be a key or null")
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
if (baseRevision !== null && typeof baseRevision !== "number") {
|
|
466
|
+
sendError(response, 400, "invalid", "baseRevision must be a number or null")
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
const result = await store.submit(projectSlug, planSlug, group, baseRevision)
|
|
470
|
+
if (!result.ok) {
|
|
471
|
+
sendJson(response, result.reason === "not-found" ? 404 : 409, {
|
|
472
|
+
error: result.reason,
|
|
473
|
+
message: "",
|
|
474
|
+
...(result.missing === undefined ? {} : { missing: result.missing }),
|
|
475
|
+
})
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
const updated = await store.getPlan(projectSlug, planSlug)
|
|
479
|
+
notify(projectSlug, planSlug, "changed", { revision: updated?.revision ?? 0 })
|
|
480
|
+
let delivery = "pending"
|
|
481
|
+
const registered = projects.get(project.directory)
|
|
482
|
+
if (registered) {
|
|
483
|
+
try {
|
|
484
|
+
const outcome = await registered.deliver(planSlug)
|
|
485
|
+
if (outcome === "woken" || outcome === "pending" || outcome === "failed") {
|
|
486
|
+
delivery = outcome === "failed" ? "pending" : outcome
|
|
487
|
+
}
|
|
488
|
+
} catch {
|
|
489
|
+
delivery = "pending"
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (delivery === "woken") {
|
|
493
|
+
notify(projectSlug, planSlug, "delivered", { submissionId: result.submission.id })
|
|
494
|
+
}
|
|
495
|
+
sendJson(response, 200, {
|
|
496
|
+
submission: {
|
|
497
|
+
id: result.submission.id,
|
|
498
|
+
groups: result.submission.groups,
|
|
499
|
+
count: result.submission.questions.length,
|
|
500
|
+
},
|
|
501
|
+
skipped: result.skipped,
|
|
502
|
+
delivery,
|
|
503
|
+
})
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/archive$/.exec(path)
|
|
508
|
+
if (method === "POST" && match) {
|
|
509
|
+
const projectSlug = slugParameter(match[1])
|
|
510
|
+
const planSlug = slugParameter(match[2])
|
|
511
|
+
if (!projectSlug || !planSlug) {
|
|
512
|
+
sendError(response, 404, "not-found")
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
const body = writeBody
|
|
516
|
+
if (typeof body?.archived !== "boolean") {
|
|
517
|
+
sendError(response, 400, "invalid", "archived must be a boolean")
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
const changed = await store.setArchived(projectSlug, planSlug, body.archived)
|
|
521
|
+
if (!changed) {
|
|
522
|
+
sendError(response, 404, "not-found")
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
const updated = await store.getPlan(projectSlug, planSlug)
|
|
526
|
+
notify(projectSlug, planSlug, "changed", { revision: updated?.revision ?? 0 })
|
|
527
|
+
sendJson(response, 200, { archived: body.archived })
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)$/.exec(path)
|
|
532
|
+
if (method === "GET" && match) {
|
|
533
|
+
const projectSlug = slugParameter(match[1])
|
|
534
|
+
const planSlug = slugParameter(match[2])
|
|
535
|
+
if (!projectSlug || !planSlug) {
|
|
536
|
+
sendError(response, 404, "not-found")
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
const project = await store.getProject(projectSlug)
|
|
540
|
+
if (!project) {
|
|
541
|
+
sendError(response, 404, "not-found")
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
const plan = await store.getPlan(projectSlug, planSlug)
|
|
545
|
+
if (!plan) {
|
|
546
|
+
sendError(response, 404, "not-found")
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
sendJson(response, 200, { project, plan: summarize(plan) })
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/events$/.exec(path)
|
|
554
|
+
if (method === "GET" && match) {
|
|
555
|
+
const projectSlug = slugParameter(match[1])
|
|
556
|
+
const planSlug = slugParameter(match[2])
|
|
557
|
+
if (!projectSlug || !planSlug) {
|
|
558
|
+
sendError(response, 404, "not-found")
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
const plan = await store.getPlan(projectSlug, planSlug)
|
|
562
|
+
if (!plan) {
|
|
563
|
+
sendError(response, 404, "not-found")
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
const nonce = newNonce()
|
|
567
|
+
response.writeHead(200, {
|
|
568
|
+
...securityHeaders(nonce),
|
|
569
|
+
"Content-Type": "text/event-stream",
|
|
570
|
+
"Cache-Control": "no-store",
|
|
571
|
+
Connection: "keep-alive",
|
|
572
|
+
})
|
|
573
|
+
response.write(
|
|
574
|
+
`event: hello\ndata: ${JSON.stringify({ instanceId, revision: plan.revision })}\n\n`,
|
|
575
|
+
)
|
|
576
|
+
const key = `${projectSlug}/${planSlug}`
|
|
577
|
+
const streams = viewers.get(key) ?? new Set()
|
|
578
|
+
streams.add(response)
|
|
579
|
+
viewers.set(key, streams)
|
|
580
|
+
response.on("close", () => {
|
|
581
|
+
streams.delete(response)
|
|
582
|
+
if (streams.size === 0) viewers.delete(key)
|
|
583
|
+
})
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (method === "DELETE" && /^\/api\/projects\/[^/]+\/plans\/[^/]+$/.test(path)) {
|
|
588
|
+
const parts = path.split("/")
|
|
589
|
+
const projectSlug = slugParameter(parts[3])
|
|
590
|
+
const planSlug = slugParameter(parts[5])
|
|
591
|
+
if (!projectSlug || !planSlug) {
|
|
592
|
+
sendError(response, 404, "not-found")
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
const body = writeBody
|
|
596
|
+
if (body?.confirm !== true) {
|
|
597
|
+
sendError(response, 400, "bad-request", "confirm is required")
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
const deleted = await store.deletePlan(projectSlug, planSlug)
|
|
601
|
+
if (!deleted) {
|
|
602
|
+
sendError(response, 404, "not-found")
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
notify(projectSlug, planSlug, "changed", { revision: 0 })
|
|
606
|
+
sendJson(response, 200, { deleted: true })
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
sendError(response, 404, "not-found")
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const runtime = {
|
|
614
|
+
/** See the module contract: start the listener on demand. */
|
|
615
|
+
ensureListening() {
|
|
616
|
+
if (server) return Promise.resolve({ url: `http://127.0.0.1:${boundPort}`, fallback })
|
|
617
|
+
if (listening) return listening
|
|
618
|
+
listening = new Promise((resolve) => {
|
|
619
|
+
const attempt = (port, isFallback) => {
|
|
620
|
+
const candidate = createServer((request, response) => {
|
|
621
|
+
handle(request, response).catch((error) => {
|
|
622
|
+
if (error instanceof DiscoveryDataError) {
|
|
623
|
+
sendJson(response, 409, { error: error.code, message: error.message })
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
log("Discovery request failed")
|
|
627
|
+
sendError(response, 500, "internal")
|
|
628
|
+
})
|
|
629
|
+
})
|
|
630
|
+
candidate.on("connection", (socket) => {
|
|
631
|
+
sockets.add(socket)
|
|
632
|
+
socket.on("close", () => sockets.delete(socket))
|
|
633
|
+
})
|
|
634
|
+
candidate.once("error", (error) => {
|
|
635
|
+
if (!isFallback && error?.code === "EADDRINUSE") {
|
|
636
|
+
attempt(0, true)
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
log("Discovery listener could not start")
|
|
640
|
+
resolve(null)
|
|
641
|
+
})
|
|
642
|
+
candidate.listen(port, "127.0.0.1", () => {
|
|
643
|
+
server = candidate
|
|
644
|
+
boundPort = candidate.address().port
|
|
645
|
+
fallback = isFallback
|
|
646
|
+
instanceId = randomUUID()
|
|
647
|
+
resolve({ url: `http://127.0.0.1:${boundPort}`, fallback })
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
attempt(Number.isInteger(requestedPort) ? requestedPort : DEFAULT_PORT, false)
|
|
651
|
+
})
|
|
652
|
+
return listening
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
/** Register one project's delivery callback. */
|
|
656
|
+
registerProject(directory, slug, deliver) {
|
|
657
|
+
const token = Symbol("discovery-project")
|
|
658
|
+
const key = canonicalDirectory(directory)
|
|
659
|
+
projects.set(key, { slug, deliver, token })
|
|
660
|
+
return () => {
|
|
661
|
+
const current = projects.get(key)
|
|
662
|
+
if (current && current.token === token) projects.delete(key)
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
/** True when an open page shows this plan. */
|
|
667
|
+
hasViewer(projectSlug, planSlug) {
|
|
668
|
+
return (viewers.get(`${projectSlug}/${planSlug}`)?.size ?? 0) > 0
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
/** Broadcast one event to the plan's open pages. */
|
|
672
|
+
notify,
|
|
673
|
+
|
|
674
|
+
/** End event streams, close connections, and stop when idle. */
|
|
675
|
+
async closeIfIdle() {
|
|
676
|
+
if (listening && server === null) {
|
|
677
|
+
// A start is in flight; let it finish, then re-check idleness.
|
|
678
|
+
try {
|
|
679
|
+
await listening
|
|
680
|
+
} catch {
|
|
681
|
+
// A failed start leaves nothing to close.
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (projects.size > 0 || server === null) return
|
|
685
|
+
for (const streams of viewers.values()) {
|
|
686
|
+
for (const response of streams) {
|
|
687
|
+
try {
|
|
688
|
+
response.end()
|
|
689
|
+
} catch {
|
|
690
|
+
// Already closed.
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
viewers.clear()
|
|
695
|
+
for (const socket of sockets) {
|
|
696
|
+
try {
|
|
697
|
+
socket.destroy()
|
|
698
|
+
} catch {
|
|
699
|
+
// Already closed.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
sockets.clear()
|
|
703
|
+
const closing = server
|
|
704
|
+
server = null
|
|
705
|
+
listening = null
|
|
706
|
+
boundPort = 0
|
|
707
|
+
fallback = false
|
|
708
|
+
instanceId = null
|
|
709
|
+
indexTemplate = null
|
|
710
|
+
assets = new Map()
|
|
711
|
+
await new Promise((resolve) => closing.close(() => resolve()))
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
/** The absolute URL of a plan page, or null before the listener starts. */
|
|
715
|
+
planUrl(projectSlug, planSlug) {
|
|
716
|
+
if (boundPort === 0) return null
|
|
717
|
+
return `http://127.0.0.1:${boundPort}/#/p/${projectSlug}/${planSlug}`
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
/** The one store every handler uses. */
|
|
721
|
+
store,
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return runtime
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Create a runtime with its own store, assets, and listener.
|
|
729
|
+
*
|
|
730
|
+
* When the process-wide slot is empty, the new runtime is installed there, so
|
|
731
|
+
* a later {@link getDiscoveryRuntime} call returns the same instance. Tests
|
|
732
|
+
* use this directly to start a listener on port 0.
|
|
733
|
+
*
|
|
734
|
+
* @param {{ store: object, assetsDir: string, version: string, port?: number,
|
|
735
|
+
* log?: (message: string) => void }} options - Runtime dependencies.
|
|
736
|
+
* @returns {object} The runtime.
|
|
737
|
+
*/
|
|
738
|
+
export function createDiscoveryRuntime(options) {
|
|
739
|
+
const runtime = buildRuntime(options)
|
|
740
|
+
if (globalThis[RUNTIME_KEY] === undefined) globalThis[RUNTIME_KEY] = runtime
|
|
741
|
+
return runtime
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The process-wide runtime, created on first use and returned unchanged
|
|
746
|
+
* afterwards.
|
|
747
|
+
*
|
|
748
|
+
* @param {{ store: object, assetsDir: string, version: string, port?: number,
|
|
749
|
+
* log?: (message: string) => void }} options - Runtime dependencies, used only
|
|
750
|
+
* when the slot is empty.
|
|
751
|
+
* @returns {object} The runtime.
|
|
752
|
+
*/
|
|
753
|
+
export function getDiscoveryRuntime(options) {
|
|
754
|
+
return globalThis[RUNTIME_KEY] ?? createDiscoveryRuntime(options)
|
|
755
|
+
}
|