patchrelay 0.35.11 → 0.35.13

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 (52) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +19 -1
  4. package/dist/cli/commands/issues.js +18 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +160 -47
  7. package/dist/cli/formatters/text.js +51 -90
  8. package/dist/cli/help.js +15 -8
  9. package/dist/cli/index.js +3 -58
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +21 -12
  12. package/dist/cli/watch/HelpBar.js +3 -3
  13. package/dist/cli/watch/IssueDetailView.js +63 -130
  14. package/dist/cli/watch/IssueRow.js +82 -27
  15. package/dist/cli/watch/StatusBar.js +8 -4
  16. package/dist/cli/watch/detail-rows.js +589 -0
  17. package/dist/cli/watch/render-rich-text.js +226 -0
  18. package/dist/cli/watch/state-visualization.js +48 -23
  19. package/dist/cli/watch/timeline-builder.js +2 -1
  20. package/dist/cli/watch/use-detail-stream.js +10 -104
  21. package/dist/cli/watch/use-watch-stream.js +11 -102
  22. package/dist/cli/watch/watch-state.js +129 -56
  23. package/dist/codex-thread-utils.js +3 -0
  24. package/dist/db/migrations.js +239 -2
  25. package/dist/db.js +628 -39
  26. package/dist/github-app-token.js +7 -0
  27. package/dist/github-failure-context.js +44 -1
  28. package/dist/github-rollup.js +47 -0
  29. package/dist/github-webhook-handler.js +423 -52
  30. package/dist/github-webhooks.js +7 -0
  31. package/dist/http.js +12 -264
  32. package/dist/idle-reconciliation.js +268 -76
  33. package/dist/issue-query-service.js +221 -129
  34. package/dist/issue-session-events.js +151 -0
  35. package/dist/issue-session.js +99 -0
  36. package/dist/linear-client.js +39 -25
  37. package/dist/linear-session-reporting.js +12 -0
  38. package/dist/linear-session-sync.js +253 -24
  39. package/dist/linear-workflow.js +33 -0
  40. package/dist/merge-queue-protocol.js +0 -51
  41. package/dist/preflight.js +1 -4
  42. package/dist/queue-health-monitor.js +11 -7
  43. package/dist/run-orchestrator.js +1364 -147
  44. package/dist/run-reporting.js +5 -3
  45. package/dist/service.js +279 -102
  46. package/dist/status-note.js +56 -0
  47. package/dist/waiting-reason.js +65 -0
  48. package/dist/webhook-handler.js +270 -79
  49. package/package.json +3 -2
  50. package/dist/cli/commands/feed.js +0 -60
  51. package/dist/cli/watch/FeedView.js +0 -28
  52. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -8,6 +8,10 @@ function capArray(arr, max) {
8
8
  }
9
9
  const DETAIL_INITIAL = {
10
10
  detailTab: "timeline",
11
+ detailScrollOffset: 0,
12
+ detailViewportRows: 0,
13
+ detailContentRows: 0,
14
+ detailUnreadBelow: 0,
11
15
  timeline: [],
12
16
  rawRuns: [],
13
17
  rawFeedEvents: [],
@@ -31,6 +35,9 @@ export const initialWatchState = {
31
35
  feedEvents: [],
32
36
  };
33
37
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
38
+ function effectiveSessionState(issue) {
39
+ return issue.sessionState ?? (TERMINAL_FACTORY_STATES.has(issue.factoryState) ? issue.factoryState : undefined);
40
+ }
34
41
  export function filterIssues(issues, filter) {
35
42
  switch (filter) {
36
43
  case "all":
@@ -38,7 +45,10 @@ export function filterIssues(issues, filter) {
38
45
  case "active":
39
46
  return issues.filter((i) => i.activeRunType !== undefined);
40
47
  case "non-done":
41
- return issues.filter((i) => !TERMINAL_FACTORY_STATES.has(i.factoryState));
48
+ return issues.filter((i) => {
49
+ const sessionState = effectiveSessionState(i);
50
+ return sessionState !== "done" && sessionState !== "failed" && !TERMINAL_FACTORY_STATES.has(i.factoryState);
51
+ });
42
52
  }
43
53
  }
44
54
  const DONE_STATES = new Set(["done"]);
@@ -50,15 +60,18 @@ export function computeAggregates(issues) {
50
60
  let done = 0;
51
61
  let failed = 0;
52
62
  for (const issue of issues) {
63
+ const sessionState = effectiveSessionState(issue);
64
+ const isDone = sessionState === "done" || DONE_STATES.has(issue.factoryState);
65
+ const isFailed = sessionState === "failed" || FAILED_STATES.has(issue.factoryState);
53
66
  if (issue.activeRunType)
54
67
  active++;
55
68
  if (!issue.activeRunType && issue.blockedByCount > 0)
56
69
  blocked++;
57
- if (!issue.activeRunType && issue.readyForExecution)
70
+ if (!issue.activeRunType && issue.readyForExecution && !isDone && !isFailed)
58
71
  ready++;
59
- if (DONE_STATES.has(issue.factoryState))
72
+ if (isDone)
60
73
  done++;
61
- if (FAILED_STATES.has(issue.factoryState))
74
+ if (isFailed)
62
75
  failed++;
63
76
  }
64
77
  return { active, blocked, ready, done, failed, total: issues.length };
@@ -70,6 +83,60 @@ function nextFilter(filter) {
70
83
  case "all": return "non-done";
71
84
  }
72
85
  }
86
+ function clampIndex(index, length) {
87
+ return Math.max(0, Math.min(index, Math.max(0, length - 1)));
88
+ }
89
+ function selectedIssueKeyForFilter(state) {
90
+ const filtered = filterIssues(state.issues, state.filter);
91
+ return filtered[state.selectedIndex]?.issueKey ?? null;
92
+ }
93
+ function selectedIndexForSnapshot(state, nextIssues) {
94
+ const nextFiltered = filterIssues(nextIssues, state.filter);
95
+ if (nextFiltered.length === 0)
96
+ return 0;
97
+ const selectedIssueKey = selectedIssueKeyForFilter(state);
98
+ if (selectedIssueKey) {
99
+ const selectedIndex = nextFiltered.findIndex((issue) => issue.issueKey === selectedIssueKey);
100
+ if (selectedIndex >= 0)
101
+ return selectedIndex;
102
+ }
103
+ return clampIndex(state.selectedIndex, nextFiltered.length);
104
+ }
105
+ const DETAIL_BOTTOM_THRESHOLD = 2;
106
+ function maxDetailScrollOffset(contentRows, viewportRows) {
107
+ return Math.max(0, contentRows - viewportRows);
108
+ }
109
+ function isDetailNearBottom(scrollOffset, contentRows, viewportRows) {
110
+ const maxOffset = maxDetailScrollOffset(contentRows, viewportRows);
111
+ return scrollOffset >= Math.max(0, maxOffset - DETAIL_BOTTOM_THRESHOLD);
112
+ }
113
+ function detailStateForPosition(state, scrollOffset, follow) {
114
+ const maxOffset = maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows);
115
+ const nextOffset = clampIndex(scrollOffset, maxOffset + 1);
116
+ const nextFollow = follow || isDetailNearBottom(nextOffset, state.detailContentRows, state.detailViewportRows);
117
+ return {
118
+ detailScrollOffset: nextFollow ? maxOffset : nextOffset,
119
+ detailUnreadBelow: nextFollow ? 0 : Math.max(0, maxOffset - nextOffset),
120
+ follow: nextFollow,
121
+ };
122
+ }
123
+ function detailStateAfterLayout(state, viewportRows, contentRows) {
124
+ const nextState = {
125
+ ...state,
126
+ detailViewportRows: Math.max(0, viewportRows),
127
+ detailContentRows: Math.max(0, contentRows),
128
+ };
129
+ const maxOffset = maxDetailScrollOffset(nextState.detailContentRows, nextState.detailViewportRows);
130
+ const shouldFollow = state.follow || isDetailNearBottom(state.detailScrollOffset, state.detailContentRows, state.detailViewportRows);
131
+ const nextOffset = shouldFollow ? maxOffset : Math.min(state.detailScrollOffset, maxOffset);
132
+ return {
133
+ detailViewportRows: nextState.detailViewportRows,
134
+ detailContentRows: nextState.detailContentRows,
135
+ detailScrollOffset: nextOffset,
136
+ detailUnreadBelow: shouldFollow ? 0 : Math.max(0, maxOffset - nextOffset),
137
+ follow: shouldFollow,
138
+ };
139
+ }
73
140
  // ─── Reducer ──────────────────────────────────────────────────────
74
141
  export function watchReducer(state, action) {
75
142
  switch (action.type) {
@@ -84,17 +151,17 @@ export function watchReducer(state, action) {
84
151
  ...state,
85
152
  lastServerMessageAt: action.receivedAt,
86
153
  issues: action.issues,
87
- selectedIndex: Math.min(state.selectedIndex, Math.max(0, action.issues.length - 1)),
154
+ selectedIndex: selectedIndexForSnapshot(state, action.issues),
88
155
  };
89
156
  case "feed-event":
90
157
  return applyFeedEvent(state, action.event, action.receivedAt);
91
158
  case "select":
92
159
  return {
93
160
  ...state,
94
- selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
161
+ selectedIndex: clampIndex(action.index, filterIssues(state.issues, state.filter).length),
95
162
  };
96
163
  case "enter-detail":
97
- return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
164
+ return { ...state, view: "detail", activeDetailKey: action.issueKey, follow: true, ...DETAIL_INITIAL };
98
165
  case "exit-detail":
99
166
  return { ...state, view: "list", activeDetailKey: null, ...DETAIL_INITIAL };
100
167
  case "detail-navigate": {
@@ -108,8 +175,46 @@ export function watchReducer(state, action) {
108
175
  const nextIssue = list[nextIdx];
109
176
  if (!nextIssue?.issueKey || nextIssue.issueKey === state.activeDetailKey)
110
177
  return state;
111
- return { ...state, activeDetailKey: nextIssue.issueKey, selectedIndex: nextIdx, ...DETAIL_INITIAL };
178
+ return { ...state, activeDetailKey: nextIssue.issueKey, selectedIndex: nextIdx, follow: true, ...DETAIL_INITIAL };
179
+ }
180
+ case "detail-scroll":
181
+ return {
182
+ ...state,
183
+ ...detailStateForPosition(state, state.detailScrollOffset + action.delta, false),
184
+ };
185
+ case "detail-page": {
186
+ const pageSize = Math.max(1, state.detailViewportRows - 2);
187
+ const delta = action.direction === "down" ? pageSize : -pageSize;
188
+ return {
189
+ ...state,
190
+ ...detailStateForPosition(state, state.detailScrollOffset + delta, false),
191
+ };
112
192
  }
193
+ case "detail-jump":
194
+ return action.target === "end"
195
+ ? {
196
+ ...state,
197
+ ...detailStateForPosition(state, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows), true),
198
+ }
199
+ : {
200
+ ...state,
201
+ ...detailStateForPosition(state, 0, false),
202
+ };
203
+ case "detail-layout-updated":
204
+ {
205
+ const nextDetailState = detailStateAfterLayout(state, action.viewportRows, action.contentRows);
206
+ if (nextDetailState.detailViewportRows === state.detailViewportRows
207
+ && nextDetailState.detailContentRows === state.detailContentRows
208
+ && nextDetailState.detailScrollOffset === state.detailScrollOffset
209
+ && nextDetailState.detailUnreadBelow === state.detailUnreadBelow
210
+ && nextDetailState.follow === state.follow) {
211
+ return state;
212
+ }
213
+ return {
214
+ ...state,
215
+ ...nextDetailState,
216
+ };
217
+ }
113
218
  case "timeline-rehydrate": {
114
219
  const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
115
220
  const activeRun = action.runs.find((r) => r.id === action.activeRunId);
@@ -119,7 +224,7 @@ export function watchReducer(state, action) {
119
224
  rawRuns: action.runs,
120
225
  rawFeedEvents: action.feedEvents,
121
226
  activeRunId: action.activeRunId,
122
- activeRunStartedAt: activeRun?.startedAt ?? null,
227
+ activeRunStartedAt: action.activeRunStartedAt ?? activeRun?.startedAt ?? null,
123
228
  issueContext: action.issueContext,
124
229
  };
125
230
  }
@@ -128,7 +233,16 @@ export function watchReducer(state, action) {
128
233
  case "cycle-filter":
129
234
  return { ...state, filter: nextFilter(state.filter), selectedIndex: 0 };
130
235
  case "toggle-follow":
131
- return { ...state, follow: !state.follow };
236
+ return state.follow
237
+ ? {
238
+ ...state,
239
+ follow: false,
240
+ detailUnreadBelow: Math.max(0, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows) - state.detailScrollOffset),
241
+ }
242
+ : {
243
+ ...state,
244
+ ...detailStateForPosition(state, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows), true),
245
+ };
132
246
  case "enter-feed":
133
247
  return { ...state, view: "feed", activeDetailKey: null, ...DETAIL_INITIAL };
134
248
  case "exit-feed":
@@ -138,64 +252,23 @@ export function watchReducer(state, action) {
138
252
  case "feed-new-event":
139
253
  return { ...state, feedEvents: capArray([...state.feedEvents, action.event], MAX_FEED_EVENTS) };
140
254
  case "switch-detail-tab":
141
- return { ...state, detailTab: action.tab };
255
+ return { ...state, follow: true, ...DETAIL_INITIAL, detailTab: action.tab };
142
256
  default:
143
257
  return state;
144
258
  }
145
259
  }
146
260
  // ─── Feed Event → Issue List + Timeline ───────────────────────────
147
261
  function applyFeedEvent(state, event, receivedAt) {
148
- if (!event.issueKey) {
149
- return { ...state, lastServerMessageAt: receivedAt };
150
- }
151
- const index = state.issues.findIndex((issue) => issue.issueKey === event.issueKey);
152
- if (index === -1) {
153
- return { ...state, lastServerMessageAt: receivedAt };
154
- }
155
- const updated = [...state.issues];
156
- const issue = { ...updated[index] };
157
- if (event.kind === "stage" && event.stage) {
158
- issue.factoryState = event.stage;
159
- }
160
- if (event.kind === "stage" && event.status === "starting" && event.stage) {
161
- issue.activeRunType = event.stage;
162
- }
163
- if (event.kind === "turn") {
164
- if (event.status === "completed" || event.status === "failed") {
165
- issue.activeRunType = undefined;
166
- issue.latestRunStatus = event.status;
167
- }
168
- }
169
- if (event.kind === "github" && event.status) {
170
- if (event.status === "check_passed" || event.status === "check_failed") {
171
- issue.prCheckStatus = event.status === "check_passed" ? "passed" : "failed";
172
- }
173
- if (event.status === "ci_repair_queued") {
174
- issue.factoryState = "repairing_ci";
175
- issue.statusNote = event.detail ?? event.summary;
176
- }
177
- if (event.status === "queue_repair_queued") {
178
- issue.factoryState = "repairing_queue";
179
- issue.statusNote = event.detail ?? event.summary;
180
- }
181
- if (event.status === "repair_deduped" || event.status === "branch_not_advanced") {
182
- issue.statusNote = event.summary;
183
- }
184
- }
185
- if ((event.kind === "turn" || event.kind === "github") && event.status === "branch_not_advanced") {
186
- issue.statusNote = event.summary;
187
- }
188
- issue.updatedAt = event.at;
189
- updated[index] = issue;
190
- // Append to timeline and raw feed events if this event matches the active detail issue
191
- const isActiveDetail = state.view === "detail" && state.activeDetailKey === event.issueKey;
262
+ const isActiveDetail = Boolean(event.issueKey)
263
+ && state.view === "detail"
264
+ && state.activeDetailKey === event.issueKey;
192
265
  const timeline = isActiveDetail
193
266
  ? capArray(appendFeedToTimeline(state.timeline, event), MAX_TIMELINE_ENTRIES)
194
267
  : state.timeline;
195
268
  const rawFeedEvents = isActiveDetail
196
269
  ? capArray([...state.rawFeedEvents, event], MAX_RAW_FEED_EVENTS)
197
270
  : state.rawFeedEvents;
198
- return { ...state, lastServerMessageAt: receivedAt, issues: updated, timeline, rawFeedEvents };
271
+ return { ...state, lastServerMessageAt: receivedAt, timeline, rawFeedEvents };
199
272
  }
200
273
  // ─── Codex Notification → Timeline + Metadata ─────────────────────
201
274
  function applyCodexNotification(state, method, params) {
@@ -0,0 +1,3 @@
1
+ export function getThreadTurns(thread) {
2
+ return Array.isArray(thread?.turns) ? thread.turns : [];
3
+ }
@@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS issues (
22
22
  pr_number INTEGER,
23
23
  pr_url TEXT,
24
24
  pr_state TEXT,
25
+ pr_head_sha TEXT,
26
+ pr_author_login TEXT,
25
27
  pr_review_state TEXT,
26
28
  pr_check_status TEXT,
27
29
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
@@ -48,6 +50,48 @@ CREATE TABLE IF NOT EXISTS runs (
48
50
  ended_at TEXT
49
51
  );
50
52
 
53
+ CREATE TABLE IF NOT EXISTS issue_sessions (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ project_id TEXT NOT NULL,
56
+ linear_issue_id TEXT NOT NULL,
57
+ issue_key TEXT,
58
+ repo_id TEXT NOT NULL,
59
+ branch_name TEXT,
60
+ worktree_path TEXT,
61
+ pr_number INTEGER,
62
+ pr_head_sha TEXT,
63
+ pr_author_login TEXT,
64
+ session_state TEXT NOT NULL DEFAULT 'idle',
65
+ waiting_reason TEXT,
66
+ summary_text TEXT,
67
+ active_thread_id TEXT,
68
+ thread_generation INTEGER NOT NULL DEFAULT 0,
69
+ active_run_id INTEGER,
70
+ last_run_type TEXT,
71
+ last_wake_reason TEXT,
72
+ ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
73
+ queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
74
+ review_fix_attempts INTEGER NOT NULL DEFAULT 0,
75
+ lease_id TEXT,
76
+ worker_id TEXT,
77
+ leased_until TEXT,
78
+ created_at TEXT NOT NULL,
79
+ updated_at TEXT NOT NULL,
80
+ UNIQUE(project_id, linear_issue_id)
81
+ );
82
+
83
+ CREATE TABLE IF NOT EXISTS issue_session_events (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ project_id TEXT NOT NULL,
86
+ linear_issue_id TEXT NOT NULL,
87
+ event_type TEXT NOT NULL,
88
+ event_json TEXT,
89
+ dedupe_key TEXT,
90
+ created_at TEXT NOT NULL,
91
+ processed_at TEXT,
92
+ consumed_by_run_id INTEGER
93
+ );
94
+
51
95
  CREATE TABLE IF NOT EXISTS webhook_events (
52
96
  id INTEGER PRIMARY KEY AUTOINCREMENT,
53
97
  webhook_id TEXT NOT NULL UNIQUE,
@@ -167,6 +211,11 @@ CREATE INDEX IF NOT EXISTS idx_issues_branch ON issues(branch_name);
167
211
  CREATE INDEX IF NOT EXISTS idx_runs_issue ON runs(issue_id);
168
212
  CREATE INDEX IF NOT EXISTS idx_runs_active ON runs(status, project_id, linear_issue_id);
169
213
  CREATE INDEX IF NOT EXISTS idx_runs_thread ON runs(thread_id);
214
+ CREATE INDEX IF NOT EXISTS idx_issue_sessions_issue ON issue_sessions(project_id, linear_issue_id);
215
+ CREATE INDEX IF NOT EXISTS idx_issue_sessions_key ON issue_sessions(issue_key);
216
+ CREATE INDEX IF NOT EXISTS idx_issue_sessions_lease ON issue_sessions(leased_until, session_state);
217
+ CREATE INDEX IF NOT EXISTS idx_issue_session_events_issue ON issue_session_events(project_id, linear_issue_id, id);
218
+ CREATE INDEX IF NOT EXISTS idx_issue_session_events_pending ON issue_session_events(processed_at, project_id, linear_issue_id, id);
170
219
  CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_id, id);
171
220
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_issue ON operator_feed_events(issue_key, id);
172
221
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_events(project_id, id);
@@ -185,6 +234,7 @@ export function runPatchRelayMigrations(connection) {
185
234
  // Explicit PR branch ownership hand-off between PatchRelay and MergeSteward
186
235
  addColumnIfMissing(connection, "issues", "branch_owner", "TEXT NOT NULL DEFAULT 'patchrelay'");
187
236
  addColumnIfMissing(connection, "issues", "branch_ownership_changed_at", "TEXT");
237
+ connection.prepare("UPDATE issues SET branch_owner = 'patchrelay' WHERE branch_owner IS NULL OR branch_owner != 'patchrelay'").run();
188
238
  // Add merge_prep_attempts for retry budget / escalation
189
239
  addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
190
240
  // Add review_fix_attempts counter
@@ -195,6 +245,7 @@ export function runPatchRelayMigrations(connection) {
195
245
  addColumnIfMissing(connection, "issues", "description", "TEXT");
196
246
  addColumnIfMissing(connection, "issues", "priority", "INTEGER");
197
247
  addColumnIfMissing(connection, "issues", "estimate", "REAL");
248
+ addColumnIfMissing(connection, "issues", "status_comment_id", "TEXT");
198
249
  addColumnIfMissing(connection, "issues", "current_linear_state_type", "TEXT");
199
250
  addColumnIfMissing(connection, "issue_dependencies", "blocker_current_linear_state_type", "TEXT");
200
251
  // Zombie/stale recovery backoff
@@ -202,6 +253,8 @@ export function runPatchRelayMigrations(connection) {
202
253
  addColumnIfMissing(connection, "issues", "last_zombie_recovery_at", "TEXT");
203
254
  // Preserve GitHub failure provenance so reconciliation can distinguish
204
255
  // branch CI failures from merge-queue evictions after webhook delivery.
256
+ addColumnIfMissing(connection, "issues", "pr_head_sha", "TEXT");
257
+ addColumnIfMissing(connection, "issues", "pr_author_login", "TEXT");
205
258
  addColumnIfMissing(connection, "issues", "last_github_failure_source", "TEXT");
206
259
  addColumnIfMissing(connection, "issues", "last_github_failure_head_sha", "TEXT");
207
260
  addColumnIfMissing(connection, "issues", "last_github_failure_signature", "TEXT");
@@ -218,8 +271,7 @@ export function runPatchRelayMigrations(connection) {
218
271
  addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
219
272
  addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
220
273
  addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
221
- // Track whether the merge queue label was successfully applied.
222
- addColumnIfMissing(connection, "issues", "queue_label_applied", "INTEGER NOT NULL DEFAULT 0");
274
+ removeRetiredIssueColumnsIfPresent(connection);
223
275
  }
224
276
  function addColumnIfMissing(connection, table, column, definition) {
225
277
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
@@ -227,3 +279,188 @@ function addColumnIfMissing(connection, table, column, definition) {
227
279
  return;
228
280
  connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
229
281
  }
282
+ function removeRetiredIssueColumnsIfPresent(connection) {
283
+ const cols = connection.prepare("PRAGMA table_info(issues)").all();
284
+ const columnNames = new Set(cols.map((column) => String(column.name)));
285
+ const retired = ["queue_label_applied", "pending_merge_prep", "merge_prep_attempts"];
286
+ if (!retired.some((name) => columnNames.has(name))) {
287
+ return;
288
+ }
289
+ connection.exec("PRAGMA foreign_keys = OFF");
290
+ try {
291
+ connection.exec(`
292
+ CREATE TABLE issues_new (
293
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
294
+ project_id TEXT NOT NULL,
295
+ linear_issue_id TEXT NOT NULL,
296
+ issue_key TEXT,
297
+ title TEXT,
298
+ description TEXT,
299
+ url TEXT,
300
+ priority INTEGER,
301
+ estimate REAL,
302
+ current_linear_state TEXT,
303
+ current_linear_state_type TEXT,
304
+ factory_state TEXT NOT NULL DEFAULT 'delegated',
305
+ pending_run_type TEXT,
306
+ pending_run_context_json TEXT,
307
+ branch_name TEXT,
308
+ branch_owner TEXT NOT NULL DEFAULT 'patchrelay',
309
+ branch_ownership_changed_at TEXT,
310
+ worktree_path TEXT,
311
+ thread_id TEXT,
312
+ active_run_id INTEGER,
313
+ status_comment_id TEXT,
314
+ agent_session_id TEXT,
315
+ pr_number INTEGER,
316
+ pr_url TEXT,
317
+ pr_state TEXT,
318
+ pr_head_sha TEXT,
319
+ pr_author_login TEXT,
320
+ pr_review_state TEXT,
321
+ pr_check_status TEXT,
322
+ last_github_failure_source TEXT,
323
+ last_github_failure_head_sha TEXT,
324
+ last_github_failure_signature TEXT,
325
+ last_github_failure_check_name TEXT,
326
+ last_github_failure_check_url TEXT,
327
+ last_github_failure_context_json TEXT,
328
+ last_github_failure_at TEXT,
329
+ last_github_ci_snapshot_head_sha TEXT,
330
+ last_github_ci_snapshot_gate_check_name TEXT,
331
+ last_github_ci_snapshot_gate_check_status TEXT,
332
+ last_github_ci_snapshot_json TEXT,
333
+ last_github_ci_snapshot_settled_at TEXT,
334
+ last_queue_signal_at TEXT,
335
+ last_queue_incident_json TEXT,
336
+ last_attempted_failure_head_sha TEXT,
337
+ last_attempted_failure_signature TEXT,
338
+ ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
339
+ queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
340
+ review_fix_attempts INTEGER NOT NULL DEFAULT 0,
341
+ zombie_recovery_attempts INTEGER NOT NULL DEFAULT 0,
342
+ last_zombie_recovery_at TEXT,
343
+ updated_at TEXT NOT NULL,
344
+ UNIQUE(project_id, linear_issue_id)
345
+ );
346
+
347
+ INSERT INTO issues_new (
348
+ id,
349
+ project_id,
350
+ linear_issue_id,
351
+ issue_key,
352
+ title,
353
+ description,
354
+ url,
355
+ priority,
356
+ estimate,
357
+ current_linear_state,
358
+ current_linear_state_type,
359
+ factory_state,
360
+ pending_run_type,
361
+ pending_run_context_json,
362
+ branch_name,
363
+ branch_owner,
364
+ branch_ownership_changed_at,
365
+ worktree_path,
366
+ thread_id,
367
+ active_run_id,
368
+ status_comment_id,
369
+ agent_session_id,
370
+ pr_number,
371
+ pr_url,
372
+ pr_state,
373
+ pr_head_sha,
374
+ pr_author_login,
375
+ pr_review_state,
376
+ pr_check_status,
377
+ last_github_failure_source,
378
+ last_github_failure_head_sha,
379
+ last_github_failure_signature,
380
+ last_github_failure_check_name,
381
+ last_github_failure_check_url,
382
+ last_github_failure_context_json,
383
+ last_github_failure_at,
384
+ last_github_ci_snapshot_head_sha,
385
+ last_github_ci_snapshot_gate_check_name,
386
+ last_github_ci_snapshot_gate_check_status,
387
+ last_github_ci_snapshot_json,
388
+ last_github_ci_snapshot_settled_at,
389
+ last_queue_signal_at,
390
+ last_queue_incident_json,
391
+ last_attempted_failure_head_sha,
392
+ last_attempted_failure_signature,
393
+ ci_repair_attempts,
394
+ queue_repair_attempts,
395
+ review_fix_attempts,
396
+ zombie_recovery_attempts,
397
+ last_zombie_recovery_at,
398
+ updated_at
399
+ )
400
+ SELECT
401
+ id,
402
+ project_id,
403
+ linear_issue_id,
404
+ issue_key,
405
+ title,
406
+ description,
407
+ url,
408
+ priority,
409
+ estimate,
410
+ current_linear_state,
411
+ current_linear_state_type,
412
+ COALESCE(factory_state, 'delegated'),
413
+ pending_run_type,
414
+ pending_run_context_json,
415
+ branch_name,
416
+ COALESCE(branch_owner, 'patchrelay'),
417
+ branch_ownership_changed_at,
418
+ worktree_path,
419
+ thread_id,
420
+ active_run_id,
421
+ status_comment_id,
422
+ agent_session_id,
423
+ pr_number,
424
+ pr_url,
425
+ pr_state,
426
+ pr_head_sha,
427
+ pr_author_login,
428
+ pr_review_state,
429
+ pr_check_status,
430
+ last_github_failure_source,
431
+ last_github_failure_head_sha,
432
+ last_github_failure_signature,
433
+ last_github_failure_check_name,
434
+ last_github_failure_check_url,
435
+ last_github_failure_context_json,
436
+ last_github_failure_at,
437
+ last_github_ci_snapshot_head_sha,
438
+ last_github_ci_snapshot_gate_check_name,
439
+ last_github_ci_snapshot_gate_check_status,
440
+ last_github_ci_snapshot_json,
441
+ last_github_ci_snapshot_settled_at,
442
+ last_queue_signal_at,
443
+ last_queue_incident_json,
444
+ last_attempted_failure_head_sha,
445
+ last_attempted_failure_signature,
446
+ COALESCE(ci_repair_attempts, 0),
447
+ COALESCE(queue_repair_attempts, 0),
448
+ COALESCE(review_fix_attempts, 0),
449
+ COALESCE(zombie_recovery_attempts, 0),
450
+ last_zombie_recovery_at,
451
+ updated_at
452
+ FROM issues;
453
+
454
+ DROP TABLE issues;
455
+ ALTER TABLE issues_new RENAME TO issues;
456
+
457
+ CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
458
+ CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
459
+ CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
460
+ CREATE INDEX IF NOT EXISTS idx_issues_branch ON issues(branch_name);
461
+ `);
462
+ }
463
+ finally {
464
+ connection.exec("PRAGMA foreign_keys = ON");
465
+ }
466
+ }