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.
- package/README.md +11 -8
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +20 -2
- package/dist/cli/watch/HelpBar.js +1 -1
- package/dist/cli/watch/IssueDetailView.js +27 -11
- package/dist/cli/watch/Timeline.js +14 -0
- package/dist/cli/watch/TimelineRow.js +62 -0
- package/dist/cli/watch/timeline-builder.js +363 -0
- package/dist/cli/watch/use-detail-stream.js +29 -107
- package/dist/cli/watch/watch-state.js +62 -182
- package/dist/config.js +1 -1
- package/dist/db/migrations.js +2 -0
- package/dist/db.js +5 -0
- package/dist/http.js +19 -0
- package/dist/issue-query-service.js +23 -0
- package/dist/linear-session-reporting.js +28 -0
- package/dist/merge-queue.js +39 -3
- package/dist/run-orchestrator.js +45 -1
- package/dist/service.js +40 -1
- package/dist/webhook-handler.js +43 -1
- package/dist/webhooks.js +16 -0
- package/package.json +1 -1
- package/dist/cli/watch/ThreadView.js +0 -26
- package/dist/cli/watch/TurnSection.js +0 -20
|
@@ -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 = {
|
|
11
|
+
const headers = {};
|
|
12
12
|
if (bearerToken) {
|
|
13
13
|
headers.authorization = `Bearer ${bearerToken}`;
|
|
14
14
|
}
|
|
15
|
-
// Rehydrate from
|
|
15
|
+
// Rehydrate from timeline endpoint
|
|
16
16
|
void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
|
|
17
|
-
// Stream codex notifications via filtered SSE
|
|
18
|
-
void
|
|
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)}/
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
50
|
+
// Rehydration is best-effort
|
|
72
51
|
}
|
|
73
52
|
}
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
59
|
+
return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
|
|
51
60
|
case "exit-detail":
|
|
52
|
-
return { ...state, view: "list", activeDetailKey: null,
|
|
53
|
-
case "
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
120
|
+
return { ...state, timeline: appendCodexItemToTimeline(state.timeline, params, state.activeRunId) };
|
|
117
121
|
case "item/completed":
|
|
118
|
-
return
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
144
|
+
return applyTokenUsageUpdate(state, params);
|
|
131
145
|
default:
|
|
132
146
|
return state;
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
153
|
+
return state;
|
|
180
154
|
return {
|
|
181
|
-
...
|
|
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
|
|
165
|
+
function applyDiffUpdate(state, params) {
|
|
192
166
|
const diff = typeof params.diff === "string" ? params.diff : undefined;
|
|
193
|
-
|
|
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
|
|
188
|
+
function applyTokenUsageUpdate(state, params) {
|
|
213
189
|
const usage = params.usage;
|
|
214
190
|
if (!usage)
|
|
215
|
-
return
|
|
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 { ...
|
|
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(
|
|
98
|
+
experimental_raw_events: z.boolean().default(true),
|
|
99
99
|
}),
|
|
100
100
|
}),
|
|
101
101
|
projects: z.array(projectSchema).default([]),
|
package/dist/db/migrations.js
CHANGED
|
@@ -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":
|