patchrelay 0.82.0 → 0.83.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.82.0",
4
- "commit": "91dee8b62165",
5
- "builtAt": "2026-06-11T01:02:50.881Z"
3
+ "version": "0.83.0",
4
+ "commit": "5e17a07e67bc",
5
+ "builtAt": "2026-06-14T17:38:15.238Z"
6
6
  }
@@ -237,6 +237,36 @@ CREATE TABLE IF NOT EXISTS issue_children (
237
237
  PRIMARY KEY (project_id, parent_linear_issue_id, child_linear_issue_id)
238
238
  );
239
239
 
240
+ CREATE TABLE IF NOT EXISTS workflow_observations (
241
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
242
+ project_id TEXT NOT NULL,
243
+ subject_id TEXT NOT NULL,
244
+ source TEXT NOT NULL,
245
+ type TEXT NOT NULL,
246
+ payload_json TEXT,
247
+ dedupe_key TEXT,
248
+ observed_at TEXT NOT NULL
249
+ );
250
+
251
+ CREATE TABLE IF NOT EXISTS workflow_tasks (
252
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
253
+ project_id TEXT NOT NULL,
254
+ subject_id TEXT NOT NULL,
255
+ task_id TEXT NOT NULL,
256
+ task_type TEXT NOT NULL,
257
+ run_type TEXT,
258
+ status TEXT NOT NULL,
259
+ reason TEXT NOT NULL,
260
+ requirements_json TEXT,
261
+ authority_epoch INTEGER NOT NULL DEFAULT 0,
262
+ gate_action TEXT NOT NULL,
263
+ gate_reason TEXT,
264
+ created_at TEXT NOT NULL,
265
+ updated_at TEXT NOT NULL,
266
+ closed_at TEXT,
267
+ UNIQUE(project_id, subject_id, task_id)
268
+ );
269
+
240
270
  CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
241
271
  CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
242
272
  CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
@@ -260,6 +290,13 @@ CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(pr
260
290
  CREATE INDEX IF NOT EXISTS idx_issue_dependencies_blocker ON issue_dependencies(project_id, blocker_linear_issue_id);
261
291
  CREATE INDEX IF NOT EXISTS idx_issue_children_parent ON issue_children(project_id, parent_linear_issue_id);
262
292
  CREATE INDEX IF NOT EXISTS idx_issue_children_child ON issue_children(project_id, child_linear_issue_id);
293
+ CREATE INDEX IF NOT EXISTS idx_workflow_observations_subject ON workflow_observations(project_id, subject_id, id);
294
+ CREATE INDEX IF NOT EXISTS idx_workflow_observations_recent ON workflow_observations(observed_at, id);
295
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_observations_dedupe
296
+ ON workflow_observations(project_id, subject_id, source, dedupe_key)
297
+ WHERE dedupe_key IS NOT NULL;
298
+ CREATE INDEX IF NOT EXISTS idx_workflow_tasks_subject ON workflow_tasks(project_id, subject_id, status, id);
299
+ CREATE INDEX IF NOT EXISTS idx_workflow_tasks_open ON workflow_tasks(status, project_id, updated_at);
263
300
  `;
264
301
  export function runPatchRelayMigrations(connection) {
265
302
  connection.exec(schema);
@@ -309,6 +346,12 @@ export function runPatchRelayMigrations(connection) {
309
346
  // Plan §4.4: hard publication-suppression flag for the
310
347
  // mid-run-approval cancellation primitive.
311
348
  addColumnIfMissing(connection, "runs", "should_not_publish", "INTEGER NOT NULL DEFAULT 0");
349
+ addColumnIfMissing(connection, "runs", "authority_epoch", "INTEGER NOT NULL DEFAULT 0");
350
+ addColumnIfMissing(connection, "runs", "lease_revoked_at", "TEXT");
351
+ addColumnIfMissing(connection, "runs", "lease_revoke_reason", "TEXT");
352
+ addColumnIfMissing(connection, "workflow_tasks", "authority_epoch", "INTEGER NOT NULL DEFAULT 0");
353
+ addColumnIfMissing(connection, "workflow_tasks", "gate_action", "TEXT NOT NULL DEFAULT 'wait'");
354
+ addColumnIfMissing(connection, "workflow_tasks", "gate_reason", "TEXT");
312
355
  addColumnIfMissing(connection, "issues", "last_blocking_review_head_sha", "TEXT");
313
356
  // Collapse awaiting_review into pr_open (state normalization)
314
357
  connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
@@ -7,22 +7,50 @@ export class RunStore {
7
7
  issues;
8
8
  issueSessionProjection;
9
9
  telemetry;
10
- constructor(connection, mapRunRow, issues, issueSessionProjection, telemetry = noopTelemetry) {
10
+ workflowObservations;
11
+ constructor(connection, mapRunRow, issues, issueSessionProjection, telemetry = noopTelemetry, workflowObservations) {
11
12
  this.connection = connection;
12
13
  this.mapRunRow = mapRunRow;
13
14
  this.issues = issues;
14
15
  this.issueSessionProjection = issueSessionProjection;
15
16
  this.telemetry = telemetry;
17
+ this.workflowObservations = workflowObservations;
16
18
  }
17
19
  projectIssueRun(issue, options) {
18
20
  this.issueSessionProjection.issueRunChanged(issue, options);
19
21
  }
22
+ appendRunnerObservation(type, run, payload = {}) {
23
+ this.workflowObservations?.appendObservation({
24
+ projectId: run.projectId,
25
+ subjectId: run.linearIssueId,
26
+ source: "runner",
27
+ type,
28
+ payloadJson: JSON.stringify({
29
+ runId: run.id,
30
+ runType: run.runType,
31
+ status: run.status,
32
+ authorityEpoch: run.authorityEpoch,
33
+ ...(run.launchPhase ? { launchPhase: run.launchPhase } : {}),
34
+ ...(run.sourceHeadSha ? { sourceHeadSha: run.sourceHeadSha } : {}),
35
+ ...(run.threadId ? { threadId: run.threadId } : {}),
36
+ ...(run.turnId ? { turnId: run.turnId } : {}),
37
+ ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
38
+ ...(run.failureReason ? { failureReason: run.failureReason } : {}),
39
+ ...(run.shouldNotPublish ? { shouldNotPublish: true } : {}),
40
+ ...(run.leaseRevokedAt ? { leaseRevokedAt: run.leaseRevokedAt } : {}),
41
+ ...(run.leaseRevokeReason ? { leaseRevokeReason: run.leaseRevokeReason } : {}),
42
+ ...(run.endedAt ? { endedAt: run.endedAt } : {}),
43
+ ...payload,
44
+ }),
45
+ dedupeKey: `runner:${type}:${run.id}`,
46
+ });
47
+ }
20
48
  createRun(params) {
21
49
  const now = isoNow();
22
50
  const result = this.connection.prepare(`
23
- INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, launch_phase, source_head_sha, prompt_text, started_at)
24
- VALUES (?, ?, ?, ?, 'queued', 'claimed', ?, ?, ?)
25
- `).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, now);
51
+ INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, launch_phase, source_head_sha, prompt_text, authority_epoch, started_at)
52
+ VALUES (?, ?, ?, ?, 'queued', 'claimed', ?, ?, ?, ?)
53
+ `).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, params.authorityEpoch ?? 0, now);
26
54
  const run = this.getRunById(Number(result.lastInsertRowid));
27
55
  const issue = this.issues.getIssue(params.projectId, params.linearIssueId);
28
56
  if (issue) {
@@ -36,6 +64,7 @@ export class RunStore {
36
64
  runType: run.runType,
37
65
  ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
38
66
  });
67
+ this.appendRunnerObservation("runner.run_claimed", run);
39
68
  return run;
40
69
  }
41
70
  getRunById(id) {
@@ -97,6 +126,7 @@ export class RunStore {
97
126
  runType: run.runType,
98
127
  ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
99
128
  });
129
+ this.appendRunnerObservation("runner.run_started", run);
100
130
  }
101
131
  updateRunTurnId(runId, turnId) {
102
132
  this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
@@ -147,6 +177,7 @@ export class RunStore {
147
177
  runType: run.runType,
148
178
  ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
149
179
  });
180
+ this.appendRunnerObservation("runner.run_finished", run);
150
181
  }
151
182
  // Plan §4.4: flag a still-running run as superseded. We deliberately
152
183
  // do NOT change `status` here — the Codex turn must finish naturally
@@ -175,6 +206,33 @@ export class RunStore {
175
206
  lastRunType: run.runType,
176
207
  });
177
208
  }
209
+ this.appendRunnerObservation("runner.run_superseded_requested", run, {
210
+ reason: params.reason,
211
+ });
212
+ }
213
+ revokeRunLease(runId, params) {
214
+ this.connection.prepare(`
215
+ UPDATE runs SET
216
+ should_not_publish = 1,
217
+ lease_revoked_at = COALESCE(lease_revoked_at, ?),
218
+ lease_revoke_reason = COALESCE(lease_revoke_reason, ?),
219
+ failure_reason = COALESCE(failure_reason, ?)
220
+ WHERE id = ?
221
+ AND status IN ('queued', 'running')
222
+ `).run(params.revokedAt ?? isoNow(), params.reason, params.reason, runId);
223
+ const run = this.getRunById(runId);
224
+ if (!run)
225
+ return;
226
+ const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
227
+ if (issue) {
228
+ this.projectIssueRun(issue, {
229
+ summaryText: params.reason,
230
+ lastRunType: run.runType,
231
+ });
232
+ }
233
+ this.appendRunnerObservation("runner.run_revoked", run, {
234
+ reason: params.reason,
235
+ });
178
236
  }
179
237
  saveCompletionCheck(runId, params) {
180
238
  this.connection.prepare(`
@@ -3,6 +3,8 @@ const REQUIRED_PATCHRELAY_TABLES = [
3
3
  "runs",
4
4
  "issue_sessions",
5
5
  "issue_session_events",
6
+ "workflow_observations",
7
+ "workflow_tasks",
6
8
  ];
7
9
  export function assertPatchRelaySchemaReady(connection, databasePath) {
8
10
  const rows = connection
@@ -0,0 +1,61 @@
1
+ import { isoNow } from "./shared.js";
2
+ export class WorkflowObservationStore {
3
+ connection;
4
+ mapWorkflowObservationRow;
5
+ constructor(connection, mapWorkflowObservationRow) {
6
+ this.connection = connection;
7
+ this.mapWorkflowObservationRow = mapWorkflowObservationRow;
8
+ }
9
+ appendObservation(params) {
10
+ if (params.dedupeKey) {
11
+ const existing = this.connection.prepare(`
12
+ SELECT * FROM workflow_observations
13
+ WHERE project_id = ? AND subject_id = ? AND source = ? AND dedupe_key = ?
14
+ ORDER BY id DESC LIMIT 1
15
+ `).get(params.projectId, params.subjectId, params.source, params.dedupeKey);
16
+ if (existing) {
17
+ return this.mapWorkflowObservationRow(existing);
18
+ }
19
+ }
20
+ const result = this.connection.prepare(`
21
+ INSERT INTO workflow_observations (
22
+ project_id, subject_id, source, type, payload_json, dedupe_key, observed_at
23
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
24
+ `).run(params.projectId, params.subjectId, params.source, params.type, params.payloadJson ?? null, params.dedupeKey ?? null, params.observedAt ?? isoNow());
25
+ return this.getObservation(Number(result.lastInsertRowid));
26
+ }
27
+ getObservation(id) {
28
+ const row = this.connection.prepare("SELECT * FROM workflow_observations WHERE id = ?")
29
+ .get(id);
30
+ return row ? this.mapWorkflowObservationRow(row) : undefined;
31
+ }
32
+ listObservations(projectId, subjectId) {
33
+ const rows = this.connection.prepare(`
34
+ SELECT * FROM workflow_observations
35
+ WHERE project_id = ? AND subject_id = ?
36
+ ORDER BY id
37
+ `).all(projectId, subjectId);
38
+ return rows.map(this.mapWorkflowObservationRow);
39
+ }
40
+ listRecentObservations(params = {}) {
41
+ const conditions = [];
42
+ const values = [];
43
+ if (params.projectId) {
44
+ conditions.push("project_id = ?");
45
+ values.push(params.projectId);
46
+ }
47
+ if (params.observedAfter) {
48
+ conditions.push("observed_at >= ?");
49
+ values.push(params.observedAfter);
50
+ }
51
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
52
+ const limit = params.limit ?? 250;
53
+ const rows = this.connection.prepare(`
54
+ SELECT * FROM workflow_observations
55
+ ${where}
56
+ ORDER BY id DESC
57
+ LIMIT ?
58
+ `).all(...values, limit);
59
+ return rows.map(this.mapWorkflowObservationRow);
60
+ }
61
+ }
@@ -0,0 +1,111 @@
1
+ import { isoNow } from "./shared.js";
2
+ export class WorkflowTaskStore {
3
+ connection;
4
+ mapWorkflowTaskRow;
5
+ constructor(connection, mapWorkflowTaskRow) {
6
+ this.connection = connection;
7
+ this.mapWorkflowTaskRow = mapWorkflowTaskRow;
8
+ }
9
+ reconcileTasks(params) {
10
+ const reconciledAt = params.reconciledAt ?? isoNow();
11
+ return this.connection.transaction(() => {
12
+ const previousOpen = this.listOpenTasks(params.projectId, params.subjectId);
13
+ const currentTaskIds = new Set(params.tasks.map((entry) => entry.task.id));
14
+ const opened = [];
15
+ const updated = [];
16
+ for (const entry of params.tasks) {
17
+ const existing = this.getTask(params.projectId, params.subjectId, entry.task.id);
18
+ const requirementsJson = entry.task.requirements ? JSON.stringify(entry.task.requirements) : undefined;
19
+ this.connection.prepare(`
20
+ INSERT INTO workflow_tasks (
21
+ project_id, subject_id, task_id, task_type, run_type, status, reason,
22
+ requirements_json, authority_epoch, gate_action, gate_reason,
23
+ created_at, updated_at, closed_at
24
+ ) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, NULL)
25
+ ON CONFLICT(project_id, subject_id, task_id) DO UPDATE SET
26
+ task_type = excluded.task_type,
27
+ run_type = excluded.run_type,
28
+ status = 'open',
29
+ reason = excluded.reason,
30
+ requirements_json = excluded.requirements_json,
31
+ authority_epoch = excluded.authority_epoch,
32
+ gate_action = excluded.gate_action,
33
+ gate_reason = excluded.gate_reason,
34
+ updated_at = excluded.updated_at,
35
+ closed_at = NULL
36
+ `).run(params.projectId, params.subjectId, entry.task.id, entry.task.type, entry.task.runType ?? null, entry.task.reason, requirementsJson ?? null, entry.authorityEpoch, entry.gateAction, entry.gateReason ?? null, existing?.createdAt ?? reconciledAt, reconciledAt);
37
+ const saved = this.getTask(params.projectId, params.subjectId, entry.task.id);
38
+ if (!existing || existing.status === "closed") {
39
+ opened.push(saved);
40
+ }
41
+ else {
42
+ updated.push(saved);
43
+ }
44
+ }
45
+ const closed = [];
46
+ for (const stale of previousOpen) {
47
+ if (currentTaskIds.has(stale.taskId))
48
+ continue;
49
+ this.connection.prepare(`
50
+ UPDATE workflow_tasks
51
+ SET status = 'closed',
52
+ updated_at = ?,
53
+ closed_at = ?
54
+ WHERE id = ?
55
+ AND status = 'open'
56
+ `).run(reconciledAt, reconciledAt, stale.id);
57
+ const closedTask = this.getTaskById(stale.id);
58
+ if (closedTask) {
59
+ closed.push(closedTask);
60
+ }
61
+ }
62
+ return {
63
+ opened,
64
+ updated,
65
+ closed,
66
+ open: this.listOpenTasks(params.projectId, params.subjectId),
67
+ };
68
+ })();
69
+ }
70
+ getTask(projectId, subjectId, taskId) {
71
+ const row = this.connection.prepare(`
72
+ SELECT * FROM workflow_tasks
73
+ WHERE project_id = ? AND subject_id = ? AND task_id = ?
74
+ `).get(projectId, subjectId, taskId);
75
+ return row ? this.mapWorkflowTaskRow(row) : undefined;
76
+ }
77
+ getTaskById(id) {
78
+ const row = this.connection.prepare("SELECT * FROM workflow_tasks WHERE id = ?").get(id);
79
+ return row ? this.mapWorkflowTaskRow(row) : undefined;
80
+ }
81
+ listTasks(projectId, subjectId) {
82
+ const rows = this.connection.prepare(`
83
+ SELECT * FROM workflow_tasks
84
+ WHERE project_id = ? AND subject_id = ?
85
+ ORDER BY id
86
+ `).all(projectId, subjectId);
87
+ return rows.map(this.mapWorkflowTaskRow);
88
+ }
89
+ listOpenTasks(projectId, subjectId) {
90
+ const rows = this.connection.prepare(`
91
+ SELECT * FROM workflow_tasks
92
+ WHERE project_id = ? AND subject_id = ? AND status = 'open'
93
+ ORDER BY id
94
+ `).all(projectId, subjectId);
95
+ return rows.map(this.mapWorkflowTaskRow);
96
+ }
97
+ listOpenRunnableTasks(projectId) {
98
+ const rows = projectId
99
+ ? this.connection.prepare(`
100
+ SELECT * FROM workflow_tasks
101
+ WHERE status = 'open' AND task_type = 'run' AND gate_action = 'start' AND project_id = ?
102
+ ORDER BY updated_at, id
103
+ `).all(projectId)
104
+ : this.connection.prepare(`
105
+ SELECT * FROM workflow_tasks
106
+ WHERE status = 'open' AND task_type = 'run' AND gate_action = 'start'
107
+ ORDER BY updated_at, id
108
+ `).all();
109
+ return rows.map(this.mapWorkflowTaskRow);
110
+ }
111
+ }
package/dist/db.js CHANGED
@@ -6,6 +6,8 @@ import { OperatorFeedStore } from "./db/operator-feed-store.js";
6
6
  import { RepositoryLinkStore } from "./db/repository-link-store.js";
7
7
  import { RunStore } from "./db/run-store.js";
8
8
  import { WebhookEventStore } from "./db/webhook-event-store.js";
9
+ import { WorkflowObservationStore } from "./db/workflow-observation-store.js";
10
+ import { WorkflowTaskStore } from "./db/workflow-task-store.js";
9
11
  import { runPatchRelayMigrations } from "./db/migrations.js";
10
12
  import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
11
13
  import { SqliteConnection } from "./db/shared.js";
@@ -14,6 +16,7 @@ import { syncIssueSessionFromIssue } from "./issue-session-projector.js";
14
16
  import { noopTelemetry } from "./telemetry.js";
15
17
  import { TrackedIssueQuery } from "./tracked-issue-query.js";
16
18
  import { WorkflowWakeResolver } from "./workflow-wake-resolver.js";
19
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
17
20
  export class PatchRelayDatabase {
18
21
  connection;
19
22
  issueSessionProjection;
@@ -25,6 +28,8 @@ export class PatchRelayDatabase {
25
28
  operatorFeed;
26
29
  repositories;
27
30
  webhookEvents;
31
+ workflowObservations;
32
+ workflowTasks;
28
33
  issues;
29
34
  issueSessions;
30
35
  workflowWakes;
@@ -45,6 +50,8 @@ export class PatchRelayDatabase {
45
50
  this.operatorFeed = new OperatorFeedStore(this.connection);
46
51
  this.repositories = new RepositoryLinkStore(this.connection);
47
52
  this.webhookEvents = new WebhookEventStore(this.connection);
53
+ this.workflowObservations = new WorkflowObservationStore(this.connection, mapWorkflowObservationRow);
54
+ this.workflowTasks = new WorkflowTaskStore(this.connection, mapWorkflowTaskRow);
48
55
  this.issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
49
56
  getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
50
57
  listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
@@ -61,10 +68,13 @@ export class PatchRelayDatabase {
61
68
  telemetry: this.telemetryProxy,
62
69
  });
63
70
  this.issues = new IssueStore(this.connection, this.issueSessionProjection);
64
- this.runs = new RunStore(this.connection, mapRunRow, this.issues, this.issueSessionProjection, this.telemetryProxy);
71
+ this.runs = new RunStore(this.connection, mapRunRow, this.issues, this.issueSessionProjection, this.telemetryProxy, this.workflowObservations);
65
72
  this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, this.issueSessionProjection, this.telemetryProxy);
66
73
  this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
67
- this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
74
+ this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, {
75
+ hasPendingWake: (projectId, linearIssueId) => this.workflowWakes.hasPendingWake(projectId, linearIssueId)
76
+ || this.workflowTasks.listOpenRunnableTasks(projectId).some((task) => task.subjectId === linearIssueId),
77
+ }, this.runs);
68
78
  }
69
79
  databasePath;
70
80
  setTelemetry(telemetry) {
@@ -148,7 +158,20 @@ export class PatchRelayDatabase {
148
158
  return this.issues.countUnresolvedBlockers(projectId, linearIssueId);
149
159
  }
150
160
  listIssuesReadyForExecution() {
151
- return this.trackedIssues.listIssuesReadyForExecution();
161
+ const ready = new Map();
162
+ for (const issue of this.issues.listIssues()) {
163
+ reconcileWorkflowTasksForIssue(this, issue);
164
+ }
165
+ for (const issue of this.trackedIssues.listIssuesReadyForExecution()) {
166
+ ready.set(`${issue.projectId}:${issue.linearIssueId}`, issue);
167
+ }
168
+ for (const task of this.workflowTasks.listOpenRunnableTasks()) {
169
+ ready.set(`${task.projectId}:${task.subjectId}`, {
170
+ projectId: task.projectId,
171
+ linearIssueId: task.subjectId,
172
+ });
173
+ }
174
+ return [...ready.values()];
152
175
  }
153
176
  /**
154
177
  * Issues idle in pr_open with no active run — candidates for state
@@ -274,7 +297,41 @@ function mapRunRow(row) {
274
297
  ...(row.report_json !== null ? { reportJson: String(row.report_json) } : {}),
275
298
  ...(row.failure_reason !== null ? { failureReason: String(row.failure_reason) } : {}),
276
299
  ...(row.should_not_publish === 1 || row.should_not_publish === true ? { shouldNotPublish: true } : {}),
300
+ authorityEpoch: Number(row.authority_epoch ?? 0),
301
+ ...(row.lease_revoked_at !== null ? { leaseRevokedAt: String(row.lease_revoked_at) } : {}),
302
+ ...(row.lease_revoke_reason !== null ? { leaseRevokeReason: String(row.lease_revoke_reason) } : {}),
277
303
  startedAt: String(row.started_at),
278
304
  ...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
279
305
  };
280
306
  }
307
+ function mapWorkflowObservationRow(row) {
308
+ return {
309
+ id: Number(row.id),
310
+ projectId: String(row.project_id),
311
+ subjectId: String(row.subject_id),
312
+ source: String(row.source),
313
+ type: String(row.type),
314
+ ...(row.payload_json !== null && row.payload_json !== undefined ? { payloadJson: String(row.payload_json) } : {}),
315
+ ...(row.dedupe_key !== null && row.dedupe_key !== undefined ? { dedupeKey: String(row.dedupe_key) } : {}),
316
+ observedAt: String(row.observed_at),
317
+ };
318
+ }
319
+ function mapWorkflowTaskRow(row) {
320
+ return {
321
+ id: Number(row.id),
322
+ projectId: String(row.project_id),
323
+ subjectId: String(row.subject_id),
324
+ taskId: String(row.task_id),
325
+ taskType: String(row.task_type),
326
+ ...(row.run_type !== null && row.run_type !== undefined ? { runType: String(row.run_type) } : {}),
327
+ status: String(row.status),
328
+ reason: String(row.reason),
329
+ ...(row.requirements_json !== null && row.requirements_json !== undefined ? { requirementsJson: String(row.requirements_json) } : {}),
330
+ authorityEpoch: Number(row.authority_epoch ?? 0),
331
+ gateAction: String(row.gate_action),
332
+ ...(row.gate_reason !== null && row.gate_reason !== undefined ? { gateReason: String(row.gate_reason) } : {}),
333
+ createdAt: String(row.created_at),
334
+ updatedAt: String(row.updated_at),
335
+ ...(row.closed_at !== null && row.closed_at !== undefined ? { closedAt: String(row.closed_at) } : {}),
336
+ };
337
+ }
@@ -0,0 +1,90 @@
1
+ import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
2
+ export async function resolveGitHubRequestedChangesContext(params) {
3
+ const { linearIssueId, event, fetchImpl } = params;
4
+ const reviewComments = params.includeInlineComments === false
5
+ ? undefined
6
+ : await fetchReviewCommentsForEvent(event, fetchImpl);
7
+ const identity = buildRequestedChangesWakeIdentity({
8
+ linearIssueId,
9
+ headSha: event.headSha,
10
+ reviewCommitId: event.reviewCommitId,
11
+ reviewId: event.reviewId,
12
+ reviewerName: event.reviewerName,
13
+ });
14
+ return {
15
+ dedupeKey: identity.dedupeKey,
16
+ context: {
17
+ requestedChangesCoalesceKey: identity.coalesceKey,
18
+ ...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
19
+ reviewBody: event.reviewBody,
20
+ reviewCommitId: event.reviewCommitId,
21
+ reviewId: event.reviewId,
22
+ reviewUrl: buildGitHubReviewUrl(event.repoFullName, event.prNumber, event.reviewId),
23
+ reviewerName: event.reviewerName,
24
+ ...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
25
+ },
26
+ };
27
+ }
28
+ async function fetchReviewCommentsForEvent(event, fetchImpl) {
29
+ if (event.triggerEvent !== "review_changes_requested") {
30
+ return undefined;
31
+ }
32
+ if (!event.repoFullName || event.prNumber === undefined || event.reviewId === undefined) {
33
+ return undefined;
34
+ }
35
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
36
+ if (!token) {
37
+ return undefined;
38
+ }
39
+ const [owner, repo] = event.repoFullName.split("/", 2);
40
+ if (!owner || !repo) {
41
+ return undefined;
42
+ }
43
+ const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${event.prNumber}/reviews/${event.reviewId}/comments?per_page=100`, {
44
+ headers: {
45
+ Authorization: `Bearer ${token}`,
46
+ Accept: "application/vnd.github+json",
47
+ "User-Agent": "patchrelay",
48
+ "X-GitHub-Api-Version": "2022-11-28",
49
+ },
50
+ });
51
+ if (!response.ok) {
52
+ throw new Error(`GitHub review comment fetch failed (${response.status})`);
53
+ }
54
+ const payload = await response.json();
55
+ if (!Array.isArray(payload)) {
56
+ return undefined;
57
+ }
58
+ const comments = [];
59
+ for (const entry of payload) {
60
+ if (!entry || typeof entry !== "object")
61
+ continue;
62
+ const record = entry;
63
+ const body = typeof record.body === "string" ? record.body.trim() : "";
64
+ const id = typeof record.id === "number" ? record.id : undefined;
65
+ if (!body || id === undefined)
66
+ continue;
67
+ comments.push({
68
+ id,
69
+ body,
70
+ ...(typeof record.path === "string" ? { path: record.path } : {}),
71
+ ...(typeof record.line === "number" ? { line: record.line } : {}),
72
+ ...(typeof record.side === "string" ? { side: record.side } : {}),
73
+ ...(typeof record.start_line === "number" ? { startLine: record.start_line } : {}),
74
+ ...(typeof record.start_side === "string" ? { startSide: record.start_side } : {}),
75
+ ...(typeof record.commit_id === "string" ? { commitId: record.commit_id } : {}),
76
+ ...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
77
+ ...(typeof record.diff_hunk === "string" ? { diffHunk: record.diff_hunk } : {}),
78
+ ...(typeof record.user?.login === "string"
79
+ ? { authorLogin: String(record.user.login) }
80
+ : {}),
81
+ });
82
+ }
83
+ return comments;
84
+ }
85
+ function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
86
+ if (!repoFullName || prNumber === undefined || reviewId === undefined) {
87
+ return undefined;
88
+ }
89
+ return `https://github.com/${repoFullName}/pull/${prNumber}#pullrequestreview-${reviewId}`;
90
+ }
@@ -6,11 +6,12 @@ import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, } f
6
6
  import { resolveGitHubWebhookIssue } from "./github-webhook-issue-resolution.js";
7
7
  import { maybeCloseLatePublishedImplementationPr } from "./github-webhook-late-publication-guard.js";
8
8
  import { projectGitHubWebhookState } from "./github-webhook-state-projector.js";
9
- import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js";
9
+ import { resolveGitHubRequestedChangesContext } from "./github-review-context.js";
10
10
  import { maybeRunSequenceBackstop } from "./github-webhook-sequence-backstop.js";
11
11
  import { maybeFanChildRebaseWakes } from "./github-webhook-stack-coordination.js";
12
12
  import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
13
13
  import { WakeDispatcher } from "./wake-dispatcher.js";
14
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
14
15
  export class GitHubWebhookHandler {
15
16
  config;
16
17
  db;
@@ -126,16 +127,68 @@ export class GitHubWebhookHandler {
126
127
  failureContextResolver: this.failureContextResolver,
127
128
  ciSnapshotResolver: this.ciSnapshotResolver,
128
129
  }, issue, event, project, resolved.linkedBy);
129
- await maybeEnqueueGitHubReactiveRun({
130
- db: this.db,
131
- logger: this.logger,
132
- feed: this.feed,
133
- wakeDispatcher: this.wakeDispatcher,
134
- issue: freshIssue,
135
- event,
136
- project,
137
- failureContextResolver: this.failureContextResolver,
138
- fetchImpl: this.fetchImpl,
130
+ const requestedChangesContext = event.triggerEvent === "review_changes_requested"
131
+ ? await resolveGitHubRequestedChangesContext({
132
+ linearIssueId: freshIssue.linearIssueId,
133
+ event,
134
+ fetchImpl: this.fetchImpl,
135
+ }).catch((error) => {
136
+ this.logger.warn({
137
+ issueKey: freshIssue.issueKey,
138
+ prNumber: event.prNumber,
139
+ reviewId: event.reviewId,
140
+ error: error instanceof Error ? error.message : String(error),
141
+ }, "Failed to fetch inline review comments for requested-changes observation");
142
+ return resolveGitHubRequestedChangesContext({
143
+ linearIssueId: freshIssue.linearIssueId,
144
+ event,
145
+ fetchImpl: this.fetchImpl,
146
+ includeInlineComments: false,
147
+ });
148
+ })
149
+ : undefined;
150
+ this.db.workflowObservations.appendObservation({
151
+ projectId: freshIssue.projectId,
152
+ subjectId: freshIssue.linearIssueId,
153
+ source: "github",
154
+ type: `github.${event.triggerEvent}`,
155
+ payloadJson: JSON.stringify({
156
+ triggerEvent: event.triggerEvent,
157
+ repoFullName: event.repoFullName,
158
+ branchName: event.branchName,
159
+ headSha: event.headSha,
160
+ prNumber: event.prNumber,
161
+ prState: event.prState,
162
+ reviewState: event.reviewState,
163
+ reviewId: event.reviewId,
164
+ reviewCommitId: event.reviewCommitId,
165
+ reviewerName: event.reviewerName,
166
+ requestedChangesContext: requestedChangesContext?.context,
167
+ checkStatus: event.checkStatus,
168
+ checkName: event.checkName,
169
+ checkUrl: event.checkUrl,
170
+ }),
171
+ dedupeKey: requestedChangesContext?.dedupeKey ?? [
172
+ event.triggerEvent,
173
+ event.repoFullName,
174
+ event.prNumber ?? event.branchName,
175
+ event.headSha,
176
+ event.reviewId ?? event.checkName ?? "",
177
+ event.reviewState ?? event.checkStatus ?? event.prState ?? "",
178
+ ].join(":"),
179
+ });
180
+ const workflowReconciliation = reconcileWorkflowTasksForIssue(this.db, freshIssue);
181
+ const changedRunnableWorkflowTask = [
182
+ ...workflowReconciliation.result.opened,
183
+ ...workflowReconciliation.result.updated,
184
+ ].some((task) => task.gateAction === "start" && task.runType);
185
+ const shouldDispatchWorkflowTask = event.triggerEvent === "review_changes_requested"
186
+ || event.triggerEvent === "check_failed"
187
+ || event.triggerEvent === "pr_closed";
188
+ await this.wakeDispatcher.withTick(async () => {
189
+ if (shouldDispatchWorkflowTask && changedRunnableWorkflowTask) {
190
+ this.wakeDispatcher.dispatchIfWakePending(freshIssue.projectId, freshIssue.linearIssueId);
191
+ }
139
192
  });
140
193
  if (event.triggerEvent === "pr_opened") {
141
194
  await maybeRunSequenceBackstop({