patchrelay 0.12.5 → 0.12.7
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/dist/build-info.json +3 -3
- package/dist/db.js +10 -0
- package/dist/github-webhook-handler.js +19 -19
- package/dist/run-orchestrator.js +74 -0
- package/dist/service-runtime.js +11 -1
- package/dist/service.js +23 -6
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/db.js
CHANGED
|
@@ -217,6 +217,16 @@ export class PatchRelayDatabase {
|
|
|
217
217
|
linearIssueId: String(row.linear_issue_id),
|
|
218
218
|
}));
|
|
219
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Issues idle in pr_open with no active run — candidates for state
|
|
222
|
+
* advancement based on stored PR metadata (missed GitHub webhooks).
|
|
223
|
+
*/
|
|
224
|
+
listIdlePrOpenIssues() {
|
|
225
|
+
const rows = this.connection
|
|
226
|
+
.prepare("SELECT * FROM issues WHERE factory_state = 'pr_open' AND active_run_id IS NULL AND pending_run_type IS NULL AND pr_number IS NOT NULL")
|
|
227
|
+
.all();
|
|
228
|
+
return rows.map(mapIssueRow);
|
|
229
|
+
}
|
|
220
230
|
listIssuesByState(projectId, state) {
|
|
221
231
|
const rows = this.connection
|
|
222
232
|
.prepare("SELECT * FROM issues WHERE project_id = ? AND factory_state = ? ORDER BY pr_number ASC")
|
|
@@ -226,32 +226,32 @@ export class GitHubWebhookHandler {
|
|
|
226
226
|
async emitLinearActivity(issue, newState, event) {
|
|
227
227
|
if (!issue.agentSessionId)
|
|
228
228
|
return;
|
|
229
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
230
|
-
if (!linear?.createAgentActivity)
|
|
231
|
-
return;
|
|
232
|
-
const messages = {
|
|
233
|
-
pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
|
|
234
|
-
awaiting_queue: "PR approved. Preparing merge.",
|
|
235
|
-
changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
|
|
236
|
-
repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
|
|
237
|
-
repairing_queue: "Merge conflict with base branch. Starting repair.",
|
|
238
|
-
done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
|
|
239
|
-
failed: "PR was closed without merging.",
|
|
240
|
-
};
|
|
241
|
-
const body = messages[newState];
|
|
242
|
-
if (!body)
|
|
243
|
-
return;
|
|
244
|
-
const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
|
|
245
|
-
? "error"
|
|
246
|
-
: "response";
|
|
247
229
|
try {
|
|
230
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
231
|
+
if (!linear?.createAgentActivity)
|
|
232
|
+
return;
|
|
233
|
+
const messages = {
|
|
234
|
+
pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
|
|
235
|
+
awaiting_queue: "PR approved. Preparing merge.",
|
|
236
|
+
changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
|
|
237
|
+
repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
|
|
238
|
+
repairing_queue: "Merge conflict with base branch. Starting repair.",
|
|
239
|
+
done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
|
|
240
|
+
failed: "PR was closed without merging.",
|
|
241
|
+
};
|
|
242
|
+
const body = messages[newState];
|
|
243
|
+
if (!body)
|
|
244
|
+
return;
|
|
245
|
+
const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
|
|
246
|
+
? "error"
|
|
247
|
+
: "response";
|
|
248
248
|
await linear.createAgentActivity({
|
|
249
249
|
agentSessionId: issue.agentSessionId,
|
|
250
250
|
content: { type, body },
|
|
251
251
|
});
|
|
252
252
|
}
|
|
253
253
|
catch {
|
|
254
|
-
// Non-blocking — don't
|
|
254
|
+
// Non-blocking — don't crash the webhook handler for a Linear activity error
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
3
4
|
import { buildHookEnv, runProjectHook } from "./hook-runner.js";
|
|
4
5
|
import { buildRunningSessionPlan, buildCompletedSessionPlan, buildFailedSessionPlan, } from "./agent-session-plan.js";
|
|
5
6
|
import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
|
|
@@ -275,6 +276,22 @@ export class RunOrchestrator {
|
|
|
275
276
|
// Complete the run
|
|
276
277
|
const trackedIssue = this.db.issueToTrackedIssue(issue);
|
|
277
278
|
const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
|
|
279
|
+
// Determine post-run state. When a re-run finds the PR already exists
|
|
280
|
+
// and makes no changes, no pr_opened webhook arrives — the state would
|
|
281
|
+
// stay in the active-run state forever. Advance based on PR metadata.
|
|
282
|
+
const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
283
|
+
let postRunState;
|
|
284
|
+
if (ACTIVE_RUN_STATES.has(freshIssue.factoryState) && freshIssue.prNumber) {
|
|
285
|
+
if (freshIssue.prReviewState === "approved") {
|
|
286
|
+
postRunState = "awaiting_queue";
|
|
287
|
+
}
|
|
288
|
+
else if (freshIssue.prState === "merged") {
|
|
289
|
+
postRunState = "done";
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
postRunState = "pr_open";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
278
295
|
this.db.transaction(() => {
|
|
279
296
|
this.db.finishRun(run.id, {
|
|
280
297
|
status: "completed",
|
|
@@ -287,8 +304,14 @@ export class RunOrchestrator {
|
|
|
287
304
|
projectId: run.projectId,
|
|
288
305
|
linearIssueId: run.linearIssueId,
|
|
289
306
|
activeRunId: null,
|
|
307
|
+
...(postRunState ? { factoryState: postRunState } : {}),
|
|
308
|
+
...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
290
309
|
});
|
|
291
310
|
});
|
|
311
|
+
// If we advanced to awaiting_queue, enqueue for merge prep
|
|
312
|
+
if (postRunState === "awaiting_queue") {
|
|
313
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
314
|
+
}
|
|
292
315
|
this.feed?.publish({
|
|
293
316
|
level: "info",
|
|
294
317
|
kind: "turn",
|
|
@@ -326,6 +349,57 @@ export class RunOrchestrator {
|
|
|
326
349
|
for (const run of this.db.listRunningRuns()) {
|
|
327
350
|
await this.reconcileRun(run);
|
|
328
351
|
}
|
|
352
|
+
// Advance issues stuck in pr_open whose stored PR metadata already
|
|
353
|
+
// shows they should transition (e.g. approved PR, missed webhook).
|
|
354
|
+
await this.reconcileIdlePrOpenIssues();
|
|
355
|
+
}
|
|
356
|
+
async reconcileIdlePrOpenIssues() {
|
|
357
|
+
for (const issue of this.db.listIdlePrOpenIssues()) {
|
|
358
|
+
if (issue.prState === "merged") {
|
|
359
|
+
this.advanceIdleIssue(issue, "done");
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (issue.prReviewState === "approved") {
|
|
363
|
+
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
// Stored metadata may be stale (missed webhooks during downtime).
|
|
367
|
+
// Query GitHub for the actual PR review state.
|
|
368
|
+
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
369
|
+
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
370
|
+
continue;
|
|
371
|
+
try {
|
|
372
|
+
const { stdout } = await execCommand("gh", [
|
|
373
|
+
"pr", "view", String(issue.prNumber),
|
|
374
|
+
"--repo", project.github.repoFullName,
|
|
375
|
+
"--json", "state,reviewDecision",
|
|
376
|
+
], { timeoutMs: 10_000 });
|
|
377
|
+
const pr = JSON.parse(stdout);
|
|
378
|
+
if (pr.state === "MERGED") {
|
|
379
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
380
|
+
this.advanceIdleIssue(issue, "done");
|
|
381
|
+
}
|
|
382
|
+
else if (pr.reviewDecision === "APPROVED") {
|
|
383
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
384
|
+
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
advanceIdleIssue(issue, newState) {
|
|
393
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState }, "Reconciliation: advancing idle issue from stored PR metadata");
|
|
394
|
+
this.db.upsertIssue({
|
|
395
|
+
projectId: issue.projectId,
|
|
396
|
+
linearIssueId: issue.linearIssueId,
|
|
397
|
+
factoryState: newState,
|
|
398
|
+
...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
399
|
+
});
|
|
400
|
+
if (newState === "awaiting_queue") {
|
|
401
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
402
|
+
}
|
|
329
403
|
}
|
|
330
404
|
async reconcileRun(run) {
|
|
331
405
|
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
package/dist/service-runtime.js
CHANGED
|
@@ -14,6 +14,7 @@ export class ServiceRuntime {
|
|
|
14
14
|
webhookQueue;
|
|
15
15
|
issueQueue;
|
|
16
16
|
ready = false;
|
|
17
|
+
linearConnected = false;
|
|
17
18
|
startupError;
|
|
18
19
|
reconcileTimer;
|
|
19
20
|
reconcileInProgress = false;
|
|
@@ -54,10 +55,14 @@ export class ServiceRuntime {
|
|
|
54
55
|
enqueueIssue(projectId, issueId) {
|
|
55
56
|
this.issueQueue.enqueue({ projectId, issueId });
|
|
56
57
|
}
|
|
58
|
+
setLinearConnected(connected) {
|
|
59
|
+
this.linearConnected = connected;
|
|
60
|
+
}
|
|
57
61
|
getReadiness() {
|
|
58
62
|
return {
|
|
59
|
-
ready: this.ready && this.codex.isStarted(),
|
|
63
|
+
ready: this.ready && this.codex.isStarted() && this.linearConnected,
|
|
60
64
|
codexStarted: this.codex.isStarted(),
|
|
65
|
+
linearConnected: this.linearConnected,
|
|
61
66
|
...(this.startupError ? { startupError: this.startupError } : {}),
|
|
62
67
|
};
|
|
63
68
|
}
|
|
@@ -85,6 +90,11 @@ export class ServiceRuntime {
|
|
|
85
90
|
this.reconcileInProgress = true;
|
|
86
91
|
try {
|
|
87
92
|
await promiseWithTimeout(this.runReconciler.reconcileActiveRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-run reconciliation");
|
|
93
|
+
// Pick up issues that became ready outside the webhook path
|
|
94
|
+
// (e.g. CLI retry, manual DB edits) without requiring a restart.
|
|
95
|
+
for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
|
|
96
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
97
|
+
}
|
|
88
98
|
}
|
|
89
99
|
catch (error) {
|
|
90
100
|
this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Background active-run reconciliation failed");
|
package/dist/service.js
CHANGED
|
@@ -73,24 +73,37 @@ export class PatchRelayService {
|
|
|
73
73
|
this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
|
|
74
74
|
}
|
|
75
75
|
this.codex.on("notification", (notification) => {
|
|
76
|
-
|
|
76
|
+
this.orchestrator.handleCodexNotification(notification).catch((error) => {
|
|
77
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
78
|
+
logger.error({ method: notification.method, error: msg }, "Unhandled error in Codex notification handler");
|
|
79
|
+
});
|
|
77
80
|
});
|
|
78
81
|
}
|
|
79
82
|
async start() {
|
|
80
83
|
// Verify Linear connectivity for all configured projects before starting.
|
|
81
|
-
//
|
|
84
|
+
// Auth errors do not prevent startup (the OAuth callback must be reachable
|
|
85
|
+
// for `patchrelay connect`), but the service reports NOT READY until at
|
|
86
|
+
// least one project has a working Linear token.
|
|
87
|
+
let anyLinearConnected = false;
|
|
82
88
|
for (const project of this.config.projects) {
|
|
83
89
|
try {
|
|
84
90
|
const client = await this.linearProvider.forProject(project.id);
|
|
85
|
-
if (
|
|
86
|
-
|
|
91
|
+
if (client) {
|
|
92
|
+
anyLinearConnected = true;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay connect' to authorize");
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
catch (error) {
|
|
90
99
|
const msg = error instanceof Error ? error.message : String(error);
|
|
91
|
-
|
|
100
|
+
this.logger.error({ projectId: project.id, error: msg }, "Linear auth failed — run 'patchrelay connect' to refresh the token. Runs for this project will fail until re-authorized.");
|
|
92
101
|
}
|
|
93
102
|
}
|
|
103
|
+
this.runtime.setLinearConnected(anyLinearConnected);
|
|
104
|
+
if (!anyLinearConnected && this.config.projects.length > 0) {
|
|
105
|
+
this.logger.error("No projects have working Linear auth — service is NOT READY. Run 'patchrelay connect' to authorize.");
|
|
106
|
+
}
|
|
94
107
|
if (this.githubAppTokenManager) {
|
|
95
108
|
await ensureGhWrapper(this.logger);
|
|
96
109
|
await this.githubAppTokenManager.start();
|
|
@@ -110,7 +123,11 @@ export class PatchRelayService {
|
|
|
110
123
|
return await this.oauthService.createStart(params);
|
|
111
124
|
}
|
|
112
125
|
async completeLinearOAuth(params) {
|
|
113
|
-
|
|
126
|
+
const result = await this.oauthService.complete(params);
|
|
127
|
+
// A successful OAuth completion means at least one project now has
|
|
128
|
+
// working Linear auth — update readiness.
|
|
129
|
+
this.runtime.setLinearConnected(true);
|
|
130
|
+
return result;
|
|
114
131
|
}
|
|
115
132
|
getLinearOAuthStateStatus(state) {
|
|
116
133
|
return this.oauthService.getStateStatus(state);
|