diffsplain 0.8.3 → 0.9.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 +10 -5
- package/dist/assets/{angular-html-e4D_THVy.js → angular-html-m_m91ZYB.js} +1 -1
- package/dist/assets/{angular-ts-Cx3870JF.js → angular-ts-CO5Z--Rb.js} +1 -1
- package/dist/assets/{apl-DZuu22Wh.js → apl-ogYYgLUu.js} +1 -1
- package/dist/assets/{astro-K-NemFAk.js → astro-DAfY3cNR.js} +1 -1
- package/dist/assets/{blade-ConbVu3U.js → blade-E6RzsLUV.js} +1 -1
- package/dist/assets/{c-DtmaBqln.js → c-kKdxhtHF.js} +1 -1
- package/dist/assets/{cobol-BMHoPOfB.js → cobol-3oDDyFNf.js} +1 -1
- package/dist/assets/{coffee-DfweJ19Y.js → coffee-dsiILuWe.js} +1 -1
- package/dist/assets/{cpp-CPh_zIOW.js → cpp-DYyNFq__.js} +1 -1
- package/dist/assets/{crystal-CAEX7C1J.js → crystal-B7dVp9TF.js} +1 -1
- package/dist/assets/{css-C8ihGqCA.js → css-DlxJ0aex.js} +1 -1
- package/dist/assets/{edge-DVZPwQ9j.js → edge-BjEVphkC.js} +1 -1
- package/dist/assets/{elixir-D03TKulP.js → elixir-0xTOtMcM.js} +1 -1
- package/dist/assets/{elm-JUX-2xVV.js → elm-iQk_Wrxq.js} +1 -1
- package/dist/assets/{erb-HbktAFZJ.js → erb-BO74ztiE.js} +1 -1
- package/dist/assets/{git-rebase-Ccd_ELAP.js → git-rebase-BN667QRY.js} +1 -1
- package/dist/assets/{glimmer-js-BcinUfi2.js → glimmer-js-DTGUIHeS.js} +1 -1
- package/dist/assets/{glimmer-ts-DuN2UkN9.js → glimmer-ts-BU3oQn2d.js} +1 -1
- package/dist/assets/{glsl-BWmCEPsR.js → glsl-KLiqLJi2.js} +1 -1
- package/dist/assets/{graphql-DJGYoBFW.js → graphql-D_idcsoJ.js} +1 -1
- package/dist/assets/{hack-ZPqBE9w3.js → hack-BXlsgPIr.js} +1 -1
- package/dist/assets/{haml-CZDZk6Gw.js → haml-fP0aUg4-.js} +1 -1
- package/dist/assets/{handlebars-d23PDS0n.js → handlebars-C1ZEaot-.js} +1 -1
- package/dist/assets/{html-BgzX2DyB.js → html-D04qIuup.js} +1 -1
- package/dist/assets/{html-derivative-Bq6M-ZFw.js → html-derivative-C6ai-9QL.js} +1 -1
- package/dist/assets/{http-DVhMl_cW.js → http-C7bz3ZvQ.js} +1 -1
- package/dist/assets/{hurl-CbspBAqF.js → hurl-jL6B6Ahr.js} +1 -1
- package/dist/assets/index-BPaJGi-m.js +1574 -0
- package/dist/assets/index-VAyBpb3u.css +2 -0
- package/dist/assets/{java--YZbq0z2.js → java-BVzvLCON.js} +1 -1
- package/dist/assets/{javascript-cvRuifRJ.js → javascript-UDbZHxCI.js} +1 -1
- package/dist/assets/{jinja-BeZmr3LP.js → jinja-DB-QKo2A.js} +1 -1
- package/dist/assets/{jison-qrpGZaxs.js → jison-B1ZbR2FF.js} +1 -1
- package/dist/assets/{json-4xUzsNJn.js → json-DV4WwJzT.js} +1 -1
- package/dist/assets/{jsx-BlXrTlSg.js → jsx-DS6_VTvW.js} +1 -1
- package/dist/assets/{julia-IN3jzmBc.js → julia-C4tevET0.js} +1 -1
- package/dist/assets/{just-C_50nLQH.js → just-CWMjefzB.js} +1 -1
- package/dist/assets/{latex-Cd-_2IS1.js → latex-tyEFf_Af.js} +1 -1
- package/dist/assets/{liquid-CEJf6Xg5.js → liquid-C3d1X1rL.js} +1 -1
- package/dist/assets/{lua-BlaV_n8i.js → lua-sXvrzLQY.js} +1 -1
- package/dist/assets/{marko-Cb-NPdc_.js → marko-CQweucif.js} +1 -1
- package/dist/assets/{mdc-Dt4ywVAS.js → mdc-TrxLWF7s.js} +1 -1
- package/dist/assets/{nginx-C_6SKViP.js → nginx-ChE2nSNJ.js} +1 -1
- package/dist/assets/{nim-BbkBjoeZ.js → nim-BY1CTRfx.js} +1 -1
- package/dist/assets/{perl-CYPU6TVa.js → perl-Cvj2PQQH.js} +1 -1
- package/dist/assets/{php-B6Oqilrc.js → php-CGmHp8HW.js} +1 -1
- package/dist/assets/{pug-C9Poo2KU.js → pug-DM4S0bRr.js} +1 -1
- package/dist/assets/{qml-DkMURMPv.js → qml-DI89Al9K.js} +1 -1
- package/dist/assets/{r-Bm-Gfi0p.js → r-Bs5_4yYh.js} +1 -1
- package/dist/assets/{razor-B0LqOok3.js → razor-DimzayhZ.js} +1 -1
- package/dist/assets/{regexp-jAbMHzwe.js → regexp-BLACz0cx.js} +1 -1
- package/dist/assets/{rst-BlWHfg8c.js → rst-DcWzxQh2.js} +1 -1
- package/dist/assets/{ruby-Bt5lKwPL.js → ruby-Dlat5E8d.js} +1 -1
- package/dist/assets/{sas-TuY2-8EF.js → sas-CCDmMaBY.js} +1 -1
- package/dist/assets/{scss-Dh1SsWlK.js → scss-BPiU2nu6.js} +1 -1
- package/dist/assets/{shellscript-CThiwmnp.js → shellscript-CKinVNrN.js} +1 -1
- package/dist/assets/{shellsession-DQ5iRUWd.js → shellsession-Cea8Pu5d.js} +1 -1
- package/dist/assets/{soy-BfM-BrZG.js → soy--BRAPY4-.js} +1 -1
- package/dist/assets/{sql-F617W1uN.js → sql-DjKiCyWK.js} +1 -1
- package/dist/assets/{stata-Ctov3ki2.js → stata-CDvo3J6y.js} +1 -1
- package/dist/assets/{surrealql-C-i2bJGB.js → surrealql-C7b7E5sh.js} +1 -1
- package/dist/assets/{svelte-oZT41pTl.js → svelte-DO0tKtiK.js} +1 -1
- package/dist/assets/{templ-CxX9Hf3e.js → templ-DhhmR_rv.js} +1 -1
- package/dist/assets/{tex-GyzJq8GJ.js → tex-Cn_V-Gg4.js} +1 -1
- package/dist/assets/{ts-tags-BugzKu0x.js → ts-tags-B6c-FTmA.js} +1 -1
- package/dist/assets/{tsx-DLqwDEUq.js → tsx-Cvtt2sJ-.js} +1 -1
- package/dist/assets/{twig-DADiR9Vq.js → twig-B7ZbCUmB.js} +1 -1
- package/dist/assets/{typescript-BlDIG_FO.js → typescript-i3C6um2B.js} +1 -1
- package/dist/assets/{vue-COXmmg2A.js → vue-DyPX0Vum.js} +1 -1
- package/dist/assets/{vue-html-Cy_3KYj1.js → vue-html-D362KpK3.js} +1 -1
- package/dist/assets/{vue-vine-CUqOY8dY.js → vue-vine-D34cBbwO.js} +1 -1
- package/dist/assets/{xml-C9JY7Wf6.js → xml-DJEk7u4n.js} +1 -1
- package/dist/assets/{xsl-DBCl8hSu.js → xsl-CoTtf90f.js} +1 -1
- package/dist/assets/{yaml-BbMebKkS.js → yaml-Cv5epQ4-.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +11 -3
- package/scripts/agent-exclusions.mjs +10 -0
- package/scripts/agent-review.mjs +94 -0
- package/scripts/build-diff-data.mjs +211 -49
- package/scripts/cli-args.mjs +36 -9
- package/scripts/coding-agents.mjs +206 -79
- package/scripts/generate-summaries.mjs +864 -425
- package/scripts/local-target.mjs +106 -0
- package/scripts/present.mjs +205 -72
- package/scripts/review-chat-context.mjs +510 -0
- package/scripts/review-chat-controller.mjs +568 -0
- package/scripts/review-chat-provider.mjs +352 -0
- package/scripts/review-chat.mjs +8 -0
- package/scripts/serve-built.mjs +206 -24
- package/scripts/summary-path.mjs +32 -11
- package/dist/assets/index-C2p_1iAk.css +0 -2
- package/dist/assets/index-divlsuKs.js +0 -1568
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
rmSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
} from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
agentCommand,
|
|
11
|
+
parseAgentResponse,
|
|
12
|
+
} from './coding-agents.mjs';
|
|
13
|
+
|
|
14
|
+
const maximumProviderOutputBytes = 1024 * 1024;
|
|
15
|
+
const forcedChildKillDelayMs = 250;
|
|
16
|
+
const noChange = () => {};
|
|
17
|
+
|
|
18
|
+
function removeTemporary(path) {
|
|
19
|
+
rmSync(path, { force: true, recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cancelledError() {
|
|
23
|
+
return new Error('The chat request was cancelled.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function spawnProcess(invocation) {
|
|
27
|
+
try {
|
|
28
|
+
return {
|
|
29
|
+
child: spawn(invocation.command, invocation.args, {
|
|
30
|
+
cwd: invocation.cwd,
|
|
31
|
+
env: invocation.env,
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { error };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function childOutput(chunks) {
|
|
41
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function childFailure(agent, status, signal, stdout, stderr) {
|
|
45
|
+
const detail = stderr.trim() || stdout.trim();
|
|
46
|
+
const suffix = detail ? `: ${detail.slice(-600)}` : '';
|
|
47
|
+
return new Error(`${agent} exited with status ${status ?? signal}${suffix}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function aborted(signal) {
|
|
51
|
+
return Boolean(signal?.aborted);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function successfulExit(status, signal) {
|
|
55
|
+
return status === 0 && !signal;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function closeError(execution, status, signal) {
|
|
59
|
+
if (aborted(execution.signal)) return cancelledError();
|
|
60
|
+
if (successfulExit(status, signal)) return undefined;
|
|
61
|
+
return childFailure(
|
|
62
|
+
execution.agent,
|
|
63
|
+
status,
|
|
64
|
+
signal,
|
|
65
|
+
childOutput(execution.stdout),
|
|
66
|
+
childOutput(execution.stderr),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function providerAgent(options) {
|
|
71
|
+
const agent = options?.agent;
|
|
72
|
+
if (!agent) throw new Error('Chat provider needs a coding agent');
|
|
73
|
+
return agent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function providerBinary(options, agent) {
|
|
77
|
+
return options.binary || agent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function providerEnvironment(options) {
|
|
81
|
+
return options.env || process.env;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function providerOptions(options) {
|
|
85
|
+
const agent = providerAgent(options);
|
|
86
|
+
return {
|
|
87
|
+
agent,
|
|
88
|
+
binary: providerBinary(options, agent),
|
|
89
|
+
model: options.model,
|
|
90
|
+
reasoning: options.reasoning,
|
|
91
|
+
accessMode: options.accessMode,
|
|
92
|
+
env: providerEnvironment(options),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function temporaryRequest() {
|
|
97
|
+
const temporary = mkdtempSync(join(tmpdir(), 'diffsplain-review-chat-'));
|
|
98
|
+
return {
|
|
99
|
+
temporary,
|
|
100
|
+
inputPath: join(temporary, 'input.json'),
|
|
101
|
+
schemaPath: join(temporary, 'schema.json'),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeRequest(paths, request) {
|
|
106
|
+
writeFileSync(paths.inputPath, JSON.stringify(request));
|
|
107
|
+
writeFileSync(paths.schemaPath, JSON.stringify(request.responseSchema));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function providerInvocation(options, request, paths) {
|
|
111
|
+
return agentCommand({
|
|
112
|
+
agent: options.agent,
|
|
113
|
+
binary: options.binary,
|
|
114
|
+
model: options.model,
|
|
115
|
+
reasoning: options.reasoning,
|
|
116
|
+
prompt: request.prompt,
|
|
117
|
+
schema: request.responseSchema,
|
|
118
|
+
schemaPath: paths.schemaPath,
|
|
119
|
+
inputPath: paths.inputPath,
|
|
120
|
+
accessMode: options.accessMode,
|
|
121
|
+
env: options.env,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function preparedProviderRequest(options, request) {
|
|
126
|
+
const paths = temporaryRequest();
|
|
127
|
+
try {
|
|
128
|
+
writeRequest(paths, request);
|
|
129
|
+
return { temporary: paths.temporary, invocation: providerInvocation(options, request, paths) };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
removeTemporary(paths.temporary);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class ChildExecution {
|
|
137
|
+
constructor({ invocation, input, agent, signal, temporary }) {
|
|
138
|
+
this.invocation = invocation;
|
|
139
|
+
this.input = input;
|
|
140
|
+
this.agent = agent;
|
|
141
|
+
this.signal = signal;
|
|
142
|
+
this.temporary = temporary;
|
|
143
|
+
this.child = undefined;
|
|
144
|
+
this.settled = false;
|
|
145
|
+
this.closed = false;
|
|
146
|
+
this.forceKillTimer = undefined;
|
|
147
|
+
this.stdout = [];
|
|
148
|
+
this.stderr = [];
|
|
149
|
+
this.outputBytes = 0;
|
|
150
|
+
this.promise = new Promise((resolvePromise, rejectPromise) => {
|
|
151
|
+
this.resolvePromise = resolvePromise;
|
|
152
|
+
this.rejectPromise = rejectPromise;
|
|
153
|
+
});
|
|
154
|
+
this.finished = new Promise((resolveFinished) => {
|
|
155
|
+
this.resolveFinished = resolveFinished;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
open() {
|
|
160
|
+
const result = spawnProcess(this.invocation);
|
|
161
|
+
if (result.error) {
|
|
162
|
+
this.reject(result.error);
|
|
163
|
+
this.finishClose();
|
|
164
|
+
}
|
|
165
|
+
else this.openChild(result.child);
|
|
166
|
+
return {
|
|
167
|
+
promise: this.promise,
|
|
168
|
+
finished: this.finished,
|
|
169
|
+
cancel: this.cancel.bind(this),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
openChild(child) {
|
|
174
|
+
this.child = child;
|
|
175
|
+
this.listen();
|
|
176
|
+
this.writeInput();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
listen() {
|
|
180
|
+
this.child.stdout.on('data', this.receiveStdout.bind(this));
|
|
181
|
+
this.child.stderr.on('data', this.receiveStderr.bind(this));
|
|
182
|
+
this.child.stdin.once('error', this.stdinError.bind(this));
|
|
183
|
+
this.child.once('error', this.childError.bind(this));
|
|
184
|
+
this.child.once('close', this.closeChild.bind(this));
|
|
185
|
+
this.signal?.addEventListener('abort', this.terminate.bind(this), { once: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
writeInput() {
|
|
189
|
+
if (this.invocation.input === 'stdin') this.child.stdin.end(this.input);
|
|
190
|
+
else this.child.stdin.end();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
receiveStdout(chunk) {
|
|
194
|
+
this.receiveOutput(this.stdout, chunk);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
receiveStderr(chunk) {
|
|
198
|
+
this.receiveOutput(this.stderr, chunk);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
receiveOutput(chunks, chunk) {
|
|
202
|
+
const nextBytes = this.outputBytes + chunk.length;
|
|
203
|
+
if (nextBytes <= maximumProviderOutputBytes) {
|
|
204
|
+
this.outputBytes = nextBytes;
|
|
205
|
+
chunks.push(chunk);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.terminate();
|
|
209
|
+
this.reject(new Error(`${this.agent} returned too much chat output`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
stdinError(error) {
|
|
213
|
+
if (error.code !== 'EPIPE') this.reject(error);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
childError(error) {
|
|
217
|
+
this.reject(error);
|
|
218
|
+
if (!this.running()) this.finishClose();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
running() {
|
|
222
|
+
return Number.isInteger(this.child?.pid) && this.child.exitCode === null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
sendSignal(signal) {
|
|
226
|
+
try {
|
|
227
|
+
this.child.kill(signal);
|
|
228
|
+
return true;
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
killChild() {
|
|
235
|
+
if (!this.running()) return;
|
|
236
|
+
this.sendSignal('SIGKILL');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
scheduleForceKill() {
|
|
240
|
+
if (this.forceKillTimer) return;
|
|
241
|
+
this.forceKillTimer = setTimeout(() => {
|
|
242
|
+
this.forceKillTimer = undefined;
|
|
243
|
+
this.killChild();
|
|
244
|
+
}, forcedChildKillDelayMs);
|
|
245
|
+
this.forceKillTimer.unref();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
terminate() {
|
|
249
|
+
if (!this.running()) return;
|
|
250
|
+
if (!this.sendSignal('SIGTERM')) return;
|
|
251
|
+
this.scheduleForceKill();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
clearForceKill() {
|
|
255
|
+
if (!this.forceKillTimer) return;
|
|
256
|
+
clearTimeout(this.forceKillTimer);
|
|
257
|
+
this.forceKillTimer = undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
settle(callback, value) {
|
|
261
|
+
if (this.settled) return;
|
|
262
|
+
this.settled = true;
|
|
263
|
+
callback(value);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
finishClose() {
|
|
267
|
+
if (this.closed) return;
|
|
268
|
+
this.closed = true;
|
|
269
|
+
this.clearForceKill();
|
|
270
|
+
removeTemporary(this.temporary);
|
|
271
|
+
this.resolveFinished();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
resolve(value) {
|
|
275
|
+
this.settle(this.resolvePromise, value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
reject(error) {
|
|
279
|
+
this.settle(this.rejectPromise, error);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
parseOutput() {
|
|
283
|
+
try {
|
|
284
|
+
this.resolve(parseAgentResponse(this.agent, childOutput(this.stdout)));
|
|
285
|
+
} catch (error) {
|
|
286
|
+
this.reject(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
closeChild(status, signal) {
|
|
291
|
+
this.clearForceKill();
|
|
292
|
+
if (!this.settled) {
|
|
293
|
+
const error = closeError(this, status, signal);
|
|
294
|
+
if (error) this.reject(error);
|
|
295
|
+
else this.parseOutput();
|
|
296
|
+
}
|
|
297
|
+
this.finishClose();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
cancel() {
|
|
301
|
+
if (this.settled) return;
|
|
302
|
+
this.terminate();
|
|
303
|
+
this.reject(cancelledError());
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function providerChild(options) {
|
|
308
|
+
return new ChildExecution(options).open();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function trackExecution(executions, execution) {
|
|
312
|
+
executions.add(execution);
|
|
313
|
+
execution.finished.finally(() => executions.delete(execution)).catch(noChange);
|
|
314
|
+
return execution;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function runRequest(options, executions, request) {
|
|
318
|
+
const prepared = preparedProviderRequest(options, request);
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
const execution = providerChild({
|
|
321
|
+
invocation: prepared.invocation,
|
|
322
|
+
input: JSON.stringify(request),
|
|
323
|
+
agent: options.agent,
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
temporary: prepared.temporary,
|
|
326
|
+
});
|
|
327
|
+
const tracked = trackExecution(executions, execution);
|
|
328
|
+
return {
|
|
329
|
+
promise: tracked.promise,
|
|
330
|
+
cancel() {
|
|
331
|
+
controller.abort();
|
|
332
|
+
tracked.cancel();
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function closeExecutions(executions) {
|
|
338
|
+
for (const execution of executions) execution.cancel();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function createCodingAgentChatProvider(options = {}) {
|
|
342
|
+
const configured = providerOptions(options);
|
|
343
|
+
const executions = new Set();
|
|
344
|
+
return {
|
|
345
|
+
run(request) {
|
|
346
|
+
return runRequest(configured, executions, request);
|
|
347
|
+
},
|
|
348
|
+
close() {
|
|
349
|
+
closeExecutions(executions);
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ReviewChatController } from './review-chat-controller.mjs';
|
|
2
|
+
|
|
3
|
+
export { ReviewChatError } from './review-chat-context.mjs';
|
|
4
|
+
export { createCodingAgentChatProvider } from './review-chat-provider.mjs';
|
|
5
|
+
|
|
6
|
+
export function createReviewChat(options) {
|
|
7
|
+
return new ReviewChatController(options).api();
|
|
8
|
+
}
|
package/scripts/serve-built.mjs
CHANGED
|
@@ -6,6 +6,11 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
6
6
|
import { createServer } from 'node:http';
|
|
7
7
|
import { dirname, extname, resolve, sep } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import {
|
|
10
|
+
ReviewChatError,
|
|
11
|
+
createCodingAgentChatProvider,
|
|
12
|
+
createReviewChat,
|
|
13
|
+
} from './review-chat.mjs';
|
|
9
14
|
|
|
10
15
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
16
|
const clientRoot = resolve(root, 'dist');
|
|
@@ -27,6 +32,13 @@ const portValue = option('--port', '2299');
|
|
|
27
32
|
const host = option('--host', 'localhost').replace(/^\[|\]$/g, '');
|
|
28
33
|
const access = option('--access', randomBytes(32).toString('base64url'));
|
|
29
34
|
const previousAccess = option('--previous-access', '');
|
|
35
|
+
const chatSnapshot = resolve(option('--chat-snapshot', output));
|
|
36
|
+
const chatAgent = option('--chat-agent');
|
|
37
|
+
const chatBinary = option('--chat-binary', chatAgent);
|
|
38
|
+
const chatModel = option('--chat-model');
|
|
39
|
+
const chatReasoning = option('--chat-reasoning');
|
|
40
|
+
const chatAccessMode = option('--chat-access-mode', 'snapshot-only');
|
|
41
|
+
const chatAccessRoot = option('--chat-access-root');
|
|
30
42
|
if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
|
|
31
43
|
throw new Error('--port must be a number from 0 to 65535');
|
|
32
44
|
}
|
|
@@ -36,6 +48,12 @@ if (!/^[A-Za-z0-9_-]{32,}$/.test(access)) {
|
|
|
36
48
|
if (previousAccess && !/^[A-Za-z0-9_-]{32,}$/.test(previousAccess)) {
|
|
37
49
|
throw new Error('--previous-access must be an unguessable URL-safe value');
|
|
38
50
|
}
|
|
51
|
+
if (!['checkout-read-only', 'snapshot-only'].includes(chatAccessMode)) {
|
|
52
|
+
throw new Error('--chat-access-mode must be checkout-read-only or snapshot-only');
|
|
53
|
+
}
|
|
54
|
+
if (chatAccessMode === 'checkout-read-only' && !chatAccessRoot) {
|
|
55
|
+
throw new Error('--chat-access-root is required for checkout chat access');
|
|
56
|
+
}
|
|
39
57
|
const incrementPort =
|
|
40
58
|
rawArgs.includes('--increment-port') || !rawArgs.includes('--port');
|
|
41
59
|
|
|
@@ -270,6 +288,60 @@ function jsonResponse(value, status = 200) {
|
|
|
270
288
|
});
|
|
271
289
|
}
|
|
272
290
|
|
|
291
|
+
const chatBodyLimitBytes = 64 * 1024;
|
|
292
|
+
|
|
293
|
+
function chatJsonContentType(value) {
|
|
294
|
+
return typeof value === 'string' &&
|
|
295
|
+
/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(value);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function chatRequestBody(request) {
|
|
299
|
+
if (!chatJsonContentType(request.headers['content-type'])) {
|
|
300
|
+
throw new ReviewChatError('Chat commands need application/json.', 415);
|
|
301
|
+
}
|
|
302
|
+
const contentLength = Number(request.headers['content-length']);
|
|
303
|
+
if (Number.isFinite(contentLength) && contentLength > chatBodyLimitBytes) {
|
|
304
|
+
throw new ReviewChatError('Chat command body is too large.', 413);
|
|
305
|
+
}
|
|
306
|
+
return new Promise((resolvePromise, reject) => {
|
|
307
|
+
const chunks = [];
|
|
308
|
+
let bytes = 0;
|
|
309
|
+
let settled = false;
|
|
310
|
+
const fail = (error) => {
|
|
311
|
+
if (settled) return;
|
|
312
|
+
settled = true;
|
|
313
|
+
request.resume();
|
|
314
|
+
reject(error);
|
|
315
|
+
};
|
|
316
|
+
request.on('data', (chunk) => {
|
|
317
|
+
bytes += chunk.length;
|
|
318
|
+
if (bytes > chatBodyLimitBytes) {
|
|
319
|
+
fail(new ReviewChatError('Chat command body is too large.', 413));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
chunks.push(chunk);
|
|
323
|
+
});
|
|
324
|
+
request.once('error', fail);
|
|
325
|
+
request.once('end', () => {
|
|
326
|
+
if (settled) return;
|
|
327
|
+
settled = true;
|
|
328
|
+
try {
|
|
329
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
330
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
331
|
+
throw new ReviewChatError('Chat commands must be JSON objects.');
|
|
332
|
+
}
|
|
333
|
+
resolvePromise(value);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
reject(
|
|
336
|
+
error instanceof ReviewChatError
|
|
337
|
+
? error
|
|
338
|
+
: new ReviewChatError('Chat command JSON is not valid.'),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
273
345
|
async function send(nodeResponse, webResponse) {
|
|
274
346
|
nodeResponse.writeHead(
|
|
275
347
|
webResponse.status,
|
|
@@ -287,6 +359,32 @@ let selectedPort = Number(portValue);
|
|
|
287
359
|
let readyState;
|
|
288
360
|
let closing = false;
|
|
289
361
|
|
|
362
|
+
function broadcastEvent(name, data = {}) {
|
|
363
|
+
const payload = JSON.stringify(data);
|
|
364
|
+
for (const client of eventClients) {
|
|
365
|
+
if (!client.destroyed) client.write(`event: ${name}\ndata: ${payload}\n\n`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const chatProviderAccess = chatAccessMode === 'checkout-read-only'
|
|
370
|
+
? { mode: chatAccessMode, root: resolve(chatAccessRoot) }
|
|
371
|
+
: { mode: chatAccessMode, reason: 'target-mismatch' };
|
|
372
|
+
const chatProvider = chatAgent
|
|
373
|
+
? createCodingAgentChatProvider({
|
|
374
|
+
agent: chatAgent,
|
|
375
|
+
binary: chatBinary,
|
|
376
|
+
model: chatModel,
|
|
377
|
+
reasoning: chatReasoning,
|
|
378
|
+
accessMode: chatProviderAccess,
|
|
379
|
+
})
|
|
380
|
+
: undefined;
|
|
381
|
+
const reviewChat = createReviewChat({
|
|
382
|
+
snapshotPath: chatSnapshot,
|
|
383
|
+
provider: chatProvider,
|
|
384
|
+
accessMode: chatProviderAccess,
|
|
385
|
+
onChange: () => broadcastEvent('chat'),
|
|
386
|
+
});
|
|
387
|
+
|
|
290
388
|
function requestAuthorityError(request) {
|
|
291
389
|
const localAddress = request.socket.localAddress || '';
|
|
292
390
|
if (!validHost(request.headers.host, localAddress)) {
|
|
@@ -298,14 +396,22 @@ function requestAuthorityError(request) {
|
|
|
298
396
|
return undefined;
|
|
299
397
|
}
|
|
300
398
|
|
|
399
|
+
function allowedMethods(url) {
|
|
400
|
+
return url.pathname === '/api/chat' ? ['GET', 'POST'] : ['GET', 'HEAD'];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function methodError(request, url) {
|
|
404
|
+
if (allowedMethods(url).includes(request.method)) return undefined;
|
|
405
|
+
return webResponse('Method not allowed', { status: 405 });
|
|
406
|
+
}
|
|
407
|
+
|
|
301
408
|
function requestContext(request) {
|
|
302
409
|
const url = parseRequestUrl(request);
|
|
303
410
|
if (!url) return { error: clientError() };
|
|
304
411
|
const authorityError = requestAuthorityError(request);
|
|
305
412
|
if (authorityError) return { error: authorityError };
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
413
|
+
const error = methodError(request, url);
|
|
414
|
+
if (error) return { error };
|
|
309
415
|
return { url };
|
|
310
416
|
}
|
|
311
417
|
|
|
@@ -349,20 +455,98 @@ async function routeResponse(url) {
|
|
|
349
455
|
return fetchAsset(url);
|
|
350
456
|
}
|
|
351
457
|
|
|
458
|
+
function chatAccessError(url) {
|
|
459
|
+
if (validAccess(url.searchParams.get('access'))) return undefined;
|
|
460
|
+
return webResponse('Forbidden', { status: 403 });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function chatCommandError(error) {
|
|
464
|
+
if (error instanceof ReviewChatError) {
|
|
465
|
+
return { status: error.status, message: error.message };
|
|
466
|
+
}
|
|
467
|
+
return { status: 400, message: 'Chat command failed.' };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function sendChatCommand(request, response) {
|
|
471
|
+
try {
|
|
472
|
+
const result = reviewChat.command(await chatRequestBody(request));
|
|
473
|
+
const status = result.accepted ? 202 : 200;
|
|
474
|
+
await send(response, jsonResponse(result.state, status));
|
|
475
|
+
} catch (error) {
|
|
476
|
+
const failure = chatCommandError(error);
|
|
477
|
+
await send(response, jsonResponse({ error: failure.message }, failure.status));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function handleChatRequest(request, response, url) {
|
|
482
|
+
const accessError = chatAccessError(url);
|
|
483
|
+
if (accessError) {
|
|
484
|
+
await send(response, accessError);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (request.method === 'GET') {
|
|
488
|
+
await send(response, jsonResponse(reviewChat.getState()));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
await sendChatCommand(request, response);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function handleEventsRequest(_request, response, url) {
|
|
495
|
+
await sendEvents(response, url);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function handlerFor(url) {
|
|
499
|
+
return {
|
|
500
|
+
'/events': handleEventsRequest,
|
|
501
|
+
'/api/chat': handleChatRequest,
|
|
502
|
+
}[url.pathname];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function throwForcedHandlerFailure(url) {
|
|
506
|
+
if (forcedHandlerFailure(url)) throw new Error('forced request handler failure');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function handleRoutedRequest(request, response, url) {
|
|
510
|
+
const handler = handlerFor(url);
|
|
511
|
+
if (handler) {
|
|
512
|
+
await handler(request, response, url);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await send(response, await routeResponse(url));
|
|
516
|
+
}
|
|
517
|
+
|
|
352
518
|
async function handleRequest(request, response) {
|
|
353
519
|
const context = requestContext(request);
|
|
354
520
|
if (context.error) {
|
|
355
521
|
await send(response, context.error);
|
|
356
522
|
return;
|
|
357
523
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
524
|
+
throwForcedHandlerFailure(context.url);
|
|
525
|
+
await handleRoutedRequest(request, response, context.url);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function stopWatchers() {
|
|
529
|
+
unwatchFile(output);
|
|
530
|
+
if (chatSnapshot !== output) unwatchFile(chatSnapshot);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function closeEventClients() {
|
|
534
|
+
for (const client of eventClients) client.end();
|
|
535
|
+
eventClients.clear();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function closeServer() {
|
|
539
|
+
if (server.listening) server.close();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function close(exitCode = 0) {
|
|
543
|
+
if (closing) return;
|
|
544
|
+
closing = true;
|
|
545
|
+
process.exitCode = exitCode;
|
|
546
|
+
stopWatchers();
|
|
547
|
+
reviewChat.close();
|
|
548
|
+
closeEventClients();
|
|
549
|
+
closeServer();
|
|
366
550
|
}
|
|
367
551
|
|
|
368
552
|
async function containHandlerFailure(response) {
|
|
@@ -390,11 +574,19 @@ watchFile(output, { interval: 100 }, (current, previous) => {
|
|
|
390
574
|
if (current.mtimeMs === previous.mtimeMs && current.size === previous.size) {
|
|
391
575
|
return;
|
|
392
576
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
577
|
+
reviewChat.refresh();
|
|
578
|
+
broadcastEvent('update');
|
|
396
579
|
});
|
|
397
580
|
|
|
581
|
+
if (chatSnapshot !== output) {
|
|
582
|
+
watchFile(chatSnapshot, { interval: 100 }, (current, previous) => {
|
|
583
|
+
if (current.mtimeMs === previous.mtimeMs && current.size === previous.size) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
reviewChat.refresh();
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
398
590
|
function urlFor(address, port) {
|
|
399
591
|
const formattedAddress = address.includes(':') ? `[${address}]` : address;
|
|
400
592
|
return `http://${formattedAddress}:${port}`;
|
|
@@ -454,15 +646,5 @@ server.on('error', (error) => {
|
|
|
454
646
|
|
|
455
647
|
listen();
|
|
456
648
|
|
|
457
|
-
function close(exitCode = 0) {
|
|
458
|
-
if (closing) return;
|
|
459
|
-
closing = true;
|
|
460
|
-
process.exitCode = exitCode;
|
|
461
|
-
unwatchFile(output);
|
|
462
|
-
for (const client of eventClients) client.end();
|
|
463
|
-
eventClients.clear();
|
|
464
|
-
if (server.listening) server.close();
|
|
465
|
-
}
|
|
466
|
-
|
|
467
649
|
process.on('SIGINT', () => close());
|
|
468
650
|
process.on('SIGTERM', () => close());
|