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.
- package/README.md +41 -9
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +19 -1
- package/dist/cli/commands/issues.js +18 -56
- package/dist/cli/commands/watch.js +5 -0
- package/dist/cli/data.js +160 -47
- package/dist/cli/formatters/text.js +51 -90
- package/dist/cli/help.js +15 -8
- package/dist/cli/index.js +3 -58
- package/dist/cli/operator-client.js +0 -82
- package/dist/cli/watch/App.js +21 -12
- package/dist/cli/watch/HelpBar.js +3 -3
- package/dist/cli/watch/IssueDetailView.js +63 -130
- package/dist/cli/watch/IssueRow.js +82 -27
- package/dist/cli/watch/StatusBar.js +8 -4
- package/dist/cli/watch/detail-rows.js +589 -0
- package/dist/cli/watch/render-rich-text.js +226 -0
- package/dist/cli/watch/state-visualization.js +48 -23
- package/dist/cli/watch/timeline-builder.js +2 -1
- package/dist/cli/watch/use-detail-stream.js +10 -104
- package/dist/cli/watch/use-watch-stream.js +11 -102
- package/dist/cli/watch/watch-state.js +129 -56
- package/dist/codex-thread-utils.js +3 -0
- package/dist/db/migrations.js +239 -2
- package/dist/db.js +628 -39
- package/dist/github-app-token.js +7 -0
- package/dist/github-failure-context.js +44 -1
- package/dist/github-rollup.js +47 -0
- package/dist/github-webhook-handler.js +423 -52
- package/dist/github-webhooks.js +7 -0
- package/dist/http.js +12 -264
- package/dist/idle-reconciliation.js +268 -76
- package/dist/issue-query-service.js +221 -129
- package/dist/issue-session-events.js +151 -0
- package/dist/issue-session.js +99 -0
- package/dist/linear-client.js +39 -25
- package/dist/linear-session-reporting.js +12 -0
- package/dist/linear-session-sync.js +253 -24
- package/dist/linear-workflow.js +33 -0
- package/dist/merge-queue-protocol.js +0 -51
- package/dist/preflight.js +1 -4
- package/dist/queue-health-monitor.js +11 -7
- package/dist/run-orchestrator.js +1364 -147
- package/dist/run-reporting.js +5 -3
- package/dist/service.js +279 -102
- package/dist/status-note.js +56 -0
- package/dist/waiting-reason.js +65 -0
- package/dist/webhook-handler.js +270 -79
- package/package.json +3 -2
- package/dist/cli/commands/feed.js +0 -60
- package/dist/cli/watch/FeedView.js +0 -28
- 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.
|
|
103
|
-
case "
|
|
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
|
|
103
|
+
text: "PatchRelay is actively working this session.",
|
|
107
104
|
});
|
|
108
105
|
break;
|
|
109
|
-
case "
|
|
106
|
+
case "idle":
|
|
110
107
|
observations.push({
|
|
111
|
-
tone:
|
|
112
|
-
text: issue.
|
|
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
|
-
|
|
118
|
+
case "failed":
|
|
124
119
|
observations.push({
|
|
125
|
-
tone: "
|
|
126
|
-
text: "
|
|
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
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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)}
|
|
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:
|
|
37
|
+
runs: Array.isArray(data.runs) ? data.runs : [],
|
|
38
|
+
feedEvents: [],
|
|
71
39
|
liveThread: data.liveThread ?? null,
|
|
72
|
-
activeRunId: data.
|
|
73
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
}
|