patchrelay 0.35.11 → 0.35.12

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 (50) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +0 -1
  4. package/dist/cli/commands/issues.js +2 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +110 -47
  7. package/dist/cli/formatters/text.js +6 -90
  8. package/dist/cli/help.js +3 -8
  9. package/dist/cli/index.js +0 -48
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +1 -12
  12. package/dist/cli/watch/HelpBar.js +2 -2
  13. package/dist/cli/watch/IssueDetailView.js +57 -26
  14. package/dist/cli/watch/IssueRow.js +71 -27
  15. package/dist/cli/watch/StatusBar.js +7 -4
  16. package/dist/cli/watch/state-visualization.js +48 -23
  17. package/dist/cli/watch/timeline-builder.js +2 -1
  18. package/dist/cli/watch/use-detail-stream.js +10 -104
  19. package/dist/cli/watch/use-watch-stream.js +11 -102
  20. package/dist/cli/watch/watch-state.js +18 -50
  21. package/dist/codex-thread-utils.js +3 -0
  22. package/dist/db/migrations.js +239 -2
  23. package/dist/db.js +628 -39
  24. package/dist/github-app-token.js +7 -0
  25. package/dist/github-failure-context.js +44 -1
  26. package/dist/github-rollup.js +47 -0
  27. package/dist/github-webhook-handler.js +248 -51
  28. package/dist/github-webhooks.js +5 -0
  29. package/dist/http.js +12 -264
  30. package/dist/idle-reconciliation.js +268 -76
  31. package/dist/issue-query-service.js +221 -129
  32. package/dist/issue-session-events.js +151 -0
  33. package/dist/issue-session.js +99 -0
  34. package/dist/linear-client.js +39 -25
  35. package/dist/linear-session-reporting.js +12 -0
  36. package/dist/linear-session-sync.js +253 -24
  37. package/dist/linear-workflow.js +33 -0
  38. package/dist/merge-queue-protocol.js +0 -51
  39. package/dist/preflight.js +1 -4
  40. package/dist/queue-health-monitor.js +11 -7
  41. package/dist/run-orchestrator.js +1295 -146
  42. package/dist/run-reporting.js +5 -3
  43. package/dist/service.js +279 -102
  44. package/dist/status-note.js +56 -0
  45. package/dist/waiting-reason.js +65 -0
  46. package/dist/webhook-handler.js +270 -79
  47. package/package.json +1 -1
  48. package/dist/cli/commands/feed.js +0 -60
  49. package/dist/cli/watch/FeedView.js +0 -28
  50. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -16,9 +16,6 @@ const PR_LOOP_STATES = ["changes_requested", "repairing_ci"];
16
16
  const QUEUE_LOOP_STATES = ["repairing_queue"];
17
17
  const EXIT_STATES = ["awaiting_input", "escalated", "failed"];
18
18
  const QUEUE_EVENT_STATUSES = new Set([
19
- "queue_label_requested",
20
- "queue_label_applied",
21
- "queue_label_failed",
22
19
  "queue_repair_queued",
23
20
  "pr_merged",
24
21
  ]);
@@ -61,12 +58,6 @@ function latestQueueObservationEvent(feedEvents) {
61
58
  }
62
59
  function describeObservationEvent(event) {
63
60
  switch (event.status) {
64
- case "queue_label_requested":
65
- return { tone: "info", text: event.summary };
66
- case "queue_label_applied":
67
- return { tone: "success", text: event.summary };
68
- case "queue_label_failed":
69
- return { tone: "warn", text: event.summary };
70
61
  case "queue_repair_queued":
71
62
  return { tone: "warn", text: event.summary };
72
63
  case "pr_merged":
@@ -82,10 +73,10 @@ function describeObservationEvent(event) {
82
73
  const active = event.status === "starting";
83
74
  return {
84
75
  tone: active ? "warn" : "info",
85
- text: active ? "PatchRelay is actively running queue repair." : event.summary,
76
+ text: active ? "PatchRelay is actively running queue repair." : `Observed queue signal: ${event.summary}`,
86
77
  };
87
78
  }
88
- return { tone: "info", text: event.summary };
79
+ return { tone: "info", text: `Observed signal: ${event.summary}` };
89
80
  }
90
81
  }
91
82
  export function buildPatchRelayStateGraph(history, currentFactoryState) {
@@ -99,19 +90,23 @@ export function buildPatchRelayStateGraph(history, currentFactoryState) {
99
90
  }
100
91
  export function buildPatchRelayQueueObservations(issue, feedEvents) {
101
92
  const observations = [];
102
- switch (issue.factoryState) {
103
- case "awaiting_queue":
93
+ switch (issue.sessionState) {
94
+ case "waiting_input":
95
+ observations.push({
96
+ tone: "warn",
97
+ text: issue.waitingReason ?? "PatchRelay is waiting for input before continuing.",
98
+ });
99
+ break;
100
+ case "running":
104
101
  observations.push({
105
102
  tone: "info",
106
- text: "PatchRelay has finished branch work and is waiting for external queue progress.",
103
+ text: "PatchRelay is actively working this session.",
107
104
  });
108
105
  break;
109
- case "repairing_queue":
106
+ case "idle":
110
107
  observations.push({
111
- tone: issue.activeRunType === "queue_repair" ? "warn" : "info",
112
- text: issue.activeRunType === "queue_repair"
113
- ? "PatchRelay is actively repairing a queue eviction."
114
- : "PatchRelay is preparing or waiting to resume queue repair.",
108
+ tone: "info",
109
+ text: "PatchRelay is idle for this issue.",
115
110
  });
116
111
  break;
117
112
  case "done":
@@ -120,12 +115,42 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
120
115
  text: "PatchRelay is complete because GitHub reports the PR has merged.",
121
116
  });
122
117
  break;
123
- default:
118
+ case "failed":
124
119
  observations.push({
125
- tone: "info",
126
- text: "Queue hand-off has not started yet; PatchRelay still owns the issue workflow.",
120
+ tone: "warn",
121
+ text: "PatchRelay needs human help to recover this session.",
127
122
  });
128
123
  break;
124
+ default:
125
+ switch (issue.factoryState) {
126
+ case "awaiting_queue":
127
+ observations.push({
128
+ tone: "info",
129
+ text: "PatchRelay has finished active work and is waiting for downstream merge flow.",
130
+ });
131
+ break;
132
+ case "repairing_queue":
133
+ observations.push({
134
+ tone: issue.activeRunType === "queue_repair" ? "warn" : "info",
135
+ text: issue.activeRunType === "queue_repair"
136
+ ? "PatchRelay is actively repairing a queue eviction."
137
+ : "PatchRelay is preparing or waiting to resume queue repair.",
138
+ });
139
+ break;
140
+ case "done":
141
+ observations.push({
142
+ tone: "success",
143
+ text: "PatchRelay is complete because GitHub reports the PR has merged.",
144
+ });
145
+ break;
146
+ default:
147
+ observations.push({
148
+ tone: "info",
149
+ text: "PatchRelay is tracking this issue.",
150
+ });
151
+ break;
152
+ }
153
+ break;
129
154
  }
130
155
  const latestEvent = latestQueueObservationEvent(feedEvents);
131
156
  if (latestEvent) {
@@ -134,7 +159,7 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
134
159
  else if (issue.factoryState === "awaiting_queue") {
135
160
  observations.push({
136
161
  tone: "info",
137
- text: "No external queue signal has been observed yet.",
162
+ text: "No downstream queue signal has been observed yet.",
138
163
  });
139
164
  }
140
165
  if (issue.prNumber !== undefined) {
@@ -1,3 +1,4 @@
1
+ import { getThreadTurns } from "../../codex-thread-utils.js";
1
2
  // ─── Build Timeline from Rehydration Data ─────────────────────────
2
3
  export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId) {
3
4
  const entries = [];
@@ -132,7 +133,7 @@ function syntheticTimestamp(startMs, endMs, index, total) {
132
133
  // ─── Items from Live Thread ───────────────────────────────────────
133
134
  function itemsFromThread(runId, thread) {
134
135
  const entries = [];
135
- for (const turn of thread.turns) {
136
+ for (const turn of getThreadTurns(thread)) {
136
137
  for (const item of turn.items) {
137
138
  entries.push({
138
139
  id: `live-${item.id}`,
@@ -14,11 +14,12 @@ export function useDetailStream(options) {
14
14
  if (bearerToken) {
15
15
  headers.authorization = `Bearer ${bearerToken}`;
16
16
  }
17
- // Rehydrate from timeline endpoint
18
17
  void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
19
- // Stream codex notifications + feed events via filtered SSE
20
- void streamEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
18
+ const intervalId = setInterval(() => {
19
+ void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
20
+ }, 3000);
21
21
  return () => {
22
+ clearInterval(intervalId);
22
23
  abortController.abort();
23
24
  };
24
25
  }, [options.issueKey, options.active]);
@@ -26,117 +27,22 @@ export function useDetailStream(options) {
26
27
  // ─── Rehydration ──────────────────────────────────────────────────
27
28
  async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
28
29
  try {
29
- const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/timeline`, baseUrl);
30
+ const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}`, baseUrl);
30
31
  const response = await fetch(url, { headers: { ...headers, accept: "application/json" }, signal });
31
32
  if (!response.ok)
32
33
  return;
33
34
  const data = await response.json();
34
- const runs = (data.runs ?? []).map((r) => ({
35
- id: r.id,
36
- runType: r.runType,
37
- status: r.status,
38
- startedAt: r.startedAt,
39
- endedAt: r.endedAt,
40
- threadId: r.threadId,
41
- ...(r.events ? { events: r.events } : {}),
42
- ...(r.report ? { report: r.report } : {}),
43
- }));
44
- let issueContext = null;
45
- if (data.issue) {
46
- const i = data.issue;
47
- issueContext = {
48
- description: typeof i.description === "string" ? i.description : undefined,
49
- currentLinearState: typeof i.currentLinearState === "string" ? i.currentLinearState : undefined,
50
- issueUrl: typeof i.issueUrl === "string" ? i.issueUrl : undefined,
51
- worktreePath: typeof i.worktreePath === "string" ? i.worktreePath : undefined,
52
- branchName: typeof i.branchName === "string" ? i.branchName : undefined,
53
- prUrl: typeof i.prUrl === "string" ? i.prUrl : undefined,
54
- priority: typeof i.priority === "number" ? i.priority : undefined,
55
- estimate: typeof i.estimate === "number" ? i.estimate : undefined,
56
- ciRepairAttempts: typeof i.ciRepairAttempts === "number" ? i.ciRepairAttempts : 0,
57
- queueRepairAttempts: typeof i.queueRepairAttempts === "number" ? i.queueRepairAttempts : 0,
58
- reviewFixAttempts: typeof i.reviewFixAttempts === "number" ? i.reviewFixAttempts : 0,
59
- latestFailureSource: typeof i.latestFailureSource === "string" ? i.latestFailureSource : undefined,
60
- latestFailureHeadSha: typeof i.latestFailureHeadSha === "string" ? i.latestFailureHeadSha : undefined,
61
- latestFailureCheckName: typeof i.latestFailureCheckName === "string" ? i.latestFailureCheckName : undefined,
62
- latestFailureStepName: typeof i.latestFailureStepName === "string" ? i.latestFailureStepName : undefined,
63
- latestFailureSummary: typeof i.latestFailureSummary === "string" ? i.latestFailureSummary : undefined,
64
- runCount: runs.length,
65
- };
66
- }
67
35
  dispatch({
68
36
  type: "timeline-rehydrate",
69
- runs,
70
- feedEvents: data.feedEvents ?? [],
37
+ runs: Array.isArray(data.runs) ? data.runs : [],
38
+ feedEvents: [],
71
39
  liveThread: data.liveThread ?? null,
72
- activeRunId: data.activeRunId ?? null,
73
- issueContext,
40
+ activeRunId: data.activeRun?.id ?? null,
41
+ activeRunStartedAt: data.activeRun?.startedAt ?? null,
42
+ issueContext: data.issueContext ?? null,
74
43
  });
75
44
  }
76
45
  catch {
77
46
  // Rehydration is best-effort
78
47
  }
79
48
  }
80
- // ─── Live SSE Stream ──────────────────────────────────────────────
81
- async function streamEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
82
- try {
83
- const url = new URL("/api/watch", baseUrl);
84
- url.searchParams.set("issue", issueKey);
85
- const headers = { ...baseHeaders, accept: "text/event-stream" };
86
- const response = await fetch(url, { headers, signal });
87
- if (!response.ok || !response.body)
88
- return;
89
- const reader = response.body.getReader();
90
- const decoder = new TextDecoder();
91
- let buffer = "";
92
- let eventType = "";
93
- let dataLines = [];
94
- while (true) {
95
- const { done, value } = await reader.read();
96
- if (done)
97
- break;
98
- buffer += decoder.decode(value, { stream: true });
99
- let newlineIndex = buffer.indexOf("\n");
100
- while (newlineIndex !== -1) {
101
- const rawLine = buffer.slice(0, newlineIndex);
102
- buffer = buffer.slice(newlineIndex + 1);
103
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
104
- if (!line) {
105
- if (dataLines.length > 0) {
106
- processEvent(dispatch, eventType, dataLines.join("\n"));
107
- dataLines = [];
108
- eventType = "";
109
- }
110
- newlineIndex = buffer.indexOf("\n");
111
- continue;
112
- }
113
- if (line.startsWith(":")) {
114
- newlineIndex = buffer.indexOf("\n");
115
- continue;
116
- }
117
- if (line.startsWith("event:")) {
118
- eventType = line.slice(6).trim();
119
- }
120
- else if (line.startsWith("data:")) {
121
- dataLines.push(line.slice(5).trimStart());
122
- }
123
- newlineIndex = buffer.indexOf("\n");
124
- }
125
- }
126
- }
127
- catch {
128
- // Stream ended or aborted
129
- }
130
- }
131
- function processEvent(dispatch, eventType, data) {
132
- try {
133
- if (eventType === "codex") {
134
- const parsed = JSON.parse(data);
135
- dispatch({ type: "codex-notification", method: parsed.method, params: parsed.params });
136
- }
137
- // Feed events are handled by the main watch stream
138
- }
139
- catch {
140
- // Ignore parse errors
141
- }
142
- }
@@ -5,127 +5,36 @@ export function useWatchStream(options) {
5
5
  useEffect(() => {
6
6
  if (options.active === false)
7
7
  return;
8
- let abortController = new AbortController();
9
- let reconnectTimeout;
10
- let attempt = 0;
8
+ const abortController = new AbortController();
11
9
  const fetchIssueSnapshot = async () => {
12
10
  const { baseUrl, bearerToken, dispatch } = optionsRef.current;
13
11
  const headers = { accept: "application/json" };
14
12
  if (bearerToken) {
15
13
  headers.authorization = `Bearer ${bearerToken}`;
16
14
  }
17
- const response = await fetch(new URL("/api/watch/issues", baseUrl), { headers });
15
+ const response = await fetch(new URL("/api/issues", baseUrl), { headers, signal: abortController.signal });
18
16
  if (!response.ok) {
19
17
  throw new Error(`Issue snapshot failed: ${response.status}`);
20
18
  }
21
19
  const payload = await response.json();
20
+ dispatch({ type: "connected" });
22
21
  dispatch({ type: "issues-snapshot", issues: Array.isArray(payload.issues) ? payload.issues : [], receivedAt: Date.now() });
23
22
  };
24
- const connect = () => {
25
- abortController = new AbortController();
26
- const { baseUrl, bearerToken, issueFilter, dispatch } = optionsRef.current;
27
- const url = new URL("/api/watch", baseUrl);
28
- if (issueFilter) {
29
- url.searchParams.set("issue", issueFilter);
23
+ void fetchIssueSnapshot().catch(() => {
24
+ if (!abortController.signal.aborted) {
25
+ optionsRef.current.dispatch({ type: "disconnected" });
30
26
  }
31
- const headers = { accept: "text/event-stream" };
32
- if (bearerToken) {
33
- headers.authorization = `Bearer ${bearerToken}`;
34
- }
35
- void fetch(url, { headers, signal: abortController.signal })
36
- .then(async (response) => {
37
- if (!response.ok || !response.body) {
38
- throw new Error(`Watch stream failed: ${response.status}`);
39
- }
40
- dispatch({ type: "connected" });
41
- attempt = 0;
42
- try {
43
- await fetchIssueSnapshot();
44
- }
45
- catch {
46
- // Keep the stream alive even if the snapshot endpoint temporarily fails.
47
- }
48
- const reader = response.body.getReader();
49
- const decoder = new TextDecoder();
50
- let buffer = "";
51
- let eventType = "";
52
- let dataLines = [];
53
- while (true) {
54
- const { done, value } = await reader.read();
55
- if (done)
56
- break;
57
- buffer += decoder.decode(value, { stream: true });
58
- let newlineIndex = buffer.indexOf("\n");
59
- while (newlineIndex !== -1) {
60
- const rawLine = buffer.slice(0, newlineIndex);
61
- buffer = buffer.slice(newlineIndex + 1);
62
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
63
- if (!line) {
64
- if (dataLines.length > 0) {
65
- processEvent(dispatch, eventType, dataLines.join("\n"));
66
- dataLines = [];
67
- eventType = "";
68
- }
69
- newlineIndex = buffer.indexOf("\n");
70
- continue;
71
- }
72
- if (line.startsWith(":")) {
73
- if (line.includes("keepalive")) {
74
- dispatch({ type: "stream-heartbeat", receivedAt: Date.now() });
75
- }
76
- newlineIndex = buffer.indexOf("\n");
77
- continue;
78
- }
79
- if (line.startsWith("event:")) {
80
- eventType = line.slice(6).trim();
81
- }
82
- else if (line.startsWith("data:")) {
83
- dataLines.push(line.slice(5).trimStart());
84
- }
85
- newlineIndex = buffer.indexOf("\n");
86
- }
27
+ });
28
+ const snapshotInterval = setInterval(() => {
29
+ void fetchIssueSnapshot().catch(() => {
30
+ if (!abortController.signal.aborted) {
31
+ optionsRef.current.dispatch({ type: "disconnected" });
87
32
  }
88
- })
89
- .catch((error) => {
90
- if (abortController.signal.aborted)
91
- return;
92
- const _msg = error instanceof Error ? error.message : String(error);
93
- })
94
- .finally(() => {
95
- if (abortController.signal.aborted)
96
- return;
97
- dispatch({ type: "disconnected" });
98
- attempt = Math.min(attempt + 1, 5);
99
- const delay = Math.min(2000 * Math.pow(2, attempt), 30000);
100
- reconnectTimeout = setTimeout(connect, delay);
101
33
  });
102
- };
103
- connect();
104
- void fetchIssueSnapshot().catch(() => undefined);
105
- const snapshotInterval = setInterval(() => {
106
- void fetchIssueSnapshot().catch(() => undefined);
107
34
  }, 5000);
108
35
  return () => {
109
36
  abortController.abort();
110
- if (reconnectTimeout !== undefined) {
111
- clearTimeout(reconnectTimeout);
112
- }
113
37
  clearInterval(snapshotInterval);
114
38
  };
115
39
  }, [options.active]);
116
40
  }
117
- function processEvent(dispatch, eventType, data) {
118
- try {
119
- if (eventType === "issues") {
120
- const issues = JSON.parse(data);
121
- dispatch({ type: "issues-snapshot", issues, receivedAt: Date.now() });
122
- }
123
- else if (eventType === "feed") {
124
- const event = JSON.parse(data);
125
- dispatch({ type: "feed-event", event, receivedAt: Date.now() });
126
- }
127
- }
128
- catch {
129
- // Ignore parse errors from malformed events
130
- }
131
- }
@@ -31,6 +31,9 @@ export const initialWatchState = {
31
31
  feedEvents: [],
32
32
  };
33
33
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
34
+ function effectiveSessionState(issue) {
35
+ return issue.sessionState ?? (TERMINAL_FACTORY_STATES.has(issue.factoryState) ? issue.factoryState : undefined);
36
+ }
34
37
  export function filterIssues(issues, filter) {
35
38
  switch (filter) {
36
39
  case "all":
@@ -38,7 +41,10 @@ export function filterIssues(issues, filter) {
38
41
  case "active":
39
42
  return issues.filter((i) => i.activeRunType !== undefined);
40
43
  case "non-done":
41
- return issues.filter((i) => !TERMINAL_FACTORY_STATES.has(i.factoryState));
44
+ return issues.filter((i) => {
45
+ const sessionState = effectiveSessionState(i);
46
+ return sessionState !== "done" && sessionState !== "failed" && !TERMINAL_FACTORY_STATES.has(i.factoryState);
47
+ });
42
48
  }
43
49
  }
44
50
  const DONE_STATES = new Set(["done"]);
@@ -50,15 +56,18 @@ export function computeAggregates(issues) {
50
56
  let done = 0;
51
57
  let failed = 0;
52
58
  for (const issue of issues) {
59
+ const sessionState = effectiveSessionState(issue);
60
+ const isDone = sessionState === "done" || DONE_STATES.has(issue.factoryState);
61
+ const isFailed = sessionState === "failed" || FAILED_STATES.has(issue.factoryState);
53
62
  if (issue.activeRunType)
54
63
  active++;
55
64
  if (!issue.activeRunType && issue.blockedByCount > 0)
56
65
  blocked++;
57
- if (!issue.activeRunType && issue.readyForExecution)
66
+ if (!issue.activeRunType && issue.readyForExecution && !isDone && !isFailed)
58
67
  ready++;
59
- if (DONE_STATES.has(issue.factoryState))
68
+ if (isDone)
60
69
  done++;
61
- if (FAILED_STATES.has(issue.factoryState))
70
+ if (isFailed)
62
71
  failed++;
63
72
  }
64
73
  return { active, blocked, ready, done, failed, total: issues.length };
@@ -119,7 +128,7 @@ export function watchReducer(state, action) {
119
128
  rawRuns: action.runs,
120
129
  rawFeedEvents: action.feedEvents,
121
130
  activeRunId: action.activeRunId,
122
- activeRunStartedAt: activeRun?.startedAt ?? null,
131
+ activeRunStartedAt: action.activeRunStartedAt ?? activeRun?.startedAt ?? null,
123
132
  issueContext: action.issueContext,
124
133
  };
125
134
  }
@@ -145,57 +154,16 @@ export function watchReducer(state, action) {
145
154
  }
146
155
  // ─── Feed Event → Issue List + Timeline ───────────────────────────
147
156
  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;
157
+ const isActiveDetail = Boolean(event.issueKey)
158
+ && state.view === "detail"
159
+ && state.activeDetailKey === event.issueKey;
192
160
  const timeline = isActiveDetail
193
161
  ? capArray(appendFeedToTimeline(state.timeline, event), MAX_TIMELINE_ENTRIES)
194
162
  : state.timeline;
195
163
  const rawFeedEvents = isActiveDetail
196
164
  ? capArray([...state.rawFeedEvents, event], MAX_RAW_FEED_EVENTS)
197
165
  : state.rawFeedEvents;
198
- return { ...state, lastServerMessageAt: receivedAt, issues: updated, timeline, rawFeedEvents };
166
+ return { ...state, lastServerMessageAt: receivedAt, timeline, rawFeedEvents };
199
167
  }
200
168
  // ─── Codex Notification → Timeline + Metadata ─────────────────────
201
169
  function applyCodexNotification(state, method, params) {
@@ -0,0 +1,3 @@
1
+ export function getThreadTurns(thread) {
2
+ return Array.isArray(thread?.turns) ? thread.turns : [];
3
+ }