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
@@ -0,0 +1,226 @@
1
+ const richTextCache = new Map();
2
+ export function lineToPlainText(line) {
3
+ return line.segments.map((segment) => segment.text).join("");
4
+ }
5
+ export function renderTextLines(text, options) {
6
+ const width = Math.max(8, options.width);
7
+ const sourceLines = text.length === 0 ? [""] : text.split("\n");
8
+ const lines = [];
9
+ for (let index = 0; index < sourceLines.length; index += 1) {
10
+ const sourceLine = sourceLines[index] ?? "";
11
+ const wrapped = wrapSegments(tokenizeSegments([{ text: sourceLine, ...(options.style ?? {}) }]), width, index === 0 ? options.firstPrefix : options.continuationPrefix ?? options.firstPrefix, options.continuationPrefix ?? options.firstPrefix, `${options.key}-${index}`);
12
+ lines.push(...wrapped);
13
+ }
14
+ return lines.length > 0 ? lines : [{ key: `${options.key}-0`, segments: [] }];
15
+ }
16
+ export function renderRichTextLines(text, options) {
17
+ const width = Math.max(8, options.width);
18
+ const cacheKey = [
19
+ options.key,
20
+ width,
21
+ segmentsKey(options.firstPrefix),
22
+ segmentsKey(options.continuationPrefix),
23
+ styleKey(options.style),
24
+ options.codeColor ?? "",
25
+ text,
26
+ ].join("\u0000");
27
+ const cached = richTextCache.get(cacheKey);
28
+ if (cached) {
29
+ return cached;
30
+ }
31
+ const lines = [];
32
+ const inputLines = text.replace(/\r\n/g, "\n").split("\n");
33
+ let paragraph = [];
34
+ let inCodeBlock = false;
35
+ let codeLines = [];
36
+ let blockIndex = 0;
37
+ const flushParagraph = () => {
38
+ if (paragraph.length === 0)
39
+ return;
40
+ const paragraphText = paragraph.join(" ").replace(/\s+/g, " ").trim();
41
+ if (paragraphText.length > 0) {
42
+ lines.push(...wrapSegments(tokenizeSegments(parseInlineMarkdown(paragraphText, options.style)), width, lines.length === 0 ? options.firstPrefix : options.continuationPrefix ?? options.firstPrefix, options.continuationPrefix ?? options.firstPrefix, `${options.key}-p-${blockIndex}`));
43
+ blockIndex += 1;
44
+ }
45
+ paragraph = [];
46
+ };
47
+ const flushCodeBlock = () => {
48
+ if (codeLines.length === 0)
49
+ return;
50
+ for (const codeLine of codeLines) {
51
+ lines.push(...renderTextLines(codeLine, {
52
+ key: `${options.key}-code-${blockIndex}`,
53
+ width,
54
+ firstPrefix: lines.length === 0 ? options.firstPrefix : options.continuationPrefix ?? options.firstPrefix,
55
+ continuationPrefix: options.continuationPrefix ?? options.firstPrefix,
56
+ style: { color: options.codeColor ?? "green" },
57
+ }));
58
+ blockIndex += 1;
59
+ }
60
+ codeLines = [];
61
+ };
62
+ const pushBlankLine = () => {
63
+ lines.push({ key: `${options.key}-blank-${blockIndex}`, segments: [] });
64
+ blockIndex += 1;
65
+ };
66
+ for (const rawLine of inputLines) {
67
+ const line = rawLine ?? "";
68
+ const trimmed = line.trim();
69
+ if (trimmed.startsWith("```")) {
70
+ flushParagraph();
71
+ if (inCodeBlock) {
72
+ flushCodeBlock();
73
+ inCodeBlock = false;
74
+ }
75
+ else {
76
+ inCodeBlock = true;
77
+ }
78
+ continue;
79
+ }
80
+ if (inCodeBlock) {
81
+ codeLines.push(line);
82
+ continue;
83
+ }
84
+ if (trimmed.length === 0) {
85
+ flushParagraph();
86
+ if (lines.length > 0) {
87
+ pushBlankLine();
88
+ }
89
+ continue;
90
+ }
91
+ const bulletMatch = line.match(/^\s*[-*]\s+(.*)$/);
92
+ if (bulletMatch?.[1]) {
93
+ flushParagraph();
94
+ lines.push(...wrapSegments(tokenizeSegments(parseInlineMarkdown(bulletMatch[1], options.style)), width, appendSegments(options.firstPrefix, [{ text: "• ", ...(options.style ?? {}) }]), appendSegments(options.continuationPrefix ?? options.firstPrefix, [{ text: " ", ...(options.style ?? {}) }]), `${options.key}-b-${blockIndex}`));
95
+ blockIndex += 1;
96
+ continue;
97
+ }
98
+ paragraph.push(trimmed);
99
+ }
100
+ flushParagraph();
101
+ flushCodeBlock();
102
+ const result = lines.length > 0 ? lines : [{ key: `${options.key}-0`, segments: [] }];
103
+ richTextCache.set(cacheKey, result);
104
+ return result;
105
+ }
106
+ function parseInlineMarkdown(text, style) {
107
+ const pattern = /\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`|\*\*([^*]+)\*\*/g;
108
+ const segments = [];
109
+ let lastIndex = 0;
110
+ for (const match of text.matchAll(pattern)) {
111
+ const index = match.index ?? 0;
112
+ if (index > lastIndex) {
113
+ segments.push({ text: text.slice(lastIndex, index), ...(style ?? {}) });
114
+ }
115
+ if (match[1] && match[2]) {
116
+ segments.push({ text: match[1], color: "cyan", bold: true });
117
+ segments.push({ text: ` (${match[2]})`, dimColor: true });
118
+ }
119
+ else if (match[3]) {
120
+ segments.push({ text: match[3], color: "yellow", bold: true });
121
+ }
122
+ else if (match[4]) {
123
+ segments.push({ text: match[4], ...(style ?? {}), bold: true });
124
+ }
125
+ lastIndex = index + match[0].length;
126
+ }
127
+ if (lastIndex < text.length) {
128
+ segments.push({ text: text.slice(lastIndex), ...(style ?? {}) });
129
+ }
130
+ return segments.length > 0 ? segments : [{ text, ...(style ?? {}) }];
131
+ }
132
+ function wrapSegments(tokens, width, firstPrefix, continuationPrefix, keyPrefix) {
133
+ const initialPrefix = cloneSegments(firstPrefix);
134
+ const nextPrefix = cloneSegments(continuationPrefix);
135
+ const lines = [];
136
+ let currentSegments = initialPrefix;
137
+ let currentWidth = segmentsWidth(initialPrefix);
138
+ let lineHasContent = false;
139
+ let lineIndex = 0;
140
+ const pushLine = () => {
141
+ lines.push({
142
+ key: `${keyPrefix}-${lineIndex}`,
143
+ segments: trimTrailingSpaces(currentSegments),
144
+ });
145
+ lineIndex += 1;
146
+ currentSegments = cloneSegments(nextPrefix);
147
+ currentWidth = segmentsWidth(currentSegments);
148
+ lineHasContent = false;
149
+ };
150
+ for (const token of tokens) {
151
+ let remaining = token.text.replace(/\t/g, " ");
152
+ while (remaining.length > 0) {
153
+ const whitespace = remaining.match(/^\s+/)?.[0];
154
+ if (whitespace) {
155
+ const spaceText = lineHasContent ? whitespace : "";
156
+ remaining = remaining.slice(whitespace.length);
157
+ if (spaceText.length === 0) {
158
+ continue;
159
+ }
160
+ if (currentWidth + spaceText.length > width) {
161
+ pushLine();
162
+ continue;
163
+ }
164
+ currentSegments.push({ ...token, text: spaceText });
165
+ currentWidth += spaceText.length;
166
+ continue;
167
+ }
168
+ const word = remaining.match(/^\S+/)?.[0] ?? remaining;
169
+ remaining = remaining.slice(word.length);
170
+ let rest = word;
171
+ while (rest.length > 0) {
172
+ const available = Math.max(1, width - currentWidth);
173
+ if (lineHasContent && rest.length > available) {
174
+ pushLine();
175
+ continue;
176
+ }
177
+ const sliceLength = Math.min(rest.length, available);
178
+ const chunk = rest.slice(0, sliceLength);
179
+ currentSegments.push({ ...token, text: chunk });
180
+ currentWidth += chunk.length;
181
+ lineHasContent = true;
182
+ rest = rest.slice(sliceLength);
183
+ if (rest.length > 0) {
184
+ pushLine();
185
+ }
186
+ }
187
+ }
188
+ }
189
+ if (lines.length === 0 || currentSegments.length > 0 || lineHasContent) {
190
+ lines.push({
191
+ key: `${keyPrefix}-${lineIndex}`,
192
+ segments: trimTrailingSpaces(currentSegments),
193
+ });
194
+ }
195
+ return lines;
196
+ }
197
+ function tokenizeSegments(segments) {
198
+ return segments.flatMap((segment) => {
199
+ const parts = segment.text.length === 0 ? [""] : segment.text.match(/\s+|\S+/g) ?? [segment.text];
200
+ return parts.map((part) => ({ ...segment, text: part }));
201
+ });
202
+ }
203
+ function cloneSegments(segments) {
204
+ return (segments ?? []).map((segment) => ({ ...segment }));
205
+ }
206
+ function segmentsWidth(segments) {
207
+ return (segments ?? []).reduce((sum, segment) => sum + segment.text.length, 0);
208
+ }
209
+ function trimTrailingSpaces(segments) {
210
+ const trimmed = cloneSegments(segments);
211
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1]?.text.trim().length === 0) {
212
+ trimmed.pop();
213
+ }
214
+ return trimmed;
215
+ }
216
+ function appendSegments(base, extra) {
217
+ return [...cloneSegments(base), ...cloneSegments(extra)];
218
+ }
219
+ function segmentsKey(segments) {
220
+ return (segments ?? []).map((segment) => `${segment.text}|${segment.color ?? ""}|${segment.dimColor ? "d" : ""}|${segment.bold ? "b" : ""}`).join(";");
221
+ }
222
+ function styleKey(style) {
223
+ if (!style)
224
+ return "";
225
+ return `${style.color ?? ""}|${style.dimColor ? "d" : ""}|${style.bold ? "b" : ""}`;
226
+ }
@@ -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
- }