patchrelay 0.26.0 → 0.29.1
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 +83 -31
- package/dist/agent-session-plan.js +0 -7
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +22 -18
- package/dist/cli/commands/feed.js +1 -1
- package/dist/cli/commands/issues.js +44 -4
- package/dist/cli/commands/linear.js +67 -0
- package/dist/cli/commands/repo.js +213 -0
- package/dist/cli/commands/setup.js +140 -21
- package/dist/cli/connect-flow.js +5 -3
- package/dist/cli/formatters/text.js +1 -1
- package/dist/cli/help.js +134 -63
- package/dist/cli/index.js +166 -188
- package/dist/cli/interactive.js +25 -0
- package/dist/cli/operator-client.js +11 -0
- package/dist/cli/service-commands.js +11 -4
- package/dist/cli/watch/App.js +1 -1
- package/dist/cli/watch/FactoryStateGraph.js +31 -0
- package/dist/cli/watch/FeedView.js +3 -2
- package/dist/cli/watch/FreshnessBadge.js +13 -0
- package/dist/cli/watch/IssueDetailView.js +9 -2
- package/dist/cli/watch/IssueListView.js +2 -2
- package/dist/cli/watch/IssueRow.js +9 -11
- package/dist/cli/watch/QueueObservationView.js +15 -0
- package/dist/cli/watch/StateHistoryView.js +0 -1
- package/dist/cli/watch/StatusBar.js +5 -2
- package/dist/cli/watch/format-utils.js +7 -0
- package/dist/cli/watch/freshness.js +30 -0
- package/dist/cli/watch/state-visualization.js +147 -0
- package/dist/cli/watch/theme.js +6 -7
- package/dist/cli/watch/use-watch-stream.js +5 -2
- package/dist/cli/watch/watch-state.js +9 -5
- package/dist/config.js +129 -36
- package/dist/db/linear-installation-store.js +23 -0
- package/dist/db/migrations.js +42 -0
- package/dist/db/repository-link-store.js +103 -0
- package/dist/db.js +61 -11
- package/dist/factory-state.js +1 -5
- package/dist/github-webhook-handler.js +115 -46
- package/dist/github-webhooks.js +4 -0
- package/dist/http.js +162 -0
- package/dist/install.js +93 -13
- package/dist/issue-query-service.js +34 -1
- package/dist/linear-client.js +80 -25
- package/dist/merge-queue-incident.js +104 -0
- package/dist/merge-queue-protocol.js +54 -0
- package/dist/preflight.js +28 -1
- package/dist/repository-linking.js +42 -0
- package/dist/run-orchestrator.js +197 -21
- package/dist/runtime-paths.js +0 -8
- package/dist/service.js +94 -49
- package/package.json +8 -7
- package/dist/cli/commands/connect.js +0 -54
- package/dist/cli/commands/project.js +0 -146
- package/dist/merge-queue.js +0 -200
- package/infra/patchrelay-reload.service +0 -6
- package/infra/patchrelay.path +0 -13
|
@@ -3,6 +3,8 @@ import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-w
|
|
|
3
3
|
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
4
4
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
5
5
|
import { buildGitHubStateActivity } from "./linear-session-reporting.js";
|
|
6
|
+
import { requestMergeQueueAdmission, resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
|
|
7
|
+
import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
|
|
6
8
|
import { resolveSecret } from "./resolve-secret.js";
|
|
7
9
|
import { safeJsonParse } from "./utils.js";
|
|
8
10
|
/**
|
|
@@ -23,16 +25,14 @@ export class GitHubWebhookHandler {
|
|
|
23
25
|
db;
|
|
24
26
|
linearProvider;
|
|
25
27
|
enqueueIssue;
|
|
26
|
-
mergeQueue;
|
|
27
28
|
logger;
|
|
28
29
|
codex;
|
|
29
30
|
feed;
|
|
30
|
-
constructor(config, db, linearProvider, enqueueIssue,
|
|
31
|
+
constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed) {
|
|
31
32
|
this.config = config;
|
|
32
33
|
this.db = db;
|
|
33
34
|
this.linearProvider = linearProvider;
|
|
34
35
|
this.enqueueIssue = enqueueIssue;
|
|
35
|
-
this.mergeQueue = mergeQueue;
|
|
36
36
|
this.logger = logger;
|
|
37
37
|
this.codex = codex;
|
|
38
38
|
this.feed = feed;
|
|
@@ -92,13 +92,7 @@ export class GitHubWebhookHandler {
|
|
|
92
92
|
const ref = pushPayload.ref;
|
|
93
93
|
const repoFullName = pushPayload.repository?.full_name;
|
|
94
94
|
if (ref && repoFullName) {
|
|
95
|
-
|
|
96
|
-
for (const project of this.config.projects) {
|
|
97
|
-
const baseBranch = project.github?.baseBranch ?? "main";
|
|
98
|
-
if (project.github?.repoFullName === repoFullName && branchName === baseBranch) {
|
|
99
|
-
this.mergeQueue.advanceQueue(project.id);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
95
|
+
// Push to base branch — external merge queue handles advancement.
|
|
102
96
|
}
|
|
103
97
|
return;
|
|
104
98
|
}
|
|
@@ -130,6 +124,7 @@ export class GitHubWebhookHandler {
|
|
|
130
124
|
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
131
125
|
...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
|
|
132
126
|
});
|
|
127
|
+
this.updateFailureProvenance(issue, event);
|
|
133
128
|
if (!isMetadataOnlyCheckEvent(event)) {
|
|
134
129
|
// Re-read issue after PR metadata upsert so guards see fresh prReviewState
|
|
135
130
|
const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
@@ -151,24 +146,16 @@ export class GitHubWebhookHandler {
|
|
|
151
146
|
void this.syncLinearSession(transitionedIssue);
|
|
152
147
|
// Schedule merge prep when entering awaiting_queue
|
|
153
148
|
if (newState === "awaiting_queue") {
|
|
154
|
-
this.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
149
|
+
const proj = this.config.projects.find((p) => p.id === issue.projectId);
|
|
150
|
+
const protocol = resolveMergeQueueProtocol(proj);
|
|
151
|
+
void requestMergeQueueAdmission({
|
|
152
|
+
issue: transitionedIssue,
|
|
153
|
+
protocol,
|
|
154
|
+
logger: this.logger,
|
|
155
|
+
feed: this.feed,
|
|
158
156
|
});
|
|
159
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
160
|
-
}
|
|
161
|
-
// Advance the merge queue when a PR merges
|
|
162
|
-
if (newState === "done" && event.triggerEvent === "pr_merged") {
|
|
163
|
-
this.mergeQueue.advanceQueue(issue.projectId);
|
|
164
157
|
}
|
|
165
158
|
}
|
|
166
|
-
// Advance the merge queue even when the state transition was suppressed
|
|
167
|
-
// (e.g., pr_merged during an active run). The PR is factually merged —
|
|
168
|
-
// the next queued issue should not wait for the active run to finish.
|
|
169
|
-
if (!newState && event.triggerEvent === "pr_merged") {
|
|
170
|
-
this.mergeQueue.advanceQueue(issue.projectId);
|
|
171
|
-
}
|
|
172
159
|
}
|
|
173
160
|
// Re-read issue after all upserts so reactive run logic sees current state
|
|
174
161
|
const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
@@ -180,6 +167,11 @@ export class GitHubWebhookHandler {
|
|
|
180
167
|
linearIssueId: issue.linearIssueId,
|
|
181
168
|
ciRepairAttempts: 0,
|
|
182
169
|
queueRepairAttempts: 0,
|
|
170
|
+
lastGitHubFailureSource: null,
|
|
171
|
+
lastGitHubFailureCheckName: null,
|
|
172
|
+
lastGitHubFailureCheckUrl: null,
|
|
173
|
+
lastGitHubFailureAt: null,
|
|
174
|
+
lastQueueIncidentJson: null,
|
|
183
175
|
});
|
|
184
176
|
}
|
|
185
177
|
this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
@@ -193,8 +185,15 @@ export class GitHubWebhookHandler {
|
|
|
193
185
|
summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
|
|
194
186
|
detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
|
|
195
187
|
});
|
|
196
|
-
|
|
197
|
-
|
|
188
|
+
// Queue eviction check runs bypass the metadata-only filter because
|
|
189
|
+
// they're individual check_run events (not check_suite), but they
|
|
190
|
+
// must drive state transitions.
|
|
191
|
+
const project = this.config.projects.find((p) => p.id === freshIssue.projectId);
|
|
192
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
193
|
+
if (event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName) {
|
|
194
|
+
this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
195
|
+
}
|
|
196
|
+
else if (!isMetadataOnlyCheckEvent(event)) {
|
|
198
197
|
this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
199
198
|
}
|
|
200
199
|
}
|
|
@@ -207,18 +206,57 @@ export class GitHubWebhookHandler {
|
|
|
207
206
|
if (TERMINAL_STATES.has(issue.factoryState))
|
|
208
207
|
return;
|
|
209
208
|
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
209
|
+
// External merge queue eviction: react only to the configured check
|
|
210
|
+
// name, not to any CI failure. Regular CI failures still get ci_repair.
|
|
211
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
212
|
+
const queueCheckName = protocol.evictionCheckName;
|
|
213
|
+
if (issue.factoryState === "awaiting_queue"
|
|
214
|
+
&& event.checkName === queueCheckName) {
|
|
215
|
+
const queueRepairContext = buildQueueRepairContextFromEvent(event);
|
|
216
|
+
this.db.upsertIssue({
|
|
217
|
+
projectId: issue.projectId,
|
|
218
|
+
linearIssueId: issue.linearIssueId,
|
|
219
|
+
pendingRunType: "queue_repair",
|
|
220
|
+
pendingRunContextJson: JSON.stringify(queueRepairContext),
|
|
221
|
+
lastGitHubFailureSource: "queue_eviction",
|
|
222
|
+
lastGitHubFailureCheckName: event.checkName ?? null,
|
|
223
|
+
lastGitHubFailureCheckUrl: event.checkUrl ?? null,
|
|
224
|
+
lastGitHubFailureAt: new Date().toISOString(),
|
|
225
|
+
lastQueueSignalAt: new Date().toISOString(),
|
|
226
|
+
lastQueueIncidentJson: JSON.stringify(queueRepairContext),
|
|
227
|
+
});
|
|
228
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
229
|
+
this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
230
|
+
this.feed?.publish({
|
|
231
|
+
level: "warn",
|
|
232
|
+
kind: "github",
|
|
233
|
+
issueKey: issue.issueKey,
|
|
234
|
+
projectId: issue.projectId,
|
|
235
|
+
stage: "repairing_queue",
|
|
236
|
+
status: "queue_repair_queued",
|
|
237
|
+
summary: `Queue repair queued after external failure from ${event.checkName}`,
|
|
238
|
+
detail: queueRepairContext.incidentSummary ?? queueRepairContext.incidentUrl ?? event.checkUrl,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
this.db.upsertIssue({
|
|
243
|
+
projectId: issue.projectId,
|
|
244
|
+
linearIssueId: issue.linearIssueId,
|
|
245
|
+
pendingRunType: "ci_repair",
|
|
246
|
+
pendingRunContextJson: JSON.stringify({
|
|
247
|
+
checkName: event.checkName,
|
|
248
|
+
checkUrl: event.checkUrl,
|
|
249
|
+
checkClass: resolveCheckClass(event.checkName, project),
|
|
250
|
+
}),
|
|
251
|
+
lastGitHubFailureSource: "branch_ci",
|
|
252
|
+
lastGitHubFailureCheckName: event.checkName ?? null,
|
|
253
|
+
lastGitHubFailureCheckUrl: event.checkUrl ?? null,
|
|
254
|
+
lastGitHubFailureAt: new Date().toISOString(),
|
|
255
|
+
lastQueueIncidentJson: null,
|
|
256
|
+
});
|
|
257
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
258
|
+
this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Enqueued CI repair run");
|
|
259
|
+
}
|
|
222
260
|
}
|
|
223
261
|
if (event.triggerEvent === "review_changes_requested") {
|
|
224
262
|
this.db.upsertIssue({
|
|
@@ -233,17 +271,48 @@ export class GitHubWebhookHandler {
|
|
|
233
271
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
234
272
|
this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
|
|
235
273
|
}
|
|
236
|
-
|
|
274
|
+
}
|
|
275
|
+
updateFailureProvenance(issue, event) {
|
|
276
|
+
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
277
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
278
|
+
const isQueueEvictionCheck = event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName;
|
|
279
|
+
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
280
|
+
if (isMetadataOnlyCheckEvent(event) && !isQueueEvictionCheck) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const source = issue.factoryState === "awaiting_queue" && isQueueEvictionCheck
|
|
284
|
+
? "queue_eviction"
|
|
285
|
+
: "branch_ci";
|
|
286
|
+
this.db.upsertIssue({
|
|
287
|
+
projectId: issue.projectId,
|
|
288
|
+
linearIssueId: issue.linearIssueId,
|
|
289
|
+
lastGitHubFailureSource: source,
|
|
290
|
+
lastGitHubFailureCheckName: event.checkName ?? null,
|
|
291
|
+
lastGitHubFailureCheckUrl: event.checkUrl ?? null,
|
|
292
|
+
lastGitHubFailureAt: new Date().toISOString(),
|
|
293
|
+
...(source === "queue_eviction"
|
|
294
|
+
? {
|
|
295
|
+
lastQueueSignalAt: new Date().toISOString(),
|
|
296
|
+
lastQueueIncidentJson: JSON.stringify(buildQueueRepairContextFromEvent(event)),
|
|
297
|
+
}
|
|
298
|
+
: {
|
|
299
|
+
lastQueueIncidentJson: null,
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck))
|
|
305
|
+
|| event.triggerEvent === "pr_synchronize"
|
|
306
|
+
|| event.triggerEvent === "pr_merged") {
|
|
237
307
|
this.db.upsertIssue({
|
|
238
308
|
projectId: issue.projectId,
|
|
239
309
|
linearIssueId: issue.linearIssueId,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
310
|
+
lastGitHubFailureSource: null,
|
|
311
|
+
lastGitHubFailureCheckName: null,
|
|
312
|
+
lastGitHubFailureCheckUrl: null,
|
|
313
|
+
lastGitHubFailureAt: null,
|
|
314
|
+
lastQueueIncidentJson: null,
|
|
244
315
|
});
|
|
245
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
246
|
-
this.logger.info({ issueKey: issue.issueKey }, "Enqueued merge queue repair run");
|
|
247
316
|
}
|
|
248
317
|
}
|
|
249
318
|
async emitLinearActivity(issue, newState, event) {
|
package/dist/github-webhooks.js
CHANGED
|
@@ -141,6 +141,10 @@ function normalizeCheckRunEvent(payload, repoFullName) {
|
|
|
141
141
|
checkStatus: passed ? "success" : "failure",
|
|
142
142
|
checkName: run.name,
|
|
143
143
|
checkUrl: run.html_url,
|
|
144
|
+
checkDetailsUrl: run.details_url,
|
|
145
|
+
checkOutputTitle: run.output?.title,
|
|
146
|
+
checkOutputSummary: run.output?.summary,
|
|
147
|
+
checkOutputText: run.output?.text,
|
|
144
148
|
eventSource: "check_run",
|
|
145
149
|
};
|
|
146
150
|
}
|
package/dist/http.js
CHANGED
|
@@ -2,6 +2,8 @@ import fastify from "fastify";
|
|
|
2
2
|
import rawBody from "fastify-raw-body";
|
|
3
3
|
import { getBuildInfo } from "./build-info.js";
|
|
4
4
|
import { matchesOperatorFeedEvent } from "./operator-feed.js";
|
|
5
|
+
import { buildStateHistory } from "./cli/watch/history-builder.js";
|
|
6
|
+
import { buildPatchRelayQueueObservations, buildPatchRelayStateGraph } from "./cli/watch/state-visualization.js";
|
|
5
7
|
export async function buildHttpServer(config, service, logger) {
|
|
6
8
|
const buildInfo = getBuildInfo();
|
|
7
9
|
const loopbackBind = isLoopbackBind(config.server.bind);
|
|
@@ -422,6 +424,19 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
422
424
|
app.get("/api/installations", async (_request, reply) => {
|
|
423
425
|
return reply.send({ ok: true, installations: service.listLinearInstallations() });
|
|
424
426
|
});
|
|
427
|
+
app.get("/api/linear/workspaces", async (_request, reply) => {
|
|
428
|
+
return reply.send({ ok: true, workspaces: service.listLinearWorkspaces() });
|
|
429
|
+
});
|
|
430
|
+
app.post("/api/linear/workspaces/sync", async (request, reply) => {
|
|
431
|
+
const workspace = getQueryParam(request, "workspace");
|
|
432
|
+
const result = await service.syncLinearWorkspace(workspace ?? undefined);
|
|
433
|
+
return reply.send({ ok: true, ...result });
|
|
434
|
+
});
|
|
435
|
+
app.delete("/api/linear/workspaces/:workspace", async (request, reply) => {
|
|
436
|
+
const { workspace } = request.params;
|
|
437
|
+
const result = service.disconnectLinearWorkspace(workspace);
|
|
438
|
+
return reply.send({ ok: true, ...result });
|
|
439
|
+
});
|
|
425
440
|
app.get("/api/oauth/linear/start", async (request, reply) => {
|
|
426
441
|
const projectId = getQueryParam(request, "projectId");
|
|
427
442
|
const result = await service.createLinearOAuthStart(projectId ? { projectId } : undefined);
|
|
@@ -585,6 +600,23 @@ function renderAgentSessionStatusPage(params) {
|
|
|
585
600
|
const checkState = params.sessionStatus.issue.prCheckStatus ?? "unknown";
|
|
586
601
|
const ciAttempts = params.sessionStatus.issue.ciRepairAttempts ?? 0;
|
|
587
602
|
const queueAttempts = params.sessionStatus.issue.queueRepairAttempts ?? 0;
|
|
603
|
+
const queueProtocol = params.sessionStatus.issue.queueProtocol;
|
|
604
|
+
const history = buildPublicStateHistory({
|
|
605
|
+
currentFactoryState: factoryState,
|
|
606
|
+
activeRunId: params.sessionStatus.activeRunId ?? null,
|
|
607
|
+
...(params.sessionStatus.feedEvents ? { feedEvents: params.sessionStatus.feedEvents } : {}),
|
|
608
|
+
runs: params.sessionStatus.runs,
|
|
609
|
+
});
|
|
610
|
+
const graph = buildPatchRelayStateGraph(history, factoryState);
|
|
611
|
+
const queueObservations = buildPatchRelayQueueObservations({
|
|
612
|
+
factoryState,
|
|
613
|
+
...(params.sessionStatus.activeRun?.runType ? { activeRunType: params.sessionStatus.activeRun.runType } : {}),
|
|
614
|
+
...(params.sessionStatus.issue.prNumber !== undefined ? { prNumber: params.sessionStatus.issue.prNumber } : {}),
|
|
615
|
+
...(params.sessionStatus.issue.prReviewState ? { prReviewState: params.sessionStatus.issue.prReviewState } : {}),
|
|
616
|
+
}, normalizeFeedEvents(params.sessionStatus.feedEvents));
|
|
617
|
+
const pathHtml = renderStatePath(history, factoryState);
|
|
618
|
+
const graphHtml = renderStateGraph(graph.main, graph.prLoops, graph.queueLoop, graph.exits);
|
|
619
|
+
const observationsHtml = renderObservationList(queueObservations);
|
|
588
620
|
return `<!doctype html>
|
|
589
621
|
<html lang="en">
|
|
590
622
|
<head>
|
|
@@ -630,6 +662,20 @@ function renderAgentSessionStatusPage(params) {
|
|
|
630
662
|
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 9px 14px; background: rgba(255,255,255,0.74); font-size: 14px; }
|
|
631
663
|
.section { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--line); }
|
|
632
664
|
.section h2 { margin: 0; font-size: 22px; }
|
|
665
|
+
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); margin-top: 14px; }
|
|
666
|
+
.card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,255,0.56); padding: 16px; }
|
|
667
|
+
.card h3 { margin: 0 0 10px; font-size: 16px; }
|
|
668
|
+
.graph-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
|
|
669
|
+
.graph-connector { color: var(--muted); }
|
|
670
|
+
.node { display: inline-flex; border-radius: 999px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }
|
|
671
|
+
.node.current { border-color: rgba(31,109,87,0.45); background: rgba(31,109,87,0.12); color: #164c3d; }
|
|
672
|
+
.node.visited { border-color: rgba(31,109,87,0.28); color: #275546; }
|
|
673
|
+
.node.upcoming { color: #6f695f; }
|
|
674
|
+
.path-list, .observation-list { margin: 0; padding-left: 18px; color: var(--muted); }
|
|
675
|
+
.path-list li, .observation-list li { margin-top: 8px; }
|
|
676
|
+
.tone-warn { color: #8d5c10; }
|
|
677
|
+
.tone-success { color: #1f6d57; }
|
|
678
|
+
.tone-info { color: var(--muted); }
|
|
633
679
|
table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
|
634
680
|
th, td { text-align: left; border-bottom: 1px solid var(--line); padding: 10px 8px; vertical-align: top; }
|
|
635
681
|
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #5f594e; }
|
|
@@ -657,6 +703,14 @@ function renderAgentSessionStatusPage(params) {
|
|
|
657
703
|
<tr><th>Pull request</th><td>${escapeHtml(prLabel ?? "none")} (${escapeHtml(prState)})</td></tr>
|
|
658
704
|
<tr><th>Review</th><td>${escapeHtml(reviewState)}</td></tr>
|
|
659
705
|
<tr><th>Checks</th><td>${escapeHtml(checkState)}</td></tr>
|
|
706
|
+
<tr><th>Queue label</th><td><code>${escapeHtml(queueProtocol?.admissionLabel ?? "queue")}</code></td></tr>
|
|
707
|
+
<tr><th>Queue check</th><td><code>${escapeHtml(queueProtocol?.evictionCheckName ?? "merge-steward/queue")}</code></td></tr>
|
|
708
|
+
<tr><th>Last queue signal</th><td><code>${escapeHtml(queueProtocol?.lastQueueSignalAt ?? queueProtocol?.lastFailureAt ?? "none")}</code></td></tr>
|
|
709
|
+
<tr><th>Last queue incident</th><td>${queueProtocol?.lastIncidentUrl
|
|
710
|
+
? `<a href="${escapeHtml(queueProtocol.lastIncidentUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(queueProtocol.lastIncidentId ?? queueProtocol.lastIncidentUrl)}</a>`
|
|
711
|
+
: escapeHtml(queueProtocol?.lastIncidentId ?? "none")}</td></tr>
|
|
712
|
+
<tr><th>Queue failure class</th><td><code>${escapeHtml(queueProtocol?.lastIncidentFailureClass ?? "unknown")}</code></td></tr>
|
|
713
|
+
<tr><th>Queue incident summary</th><td>${escapeHtml(queueProtocol?.lastIncidentSummary ?? "none")}</td></tr>
|
|
660
714
|
<tr><th>Latest plan</th><td>${escapeHtml(latestPlan)}</td></tr>
|
|
661
715
|
<tr><th>Active command</th><td><code>${escapeHtml(activeCommand)}</code></td></tr>
|
|
662
716
|
<tr><th>Latest summary</th><td>${escapeHtml(latestAgentMessage)}</td></tr>
|
|
@@ -670,6 +724,23 @@ function renderAgentSessionStatusPage(params) {
|
|
|
670
724
|
<span class="chip"><strong>CI repairs:</strong> ${escapeHtml(String(ciAttempts))}</span>
|
|
671
725
|
<span class="chip"><strong>Queue repairs:</strong> ${escapeHtml(String(queueAttempts))}</span>
|
|
672
726
|
</div>
|
|
727
|
+
<div class="section">
|
|
728
|
+
<h2>State Path</h2>
|
|
729
|
+
<div class="grid">
|
|
730
|
+
<div class="card">
|
|
731
|
+
<h3>Native Graph</h3>
|
|
732
|
+
${graphHtml}
|
|
733
|
+
</div>
|
|
734
|
+
<div class="card">
|
|
735
|
+
<h3>Queue Observation</h3>
|
|
736
|
+
${observationsHtml}
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
<div class="card" style="margin-top: 18px;">
|
|
740
|
+
<h3>Observed Path</h3>
|
|
741
|
+
${pathHtml}
|
|
742
|
+
</div>
|
|
743
|
+
</div>
|
|
673
744
|
<div class="section">
|
|
674
745
|
<h2>Recent Stages</h2>
|
|
675
746
|
<table>
|
|
@@ -717,3 +788,94 @@ function formatStageRow(run) {
|
|
|
717
788
|
const endedAt = run.endedAt ?? "-";
|
|
718
789
|
return `<tr><td><code>${escapeHtml(runType)}</code></td><td>${escapeHtml(status)}</td><td><code>${escapeHtml(startedAt)}</code></td><td><code>${escapeHtml(endedAt)}</code></td></tr>`;
|
|
719
790
|
}
|
|
791
|
+
function normalizeFeedEvents(feedEvents) {
|
|
792
|
+
return (feedEvents ?? []).map((event, index) => ({
|
|
793
|
+
id: event.id ?? -(index + 1),
|
|
794
|
+
at: event.at,
|
|
795
|
+
level: event.level === "warn" || event.level === "error" ? event.level : "info",
|
|
796
|
+
kind: event.kind === "service"
|
|
797
|
+
|| event.kind === "webhook"
|
|
798
|
+
|| event.kind === "agent"
|
|
799
|
+
|| event.kind === "comment"
|
|
800
|
+
|| event.kind === "stage"
|
|
801
|
+
|| event.kind === "turn"
|
|
802
|
+
|| event.kind === "workflow"
|
|
803
|
+
|| event.kind === "hook"
|
|
804
|
+
|| event.kind === "github"
|
|
805
|
+
|| event.kind === "linear"
|
|
806
|
+
? event.kind
|
|
807
|
+
: "service",
|
|
808
|
+
summary: event.summary ?? "",
|
|
809
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
810
|
+
...(event.issueKey ? { issueKey: event.issueKey } : {}),
|
|
811
|
+
...(event.projectId ? { projectId: event.projectId } : {}),
|
|
812
|
+
...(event.stage ? { stage: event.stage } : {}),
|
|
813
|
+
...(event.status ? { status: event.status } : {}),
|
|
814
|
+
...(event.workflowId ? { workflowId: event.workflowId } : {}),
|
|
815
|
+
...(event.nextStage ? { nextStage: event.nextStage } : {}),
|
|
816
|
+
}));
|
|
817
|
+
}
|
|
818
|
+
function buildPublicStateHistory(params) {
|
|
819
|
+
const runs = params.runs.flatMap((entry, index) => {
|
|
820
|
+
if (!entry.run?.runType || !entry.run?.status || !entry.run?.startedAt) {
|
|
821
|
+
return [];
|
|
822
|
+
}
|
|
823
|
+
return [{
|
|
824
|
+
id: entry.run.id ?? index + 1,
|
|
825
|
+
runType: entry.run.runType,
|
|
826
|
+
status: entry.run.status,
|
|
827
|
+
startedAt: entry.run.startedAt,
|
|
828
|
+
endedAt: entry.run.endedAt,
|
|
829
|
+
...(entry.report ? {
|
|
830
|
+
report: {
|
|
831
|
+
runType: entry.run.runType,
|
|
832
|
+
status: entry.run.status,
|
|
833
|
+
prompt: "",
|
|
834
|
+
assistantMessages: entry.report.assistantMessages ?? [],
|
|
835
|
+
plans: [],
|
|
836
|
+
reasoning: [],
|
|
837
|
+
commands: (entry.report.commands ?? []),
|
|
838
|
+
fileChanges: (entry.report.fileChanges ?? []),
|
|
839
|
+
toolCalls: [],
|
|
840
|
+
eventCounts: {},
|
|
841
|
+
},
|
|
842
|
+
} : {}),
|
|
843
|
+
}];
|
|
844
|
+
});
|
|
845
|
+
return buildStateHistory(runs, normalizeFeedEvents(params.feedEvents), params.currentFactoryState, params.activeRunId);
|
|
846
|
+
}
|
|
847
|
+
function renderStateGraph(main, prLoops, queueLoop, exits) {
|
|
848
|
+
return [
|
|
849
|
+
renderGraphRow("main", main, true),
|
|
850
|
+
renderGraphRow("pr loops", prLoops, false),
|
|
851
|
+
renderGraphRow("queue loop", queueLoop, false),
|
|
852
|
+
renderGraphRow("exits", exits, false),
|
|
853
|
+
].join("");
|
|
854
|
+
}
|
|
855
|
+
function renderGraphRow(label, nodes, withConnectors) {
|
|
856
|
+
const items = nodes.map((node, index) => {
|
|
857
|
+
const connector = withConnectors && index > 0 ? '<span class="graph-connector">→</span>' : "";
|
|
858
|
+
return `${connector}<span class="node ${escapeHtml(node.status)}">${escapeHtml(node.label)}</span>`;
|
|
859
|
+
}).join("");
|
|
860
|
+
return `<div class="graph-row"><strong>${escapeHtml(label)}:</strong> ${items}</div>`;
|
|
861
|
+
}
|
|
862
|
+
function renderObservationList(observations) {
|
|
863
|
+
if (observations.length === 0) {
|
|
864
|
+
return '<p>No queue observation is available yet.</p>';
|
|
865
|
+
}
|
|
866
|
+
return `<ul class="observation-list">${observations.map((observation) => `<li class="tone-${escapeHtml(observation.tone)}">${escapeHtml(observation.text)}</li>`).join("")}</ul>`;
|
|
867
|
+
}
|
|
868
|
+
function renderStatePath(history, currentFactoryState) {
|
|
869
|
+
if (history.length === 0) {
|
|
870
|
+
return `<p>Current native state: <code>${escapeHtml(currentFactoryState)}</code>.</p>`;
|
|
871
|
+
}
|
|
872
|
+
const items = [];
|
|
873
|
+
for (const node of history) {
|
|
874
|
+
items.push(`<li><code>${escapeHtml(node.state)}</code>${node.reason ? ` — ${escapeHtml(node.reason)}` : ""}${node.isCurrent ? " (current)" : ""}</li>`);
|
|
875
|
+
for (const trip of node.sideTrips) {
|
|
876
|
+
const returnText = trip.returnState ? ` → ${trip.returnedAt ? escapeHtml(trip.returnState) : escapeHtml(trip.returnState)}` : "";
|
|
877
|
+
items.push(`<li><code>${escapeHtml(trip.state)}</code> side trip${trip.reason ? ` — ${escapeHtml(trip.reason)}` : ""}${returnText ? ` ${returnText}` : ""}</li>`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return `<ul class="path-list">${items.join("")}</ul>`;
|
|
881
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, dirname } from "node:path";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultLogPath, getDefaultWebhookArchiveDir, getPatchRelayConfigDir, getPatchRelayDataDir, getPatchRelayStateDir,
|
|
6
|
+
import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultLogPath, getDefaultWebhookArchiveDir, getPatchRelayConfigDir, getPatchRelayDataDir, getPatchRelayStateDir, getSystemdUnitPath, readBundledAsset, } from "./runtime-paths.js";
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
8
|
import { enforceArbitraryFilePermissions } from "./file-permissions.js";
|
|
9
9
|
import { ensureAbsolutePath } from "./utils.js";
|
|
@@ -178,21 +178,13 @@ export async function initializePatchRelayHome(options) {
|
|
|
178
178
|
export async function installServiceUnits(options) {
|
|
179
179
|
const force = options?.force ?? false;
|
|
180
180
|
const unitPath = getSystemdUnitPath();
|
|
181
|
-
const reloadUnitPath = getSystemdReloadUnitPath();
|
|
182
|
-
const pathUnitPath = getSystemdPathUnitPath();
|
|
183
181
|
const serviceStatus = await writeTemplateFile(unitPath, renderTemplate(readBundledAsset("infra/patchrelay.service")), force);
|
|
184
|
-
const reloadStatus = await writeTemplateFile(reloadUnitPath, renderTemplate(readBundledAsset("infra/patchrelay-reload.service")), force);
|
|
185
|
-
const pathStatus = await writeTemplateFile(pathUnitPath, renderTemplate(readBundledAsset("infra/patchrelay.path")), force);
|
|
186
182
|
return {
|
|
187
183
|
unitPath,
|
|
188
|
-
reloadUnitPath,
|
|
189
|
-
pathUnitPath,
|
|
190
184
|
runtimeEnvPath: getDefaultRuntimeEnvPath(),
|
|
191
185
|
serviceEnvPath: getDefaultServiceEnvPath(),
|
|
192
186
|
configPath: getDefaultConfigPath(),
|
|
193
187
|
serviceStatus,
|
|
194
|
-
reloadStatus,
|
|
195
|
-
pathStatus,
|
|
196
188
|
};
|
|
197
189
|
}
|
|
198
190
|
export async function upsertProjectInConfig(options) {
|
|
@@ -231,7 +223,7 @@ export async function upsertProjectInConfig(options) {
|
|
|
231
223
|
delete nextProject.linear_team_ids;
|
|
232
224
|
}
|
|
233
225
|
if (existingProjects.length - (existingProject ? 1 : 0) > 0 && issueKeyPrefixes.length === 0 && linearTeamIds.length === 0) {
|
|
234
|
-
throw new Error("Adding or updating a
|
|
226
|
+
throw new Error("Adding or updating a repo in a multi-repo config requires routing. Use --prefix or --team.");
|
|
235
227
|
}
|
|
236
228
|
if (existingProjects.length - (existingProject ? 1 : 0) > 0) {
|
|
237
229
|
const unscoped = existingProjects.find((project, index) => {
|
|
@@ -244,7 +236,7 @@ export async function upsertProjectInConfig(options) {
|
|
|
244
236
|
return prefixes.length === 0 && teamIds.length === 0 && labels.length === 0;
|
|
245
237
|
});
|
|
246
238
|
if (unscoped) {
|
|
247
|
-
throw new Error(`Existing
|
|
239
|
+
throw new Error(`Existing repo ${String(unscoped.id ?? "unknown")} has no routing configured. Add routing before configuring multiple repos.`);
|
|
248
240
|
}
|
|
249
241
|
}
|
|
250
242
|
for (const prefix of issueKeyPrefixes) {
|
|
@@ -252,7 +244,7 @@ export async function upsertProjectInConfig(options) {
|
|
|
252
244
|
Array.isArray(project.issue_key_prefixes) &&
|
|
253
245
|
project.issue_key_prefixes.map(String).includes(prefix));
|
|
254
246
|
if (owner) {
|
|
255
|
-
throw new Error(`Issue key prefix "${prefix}" is already configured for
|
|
247
|
+
throw new Error(`Issue key prefix "${prefix}" is already configured for repo ${String(owner.id ?? "unknown")}`);
|
|
256
248
|
}
|
|
257
249
|
}
|
|
258
250
|
for (const teamId of linearTeamIds) {
|
|
@@ -260,7 +252,7 @@ export async function upsertProjectInConfig(options) {
|
|
|
260
252
|
Array.isArray(project.linear_team_ids) &&
|
|
261
253
|
project.linear_team_ids.map(String).includes(teamId));
|
|
262
254
|
if (owner) {
|
|
263
|
-
throw new Error(`Linear team id "${teamId}" is already configured for
|
|
255
|
+
throw new Error(`Linear team id "${teamId}" is already configured for repo ${String(owner.id ?? "unknown")}`);
|
|
264
256
|
}
|
|
265
257
|
}
|
|
266
258
|
const normalizedExistingProject = existingProject &&
|
|
@@ -316,3 +308,91 @@ export async function upsertProjectInConfig(options) {
|
|
|
316
308
|
},
|
|
317
309
|
};
|
|
318
310
|
}
|
|
311
|
+
export async function upsertRepositoryInConfig(options) {
|
|
312
|
+
const configPath = options.configPath ?? getDefaultConfigPath();
|
|
313
|
+
if (!existsSync(configPath)) {
|
|
314
|
+
throw new Error(`Config file not found: ${configPath}. Run "patchrelay init" first.`);
|
|
315
|
+
}
|
|
316
|
+
const githubRepo = options.githubRepo.trim();
|
|
317
|
+
if (!githubRepo) {
|
|
318
|
+
throw new Error("githubRepo is required.");
|
|
319
|
+
}
|
|
320
|
+
const localPath = ensureAbsolutePath(options.localPath);
|
|
321
|
+
const workspace = options.workspace?.trim() || undefined;
|
|
322
|
+
const linearTeamIds = [...new Set(options.linearTeamIds.map((value) => value.trim()).filter(Boolean))];
|
|
323
|
+
const linearProjectIds = [...new Set((options.linearProjectIds ?? []).map((value) => value.trim()).filter(Boolean))];
|
|
324
|
+
const issueKeyPrefixes = [...new Set((options.issueKeyPrefixes ?? []).map((value) => value.trim()).filter(Boolean))];
|
|
325
|
+
const original = await readFile(configPath, "utf8");
|
|
326
|
+
const parsed = parseConfigObject(original, configPath);
|
|
327
|
+
const existingRepositories = Array.isArray(parsed.repositories) ? parsed.repositories : [];
|
|
328
|
+
const existingIndex = existingRepositories.findIndex((repository) => String(repository.github_repo ?? "") === githubRepo);
|
|
329
|
+
const existing = existingIndex >= 0 ? existingRepositories[existingIndex] : undefined;
|
|
330
|
+
const nextRepository = {
|
|
331
|
+
...(existing ?? {}),
|
|
332
|
+
github_repo: githubRepo,
|
|
333
|
+
local_path: localPath,
|
|
334
|
+
...(workspace ? { workspace } : {}),
|
|
335
|
+
linear_team_ids: linearTeamIds,
|
|
336
|
+
linear_project_ids: linearProjectIds,
|
|
337
|
+
issue_key_prefixes: issueKeyPrefixes,
|
|
338
|
+
};
|
|
339
|
+
const normalizedExisting = existing && JSON.stringify(existing);
|
|
340
|
+
const normalizedNext = JSON.stringify(nextRepository);
|
|
341
|
+
const status = existing === undefined ? "created" : normalizedExisting === normalizedNext ? "unchanged" : "updated";
|
|
342
|
+
const document = parseConfigObject(original, configPath);
|
|
343
|
+
if ("repositories" in document && document.repositories !== undefined && !Array.isArray(document.repositories)) {
|
|
344
|
+
throw new Error(`Config file field "repositories" must be a JSON array: ${configPath}`);
|
|
345
|
+
}
|
|
346
|
+
if (status !== "unchanged") {
|
|
347
|
+
const nextRepositories = [...existingRepositories];
|
|
348
|
+
if (existingIndex >= 0) {
|
|
349
|
+
nextRepositories[existingIndex] = nextRepository;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
nextRepositories.push(nextRepository);
|
|
353
|
+
}
|
|
354
|
+
document.repositories = nextRepositories;
|
|
355
|
+
const next = stringifyConfig(document);
|
|
356
|
+
await writeFile(configPath, next, "utf8");
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
loadConfig(configPath, { profile: "write_config" });
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (status !== "unchanged") {
|
|
363
|
+
await writeFile(configPath, original, "utf8");
|
|
364
|
+
}
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
configPath,
|
|
369
|
+
status,
|
|
370
|
+
repository: {
|
|
371
|
+
githubRepo,
|
|
372
|
+
localPath,
|
|
373
|
+
...(workspace ? { workspace } : {}),
|
|
374
|
+
linearTeamIds,
|
|
375
|
+
linearProjectIds,
|
|
376
|
+
issueKeyPrefixes,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
export async function removeRepositoryFromConfig(options) {
|
|
381
|
+
const configPath = options.configPath ?? getDefaultConfigPath();
|
|
382
|
+
if (!existsSync(configPath)) {
|
|
383
|
+
throw new Error(`Config file not found: ${configPath}. Run "patchrelay init" first.`);
|
|
384
|
+
}
|
|
385
|
+
const original = await readFile(configPath, "utf8");
|
|
386
|
+
const document = parseConfigObject(original, configPath);
|
|
387
|
+
const existingRepositories = Array.isArray(document.repositories) ? document.repositories : [];
|
|
388
|
+
const nextRepositories = existingRepositories.filter((repository) => String(repository.github_repo ?? "") !== options.githubRepo);
|
|
389
|
+
if (nextRepositories.length === existingRepositories.length) {
|
|
390
|
+
return { configPath, removed: false };
|
|
391
|
+
}
|
|
392
|
+
document.repositories = nextRepositories;
|
|
393
|
+
await writeFile(configPath, stringifyConfig(document), "utf8");
|
|
394
|
+
return { configPath, removed: true };
|
|
395
|
+
}
|
|
396
|
+
export async function ensureRepositoryProjectSettings(repoPath) {
|
|
397
|
+
await writeTemplateFile(getRepoProjectSettingsPath(ensureAbsolutePath(repoPath)), stringifyConfig(defaultRepoProjectSettings()), false);
|
|
398
|
+
}
|