@agentsdance/codejury 0.1.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/LICENSE +21 -0
- package/README.md +246 -0
- package/bin/jury.js +1152 -0
- package/jury.config.example.json +43 -0
- package/lib/agents.js +295 -0
- package/lib/config.js +157 -0
- package/lib/findings.js +112 -0
- package/lib/loop.js +400 -0
- package/lib/prompt.js +67 -0
- package/lib/reply.js +134 -0
- package/lib/repository.js +185 -0
- package/lib/server.js +224 -0
- package/lib/store.js +316 -0
- package/lib/style.js +90 -0
- package/lib/triage.js +144 -0
- package/package.json +44 -0
- package/prompts/feedback.md +54 -0
- package/prompts/review-round.md +52 -0
- package/web/index.html +1699 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Bind a PR URL to the checkout that jury is about to read and modify.
|
|
2
|
+
//
|
|
3
|
+
// A URL is not just a label. Letting it name one repository while every git
|
|
4
|
+
// command runs in another can review, commit, and push unrelated code under the
|
|
5
|
+
// PR's name. Keep this check independent of a hosting CLI so it also works for
|
|
6
|
+
// self-hosted GitHub and GitLab instances.
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
function normalized(host, pathname) {
|
|
16
|
+
const path = pathname
|
|
17
|
+
.replace(/^\/+|\/+$/g, "")
|
|
18
|
+
.replace(/\.git$/i, "");
|
|
19
|
+
if (!host || !path) return null;
|
|
20
|
+
return { host: host.toLowerCase(), path, display: `${host.toLowerCase()}/${path}` };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Repository identity carried by a GitHub or GitLab-style PR URL. */
|
|
24
|
+
export function repositoryFromPrUrl(value) {
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(value);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
33
|
+
const marker = parts.findIndex((p) => p === "pull" || p === "merge_requests");
|
|
34
|
+
if (marker < 2 || !/^\d+$/.test(parts[marker + 1] ?? "")) return null;
|
|
35
|
+
|
|
36
|
+
const repo = parts.slice(0, marker);
|
|
37
|
+
// Canonical GitLab links use /group/project/-/merge_requests/123.
|
|
38
|
+
if (repo.at(-1) === "-") repo.pop();
|
|
39
|
+
if (repo.length < 2) return null;
|
|
40
|
+
return normalized(url.hostname, repo.join("/"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Repository identity carried by an HTTPS, SSH URL, or scp-style git remote. */
|
|
44
|
+
export function repositoryFromRemote(value) {
|
|
45
|
+
const remote = String(value ?? "").trim();
|
|
46
|
+
const scp = remote.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/);
|
|
47
|
+
if (scp && !/^[a-z][a-z\d+.-]*:\/\//i.test(remote)) {
|
|
48
|
+
return normalized(scp[1], scp[2]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(remote);
|
|
53
|
+
return normalized(url.hostname, url.pathname);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Refuse to let a PR URL label operations in an unrelated local checkout. */
|
|
60
|
+
export function assertPrCheckout(prUrl, remoteUrl, dir) {
|
|
61
|
+
const target = repositoryFromPrUrl(prUrl);
|
|
62
|
+
if (!target) {
|
|
63
|
+
throw new Error(`cannot identify a pull request repository from "${prUrl}"`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const checkout = repositoryFromRemote(remoteUrl);
|
|
67
|
+
if (!checkout) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`PR targets ${target.display}, but ${dir} has no identifiable origin — ` +
|
|
70
|
+
`run from that repository or pass --dir /path/to/its/checkout`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (checkout.host !== target.host || checkout.path !== target.path) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`PR targets ${target.display}, but ${dir} is ${checkout.display} — ` +
|
|
77
|
+
`run from the target repository or pass --dir /path/to/${target.path.split("/").at(-1)}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function githubNumber(prUrl) {
|
|
83
|
+
try {
|
|
84
|
+
const parts = new URL(prUrl).pathname.split("/").filter(Boolean);
|
|
85
|
+
const marker = parts.indexOf("pull");
|
|
86
|
+
return marker >= 0 && /^\d+$/.test(parts[marker + 1] ?? "") ? parts[marker + 1] : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function remoteForRepository(origin, repoPath) {
|
|
93
|
+
const scp = String(origin).match(/^((?:[^@/\s]+@)?[^:/\s]+):(.+)$/);
|
|
94
|
+
if (scp && !/^[a-z][a-z\d+.-]*:\/\//i.test(origin)) return `${scp[1]}:${repoPath}.git`;
|
|
95
|
+
const url = new URL(origin);
|
|
96
|
+
url.pathname = `/${repoPath}.git`;
|
|
97
|
+
url.search = "";
|
|
98
|
+
url.hash = "";
|
|
99
|
+
return url.toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a GitHub PR to an isolated checkout of its exact head.
|
|
104
|
+
*
|
|
105
|
+
* The checkout is a clone rather than a mutation of the caller's repository:
|
|
106
|
+
* running `jury <url>` from master (or from an unrelated repo) must not switch
|
|
107
|
+
* that working tree out from under the user. The returned cleanup is owned by
|
|
108
|
+
* the CLI and runs after the review finishes or fails.
|
|
109
|
+
*/
|
|
110
|
+
export async function resolvePrCheckout(prUrl, {
|
|
111
|
+
allowPush = true,
|
|
112
|
+
exec = run,
|
|
113
|
+
makeTemp = mkdtemp,
|
|
114
|
+
remove = (dir) => rm(dir, { recursive: true, force: true }),
|
|
115
|
+
} = {}) {
|
|
116
|
+
const target = repositoryFromPrUrl(prUrl);
|
|
117
|
+
const number = githubNumber(prUrl);
|
|
118
|
+
if (!target || !number) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`cannot resolve the PR head from "${prUrl}" automatically — ` +
|
|
121
|
+
"run from its checked-out branch with --dir, or use a GitHub pull request URL",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let details;
|
|
126
|
+
try {
|
|
127
|
+
const { stdout } = await exec("gh", [
|
|
128
|
+
"pr", "view", prUrl, "--json",
|
|
129
|
+
"title,body,state,baseRefName,headRefName,headRefOid,headRepository",
|
|
130
|
+
]);
|
|
131
|
+
details = JSON.parse(stdout);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`could not resolve ${target.display} pull request #${number}: ${String(err.message).split("\n")[0]} — ` +
|
|
135
|
+
"check gh authentication, or check out the PR and pass --dir",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const headRepo = details.headRepository?.nameWithOwner;
|
|
140
|
+
if (!details.headRefOid || !details.headRefName || !details.baseRefName || !headRepo) {
|
|
141
|
+
throw new Error(`pull request #${number} does not expose a usable head branch`);
|
|
142
|
+
}
|
|
143
|
+
if (allowPush && details.state !== "OPEN") {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`pull request #${number} is ${String(details.state).toLowerCase()} — ` +
|
|
146
|
+
"use --no-push for a read-only review",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const worktree = await makeTemp(path.join(tmpdir(), `jury-pr-${number}-`));
|
|
151
|
+
try {
|
|
152
|
+
await exec("gh", ["repo", "clone", target.display, worktree, "--", "--quiet"]);
|
|
153
|
+
await exec("git", ["fetch", "--quiet", "origin", `refs/pull/${number}/head`], { cwd: worktree });
|
|
154
|
+
await exec("git", ["checkout", "--quiet", "-b", `jury-pr-${number}`, "FETCH_HEAD"], { cwd: worktree });
|
|
155
|
+
|
|
156
|
+
const { stdout: actual } = await exec("git", ["rev-parse", "HEAD"], { cwd: worktree });
|
|
157
|
+
if (actual.trim() !== details.headRefOid) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`resolved PR head ${details.headRefOid.slice(0, 12)}, but checked out ${actual.trim().slice(0, 12)}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { stdout: origin } = await exec("git", ["remote", "get-url", "origin"], { cwd: worktree });
|
|
164
|
+
let pushRemote = "origin";
|
|
165
|
+
if (headRepo !== target.path) {
|
|
166
|
+
pushRemote = "pr-head";
|
|
167
|
+
await exec("git", ["remote", "add", pushRemote, remoteForRepository(origin.trim(), headRepo)], { cwd: worktree });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
worktree,
|
|
172
|
+
title: details.title || undefined,
|
|
173
|
+
summary: details.body ? details.body.replace(/\r/g, "").trim().slice(0, 4000) : undefined,
|
|
174
|
+
state: details.state,
|
|
175
|
+
trunk: details.baseRefName,
|
|
176
|
+
branch: details.headRefName,
|
|
177
|
+
sha: details.headRefOid,
|
|
178
|
+
pushTarget: { remote: pushRemote, branch: details.headRefName },
|
|
179
|
+
cleanup: () => remove(worktree),
|
|
180
|
+
};
|
|
181
|
+
} catch (err) {
|
|
182
|
+
await remove(worktree);
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The console server. Serves web/ and the runs found on disk.
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { readFile, stat, open } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { listRuns, runsDir, readEvents, foldEvents } from "./store.js";
|
|
7
|
+
import { conversation } from "./loop.js";
|
|
8
|
+
import { watch } from "node:fs";
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const webRoot = path.join(here, "..", "web");
|
|
12
|
+
|
|
13
|
+
const TYPES = {
|
|
14
|
+
".html": "text/html; charset=utf-8",
|
|
15
|
+
".js": "text/javascript; charset=utf-8",
|
|
16
|
+
".css": "text/css; charset=utf-8",
|
|
17
|
+
".json": "application/json",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function serve({ port = 3080, cwd = process.cwd(), onLog = console.log } = {}) {
|
|
22
|
+
const base = runsDir(cwd);
|
|
23
|
+
|
|
24
|
+
const server = http.createServer(async (req, res) => {
|
|
25
|
+
const url = new URL(req.url, "http://localhost");
|
|
26
|
+
res.setHeader("Cache-Control", "no-store");
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (url.pathname === "/api/run" || url.pathname === "/api/runs") {
|
|
30
|
+
// Read per request. A run is written while the console is already up,
|
|
31
|
+
// so anything resolved once at boot would never be picked up.
|
|
32
|
+
const { runs, skipped } = await listRuns(base);
|
|
33
|
+
for (const s of skipped) onLog(`skipped ${s.dir}: ${s.reason}`);
|
|
34
|
+
if (runs.length === 0) return json(res, 404, { error: "no runs yet" });
|
|
35
|
+
return json(res, 200, runs.length === 1 ? runs[0] : { targets: runs });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The conversation, folded per reviewer. Same event log the timeline
|
|
39
|
+
// reads — a thread is derived, never stored, so a replayed run shows the
|
|
40
|
+
// exact exchange that happened rather than a summary written afterwards.
|
|
41
|
+
if (url.pathname === "/api/conversation") {
|
|
42
|
+
const slug = path.basename(url.searchParams.get("run") ?? "");
|
|
43
|
+
if (!slug) return json(res, 400, { error: "run is required" });
|
|
44
|
+
const events = await readEvents(path.join(base, slug));
|
|
45
|
+
return json(res, 200, { slug, threads: conversation(events) });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Live stream. Polling was fine for a finished run and useless for a
|
|
49
|
+
// live one: a reviewer talks for twenty minutes and the console has
|
|
50
|
+
// nothing to show until it exits. Here every appended event is pushed as
|
|
51
|
+
// it lands, so the conversation appears at the speed it is spoken.
|
|
52
|
+
if (url.pathname === "/api/stream") {
|
|
53
|
+
const slug = path.basename(url.searchParams.get("run") ?? "");
|
|
54
|
+
if (!slug) return json(res, 400, { error: "run is required" });
|
|
55
|
+
return stream(res, path.join(base, slug), slug, onLog);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Verbatim artifacts: the prompt a reviewer was given, the stream it
|
|
59
|
+
// produced. Both names are validated against the run directory rather
|
|
60
|
+
// than trusted, since they arrive from the query string.
|
|
61
|
+
if (url.pathname === "/api/raw") {
|
|
62
|
+
const slug = path.basename(url.searchParams.get("run") ?? "");
|
|
63
|
+
const name = path.basename(url.searchParams.get("file") ?? "");
|
|
64
|
+
if (!slug || !name) return json(res, 400, { error: "run and file are required" });
|
|
65
|
+
const file = path.join(base, slug, name);
|
|
66
|
+
if (!file.startsWith(path.join(base, slug) + path.sep)) {
|
|
67
|
+
return json(res, 403, { error: "forbidden" });
|
|
68
|
+
}
|
|
69
|
+
const body = await readFile(file, "utf8");
|
|
70
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
71
|
+
return res.end(body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (url.pathname === "/api/health") {
|
|
75
|
+
const { runs, skipped } = await listRuns(base);
|
|
76
|
+
// assets carries index.html's mtime so an open page can notice the page
|
|
77
|
+
// itself changed and reload, instead of silently serving a stale build.
|
|
78
|
+
let assets = "";
|
|
79
|
+
try {
|
|
80
|
+
assets = String((await stat(path.join(webRoot, "index.html"))).mtimeMs);
|
|
81
|
+
} catch { /* embedded/missing is fine */ }
|
|
82
|
+
return json(res, 200, {
|
|
83
|
+
ok: true, runs: runs.length, skipped: skipped.length, dir: base, assets,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// static
|
|
88
|
+
const rel = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
|
|
89
|
+
const file = path.join(webRoot, rel);
|
|
90
|
+
if (!file.startsWith(webRoot)) return json(res, 403, { error: "forbidden" });
|
|
91
|
+
const body = await readFile(file);
|
|
92
|
+
res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] ?? "application/octet-stream" });
|
|
93
|
+
res.end(body);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "not found" });
|
|
96
|
+
json(res, 500, { error: err.message });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const bound = await listen(server, port, onLog);
|
|
101
|
+
return { server, port: bound, url: `http://127.0.0.1:${bound}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function json(res, code, body) {
|
|
105
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
106
|
+
res.end(JSON.stringify(body));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Walk forward rather than dying on "address already in use".
|
|
110
|
+
function listen(server, port, onLog) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
let p = port;
|
|
113
|
+
const attempt = () => {
|
|
114
|
+
server.once("error", (err) => {
|
|
115
|
+
if (err.code === "EADDRINUSE" && p < port + 20) {
|
|
116
|
+
onLog(`port ${p} busy, trying ${p + 1}`);
|
|
117
|
+
p += 1;
|
|
118
|
+
attempt();
|
|
119
|
+
} else reject(err);
|
|
120
|
+
});
|
|
121
|
+
server.listen(p, "127.0.0.1", () => resolve(p));
|
|
122
|
+
};
|
|
123
|
+
attempt();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Server-sent events over the run's append-only log.
|
|
129
|
+
*
|
|
130
|
+
* The log is the transport. Nothing here holds run state: on connect the whole
|
|
131
|
+
* file is replayed so a page opened mid-run catches up, then fs.watch drives
|
|
132
|
+
* incremental reads from the last byte offset. Because the log only ever grows,
|
|
133
|
+
* "what is new" is a file length comparison rather than a diff.
|
|
134
|
+
*/
|
|
135
|
+
async function stream(res, dir, slug, onLog) {
|
|
136
|
+
res.writeHead(200, {
|
|
137
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
138
|
+
"Cache-Control": "no-cache, no-transform",
|
|
139
|
+
Connection: "keep-alive",
|
|
140
|
+
// The console is same-origin, but a proxy that buffers would defeat the
|
|
141
|
+
// entire point of streaming.
|
|
142
|
+
"X-Accel-Buffering": "no",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const file = path.join(dir, "events.ndjson");
|
|
146
|
+
let offset = 0;
|
|
147
|
+
let closed = false;
|
|
148
|
+
let reading = false;
|
|
149
|
+
let again = false;
|
|
150
|
+
|
|
151
|
+
const send = (event, data) => {
|
|
152
|
+
if (closed) return;
|
|
153
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Reads are serialised: fs.watch fires several times for one append, and two
|
|
157
|
+
// concurrent readers would both start from the same offset and emit the same
|
|
158
|
+
// lines twice.
|
|
159
|
+
const pump = async () => {
|
|
160
|
+
if (closed) return;
|
|
161
|
+
if (reading) { again = true; return; }
|
|
162
|
+
reading = true;
|
|
163
|
+
try {
|
|
164
|
+
const { size } = await stat(file).catch(() => ({ size: 0 }));
|
|
165
|
+
if (size > offset) {
|
|
166
|
+
const fh = await open(file, "r");
|
|
167
|
+
try {
|
|
168
|
+
const len = size - offset;
|
|
169
|
+
const buf = Buffer.alloc(len);
|
|
170
|
+
await fh.read(buf, 0, len, offset);
|
|
171
|
+
const text = buf.toString("utf8");
|
|
172
|
+
// A partial final line means an append landed mid-write. Leave it in
|
|
173
|
+
// the stream and pick it up on the next pump rather than emitting
|
|
174
|
+
// half an event.
|
|
175
|
+
const cut = text.lastIndexOf("\n");
|
|
176
|
+
if (cut >= 0) {
|
|
177
|
+
offset += Buffer.byteLength(text.slice(0, cut + 1), "utf8");
|
|
178
|
+
for (const line of text.slice(0, cut).split("\n")) {
|
|
179
|
+
if (!line.trim()) continue;
|
|
180
|
+
try { send("event", JSON.parse(line)); } catch { /* torn line */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
await fh.close().catch(() => {});
|
|
185
|
+
}
|
|
186
|
+
} else if (size < offset) {
|
|
187
|
+
// Truncated or replaced underneath us — restart rather than serve
|
|
188
|
+
// nonsense from a stale offset.
|
|
189
|
+
offset = 0;
|
|
190
|
+
again = true;
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
onLog?.(`stream ${slug}: ${err.message}`);
|
|
194
|
+
} finally {
|
|
195
|
+
reading = false;
|
|
196
|
+
if (again && !closed) { again = false; await pump(); }
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
await pump();
|
|
201
|
+
send("ready", { slug });
|
|
202
|
+
|
|
203
|
+
let watcher = null;
|
|
204
|
+
try {
|
|
205
|
+
watcher = watch(dir, () => { pump(); });
|
|
206
|
+
} catch { /* fall back to the interval alone */ }
|
|
207
|
+
// fs.watch is unreliable on some filesystems (and silent on network mounts),
|
|
208
|
+
// so a slow poll backs it up. It is a safety net, not the mechanism.
|
|
209
|
+
const poll = setInterval(pump, 1000);
|
|
210
|
+
// Comment frames keep intermediaries from reaping an idle connection during
|
|
211
|
+
// a long agent turn.
|
|
212
|
+
const beat = setInterval(() => { if (!closed) res.write(": ping\n\n"); }, 15000);
|
|
213
|
+
|
|
214
|
+
const done = () => {
|
|
215
|
+
if (closed) return;
|
|
216
|
+
closed = true;
|
|
217
|
+
clearInterval(poll);
|
|
218
|
+
clearInterval(beat);
|
|
219
|
+
watcher?.close();
|
|
220
|
+
res.end();
|
|
221
|
+
};
|
|
222
|
+
res.on("close", done);
|
|
223
|
+
res.on("error", done);
|
|
224
|
+
}
|