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.
Files changed (57) hide show
  1. package/README.md +83 -31
  2. package/dist/agent-session-plan.js +0 -7
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/args.js +22 -18
  5. package/dist/cli/commands/feed.js +1 -1
  6. package/dist/cli/commands/issues.js +44 -4
  7. package/dist/cli/commands/linear.js +67 -0
  8. package/dist/cli/commands/repo.js +213 -0
  9. package/dist/cli/commands/setup.js +140 -21
  10. package/dist/cli/connect-flow.js +5 -3
  11. package/dist/cli/formatters/text.js +1 -1
  12. package/dist/cli/help.js +134 -63
  13. package/dist/cli/index.js +166 -188
  14. package/dist/cli/interactive.js +25 -0
  15. package/dist/cli/operator-client.js +11 -0
  16. package/dist/cli/service-commands.js +11 -4
  17. package/dist/cli/watch/App.js +1 -1
  18. package/dist/cli/watch/FactoryStateGraph.js +31 -0
  19. package/dist/cli/watch/FeedView.js +3 -2
  20. package/dist/cli/watch/FreshnessBadge.js +13 -0
  21. package/dist/cli/watch/IssueDetailView.js +9 -2
  22. package/dist/cli/watch/IssueListView.js +2 -2
  23. package/dist/cli/watch/IssueRow.js +9 -11
  24. package/dist/cli/watch/QueueObservationView.js +15 -0
  25. package/dist/cli/watch/StateHistoryView.js +0 -1
  26. package/dist/cli/watch/StatusBar.js +5 -2
  27. package/dist/cli/watch/format-utils.js +7 -0
  28. package/dist/cli/watch/freshness.js +30 -0
  29. package/dist/cli/watch/state-visualization.js +147 -0
  30. package/dist/cli/watch/theme.js +6 -7
  31. package/dist/cli/watch/use-watch-stream.js +5 -2
  32. package/dist/cli/watch/watch-state.js +9 -5
  33. package/dist/config.js +129 -36
  34. package/dist/db/linear-installation-store.js +23 -0
  35. package/dist/db/migrations.js +42 -0
  36. package/dist/db/repository-link-store.js +103 -0
  37. package/dist/db.js +61 -11
  38. package/dist/factory-state.js +1 -5
  39. package/dist/github-webhook-handler.js +115 -46
  40. package/dist/github-webhooks.js +4 -0
  41. package/dist/http.js +162 -0
  42. package/dist/install.js +93 -13
  43. package/dist/issue-query-service.js +34 -1
  44. package/dist/linear-client.js +80 -25
  45. package/dist/merge-queue-incident.js +104 -0
  46. package/dist/merge-queue-protocol.js +54 -0
  47. package/dist/preflight.js +28 -1
  48. package/dist/repository-linking.js +42 -0
  49. package/dist/run-orchestrator.js +197 -21
  50. package/dist/runtime-paths.js +0 -8
  51. package/dist/service.js +94 -49
  52. package/package.json +8 -7
  53. package/dist/cli/commands/connect.js +0 -54
  54. package/dist/cli/commands/project.js +0 -146
  55. package/dist/merge-queue.js +0 -200
  56. package/infra/patchrelay-reload.service +0 -6
  57. 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, mergeQueue, logger, codex, feed) {
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
- const branchName = ref.replace("refs/heads/", "");
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.db.upsertIssue({
155
- projectId: issue.projectId,
156
- linearIssueId: issue.linearIssueId,
157
- pendingMergePrep: true,
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
- if (!isMetadataOnlyCheckEvent(event)) {
197
- const project = this.config.projects.find((p) => p.id === freshIssue.projectId);
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
- this.db.upsertIssue({
211
- projectId: issue.projectId,
212
- linearIssueId: issue.linearIssueId,
213
- pendingRunType: "ci_repair",
214
- pendingRunContextJson: JSON.stringify({
215
- checkName: event.checkName,
216
- checkUrl: event.checkUrl,
217
- checkClass: resolveCheckClass(event.checkName, project),
218
- }),
219
- });
220
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
221
- this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Enqueued CI repair run");
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
- if (event.triggerEvent === "merge_group_failed") {
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
- pendingRunType: "queue_repair",
241
- pendingRunContextJson: JSON.stringify({
242
- failureReason: event.mergeGroupFailureReason,
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) {
@@ -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, getSystemdPathUnitPath, getSystemdReloadUnitPath, getSystemdUnitPath, readBundledAsset, } from "./runtime-paths.js";
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 project in a multi-project config requires routing. Use --issue-prefix or --team-id.");
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 project ${String(unscoped.id ?? "unknown")} has no routing configured. Add routing before configuring multiple projects.`);
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 project ${String(owner.id ?? "unknown")}`);
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 project ${String(owner.id ?? "unknown")}`);
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
+ }