patchrelay 0.15.0 → 0.17.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.
@@ -8,70 +8,50 @@ export function useDetailStream(options) {
8
8
  return;
9
9
  const abortController = new AbortController();
10
10
  const { baseUrl, bearerToken, dispatch } = optionsRef.current;
11
- const headers = { accept: "application/json" };
11
+ const headers = {};
12
12
  if (bearerToken) {
13
13
  headers.authorization = `Bearer ${bearerToken}`;
14
14
  }
15
- // Rehydrate from thread/read via /api/issues/:key/live
15
+ // Rehydrate from timeline endpoint
16
16
  void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
17
- // Stream codex notifications via filtered SSE
18
- void streamCodexEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
17
+ // Stream codex notifications + feed events via filtered SSE
18
+ void streamEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
19
19
  return () => {
20
20
  abortController.abort();
21
21
  };
22
22
  }, [options.issueKey]);
23
23
  }
24
+ // ─── Rehydration ──────────────────────────────────────────────────
24
25
  async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
25
26
  try {
26
- const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/live`, baseUrl);
27
- const response = await fetch(url, { headers, signal });
28
- if (!response.ok)
29
- return;
30
- const data = await response.json();
31
- const threadData = data.thread;
32
- if (threadData) {
33
- dispatch({ type: "thread-snapshot", thread: materializeThread(threadData) });
34
- return;
35
- }
36
- // No active thread — fall back to latest run report
37
- await rehydrateFromReport(baseUrl, issueKey, headers, signal, dispatch);
38
- }
39
- catch {
40
- // Rehydration is best-effort — SSE stream will provide updates
41
- }
42
- }
43
- async function rehydrateFromReport(baseUrl, issueKey, headers, signal, dispatch) {
44
- try {
45
- const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/report`, baseUrl);
46
- const response = await fetch(url, { headers, signal });
27
+ const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/timeline`, baseUrl);
28
+ const response = await fetch(url, { headers: { ...headers, accept: "application/json" }, signal });
47
29
  if (!response.ok)
48
30
  return;
49
31
  const data = await response.json();
50
- const latest = data.runs?.[0];
51
- if (!latest)
52
- return;
53
- const report = {
54
- runType: latest.run.runType,
55
- status: latest.run.status,
56
- summary: typeof latest.summary?.latestAssistantMessage === "string"
57
- ? latest.summary.latestAssistantMessage
58
- : latest.report?.assistantMessages.at(-1),
59
- commands: latest.report?.commands.map((c) => ({
60
- command: c.command,
61
- ...(typeof c.exitCode === "number" ? { exitCode: c.exitCode } : {}),
62
- ...(typeof c.durationMs === "number" ? { durationMs: c.durationMs } : {}),
63
- })) ?? [],
64
- fileChanges: latest.report?.fileChanges.length ?? 0,
65
- toolCalls: latest.report?.toolCalls.length ?? 0,
66
- assistantMessages: latest.report?.assistantMessages ?? [],
67
- };
68
- dispatch({ type: "report-snapshot", report });
32
+ const runs = (data.runs ?? []).map((r) => ({
33
+ id: r.id,
34
+ runType: r.runType,
35
+ status: r.status,
36
+ startedAt: r.startedAt,
37
+ endedAt: r.endedAt,
38
+ threadId: r.threadId,
39
+ ...(r.report ? { report: r.report } : {}),
40
+ }));
41
+ dispatch({
42
+ type: "timeline-rehydrate",
43
+ runs,
44
+ feedEvents: data.feedEvents ?? [],
45
+ liveThread: data.liveThread ?? null,
46
+ activeRunId: data.activeRunId ?? null,
47
+ });
69
48
  }
70
49
  catch {
71
- // Report fetch is best-effort
50
+ // Rehydration is best-effort
72
51
  }
73
52
  }
74
- async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
53
+ // ─── Live SSE Stream ──────────────────────────────────────────────
54
+ async function streamEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
75
55
  try {
76
56
  const url = new URL("/api/watch", baseUrl);
77
57
  url.searchParams.set("issue", issueKey);
@@ -96,7 +76,7 @@ async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatc
96
76
  const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
97
77
  if (!line) {
98
78
  if (dataLines.length > 0) {
99
- processDetailEvent(dispatch, eventType, dataLines.join("\n"));
79
+ processEvent(dispatch, eventType, dataLines.join("\n"));
100
80
  dataLines = [];
101
81
  eventType = "";
102
82
  }
@@ -121,73 +101,15 @@ async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatc
121
101
  // Stream ended or aborted
122
102
  }
123
103
  }
124
- function processDetailEvent(dispatch, eventType, data) {
104
+ function processEvent(dispatch, eventType, data) {
125
105
  try {
126
106
  if (eventType === "codex") {
127
107
  const parsed = JSON.parse(data);
128
108
  dispatch({ type: "codex-notification", method: parsed.method, params: parsed.params });
129
109
  }
130
- // Feed events are already handled by the main watch stream
110
+ // Feed events are handled by the main watch stream
131
111
  }
132
112
  catch {
133
113
  // Ignore parse errors
134
114
  }
135
115
  }
136
- // ─── Thread Materialization from thread/read ──────────────────────
137
- function materializeThread(summary) {
138
- return {
139
- threadId: summary.id,
140
- status: summary.status,
141
- turns: summary.turns.map(materializeTurn),
142
- };
143
- }
144
- function materializeTurn(turn) {
145
- return {
146
- id: turn.id,
147
- status: turn.status,
148
- items: turn.items.map(materializeItem),
149
- };
150
- }
151
- function materializeItem(item) {
152
- // CodexThreadItem has an index-signature catch-all that defeats narrowing.
153
- // Access fields via Record<string, unknown> and coerce explicitly.
154
- const r = item;
155
- const id = String(r.id ?? "unknown");
156
- const type = String(r.type ?? "unknown");
157
- const base = { id, type, status: "completed" };
158
- switch (type) {
159
- case "agentMessage":
160
- return { ...base, text: String(r.text ?? "") };
161
- case "commandExecution":
162
- return {
163
- ...base,
164
- command: String(r.command ?? ""),
165
- status: String(r.status ?? "completed"),
166
- ...(typeof r.exitCode === "number" ? { exitCode: r.exitCode } : {}),
167
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
168
- ...(typeof r.aggregatedOutput === "string" ? { output: r.aggregatedOutput } : {}),
169
- };
170
- case "fileChange":
171
- return { ...base, status: String(r.status ?? "completed"), changes: Array.isArray(r.changes) ? r.changes : [] };
172
- case "mcpToolCall":
173
- return {
174
- ...base,
175
- status: String(r.status ?? "completed"),
176
- toolName: `${String(r.server ?? "")}/${String(r.tool ?? "")}`,
177
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
178
- };
179
- case "dynamicToolCall":
180
- return {
181
- ...base,
182
- status: String(r.status ?? "completed"),
183
- toolName: String(r.tool ?? ""),
184
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
185
- };
186
- case "plan":
187
- return { ...base, text: String(r.text ?? "") };
188
- case "reasoning":
189
- return { ...base, text: Array.isArray(r.summary) ? r.summary.join("\n") : "" };
190
- default:
191
- return base;
192
- }
193
- }
@@ -1,13 +1,21 @@
1
+ import { buildTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
2
+ const DETAIL_INITIAL = {
3
+ timeline: [],
4
+ activeRunId: null,
5
+ activeRunStartedAt: null,
6
+ tokenUsage: null,
7
+ diffSummary: null,
8
+ plan: null,
9
+ };
1
10
  export const initialWatchState = {
2
11
  connected: false,
3
12
  issues: [],
4
13
  selectedIndex: 0,
5
14
  view: "list",
6
15
  activeDetailKey: null,
7
- thread: null,
8
- report: null,
9
16
  filter: "non-done",
10
17
  follow: true,
18
+ ...DETAIL_INITIAL,
11
19
  };
12
20
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
13
21
  export function filterIssues(issues, filter) {
@@ -27,6 +35,7 @@ function nextFilter(filter) {
27
35
  case "all": return "non-done";
28
36
  }
29
37
  }
38
+ // ─── Reducer ──────────────────────────────────────────────────────
30
39
  export function watchReducer(state, action) {
31
40
  switch (action.type) {
32
41
  case "connected":
@@ -47,13 +56,19 @@ export function watchReducer(state, action) {
47
56
  selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
48
57
  };
49
58
  case "enter-detail":
50
- return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null, report: null };
59
+ return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
51
60
  case "exit-detail":
52
- return { ...state, view: "list", activeDetailKey: null, thread: null, report: null };
53
- case "thread-snapshot":
54
- return { ...state, thread: action.thread };
55
- case "report-snapshot":
56
- return { ...state, report: action.report };
61
+ return { ...state, view: "list", activeDetailKey: null, ...DETAIL_INITIAL };
62
+ case "timeline-rehydrate": {
63
+ const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
64
+ const activeRun = action.runs.find((r) => r.id === action.activeRunId);
65
+ return {
66
+ ...state,
67
+ timeline,
68
+ activeRunId: action.activeRunId,
69
+ activeRunStartedAt: activeRun?.startedAt ?? null,
70
+ };
71
+ }
57
72
  case "codex-notification":
58
73
  return applyCodexNotification(state, action.method, action.params);
59
74
  case "cycle-filter":
@@ -62,7 +77,7 @@ export function watchReducer(state, action) {
62
77
  return { ...state, follow: !state.follow };
63
78
  }
64
79
  }
65
- // ─── Feed Event Application ───────────────────────────────────────
80
+ // ─── Feed Event Issue List + Timeline ───────────────────────────
66
81
  function applyFeedEvent(state, event) {
67
82
  if (!event.issueKey) {
68
83
  return state;
@@ -92,93 +107,52 @@ function applyFeedEvent(state, event) {
92
107
  }
93
108
  issue.updatedAt = event.at;
94
109
  updated[index] = issue;
95
- return { ...state, issues: updated };
110
+ // Append to timeline if this event matches the active detail issue
111
+ const timeline = state.view === "detail" && state.activeDetailKey === event.issueKey
112
+ ? appendFeedToTimeline(state.timeline, event)
113
+ : state.timeline;
114
+ return { ...state, issues: updated, timeline };
96
115
  }
97
- // ─── Codex Notification Application ───────────────────────────────
116
+ // ─── Codex Notification Timeline + Metadata ─────────────────────
98
117
  function applyCodexNotification(state, method, params) {
99
- if (!state.thread) {
100
- // No thread loaded yet — only turn/started can bootstrap one
101
- if (method === "turn/started") {
102
- return bootstrapThreadFromTurnStarted(state, params);
103
- }
104
- return state;
105
- }
106
118
  switch (method) {
107
- case "turn/started":
108
- return withThread(state, addTurn(state.thread, params));
109
- case "turn/completed":
110
- return withThread(state, completeTurn(state.thread, params));
111
- case "turn/plan/updated":
112
- return withThread(state, updatePlan(state.thread, params));
113
- case "turn/diff/updated":
114
- return withThread(state, updateDiff(state.thread, params));
115
119
  case "item/started":
116
- return withThread(state, addItem(state.thread, params));
120
+ return { ...state, timeline: appendCodexItemToTimeline(state.timeline, params, state.activeRunId) };
117
121
  case "item/completed":
118
- return withThread(state, completeItem(state.thread, params));
122
+ return { ...state, timeline: completeCodexItemInTimeline(state.timeline, params) };
119
123
  case "item/agentMessage/delta":
120
- return withThread(state, appendItemText(state.thread, params));
121
- case "item/commandExecution/outputDelta":
122
- return withThread(state, appendItemOutput(state.thread, params));
123
124
  case "item/plan/delta":
124
- return withThread(state, appendItemText(state.thread, params));
125
- case "item/reasoning/summaryTextDelta":
126
- return withThread(state, appendItemText(state.thread, params));
127
- case "thread/status/changed":
128
- return withThread(state, updateThreadStatus(state.thread, params));
125
+ case "item/reasoning/summaryTextDelta": {
126
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
127
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
128
+ if (!itemId || !delta)
129
+ return state;
130
+ return { ...state, timeline: appendDeltaToTimelineItem(state.timeline, itemId, "text", delta) };
131
+ }
132
+ case "item/commandExecution/outputDelta": {
133
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
134
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
135
+ if (!itemId || !delta)
136
+ return state;
137
+ return { ...state, timeline: appendDeltaToTimelineItem(state.timeline, itemId, "output", delta) };
138
+ }
139
+ case "turn/plan/updated":
140
+ return applyPlanUpdate(state, params);
141
+ case "turn/diff/updated":
142
+ return applyDiffUpdate(state, params);
129
143
  case "thread/tokenUsage/updated":
130
- return withThread(state, updateTokenUsage(state.thread, params));
144
+ return applyTokenUsageUpdate(state, params);
131
145
  default:
132
146
  return state;
133
147
  }
134
148
  }
135
- function withThread(state, thread) {
136
- return { ...state, thread };
137
- }
138
- function bootstrapThreadFromTurnStarted(state, params) {
139
- const turnObj = params.turn;
140
- const threadId = typeof params.threadId === "string" ? params.threadId : "unknown";
141
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
142
- return {
143
- ...state,
144
- thread: {
145
- threadId,
146
- status: "active",
147
- turns: [{ id: turnId, status: "inProgress", items: [] }],
148
- },
149
- };
150
- }
151
- // ─── Turn Handlers ────────────────────────────────────────────────
152
- function addTurn(thread, params) {
153
- const turnObj = params.turn;
154
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
155
- const existing = thread.turns.find((t) => t.id === turnId);
156
- if (existing) {
157
- return thread;
158
- }
159
- return {
160
- ...thread,
161
- status: "active",
162
- turns: [...thread.turns, { id: turnId, status: "inProgress", items: [] }],
163
- };
164
- }
165
- function completeTurn(thread, params) {
166
- const turnObj = params.turn;
167
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : undefined;
168
- const status = typeof turnObj?.status === "string" ? turnObj.status : "completed";
169
- if (!turnId)
170
- return thread;
171
- return {
172
- ...thread,
173
- turns: thread.turns.map((t) => t.id === turnId ? { ...t, status } : t),
174
- };
175
- }
176
- function updatePlan(thread, params) {
149
+ // ─── Metadata Handlers (header, not timeline) ─────────────────────
150
+ function applyPlanUpdate(state, params) {
177
151
  const plan = params.plan;
178
152
  if (!Array.isArray(plan))
179
- return thread;
153
+ return state;
180
154
  return {
181
- ...thread,
155
+ ...state,
182
156
  plan: plan.map((entry) => {
183
157
  const e = entry;
184
158
  return {
@@ -188,9 +162,11 @@ function updatePlan(thread, params) {
188
162
  }),
189
163
  };
190
164
  }
191
- function updateDiff(thread, params) {
165
+ function applyDiffUpdate(state, params) {
192
166
  const diff = typeof params.diff === "string" ? params.diff : undefined;
193
- return { ...thread, diff, diffSummary: diff ? parseDiffSummary(diff) : undefined };
167
+ if (!diff)
168
+ return state;
169
+ return { ...state, diffSummary: parseDiffSummary(diff) };
194
170
  }
195
171
  function parseDiffSummary(diff) {
196
172
  const files = new Set();
@@ -209,109 +185,13 @@ function parseDiffSummary(diff) {
209
185
  }
210
186
  return { filesChanged: files.size, linesAdded: added, linesRemoved: removed };
211
187
  }
212
- function updateTokenUsage(thread, params) {
188
+ function applyTokenUsageUpdate(state, params) {
213
189
  const usage = params.usage;
214
190
  if (!usage)
215
- return thread;
191
+ return state;
216
192
  const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens
217
193
  : typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
218
194
  const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens
219
195
  : typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
220
- return { ...thread, tokenUsage: { inputTokens, outputTokens } };
221
- }
222
- function updateThreadStatus(thread, params) {
223
- const statusObj = params.status;
224
- const statusType = typeof statusObj?.type === "string" ? statusObj.type : undefined;
225
- if (!statusType)
226
- return thread;
227
- return { ...thread, status: statusType };
228
- }
229
- // ─── Item Handlers ────────────────────────────────────────────────
230
- function getLatestTurn(thread) {
231
- return thread.turns[thread.turns.length - 1];
232
- }
233
- function updateLatestTurn(thread, updater) {
234
- const last = getLatestTurn(thread);
235
- if (!last)
236
- return thread;
237
- return {
238
- ...thread,
239
- turns: [...thread.turns.slice(0, -1), updater(last)],
240
- };
241
- }
242
- function addItem(thread, params) {
243
- const itemObj = params.item;
244
- if (!itemObj)
245
- return thread;
246
- const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
247
- const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
248
- const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
249
- const item = { id, type, status };
250
- if (type === "agentMessage" && typeof itemObj.text === "string") {
251
- item.text = itemObj.text;
252
- }
253
- if (type === "commandExecution") {
254
- const cmd = itemObj.command;
255
- item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
256
- }
257
- if (type === "mcpToolCall") {
258
- const server = typeof itemObj.server === "string" ? itemObj.server : "";
259
- const tool = typeof itemObj.tool === "string" ? itemObj.tool : "";
260
- item.toolName = `${server}/${tool}`;
261
- }
262
- if (type === "dynamicToolCall") {
263
- item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
264
- }
265
- return updateLatestTurn(thread, (turn) => ({
266
- ...turn,
267
- items: [...turn.items, item],
268
- }));
269
- }
270
- function completeItem(thread, params) {
271
- const itemObj = params.item;
272
- if (!itemObj)
273
- return thread;
274
- const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
275
- if (!id)
276
- return thread;
277
- const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
278
- const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
279
- const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
280
- const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
281
- const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
282
- return updateLatestTurn(thread, (turn) => ({
283
- ...turn,
284
- items: turn.items.map((item) => {
285
- if (item.id !== id)
286
- return item;
287
- return {
288
- ...item,
289
- status,
290
- ...(exitCode !== undefined ? { exitCode } : {}),
291
- ...(durationMs !== undefined ? { durationMs } : {}),
292
- ...(text !== undefined ? { text } : {}),
293
- ...(changes !== undefined ? { changes } : {}),
294
- };
295
- }),
296
- }));
297
- }
298
- function appendItemText(thread, params) {
299
- const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
300
- const delta = typeof params.delta === "string" ? params.delta : undefined;
301
- if (!itemId || !delta)
302
- return thread;
303
- return updateLatestTurn(thread, (turn) => ({
304
- ...turn,
305
- items: turn.items.map((item) => item.id === itemId ? { ...item, text: (item.text ?? "") + delta } : item),
306
- }));
307
- }
308
- function appendItemOutput(thread, params) {
309
- const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
310
- const delta = typeof params.delta === "string" ? params.delta : undefined;
311
- if (!itemId || !delta)
312
- return thread;
313
- return updateLatestTurn(thread, (turn) => ({
314
- ...turn,
315
- items: turn.items.map((item) => item.id === itemId ? { ...item, output: (item.output ?? "") + delta } : item),
316
- }));
196
+ return { ...state, tokenUsage: { inputTokens, outputTokens } };
317
197
  }
package/dist/config.js CHANGED
@@ -95,7 +95,7 @@ const configSchema = z.object({
95
95
  approval_policy: z.enum(["never", "on-request", "on-failure", "untrusted"]).default("never"),
96
96
  sandbox_mode: z.enum(["danger-full-access", "workspace-write", "read-only"]).default("danger-full-access"),
97
97
  persist_extended_history: z.boolean().default(false),
98
- experimental_raw_events: z.boolean().default(false),
98
+ experimental_raw_events: z.boolean().default(true),
99
99
  }),
100
100
  }),
101
101
  projects: z.array(projectSchema).default([]),
@@ -131,6 +131,8 @@ export function runPatchRelayMigrations(connection) {
131
131
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
132
132
  // Add pending_merge_prep column for merge queue stewardship
133
133
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
134
+ // Add merge_prep_attempts for retry budget / escalation
135
+ addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
134
136
  }
135
137
  function addColumnIfMissing(connection, table, column, definition) {
136
138
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
package/dist/db.js CHANGED
@@ -149,6 +149,10 @@ export class PatchRelayDatabase {
149
149
  sets.push("queue_repair_attempts = @queueRepairAttempts");
150
150
  values.queueRepairAttempts = params.queueRepairAttempts;
151
151
  }
152
+ if (params.mergePrepAttempts !== undefined) {
153
+ sets.push("merge_prep_attempts = @mergePrepAttempts");
154
+ values.mergePrepAttempts = params.mergePrepAttempts;
155
+ }
152
156
  if (params.pendingMergePrep !== undefined) {
153
157
  sets.push("pending_merge_prep = @pendingMergePrep");
154
158
  values.pendingMergePrep = params.pendingMergePrep ? 1 : 0;
@@ -385,6 +389,7 @@ function mapIssueRow(row) {
385
389
  ...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
386
390
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
387
391
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
392
+ mergePrepAttempts: Number(row.merge_prep_attempts ?? 0),
388
393
  pendingMergePrep: Boolean(row.pending_merge_prep),
389
394
  };
390
395
  }
package/dist/http.js CHANGED
@@ -259,6 +259,14 @@ export async function buildHttpServer(config, service, logger) {
259
259
  }
260
260
  return reply.send({ ok: true, ...result });
261
261
  });
262
+ app.get("/api/issues/:issueKey/timeline", async (request, reply) => {
263
+ const issueKey = request.params.issueKey;
264
+ const result = await service.getIssueTimeline(issueKey);
265
+ if (!result) {
266
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
267
+ }
268
+ return reply.send({ ok: true, ...result });
269
+ });
262
270
  app.get("/api/issues/:issueKey/live", async (request, reply) => {
263
271
  const issueKey = request.params.issueKey;
264
272
  const result = await service.getActiveRunStatus(issueKey);
@@ -290,6 +298,17 @@ export async function buildHttpServer(config, service, logger) {
290
298
  });
291
299
  }
292
300
  if (managementRoutesEnabled) {
301
+ app.post("/api/issues/:issueKey/retry", async (request, reply) => {
302
+ const issueKey = request.params.issueKey;
303
+ const result = service.retryIssue(issueKey);
304
+ if (!result) {
305
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
306
+ }
307
+ if ("error" in result) {
308
+ return reply.code(409).send({ ok: false, reason: result.error });
309
+ }
310
+ return reply.send({ ok: true, ...result });
311
+ });
293
312
  app.get("/api/feed", async (request, reply) => {
294
313
  const feedQuery = {
295
314
  limit: getPositiveIntegerQueryParam(request, "limit") ?? 50,
@@ -58,6 +58,29 @@ export class IssueQueryService {
58
58
  })),
59
59
  };
60
60
  }
61
+ async getIssueTimeline(issueKey) {
62
+ const issue = this.db.getTrackedIssueByKey(issueKey);
63
+ if (!issue)
64
+ return undefined;
65
+ const fullIssue = this.db.getIssueByKey(issueKey);
66
+ const runs = this.db.listRunsForIssue(issue.projectId, issue.linearIssueId).map((run) => ({
67
+ id: run.id,
68
+ runType: run.runType,
69
+ status: run.status,
70
+ startedAt: run.startedAt,
71
+ endedAt: run.endedAt,
72
+ threadId: run.threadId,
73
+ ...(run.reportJson ? { report: JSON.parse(run.reportJson) } : {}),
74
+ }));
75
+ const feedEvents = this.db.operatorFeed.list({ issueKey, limit: 500 });
76
+ let liveThread = undefined;
77
+ const activeRunId = fullIssue?.activeRunId;
78
+ const activeRun = activeRunId !== undefined ? runs.find((r) => r.id === activeRunId) : undefined;
79
+ if (activeRun?.threadId) {
80
+ liveThread = await this.codex.readThread(activeRun.threadId, true).catch(() => undefined);
81
+ }
82
+ return { issue, runs, feedEvents, liveThread, activeRunId };
83
+ }
61
84
  async getActiveRunStatus(issueKey) {
62
85
  return await this.runStatusProvider.getActiveRunStatus(issueKey);
63
86
  }
@@ -82,6 +82,12 @@ export function buildRunFailureActivity(runType, reason) {
82
82
  body: reason ? `${label} failed.\n\n${reason}` : `${label} failed.`,
83
83
  };
84
84
  }
85
+ export function buildStopConfirmationActivity() {
86
+ return {
87
+ type: "response",
88
+ body: "PatchRelay has stopped work as requested. Delegate the issue again or provide new instructions to resume.",
89
+ };
90
+ }
85
91
  export function buildGitHubStateActivity(newState, event) {
86
92
  switch (newState) {
87
93
  case "pr_open": {
@@ -119,6 +125,28 @@ export function buildGitHubStateActivity(newState, event) {
119
125
  return undefined;
120
126
  }
121
127
  }
128
+ export function buildMergePrepActivity(step, detail) {
129
+ switch (step) {
130
+ case "auto_merge":
131
+ return { type: "action", action: "Enabling", parameter: "auto-merge" };
132
+ case "branch_update":
133
+ return { type: "action", action: "Updating", parameter: detail ? `branch to latest ${detail}` : "branch to latest base" };
134
+ case "conflict":
135
+ return { type: "action", action: "Repairing", parameter: "merge conflict with base branch" };
136
+ case "blocked":
137
+ return { type: "error", body: "Branch is up to date but auto-merge could not be enabled — check repository settings." };
138
+ case "fetch_retry":
139
+ return { type: "thought", body: "Merge prep: fetch failed, will retry." };
140
+ case "push_retry":
141
+ return { type: "thought", body: "Merge prep: push failed, will retry." };
142
+ }
143
+ }
144
+ export function buildMergePrepEscalationActivity(attempts) {
145
+ return {
146
+ type: "error",
147
+ body: `Merge preparation failed ${attempts} times due to infrastructure issues. PatchRelay needs human help to continue.`,
148
+ };
149
+ }
122
150
  export function summarizeIssueStateForLinear(issue) {
123
151
  switch (issue.factoryState) {
124
152
  case "awaiting_review":