patchrelay 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 +271 -0
- package/config/patchrelay.example.json +5 -0
- package/dist/build-info.js +29 -0
- package/dist/build-info.json +6 -0
- package/dist/cli/data.js +461 -0
- package/dist/cli/formatters/json.js +3 -0
- package/dist/cli/formatters/text.js +119 -0
- package/dist/cli/index.js +761 -0
- package/dist/codex-app-server.js +353 -0
- package/dist/codex-types.js +1 -0
- package/dist/config-types.js +1 -0
- package/dist/config.js +494 -0
- package/dist/db/authoritative-ledger-store.js +437 -0
- package/dist/db/issue-workflow-store.js +690 -0
- package/dist/db/linear-installation-store.js +184 -0
- package/dist/db/migrations.js +183 -0
- package/dist/db/shared.js +101 -0
- package/dist/db/stage-event-store.js +33 -0
- package/dist/db/webhook-event-store.js +46 -0
- package/dist/db-ports.js +5 -0
- package/dist/db-types.js +1 -0
- package/dist/db.js +40 -0
- package/dist/file-permissions.js +40 -0
- package/dist/http.js +321 -0
- package/dist/index.js +69 -0
- package/dist/install.js +302 -0
- package/dist/installation-ports.js +1 -0
- package/dist/issue-query-service.js +68 -0
- package/dist/ledger-ports.js +1 -0
- package/dist/linear-client.js +338 -0
- package/dist/linear-oauth-service.js +131 -0
- package/dist/linear-oauth.js +154 -0
- package/dist/linear-types.js +1 -0
- package/dist/linear-workflow.js +78 -0
- package/dist/logging.js +62 -0
- package/dist/preflight.js +227 -0
- package/dist/project-resolution.js +51 -0
- package/dist/reconciliation-action-applier.js +55 -0
- package/dist/reconciliation-actions.js +1 -0
- package/dist/reconciliation-engine.js +312 -0
- package/dist/reconciliation-snapshot-builder.js +96 -0
- package/dist/reconciliation-types.js +1 -0
- package/dist/runtime-paths.js +89 -0
- package/dist/service-queue.js +49 -0
- package/dist/service-runtime.js +96 -0
- package/dist/service-stage-finalizer.js +348 -0
- package/dist/service-stage-runner.js +233 -0
- package/dist/service-webhook-processor.js +181 -0
- package/dist/service-webhooks.js +148 -0
- package/dist/service.js +139 -0
- package/dist/stage-agent-activity-publisher.js +33 -0
- package/dist/stage-event-ports.js +1 -0
- package/dist/stage-failure.js +92 -0
- package/dist/stage-launch.js +54 -0
- package/dist/stage-lifecycle-publisher.js +213 -0
- package/dist/stage-reporting.js +153 -0
- package/dist/stage-turn-input-dispatcher.js +102 -0
- package/dist/token-crypto.js +21 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +163 -0
- package/dist/webhook-agent-session-handler.js +157 -0
- package/dist/webhook-archive.js +24 -0
- package/dist/webhook-comment-handler.js +89 -0
- package/dist/webhook-desired-stage-recorder.js +150 -0
- package/dist/webhook-event-ports.js +1 -0
- package/dist/webhook-installation-handler.js +57 -0
- package/dist/webhooks.js +301 -0
- package/dist/workflow-policy.js +42 -0
- package/dist/workflow-ports.js +1 -0
- package/dist/workflow-types.js +1 -0
- package/dist/worktree-manager.js +66 -0
- package/infra/patchrelay-reload.service +6 -0
- package/infra/patchrelay.path +11 -0
- package/infra/patchrelay.service +28 -0
- package/package.json +55 -0
- package/runtime.env.example +8 -0
- package/service.env.example +7 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { sanitizeDiagnosticText } from "./utils.js";
|
|
4
|
+
export function resolveCodexAppServerLaunch(config) {
|
|
5
|
+
if (!config.sourceBashrc) {
|
|
6
|
+
return {
|
|
7
|
+
command: config.bin,
|
|
8
|
+
args: config.args,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
command: config.shellBin ?? "bash",
|
|
13
|
+
args: [
|
|
14
|
+
"-lc",
|
|
15
|
+
'source ~/.bashrc >/dev/null 2>&1 || true; exec "$0" "$@"',
|
|
16
|
+
config.bin,
|
|
17
|
+
...config.args,
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export class CodexAppServerClient extends EventEmitter {
|
|
22
|
+
config;
|
|
23
|
+
logger;
|
|
24
|
+
spawnProcess;
|
|
25
|
+
child;
|
|
26
|
+
nextRequestId = 1;
|
|
27
|
+
pending = new Map();
|
|
28
|
+
stdoutBuffer = "";
|
|
29
|
+
started = false;
|
|
30
|
+
stopping = false;
|
|
31
|
+
constructor(config, logger, spawnProcess = spawn) {
|
|
32
|
+
super();
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
this.spawnProcess = spawnProcess;
|
|
36
|
+
}
|
|
37
|
+
isStarted() {
|
|
38
|
+
return this.started;
|
|
39
|
+
}
|
|
40
|
+
async start() {
|
|
41
|
+
if (this.started) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this.stopping = false;
|
|
45
|
+
const launch = resolveCodexAppServerLaunch(this.config);
|
|
46
|
+
this.logger.info({ command: launch.command, args: launch.args }, "Starting Codex app-server");
|
|
47
|
+
this.child = this.spawnProcess(launch.command, launch.args, {
|
|
48
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
49
|
+
});
|
|
50
|
+
this.child.stderr.on("data", (chunk) => {
|
|
51
|
+
const line = chunk.toString().trim();
|
|
52
|
+
if (line) {
|
|
53
|
+
this.logger.warn({ output: sanitizeDiagnosticText(line) }, "Codex app-server stderr");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
this.child.on("error", (error) => {
|
|
57
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
58
|
+
this.logger.error({
|
|
59
|
+
error: sanitizeDiagnosticText(err.message),
|
|
60
|
+
pendingRequestCount: this.pending.size,
|
|
61
|
+
}, "Codex app-server process errored");
|
|
62
|
+
this.rejectAllPending(err);
|
|
63
|
+
});
|
|
64
|
+
this.child.on("close", (code, signal) => {
|
|
65
|
+
this.started = false;
|
|
66
|
+
const log = this.stopping ? this.logger.info.bind(this.logger) : this.logger.warn.bind(this.logger);
|
|
67
|
+
log({
|
|
68
|
+
code: code ?? 1,
|
|
69
|
+
signal: signal ?? null,
|
|
70
|
+
pendingRequestCount: this.pending.size,
|
|
71
|
+
}, this.stopping ? "Codex app-server stopped" : "Codex app-server exited");
|
|
72
|
+
this.stopping = false;
|
|
73
|
+
this.rejectAllPending(new Error(`Codex app-server exited with code ${code ?? 1}`));
|
|
74
|
+
});
|
|
75
|
+
this.child.stdout.on("data", (chunk) => {
|
|
76
|
+
this.stdoutBuffer += chunk.toString("utf8");
|
|
77
|
+
this.drainMessages();
|
|
78
|
+
});
|
|
79
|
+
const initializeResponse = await this.sendRequest("initialize", {
|
|
80
|
+
clientInfo: {
|
|
81
|
+
name: "patchrelay",
|
|
82
|
+
title: "PatchRelay",
|
|
83
|
+
version: "0.1.0",
|
|
84
|
+
},
|
|
85
|
+
capabilities: null,
|
|
86
|
+
});
|
|
87
|
+
const serverInfo = initializeResponse && typeof initializeResponse === "object" && "serverInfo" in initializeResponse
|
|
88
|
+
? initializeResponse.serverInfo
|
|
89
|
+
: undefined;
|
|
90
|
+
this.logger.info({
|
|
91
|
+
serverName: typeof serverInfo?.name === "string" ? serverInfo.name : undefined,
|
|
92
|
+
serverVersion: typeof serverInfo?.version === "string" ? serverInfo.version : undefined,
|
|
93
|
+
}, "Connected to Codex app-server");
|
|
94
|
+
this.sendNotification("initialized");
|
|
95
|
+
this.started = true;
|
|
96
|
+
}
|
|
97
|
+
async stop() {
|
|
98
|
+
if (!this.child) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.logger.info("Stopping Codex app-server");
|
|
102
|
+
this.stopping = true;
|
|
103
|
+
this.child.kill("SIGTERM");
|
|
104
|
+
this.child = undefined;
|
|
105
|
+
this.started = false;
|
|
106
|
+
}
|
|
107
|
+
async startThread(options) {
|
|
108
|
+
const params = {
|
|
109
|
+
cwd: options.cwd,
|
|
110
|
+
approvalPolicy: this.config.approvalPolicy,
|
|
111
|
+
sandbox: this.config.sandboxMode,
|
|
112
|
+
serviceName: this.config.serviceName ?? "patchrelay",
|
|
113
|
+
model: this.config.model ?? null,
|
|
114
|
+
modelProvider: this.config.modelProvider ?? null,
|
|
115
|
+
baseInstructions: this.config.baseInstructions ?? null,
|
|
116
|
+
developerInstructions: this.config.developerInstructions ?? null,
|
|
117
|
+
experimentalRawEvents: false,
|
|
118
|
+
};
|
|
119
|
+
if (this.config.persistExtendedHistory) {
|
|
120
|
+
this.logger.warn("persistExtendedHistory is requested but not enabled in the active app-server capability handshake; ignoring");
|
|
121
|
+
}
|
|
122
|
+
const response = (await this.sendRequest("thread/start", params));
|
|
123
|
+
return this.mapThread(response.thread);
|
|
124
|
+
}
|
|
125
|
+
async resumeThread(threadId, cwd) {
|
|
126
|
+
const params = {
|
|
127
|
+
threadId,
|
|
128
|
+
cwd: cwd ?? null,
|
|
129
|
+
approvalPolicy: this.config.approvalPolicy,
|
|
130
|
+
sandbox: this.config.sandboxMode,
|
|
131
|
+
model: this.config.model ?? null,
|
|
132
|
+
modelProvider: this.config.modelProvider ?? null,
|
|
133
|
+
baseInstructions: this.config.baseInstructions ?? null,
|
|
134
|
+
developerInstructions: this.config.developerInstructions ?? null,
|
|
135
|
+
};
|
|
136
|
+
if (this.config.persistExtendedHistory) {
|
|
137
|
+
this.logger.warn("persistExtendedHistory is requested but not enabled in the active app-server capability handshake; ignoring");
|
|
138
|
+
}
|
|
139
|
+
const response = (await this.sendRequest("thread/resume", params));
|
|
140
|
+
return this.mapThread(response.thread);
|
|
141
|
+
}
|
|
142
|
+
async forkThread(threadId, cwd) {
|
|
143
|
+
const params = {
|
|
144
|
+
threadId,
|
|
145
|
+
cwd: cwd ?? null,
|
|
146
|
+
approvalPolicy: this.config.approvalPolicy,
|
|
147
|
+
sandbox: this.config.sandboxMode,
|
|
148
|
+
model: this.config.model ?? null,
|
|
149
|
+
modelProvider: this.config.modelProvider ?? null,
|
|
150
|
+
baseInstructions: this.config.baseInstructions ?? null,
|
|
151
|
+
developerInstructions: this.config.developerInstructions ?? null,
|
|
152
|
+
};
|
|
153
|
+
if (this.config.persistExtendedHistory) {
|
|
154
|
+
this.logger.warn("persistExtendedHistory is requested but not enabled in the active app-server capability handshake; ignoring");
|
|
155
|
+
}
|
|
156
|
+
const response = (await this.sendRequest("thread/fork", params));
|
|
157
|
+
return this.mapThread(response.thread);
|
|
158
|
+
}
|
|
159
|
+
async startTurn(options) {
|
|
160
|
+
const response = (await this.sendRequest("turn/start", {
|
|
161
|
+
threadId: options.threadId,
|
|
162
|
+
cwd: options.cwd,
|
|
163
|
+
input: [
|
|
164
|
+
{
|
|
165
|
+
type: "text",
|
|
166
|
+
text: options.input,
|
|
167
|
+
text_elements: [],
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
}));
|
|
171
|
+
return {
|
|
172
|
+
threadId: options.threadId,
|
|
173
|
+
turnId: String(response.turn.id),
|
|
174
|
+
status: String(response.turn.status),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
async readThread(threadId, includeTurns = true) {
|
|
178
|
+
const response = (await this.sendRequest("thread/read", {
|
|
179
|
+
threadId,
|
|
180
|
+
includeTurns,
|
|
181
|
+
}));
|
|
182
|
+
return this.mapThread(response.thread);
|
|
183
|
+
}
|
|
184
|
+
async listThreads() {
|
|
185
|
+
const response = (await this.sendRequest("thread/list", {}));
|
|
186
|
+
return response.data.map((thread) => this.mapThread(thread));
|
|
187
|
+
}
|
|
188
|
+
async steerTurn(options) {
|
|
189
|
+
await this.sendRequest("turn/steer", {
|
|
190
|
+
threadId: options.threadId,
|
|
191
|
+
expectedTurnId: options.turnId,
|
|
192
|
+
input: [
|
|
193
|
+
{
|
|
194
|
+
type: "text",
|
|
195
|
+
text: options.input,
|
|
196
|
+
text_elements: [],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
sendNotification(method, params) {
|
|
202
|
+
this.writeMessage({
|
|
203
|
+
jsonrpc: "2.0",
|
|
204
|
+
method,
|
|
205
|
+
...(params === undefined ? {} : { params }),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async sendRequest(method, params) {
|
|
209
|
+
if (!this.child?.stdin) {
|
|
210
|
+
throw new Error("Codex app-server is not running");
|
|
211
|
+
}
|
|
212
|
+
const id = this.nextRequestId++;
|
|
213
|
+
const promise = new Promise((resolve, reject) => {
|
|
214
|
+
this.pending.set(id, { resolve, reject });
|
|
215
|
+
});
|
|
216
|
+
this.writeMessage({
|
|
217
|
+
jsonrpc: "2.0",
|
|
218
|
+
id,
|
|
219
|
+
method,
|
|
220
|
+
params,
|
|
221
|
+
});
|
|
222
|
+
return promise.catch((error) => {
|
|
223
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
224
|
+
this.logger.error({
|
|
225
|
+
method,
|
|
226
|
+
requestId: id,
|
|
227
|
+
error: sanitizeDiagnosticText(err.message),
|
|
228
|
+
}, "Codex app-server request failed");
|
|
229
|
+
throw err;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
writeMessage(message) {
|
|
233
|
+
if (!this.child?.stdin) {
|
|
234
|
+
throw new Error("Codex app-server stdin is unavailable");
|
|
235
|
+
}
|
|
236
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
237
|
+
}
|
|
238
|
+
drainMessages() {
|
|
239
|
+
while (true) {
|
|
240
|
+
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
241
|
+
if (newlineIndex === -1) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
245
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
246
|
+
if (!line) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
this.handleMessage(JSON.parse(line));
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
254
|
+
this.started = false;
|
|
255
|
+
this.logger.error({
|
|
256
|
+
error: sanitizeDiagnosticText(err.message),
|
|
257
|
+
output: sanitizeDiagnosticText(line),
|
|
258
|
+
}, "Failed to parse Codex app-server stdout message");
|
|
259
|
+
this.rejectAllPending(new Error(`Codex app-server emitted invalid JSON: ${err.message}`));
|
|
260
|
+
this.child?.kill("SIGTERM");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
handleMessage(message) {
|
|
266
|
+
if ("method" in message && "id" in message) {
|
|
267
|
+
void this.handleServerRequest(message);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if ("method" in message) {
|
|
271
|
+
const notification = {
|
|
272
|
+
method: message.method,
|
|
273
|
+
params: (message.params ?? {}),
|
|
274
|
+
};
|
|
275
|
+
this.emit("notification", notification);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const id = Number(message.id);
|
|
279
|
+
const pending = this.pending.get(id);
|
|
280
|
+
if (!pending) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.pending.delete(id);
|
|
284
|
+
if ("error" in message) {
|
|
285
|
+
pending.reject(new Error(JSON.stringify(message.error)));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
pending.resolve(message.result);
|
|
289
|
+
}
|
|
290
|
+
async handleServerRequest(request) {
|
|
291
|
+
const id = request.id;
|
|
292
|
+
let result;
|
|
293
|
+
switch (request.method) {
|
|
294
|
+
case "item/commandExecution/requestApproval":
|
|
295
|
+
result = { decision: this.resolveSessionApprovalDecision() };
|
|
296
|
+
break;
|
|
297
|
+
case "item/fileChange/requestApproval":
|
|
298
|
+
result = { decision: this.resolveSessionApprovalDecision() };
|
|
299
|
+
break;
|
|
300
|
+
case "execCommandApproval":
|
|
301
|
+
result = { decision: this.resolveOneShotApprovalDecision() };
|
|
302
|
+
break;
|
|
303
|
+
case "applyPatchApproval":
|
|
304
|
+
result = { decision: this.resolveOneShotApprovalDecision() };
|
|
305
|
+
break;
|
|
306
|
+
default:
|
|
307
|
+
result = null;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
this.writeMessage({
|
|
311
|
+
jsonrpc: "2.0",
|
|
312
|
+
id,
|
|
313
|
+
result,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
resolveSessionApprovalDecision() {
|
|
317
|
+
return this.config.approvalPolicy === "never" ? "acceptForSession" : "rejectForSession";
|
|
318
|
+
}
|
|
319
|
+
resolveOneShotApprovalDecision() {
|
|
320
|
+
return this.config.approvalPolicy === "never" ? "accept" : "reject";
|
|
321
|
+
}
|
|
322
|
+
rejectAllPending(error) {
|
|
323
|
+
for (const pending of this.pending.values()) {
|
|
324
|
+
pending.reject(error);
|
|
325
|
+
}
|
|
326
|
+
this.pending.clear();
|
|
327
|
+
}
|
|
328
|
+
mapThread(thread) {
|
|
329
|
+
const turns = Array.isArray(thread.turns) ? thread.turns : [];
|
|
330
|
+
const rawStatus = thread.status;
|
|
331
|
+
const status = rawStatus && typeof rawStatus === "object" && "type" in rawStatus
|
|
332
|
+
? String(rawStatus.type)
|
|
333
|
+
: String(rawStatus ?? "unknown");
|
|
334
|
+
return {
|
|
335
|
+
id: String(thread.id),
|
|
336
|
+
preview: String(thread.preview ?? ""),
|
|
337
|
+
cwd: String(thread.cwd ?? ""),
|
|
338
|
+
status,
|
|
339
|
+
...(thread.path === null || thread.path === undefined ? {} : { path: String(thread.path) }),
|
|
340
|
+
turns: turns.map((turn) => {
|
|
341
|
+
const value = turn;
|
|
342
|
+
return {
|
|
343
|
+
id: String(value.id),
|
|
344
|
+
status: String(value.status),
|
|
345
|
+
...(value.error && typeof value.error === "object"
|
|
346
|
+
? { error: { message: String(value.error.message ?? "Unknown error") } }
|
|
347
|
+
: {}),
|
|
348
|
+
items: Array.isArray(value.items) ? value.items : [],
|
|
349
|
+
};
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|