patchrelay 0.16.0 → 0.17.1
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/dist/agent-session-plan.js +1 -2
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +1 -1
- package/dist/cli/watch/IssueDetailView.js +28 -13
- package/dist/cli/watch/IssueRow.js +0 -1
- 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 -193
- package/dist/db/migrations.js +4 -0
- package/dist/db.js +5 -0
- package/dist/factory-state.js +3 -1
- package/dist/github-webhook-handler.js +5 -4
- package/dist/http.js +8 -0
- package/dist/issue-query-service.js +23 -0
- package/dist/linear-session-reporting.js +0 -2
- package/dist/merge-queue.js +0 -1
- package/dist/run-orchestrator.js +77 -17
- package/dist/service.js +3 -0
- package/package.json +1 -1
- package/dist/cli/watch/FeedTimeline.js +0 -23
- 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,14 +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,
|
|
11
|
-
|
|
18
|
+
...DETAIL_INITIAL,
|
|
12
19
|
};
|
|
13
20
|
const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
|
|
14
21
|
export function filterIssues(issues, filter) {
|
|
@@ -28,6 +35,7 @@ function nextFilter(filter) {
|
|
|
28
35
|
case "all": return "non-done";
|
|
29
36
|
}
|
|
30
37
|
}
|
|
38
|
+
// ─── Reducer ──────────────────────────────────────────────────────
|
|
31
39
|
export function watchReducer(state, action) {
|
|
32
40
|
switch (action.type) {
|
|
33
41
|
case "connected":
|
|
@@ -48,13 +56,19 @@ export function watchReducer(state, action) {
|
|
|
48
56
|
selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
|
|
49
57
|
};
|
|
50
58
|
case "enter-detail":
|
|
51
|
-
return { ...state, view: "detail", activeDetailKey: action.issueKey,
|
|
59
|
+
return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
|
|
52
60
|
case "exit-detail":
|
|
53
|
-
return { ...state, view: "list", activeDetailKey: null,
|
|
54
|
-
case "
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
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
|
+
}
|
|
58
72
|
case "codex-notification":
|
|
59
73
|
return applyCodexNotification(state, action.method, action.params);
|
|
60
74
|
case "cycle-filter":
|
|
@@ -63,7 +77,7 @@ export function watchReducer(state, action) {
|
|
|
63
77
|
return { ...state, follow: !state.follow };
|
|
64
78
|
}
|
|
65
79
|
}
|
|
66
|
-
// ─── Feed Event
|
|
80
|
+
// ─── Feed Event → Issue List + Timeline ───────────────────────────
|
|
67
81
|
function applyFeedEvent(state, event) {
|
|
68
82
|
if (!event.issueKey) {
|
|
69
83
|
return state;
|
|
@@ -93,103 +107,52 @@ function applyFeedEvent(state, event) {
|
|
|
93
107
|
}
|
|
94
108
|
issue.updatedAt = event.at;
|
|
95
109
|
updated[index] = issue;
|
|
96
|
-
// Append to
|
|
97
|
-
const
|
|
98
|
-
?
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
summary: event.summary,
|
|
102
|
-
...(event.status ? { status: event.status } : {}),
|
|
103
|
-
...(event.detail ? { detail: event.detail } : {}),
|
|
104
|
-
}]
|
|
105
|
-
: state.detailFeed;
|
|
106
|
-
return { ...state, issues: updated, detailFeed };
|
|
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 };
|
|
107
115
|
}
|
|
108
|
-
// ─── Codex Notification
|
|
116
|
+
// ─── Codex Notification → Timeline + Metadata ─────────────────────
|
|
109
117
|
function applyCodexNotification(state, method, params) {
|
|
110
|
-
if (!state.thread) {
|
|
111
|
-
// No thread loaded yet — only turn/started can bootstrap one
|
|
112
|
-
if (method === "turn/started") {
|
|
113
|
-
return bootstrapThreadFromTurnStarted(state, params);
|
|
114
|
-
}
|
|
115
|
-
return state;
|
|
116
|
-
}
|
|
117
118
|
switch (method) {
|
|
118
|
-
case "turn/started":
|
|
119
|
-
return withThread(state, addTurn(state.thread, params));
|
|
120
|
-
case "turn/completed":
|
|
121
|
-
return withThread(state, completeTurn(state.thread, params));
|
|
122
|
-
case "turn/plan/updated":
|
|
123
|
-
return withThread(state, updatePlan(state.thread, params));
|
|
124
|
-
case "turn/diff/updated":
|
|
125
|
-
return withThread(state, updateDiff(state.thread, params));
|
|
126
119
|
case "item/started":
|
|
127
|
-
return
|
|
120
|
+
return { ...state, timeline: appendCodexItemToTimeline(state.timeline, params, state.activeRunId) };
|
|
128
121
|
case "item/completed":
|
|
129
|
-
return
|
|
122
|
+
return { ...state, timeline: completeCodexItemInTimeline(state.timeline, params) };
|
|
130
123
|
case "item/agentMessage/delta":
|
|
131
|
-
return withThread(state, appendItemText(state.thread, params));
|
|
132
|
-
case "item/commandExecution/outputDelta":
|
|
133
|
-
return withThread(state, appendItemOutput(state.thread, params));
|
|
134
124
|
case "item/plan/delta":
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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);
|
|
140
143
|
case "thread/tokenUsage/updated":
|
|
141
|
-
return
|
|
144
|
+
return applyTokenUsageUpdate(state, params);
|
|
142
145
|
default:
|
|
143
146
|
return state;
|
|
144
147
|
}
|
|
145
148
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
function bootstrapThreadFromTurnStarted(state, params) {
|
|
150
|
-
const turnObj = params.turn;
|
|
151
|
-
const threadId = typeof params.threadId === "string" ? params.threadId : "unknown";
|
|
152
|
-
const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
|
|
153
|
-
return {
|
|
154
|
-
...state,
|
|
155
|
-
thread: {
|
|
156
|
-
threadId,
|
|
157
|
-
status: "active",
|
|
158
|
-
turns: [{ id: turnId, status: "inProgress", items: [] }],
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
// ─── Turn Handlers ────────────────────────────────────────────────
|
|
163
|
-
function addTurn(thread, params) {
|
|
164
|
-
const turnObj = params.turn;
|
|
165
|
-
const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
|
|
166
|
-
const existing = thread.turns.find((t) => t.id === turnId);
|
|
167
|
-
if (existing) {
|
|
168
|
-
return thread;
|
|
169
|
-
}
|
|
170
|
-
return {
|
|
171
|
-
...thread,
|
|
172
|
-
status: "active",
|
|
173
|
-
turns: [...thread.turns, { id: turnId, status: "inProgress", items: [] }],
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
function completeTurn(thread, params) {
|
|
177
|
-
const turnObj = params.turn;
|
|
178
|
-
const turnId = typeof turnObj?.id === "string" ? turnObj.id : undefined;
|
|
179
|
-
const status = typeof turnObj?.status === "string" ? turnObj.status : "completed";
|
|
180
|
-
if (!turnId)
|
|
181
|
-
return thread;
|
|
182
|
-
return {
|
|
183
|
-
...thread,
|
|
184
|
-
turns: thread.turns.map((t) => t.id === turnId ? { ...t, status } : t),
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
function updatePlan(thread, params) {
|
|
149
|
+
// ─── Metadata Handlers (header, not timeline) ─────────────────────
|
|
150
|
+
function applyPlanUpdate(state, params) {
|
|
188
151
|
const plan = params.plan;
|
|
189
152
|
if (!Array.isArray(plan))
|
|
190
|
-
return
|
|
153
|
+
return state;
|
|
191
154
|
return {
|
|
192
|
-
...
|
|
155
|
+
...state,
|
|
193
156
|
plan: plan.map((entry) => {
|
|
194
157
|
const e = entry;
|
|
195
158
|
return {
|
|
@@ -199,9 +162,11 @@ function updatePlan(thread, params) {
|
|
|
199
162
|
}),
|
|
200
163
|
};
|
|
201
164
|
}
|
|
202
|
-
function
|
|
165
|
+
function applyDiffUpdate(state, params) {
|
|
203
166
|
const diff = typeof params.diff === "string" ? params.diff : undefined;
|
|
204
|
-
|
|
167
|
+
if (!diff)
|
|
168
|
+
return state;
|
|
169
|
+
return { ...state, diffSummary: parseDiffSummary(diff) };
|
|
205
170
|
}
|
|
206
171
|
function parseDiffSummary(diff) {
|
|
207
172
|
const files = new Set();
|
|
@@ -220,109 +185,13 @@ function parseDiffSummary(diff) {
|
|
|
220
185
|
}
|
|
221
186
|
return { filesChanged: files.size, linesAdded: added, linesRemoved: removed };
|
|
222
187
|
}
|
|
223
|
-
function
|
|
188
|
+
function applyTokenUsageUpdate(state, params) {
|
|
224
189
|
const usage = params.usage;
|
|
225
190
|
if (!usage)
|
|
226
|
-
return
|
|
191
|
+
return state;
|
|
227
192
|
const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens
|
|
228
193
|
: typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
229
194
|
const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens
|
|
230
195
|
: typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
231
|
-
return { ...
|
|
232
|
-
}
|
|
233
|
-
function updateThreadStatus(thread, params) {
|
|
234
|
-
const statusObj = params.status;
|
|
235
|
-
const statusType = typeof statusObj?.type === "string" ? statusObj.type : undefined;
|
|
236
|
-
if (!statusType)
|
|
237
|
-
return thread;
|
|
238
|
-
return { ...thread, status: statusType };
|
|
239
|
-
}
|
|
240
|
-
// ─── Item Handlers ────────────────────────────────────────────────
|
|
241
|
-
function getLatestTurn(thread) {
|
|
242
|
-
return thread.turns[thread.turns.length - 1];
|
|
243
|
-
}
|
|
244
|
-
function updateLatestTurn(thread, updater) {
|
|
245
|
-
const last = getLatestTurn(thread);
|
|
246
|
-
if (!last)
|
|
247
|
-
return thread;
|
|
248
|
-
return {
|
|
249
|
-
...thread,
|
|
250
|
-
turns: [...thread.turns.slice(0, -1), updater(last)],
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
function addItem(thread, params) {
|
|
254
|
-
const itemObj = params.item;
|
|
255
|
-
if (!itemObj)
|
|
256
|
-
return thread;
|
|
257
|
-
const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
|
|
258
|
-
const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
|
|
259
|
-
const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
|
|
260
|
-
const item = { id, type, status };
|
|
261
|
-
if (type === "agentMessage" && typeof itemObj.text === "string") {
|
|
262
|
-
item.text = itemObj.text;
|
|
263
|
-
}
|
|
264
|
-
if (type === "commandExecution") {
|
|
265
|
-
const cmd = itemObj.command;
|
|
266
|
-
item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
|
|
267
|
-
}
|
|
268
|
-
if (type === "mcpToolCall") {
|
|
269
|
-
const server = typeof itemObj.server === "string" ? itemObj.server : "";
|
|
270
|
-
const tool = typeof itemObj.tool === "string" ? itemObj.tool : "";
|
|
271
|
-
item.toolName = `${server}/${tool}`;
|
|
272
|
-
}
|
|
273
|
-
if (type === "dynamicToolCall") {
|
|
274
|
-
item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
|
|
275
|
-
}
|
|
276
|
-
return updateLatestTurn(thread, (turn) => ({
|
|
277
|
-
...turn,
|
|
278
|
-
items: [...turn.items, item],
|
|
279
|
-
}));
|
|
280
|
-
}
|
|
281
|
-
function completeItem(thread, params) {
|
|
282
|
-
const itemObj = params.item;
|
|
283
|
-
if (!itemObj)
|
|
284
|
-
return thread;
|
|
285
|
-
const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
|
|
286
|
-
if (!id)
|
|
287
|
-
return thread;
|
|
288
|
-
const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
|
|
289
|
-
const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
|
|
290
|
-
const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
|
|
291
|
-
const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
|
|
292
|
-
const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
|
|
293
|
-
return updateLatestTurn(thread, (turn) => ({
|
|
294
|
-
...turn,
|
|
295
|
-
items: turn.items.map((item) => {
|
|
296
|
-
if (item.id !== id)
|
|
297
|
-
return item;
|
|
298
|
-
return {
|
|
299
|
-
...item,
|
|
300
|
-
status,
|
|
301
|
-
...(exitCode !== undefined ? { exitCode } : {}),
|
|
302
|
-
...(durationMs !== undefined ? { durationMs } : {}),
|
|
303
|
-
...(text !== undefined ? { text } : {}),
|
|
304
|
-
...(changes !== undefined ? { changes } : {}),
|
|
305
|
-
};
|
|
306
|
-
}),
|
|
307
|
-
}));
|
|
308
|
-
}
|
|
309
|
-
function appendItemText(thread, params) {
|
|
310
|
-
const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
|
|
311
|
-
const delta = typeof params.delta === "string" ? params.delta : undefined;
|
|
312
|
-
if (!itemId || !delta)
|
|
313
|
-
return thread;
|
|
314
|
-
return updateLatestTurn(thread, (turn) => ({
|
|
315
|
-
...turn,
|
|
316
|
-
items: turn.items.map((item) => item.id === itemId ? { ...item, text: (item.text ?? "") + delta } : item),
|
|
317
|
-
}));
|
|
318
|
-
}
|
|
319
|
-
function appendItemOutput(thread, params) {
|
|
320
|
-
const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
|
|
321
|
-
const delta = typeof params.delta === "string" ? params.delta : undefined;
|
|
322
|
-
if (!itemId || !delta)
|
|
323
|
-
return thread;
|
|
324
|
-
return updateLatestTurn(thread, (turn) => ({
|
|
325
|
-
...turn,
|
|
326
|
-
items: turn.items.map((item) => item.id === itemId ? { ...item, output: (item.output ?? "") + delta } : item),
|
|
327
|
-
}));
|
|
196
|
+
return { ...state, tokenUsage: { inputTokens, outputTokens } };
|
|
328
197
|
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -133,6 +133,10 @@ export function runPatchRelayMigrations(connection) {
|
|
|
133
133
|
addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
|
|
134
134
|
// Add merge_prep_attempts for retry budget / escalation
|
|
135
135
|
addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
136
|
+
// Add review_fix_attempts counter
|
|
137
|
+
addColumnIfMissing(connection, "issues", "review_fix_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
138
|
+
// Collapse awaiting_review into pr_open (state normalization)
|
|
139
|
+
connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
|
|
136
140
|
}
|
|
137
141
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
138
142
|
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.reviewFixAttempts !== undefined) {
|
|
153
|
+
sets.push("review_fix_attempts = @reviewFixAttempts");
|
|
154
|
+
values.reviewFixAttempts = params.reviewFixAttempts;
|
|
155
|
+
}
|
|
152
156
|
if (params.mergePrepAttempts !== undefined) {
|
|
153
157
|
sets.push("merge_prep_attempts = @mergePrepAttempts");
|
|
154
158
|
values.mergePrepAttempts = params.mergePrepAttempts;
|
|
@@ -389,6 +393,7 @@ function mapIssueRow(row) {
|
|
|
389
393
|
...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
|
|
390
394
|
ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
|
|
391
395
|
queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
|
|
396
|
+
reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
|
|
392
397
|
mergePrepAttempts: Number(row.merge_prep_attempts ?? 0),
|
|
393
398
|
pendingMergePrep: Boolean(row.pending_merge_prep),
|
|
394
399
|
};
|
package/dist/factory-state.js
CHANGED
|
@@ -5,10 +5,12 @@ export const ACTIVE_RUN_STATES = new Set([
|
|
|
5
5
|
"changes_requested",
|
|
6
6
|
"repairing_queue",
|
|
7
7
|
]);
|
|
8
|
-
/** Which factory states are terminal (no further transitions possible). */
|
|
8
|
+
/** Which factory states are terminal (no further transitions possible except pr_merged → done). */
|
|
9
9
|
export const TERMINAL_STATES = new Set([
|
|
10
10
|
"done",
|
|
11
11
|
"escalated",
|
|
12
|
+
"failed",
|
|
13
|
+
"awaiting_input",
|
|
12
14
|
]);
|
|
13
15
|
// ─── Semantic guards ─────────────────────────────────────────────
|
|
14
16
|
//
|
|
@@ -158,8 +158,11 @@ export class GitHubWebhookHandler {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
-
//
|
|
162
|
-
|
|
161
|
+
// Re-read issue after all upserts so reactive run logic sees current state
|
|
162
|
+
const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
163
|
+
// Reset repair counters on new push — but only when no repair run is active,
|
|
164
|
+
// since Codex pushes during repair and resetting mid-run would bypass budgets.
|
|
165
|
+
if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
|
|
163
166
|
this.db.upsertIssue({
|
|
164
167
|
projectId: issue.projectId,
|
|
165
168
|
linearIssueId: issue.linearIssueId,
|
|
@@ -167,8 +170,6 @@ export class GitHubWebhookHandler {
|
|
|
167
170
|
queueRepairAttempts: 0,
|
|
168
171
|
});
|
|
169
172
|
}
|
|
170
|
-
// Re-read issue after all upserts so reactive run logic sees current state
|
|
171
|
-
const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
172
173
|
this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
173
174
|
this.feed?.publish({
|
|
174
175
|
level: event.triggerEvent.includes("failed") ? "warn" : "info",
|
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);
|
|
@@ -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
|
}
|
|
@@ -13,7 +13,6 @@ function describeNextState(state, prNumber) {
|
|
|
13
13
|
const prLabel = prNumber ? `PR #${prNumber}` : "the pull request";
|
|
14
14
|
switch (state) {
|
|
15
15
|
case "pr_open":
|
|
16
|
-
case "awaiting_review":
|
|
17
16
|
return `${prLabel} is ready for review.`;
|
|
18
17
|
case "awaiting_queue":
|
|
19
18
|
return `${prLabel} is approved and back in the merge flow.`;
|
|
@@ -149,7 +148,6 @@ export function buildMergePrepEscalationActivity(attempts) {
|
|
|
149
148
|
}
|
|
150
149
|
export function summarizeIssueStateForLinear(issue) {
|
|
151
150
|
switch (issue.factoryState) {
|
|
152
|
-
case "awaiting_review":
|
|
153
151
|
case "pr_open":
|
|
154
152
|
return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
|
|
155
153
|
case "awaiting_queue":
|
package/dist/merge-queue.js
CHANGED
|
@@ -103,7 +103,6 @@ export class MergeQueue {
|
|
|
103
103
|
pendingRunType: "queue_repair",
|
|
104
104
|
pendingRunContextJson: JSON.stringify({ failureReason: "merge_conflict" }),
|
|
105
105
|
pendingMergePrep: false,
|
|
106
|
-
mergePrepAttempts: 0,
|
|
107
106
|
});
|
|
108
107
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
109
108
|
this.feed?.publish({
|