patchrelay 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +64 -62
  2. package/dist/agent-session-plan.js +17 -17
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/commands/issues.js +12 -12
  5. package/dist/cli/data.js +109 -298
  6. package/dist/cli/formatters/text.js +22 -28
  7. package/dist/config.js +13 -166
  8. package/dist/db/migrations.js +46 -154
  9. package/dist/db.js +369 -45
  10. package/dist/factory-state.js +55 -0
  11. package/dist/github-webhook-handler.js +199 -0
  12. package/dist/github-webhooks.js +166 -0
  13. package/dist/hook-runner.js +28 -0
  14. package/dist/http.js +48 -22
  15. package/dist/issue-query-service.js +33 -38
  16. package/dist/linear-workflow.js +5 -118
  17. package/dist/preflight.js +1 -6
  18. package/dist/project-resolution.js +12 -1
  19. package/dist/run-orchestrator.js +446 -0
  20. package/dist/{stage-reporting.js → run-reporting.js} +11 -13
  21. package/dist/service-runtime.js +21 -54
  22. package/dist/service-webhooks.js +7 -52
  23. package/dist/service.js +39 -61
  24. package/dist/webhook-handler.js +387 -0
  25. package/dist/webhook-installation-handler.js +3 -8
  26. package/package.json +2 -1
  27. package/dist/db/authoritative-ledger-store.js +0 -536
  28. package/dist/db/issue-projection-store.js +0 -54
  29. package/dist/db/issue-workflow-coordinator.js +0 -320
  30. package/dist/db/issue-workflow-store.js +0 -194
  31. package/dist/db/run-report-store.js +0 -33
  32. package/dist/db/stage-event-store.js +0 -33
  33. package/dist/db/webhook-event-store.js +0 -59
  34. package/dist/db-ports.js +0 -5
  35. package/dist/ledger-ports.js +0 -1
  36. package/dist/reconciliation-action-applier.js +0 -68
  37. package/dist/reconciliation-actions.js +0 -1
  38. package/dist/reconciliation-engine.js +0 -350
  39. package/dist/reconciliation-snapshot-builder.js +0 -135
  40. package/dist/reconciliation-types.js +0 -1
  41. package/dist/service-stage-finalizer.js +0 -753
  42. package/dist/service-stage-runner.js +0 -336
  43. package/dist/service-webhook-processor.js +0 -411
  44. package/dist/stage-agent-activity-publisher.js +0 -59
  45. package/dist/stage-event-ports.js +0 -1
  46. package/dist/stage-failure.js +0 -92
  47. package/dist/stage-handoff.js +0 -107
  48. package/dist/stage-launch.js +0 -84
  49. package/dist/stage-lifecycle-publisher.js +0 -284
  50. package/dist/stage-turn-input-dispatcher.js +0 -104
  51. package/dist/webhook-agent-session-handler.js +0 -228
  52. package/dist/webhook-comment-handler.js +0 -141
  53. package/dist/webhook-desired-stage-recorder.js +0 -122
  54. package/dist/webhook-event-ports.js +0 -1
  55. package/dist/workflow-policy.js +0 -149
  56. package/dist/workflow-ports.js +0 -1
  57. /package/dist/{installation-ports.js → github-types.js} +0 -0
@@ -0,0 +1,199 @@
1
+ import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
+ import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
3
+ import { safeJsonParse } from "./utils.js";
4
+ export class GitHubWebhookHandler {
5
+ config;
6
+ db;
7
+ linearProvider;
8
+ enqueueIssue;
9
+ logger;
10
+ feed;
11
+ constructor(config, db, linearProvider, enqueueIssue, logger, feed) {
12
+ this.config = config;
13
+ this.db = db;
14
+ this.linearProvider = linearProvider;
15
+ this.enqueueIssue = enqueueIssue;
16
+ this.logger = logger;
17
+ this.feed = feed;
18
+ }
19
+ async acceptGitHubWebhook(params) {
20
+ // Deduplicate
21
+ if (this.db.isWebhookDuplicate(params.deliveryId)) {
22
+ return { status: 200, body: { ok: true, duplicate: true } };
23
+ }
24
+ // Store the event
25
+ const stored = this.db.insertWebhookEvent(params.deliveryId, new Date().toISOString());
26
+ // Parse payload
27
+ const payload = safeJsonParse(params.rawBody.toString("utf8"));
28
+ if (!payload) {
29
+ return { status: 400, body: { ok: false, reason: "invalid_json" } };
30
+ }
31
+ // Find matching project by repo
32
+ const repoFullName = typeof payload === "object" && payload !== null && "repository" in payload
33
+ ? payload.repository
34
+ : undefined;
35
+ const repoName = typeof repoFullName === "object" && repoFullName !== null && "full_name" in repoFullName
36
+ ? String(repoFullName.full_name)
37
+ : undefined;
38
+ const project = repoName
39
+ ? this.config.projects.find((p) => p.github?.repoFullName === repoName)
40
+ : undefined;
41
+ // Verify signature using global GitHub App webhook secret
42
+ const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET;
43
+ if (webhookSecret) {
44
+ if (!verifyGitHubWebhookSignature(params.rawBody, webhookSecret, params.signature)) {
45
+ return { status: 401, body: { ok: false, reason: "invalid_signature" } };
46
+ }
47
+ }
48
+ if (stored.duplicate) {
49
+ return { status: 200, body: { ok: true, duplicate: true } };
50
+ }
51
+ return {
52
+ status: 200,
53
+ body: {
54
+ ok: true,
55
+ accepted: true,
56
+ webhookEventId: stored.id,
57
+ eventType: params.eventType,
58
+ projectId: project?.id,
59
+ },
60
+ };
61
+ }
62
+ async processGitHubWebhookEvent(params) {
63
+ const payload = safeJsonParse(params.rawBody);
64
+ if (!payload || typeof payload !== "object")
65
+ return;
66
+ const event = normalizeGitHubWebhook({
67
+ eventType: params.eventType,
68
+ payload: payload,
69
+ });
70
+ if (!event) {
71
+ this.logger.debug({ eventType: params.eventType }, "GitHub webhook: unrecognized event type or action");
72
+ return;
73
+ }
74
+ // Route to issue via branch name
75
+ const issue = this.db.getIssueByBranch(event.branchName);
76
+ if (!issue) {
77
+ this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue for branch");
78
+ return;
79
+ }
80
+ // Update PR state on the issue
81
+ this.db.upsertIssue({
82
+ projectId: issue.projectId,
83
+ linearIssueId: issue.linearIssueId,
84
+ ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
85
+ ...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
86
+ ...(event.prState !== undefined ? { prState: event.prState } : {}),
87
+ ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
88
+ ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
89
+ });
90
+ // Drive factory state transitions from GitHub events
91
+ const newState = resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState);
92
+ if (newState) {
93
+ this.db.upsertIssue({
94
+ projectId: issue.projectId,
95
+ linearIssueId: issue.linearIssueId,
96
+ factoryState: newState,
97
+ });
98
+ this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
99
+ // Emit Linear activity for significant state changes
100
+ void this.emitLinearActivity(issue, newState, event);
101
+ }
102
+ // Reset repair counters on new push
103
+ if (event.triggerEvent === "pr_synchronize") {
104
+ this.db.upsertIssue({
105
+ projectId: issue.projectId,
106
+ linearIssueId: issue.linearIssueId,
107
+ ciRepairAttempts: 0,
108
+ queueRepairAttempts: 0,
109
+ });
110
+ }
111
+ this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
112
+ this.feed?.publish({
113
+ level: event.triggerEvent.includes("failed") ? "warn" : "info",
114
+ kind: "github",
115
+ issueKey: issue.issueKey,
116
+ projectId: issue.projectId,
117
+ stage: issue.factoryState,
118
+ status: event.triggerEvent,
119
+ summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
120
+ detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
121
+ });
122
+ // Trigger reactive runs if applicable
123
+ this.maybeEnqueueReactiveRun(issue, event);
124
+ }
125
+ maybeEnqueueReactiveRun(issue, event) {
126
+ // Don't trigger if there's already an active run
127
+ if (issue.activeRunId !== undefined)
128
+ return;
129
+ if (event.triggerEvent === "check_failed" && issue.prState === "open") {
130
+ this.db.upsertIssue({
131
+ projectId: issue.projectId,
132
+ linearIssueId: issue.linearIssueId,
133
+ pendingRunType: "ci_repair",
134
+ pendingRunContextJson: JSON.stringify({
135
+ checkName: event.checkName,
136
+ checkUrl: event.checkUrl,
137
+ }),
138
+ });
139
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
140
+ this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Enqueued CI repair run");
141
+ }
142
+ if (event.triggerEvent === "review_changes_requested") {
143
+ this.db.upsertIssue({
144
+ projectId: issue.projectId,
145
+ linearIssueId: issue.linearIssueId,
146
+ pendingRunType: "review_fix",
147
+ pendingRunContextJson: JSON.stringify({
148
+ reviewBody: event.reviewBody,
149
+ reviewerName: event.reviewerName,
150
+ }),
151
+ });
152
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
153
+ this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
154
+ }
155
+ if (event.triggerEvent === "merge_group_failed") {
156
+ this.db.upsertIssue({
157
+ projectId: issue.projectId,
158
+ linearIssueId: issue.linearIssueId,
159
+ pendingRunType: "queue_repair",
160
+ pendingRunContextJson: JSON.stringify({
161
+ failureReason: event.mergeGroupFailureReason,
162
+ }),
163
+ });
164
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
165
+ this.logger.info({ issueKey: issue.issueKey }, "Enqueued merge queue repair run");
166
+ }
167
+ }
168
+ async emitLinearActivity(issue, newState, event) {
169
+ if (!issue.agentSessionId)
170
+ return;
171
+ const linear = await this.linearProvider.forProject(issue.projectId);
172
+ if (!linear?.createAgentActivity)
173
+ return;
174
+ const messages = {
175
+ pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
176
+ awaiting_queue: "PR approved. Awaiting merge queue.",
177
+ changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
178
+ repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
179
+ repairing_queue: "Merge queue failed. Starting repair.",
180
+ done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
181
+ failed: "PR was closed without merging.",
182
+ };
183
+ const body = messages[newState];
184
+ if (!body)
185
+ return;
186
+ const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
187
+ ? "error"
188
+ : "response";
189
+ try {
190
+ await linear.createAgentActivity({
191
+ agentSessionId: issue.agentSessionId,
192
+ content: { type, body },
193
+ });
194
+ }
195
+ catch {
196
+ // Non-blocking — don't fail the webhook for a Linear activity error
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,166 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ export function verifyGitHubWebhookSignature(rawBody, secret, signature) {
3
+ if (!signature.startsWith("sha256=")) {
4
+ return false;
5
+ }
6
+ const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
7
+ const provided = signature.slice("sha256=".length);
8
+ if (expected.length !== provided.length) {
9
+ return false;
10
+ }
11
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(provided, "hex"));
12
+ }
13
+ export function normalizeGitHubWebhook(params) {
14
+ const { eventType, payload } = params;
15
+ const repoFullName = payload.repository?.full_name ?? "";
16
+ if (eventType === "pull_request" && payload.pull_request) {
17
+ return normalizePullRequestEvent(payload, repoFullName);
18
+ }
19
+ if (eventType === "pull_request_review" && payload.pull_request && payload.review) {
20
+ return normalizePullRequestReviewEvent(payload, repoFullName);
21
+ }
22
+ if (eventType === "check_suite" && payload.check_suite) {
23
+ return normalizeCheckSuiteEvent(payload, repoFullName);
24
+ }
25
+ if (eventType === "check_run" && payload.check_run) {
26
+ return normalizeCheckRunEvent(payload, repoFullName);
27
+ }
28
+ if (eventType === "merge_group" && payload.merge_group) {
29
+ return normalizeMergeGroupEvent(payload, repoFullName);
30
+ }
31
+ return undefined;
32
+ }
33
+ function normalizePullRequestEvent(payload, repoFullName) {
34
+ const pr = payload.pull_request;
35
+ const action = payload.action;
36
+ let triggerEvent;
37
+ let prState;
38
+ if (action === "opened" || action === "reopened") {
39
+ triggerEvent = "pr_opened";
40
+ prState = "open";
41
+ }
42
+ else if (action === "synchronize") {
43
+ triggerEvent = "pr_synchronize";
44
+ prState = "open";
45
+ }
46
+ else if (action === "closed") {
47
+ if (pr.merged) {
48
+ triggerEvent = "pr_merged";
49
+ prState = "merged";
50
+ }
51
+ else {
52
+ triggerEvent = "pr_closed";
53
+ prState = "closed";
54
+ }
55
+ }
56
+ else {
57
+ return undefined;
58
+ }
59
+ return {
60
+ triggerEvent,
61
+ repoFullName,
62
+ branchName: pr.head.ref,
63
+ headSha: pr.head.sha,
64
+ prNumber: pr.number,
65
+ prUrl: pr.html_url,
66
+ prState,
67
+ };
68
+ }
69
+ function normalizePullRequestReviewEvent(payload, repoFullName) {
70
+ if (payload.action !== "submitted")
71
+ return undefined;
72
+ const pr = payload.pull_request;
73
+ const review = payload.review;
74
+ const state = review.state?.toLowerCase();
75
+ let triggerEvent;
76
+ let reviewState;
77
+ if (state === "approved") {
78
+ triggerEvent = "review_approved";
79
+ reviewState = "approved";
80
+ }
81
+ else if (state === "changes_requested") {
82
+ triggerEvent = "review_changes_requested";
83
+ reviewState = "changes_requested";
84
+ }
85
+ else if (state === "commented") {
86
+ triggerEvent = "review_commented";
87
+ reviewState = "commented";
88
+ }
89
+ else {
90
+ return undefined;
91
+ }
92
+ return {
93
+ triggerEvent,
94
+ repoFullName,
95
+ branchName: pr.head.ref,
96
+ headSha: pr.head.sha,
97
+ prNumber: pr.number,
98
+ prUrl: pr.html_url,
99
+ prState: "open",
100
+ reviewState,
101
+ reviewBody: review.body ?? undefined,
102
+ reviewerName: review.user?.login ?? undefined,
103
+ };
104
+ }
105
+ function normalizeCheckSuiteEvent(payload, repoFullName) {
106
+ if (payload.action !== "completed")
107
+ return undefined;
108
+ const suite = payload.check_suite;
109
+ const conclusion = suite.conclusion?.toLowerCase();
110
+ const pr = suite.pull_requests?.[0];
111
+ const branchName = pr?.head.ref ?? suite.head_branch ?? "";
112
+ if (!branchName)
113
+ return undefined;
114
+ const passed = conclusion === "success" || conclusion === "neutral" || conclusion === "skipped";
115
+ return {
116
+ triggerEvent: passed ? "check_passed" : "check_failed",
117
+ repoFullName,
118
+ branchName,
119
+ headSha: suite.head_sha,
120
+ prNumber: pr?.number,
121
+ checkStatus: passed ? "success" : "failure",
122
+ };
123
+ }
124
+ function normalizeCheckRunEvent(payload, repoFullName) {
125
+ if (payload.action !== "completed")
126
+ return undefined;
127
+ const run = payload.check_run;
128
+ const conclusion = run.conclusion?.toLowerCase();
129
+ const pr = run.check_suite?.pull_requests?.[0];
130
+ const branchName = pr?.head.ref ?? run.check_suite?.head_branch ?? "";
131
+ if (!branchName)
132
+ return undefined;
133
+ const passed = conclusion === "success" || conclusion === "neutral" || conclusion === "skipped";
134
+ return {
135
+ triggerEvent: passed ? "check_passed" : "check_failed",
136
+ repoFullName,
137
+ branchName,
138
+ headSha: run.head_sha,
139
+ prNumber: pr?.number,
140
+ checkStatus: passed ? "success" : "failure",
141
+ checkName: run.name,
142
+ checkUrl: run.html_url,
143
+ };
144
+ }
145
+ function normalizeMergeGroupEvent(payload, repoFullName) {
146
+ const group = payload.merge_group;
147
+ const action = payload.action;
148
+ if (action === "checks_passed") {
149
+ return {
150
+ triggerEvent: "merge_group_passed",
151
+ repoFullName,
152
+ branchName: group.head_ref,
153
+ headSha: group.head_sha,
154
+ };
155
+ }
156
+ if (action === "checks_failed" || action === "destroyed") {
157
+ return {
158
+ triggerEvent: "merge_group_failed",
159
+ repoFullName,
160
+ branchName: group.head_ref,
161
+ headSha: group.head_sha,
162
+ mergeGroupFailureReason: action,
163
+ };
164
+ }
165
+ return undefined;
166
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { execCommand } from "./utils.js";
4
+ export async function runProjectHook(repoPath, hookName, options) {
5
+ const hookPath = path.join(repoPath, ".patchrelay", "hooks", hookName);
6
+ if (!existsSync(hookPath)) {
7
+ return { ran: false };
8
+ }
9
+ const result = await execCommand(hookPath, [], {
10
+ cwd: options.cwd,
11
+ env: { ...process.env, ...(options.env ?? {}) },
12
+ timeoutMs: options.timeoutMs ?? 120_000,
13
+ });
14
+ return {
15
+ ran: true,
16
+ exitCode: result.exitCode,
17
+ stdout: result.stdout,
18
+ stderr: result.stderr,
19
+ };
20
+ }
21
+ export function buildHookEnv(issueKey, branchName, stage, worktreePath) {
22
+ return {
23
+ PATCHRELAY_ISSUE_KEY: issueKey,
24
+ PATCHRELAY_BRANCH: branchName,
25
+ PATCHRELAY_STAGE: stage,
26
+ PATCHRELAY_WORKTREE: worktreePath,
27
+ };
28
+ }
package/dist/http.js CHANGED
@@ -178,6 +178,32 @@ export async function buildHttpServer(config, service, logger) {
178
178
  });
179
179
  return reply.code(result.status).send(result.body);
180
180
  });
181
+ app.post(config.ingress.githubWebhookPath, {
182
+ config: {
183
+ rawBody: true,
184
+ },
185
+ }, async (request, reply) => {
186
+ const rawBody = typeof request.rawBody === "string" ? Buffer.from(request.rawBody) : request.rawBody;
187
+ if (!rawBody) {
188
+ return reply.code(400).send({ ok: false, reason: "missing_raw_body" });
189
+ }
190
+ const deliveryId = getHeader(request, "x-github-delivery");
191
+ if (!deliveryId) {
192
+ return reply.code(400).send({ ok: false, reason: "missing_delivery_header" });
193
+ }
194
+ const eventType = getHeader(request, "x-github-event");
195
+ if (!eventType) {
196
+ return reply.code(400).send({ ok: false, reason: "missing_event_type" });
197
+ }
198
+ const signature = getHeader(request, "x-hub-signature-256") ?? "";
199
+ const result = await service.acceptGitHubWebhook({
200
+ deliveryId,
201
+ eventType,
202
+ signature,
203
+ rawBody,
204
+ });
205
+ return reply.code(result.status).send(result.body);
206
+ });
181
207
  app.get("/agent/session/:issueKey", async (request, reply) => {
182
208
  const issueKey = request.params.issueKey;
183
209
  const token = getQueryParam(request, "token");
@@ -235,17 +261,17 @@ export async function buildHttpServer(config, service, logger) {
235
261
  });
236
262
  app.get("/api/issues/:issueKey/live", async (request, reply) => {
237
263
  const issueKey = request.params.issueKey;
238
- const result = await service.getActiveStageStatus(issueKey);
264
+ const result = await service.getActiveRunStatus(issueKey);
239
265
  if (!result) {
240
- return reply.code(404).send({ ok: false, reason: "active_stage_not_found" });
266
+ return reply.code(404).send({ ok: false, reason: "active_run_not_found" });
241
267
  }
242
268
  return reply.send({ ok: true, ...result });
243
269
  });
244
- app.get("/api/issues/:issueKey/stages/:stageRunId/events", async (request, reply) => {
245
- const { issueKey, stageRunId } = request.params;
246
- const result = await service.getStageEvents(issueKey, Number(stageRunId));
270
+ app.get("/api/issues/:issueKey/runs/:runId/events", async (request, reply) => {
271
+ const { issueKey, runId } = request.params;
272
+ const result = await service.getRunEvents(issueKey, Number(runId));
247
273
  if (!result) {
248
- return reply.code(404).send({ ok: false, reason: "stage_run_not_found" });
274
+ return reply.code(404).send({ ok: false, reason: "run_not_found" });
249
275
  }
250
276
  return reply.send({ ok: true, ...result });
251
277
  });
@@ -448,10 +474,10 @@ function renderAgentSessionStatusErrorPage(message) {
448
474
  function renderAgentSessionStatusPage(params) {
449
475
  const issueTitle = params.sessionStatus.issue.title ?? params.sessionStatus.issue.issueKey ?? params.issueKey;
450
476
  const issueUrl = params.sessionStatus.issue.issueUrl;
451
- const activeStage = formatStageChip(params.sessionStatus.activeStageRun);
452
- const latestStage = formatStageChip(params.sessionStatus.latestStageRun);
477
+ const activeStage = formatStageChip(params.sessionStatus.activeRun);
478
+ const latestStage = formatStageChip(params.sessionStatus.latestRun);
453
479
  const threadInfo = formatThread(params.sessionStatus.liveThread);
454
- const stagesRows = params.sessionStatus.stages.slice(-8).map((entry) => formatStageRow(entry.stageRun)).join("");
480
+ const stagesRows = params.sessionStatus.runs.slice(-8).map((entry) => formatStageRow(entry.run)).join("");
455
481
  return `<!doctype html>
456
482
  <html lang="en">
457
483
  <head>
@@ -535,13 +561,13 @@ function renderAgentSessionStatusPage(params) {
535
561
  </body>
536
562
  </html>`;
537
563
  }
538
- function formatStageChip(stageRun) {
539
- if (!stageRun) {
564
+ function formatStageChip(run) {
565
+ if (!run) {
540
566
  return "none";
541
567
  }
542
- const stage = stageRun.stage ?? "unknown";
543
- const status = stageRun.status ?? "unknown";
544
- return `<code>${escapeHtml(stage)}</code> (${escapeHtml(status)})`;
568
+ const runType = run.runType ?? "unknown";
569
+ const status = run.status ?? "unknown";
570
+ return `<code>${escapeHtml(runType)}</code> (${escapeHtml(status)})`;
545
571
  }
546
572
  function formatThread(liveThread) {
547
573
  if (!liveThread) {
@@ -551,13 +577,13 @@ function formatThread(liveThread) {
551
577
  const status = liveThread.threadStatus ?? "unknown";
552
578
  return `<code>${escapeHtml(threadId)}</code> (${escapeHtml(status)})`;
553
579
  }
554
- function formatStageRow(stageRun) {
555
- if (!stageRun) {
556
- return '<tr><td colspan="4">Unknown stage record</td></tr>';
580
+ function formatStageRow(run) {
581
+ if (!run) {
582
+ return '<tr><td colspan="4">Unknown run record</td></tr>';
557
583
  }
558
- const stage = stageRun.stage ?? "unknown";
559
- const status = stageRun.status ?? "unknown";
560
- const startedAt = stageRun.startedAt ?? "-";
561
- const endedAt = stageRun.endedAt ?? "-";
562
- return `<tr><td><code>${escapeHtml(stage)}</code></td><td>${escapeHtml(status)}</td><td><code>${escapeHtml(startedAt)}</code></td><td><code>${escapeHtml(endedAt)}</code></td></tr>`;
584
+ const runType = run.runType ?? "unknown";
585
+ const status = run.status ?? "unknown";
586
+ const startedAt = run.startedAt ?? "-";
587
+ const endedAt = run.endedAt ?? "-";
588
+ 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>`;
563
589
  }
@@ -1,82 +1,77 @@
1
- import { summarizeCurrentThread } from "./stage-reporting.js";
1
+ import { summarizeCurrentThread } from "./run-reporting.js";
2
2
  import { safeJsonParse } from "./utils.js";
3
3
  export class IssueQueryService {
4
- stores;
4
+ db;
5
5
  codex;
6
- stageFinalizer;
7
- constructor(stores, codex, stageFinalizer) {
8
- this.stores = stores;
6
+ runStatusProvider;
7
+ constructor(db, codex, runStatusProvider) {
8
+ this.db = db;
9
9
  this.codex = codex;
10
- this.stageFinalizer = stageFinalizer;
10
+ this.runStatusProvider = runStatusProvider;
11
11
  }
12
12
  async getIssueOverview(issueKey) {
13
- const result = this.stores.issueWorkflows.getIssueOverview(issueKey);
14
- if (!result) {
13
+ const result = this.db.getIssueOverview(issueKey);
14
+ if (!result)
15
15
  return undefined;
16
- }
17
- const activeStatus = await this.stageFinalizer.getActiveStageStatus(issueKey);
18
- const activeStageRun = activeStatus?.stageRun ?? result.activeStageRun;
19
- const latestStageRun = this.stores.issueWorkflows.getLatestStageRunForIssue(result.issue.projectId, result.issue.linearIssueId);
16
+ const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
17
+ const activeRun = activeStatus?.run ?? result.activeRun;
18
+ const latestRun = this.db.getLatestRunForIssue(result.issue.projectId, result.issue.linearIssueId);
20
19
  let liveThread;
21
20
  if (activeStatus?.liveThread) {
22
21
  liveThread = activeStatus.liveThread;
23
22
  }
24
- else if (activeStageRun?.threadId) {
25
- liveThread = await this.codex.readThread(activeStageRun.threadId, true).then(summarizeCurrentThread).catch(() => undefined);
23
+ else if (activeRun?.threadId) {
24
+ liveThread = await this.codex.readThread(activeRun.threadId, true).then(summarizeCurrentThread).catch(() => undefined);
26
25
  }
27
26
  return {
28
27
  ...result,
29
- ...(activeStageRun ? { activeStageRun } : {}),
30
- ...(latestStageRun ? { latestStageRun } : {}),
28
+ ...(activeRun ? { activeRun } : {}),
29
+ ...(latestRun ? { latestRun } : {}),
31
30
  ...(liveThread ? { liveThread } : {}),
32
31
  };
33
32
  }
34
33
  async getIssueReport(issueKey) {
35
- const issue = this.stores.issueWorkflows.getTrackedIssueByKey(issueKey);
36
- if (!issue) {
34
+ const issue = this.db.getTrackedIssueByKey(issueKey);
35
+ if (!issue)
37
36
  return undefined;
38
- }
39
37
  return {
40
38
  issue,
41
- stages: this.stores.issueWorkflows.listStageRunsForIssue(issue.projectId, issue.linearIssueId).map((stageRun) => ({
42
- stageRun,
43
- ...(stageRun.reportJson ? { report: JSON.parse(stageRun.reportJson) } : {}),
39
+ runs: this.db.listRunsForIssue(issue.projectId, issue.linearIssueId).map((run) => ({
40
+ run,
41
+ ...(run.reportJson ? { report: JSON.parse(run.reportJson) } : {}),
44
42
  })),
45
43
  };
46
44
  }
47
- async getStageEvents(issueKey, stageRunId) {
48
- const issue = this.stores.issueWorkflows.getTrackedIssueByKey(issueKey);
49
- if (!issue) {
45
+ async getRunEvents(issueKey, runId) {
46
+ const issue = this.db.getTrackedIssueByKey(issueKey);
47
+ if (!issue)
50
48
  return undefined;
51
- }
52
- const stageRun = this.stores.issueWorkflows.getStageRun(stageRunId);
53
- if (!stageRun || stageRun.projectId !== issue.projectId || stageRun.linearIssueId !== issue.linearIssueId) {
49
+ const run = this.db.getRun(runId);
50
+ if (!run || run.projectId !== issue.projectId || run.linearIssueId !== issue.linearIssueId)
54
51
  return undefined;
55
- }
56
52
  return {
57
53
  issue,
58
- stageRun,
59
- events: this.stores.stageEvents.listThreadEvents(stageRunId).map((event) => ({
54
+ run,
55
+ events: this.db.listThreadEvents(runId).map((event) => ({
60
56
  ...event,
61
57
  parsedEvent: safeJsonParse(event.eventJson),
62
58
  })),
63
59
  };
64
60
  }
65
- async getActiveStageStatus(issueKey) {
66
- return await this.stageFinalizer.getActiveStageStatus(issueKey);
61
+ async getActiveRunStatus(issueKey) {
62
+ return await this.runStatusProvider.getActiveRunStatus(issueKey);
67
63
  }
68
64
  async getPublicAgentSessionStatus(issueKey) {
69
65
  const overview = await this.getIssueOverview(issueKey);
70
- if (!overview) {
66
+ if (!overview)
71
67
  return undefined;
72
- }
73
68
  const report = await this.getIssueReport(issueKey);
74
69
  return {
75
70
  issue: overview.issue,
76
- ...(overview.activeStageRun ? { activeStageRun: overview.activeStageRun } : {}),
77
- ...(overview.latestStageRun ? { latestStageRun: overview.latestStageRun } : {}),
71
+ ...(overview.activeRun ? { activeRun: overview.activeRun } : {}),
72
+ ...(overview.latestRun ? { latestRun: overview.latestRun } : {}),
78
73
  ...(overview.liveThread ? { liveThread: overview.liveThread } : {}),
79
- stages: report?.stages ?? [],
74
+ runs: report?.runs ?? [],
80
75
  generatedAt: new Date().toISOString(),
81
76
  };
82
77
  }