patchrelay 0.19.0 → 0.20.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/build-info.json +3 -3
- package/dist/cli/watch/App.js +72 -4
- package/dist/cli/watch/HelpBar.js +1 -1
- package/dist/cli/watch/ItemLine.js +3 -0
- package/dist/cli/watch/format-utils.js +43 -0
- package/dist/cli/watch/sse-parser.js +43 -0
- package/dist/cli/watch/theme.js +55 -0
- package/dist/cli/watch/timeline-builder.js +1 -1
- package/dist/db.js +4 -0
- package/dist/github-webhook-handler.js +57 -1
- package/dist/http.js +16 -0
- package/dist/service.js +55 -1
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useReducer, useMemo, useCallback } from "react";
|
|
3
|
-
import { Box, useApp, useInput } from "ink";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useReducer, useMemo, useCallback, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
4
|
import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
|
|
5
5
|
import { useWatchStream } from "./use-watch-stream.js";
|
|
6
6
|
import { useDetailStream } from "./use-detail-stream.js";
|
|
@@ -8,6 +8,23 @@ import { useFeedStream } from "./use-feed-stream.js";
|
|
|
8
8
|
import { IssueListView } from "./IssueListView.js";
|
|
9
9
|
import { IssueDetailView } from "./IssueDetailView.js";
|
|
10
10
|
import { FeedView } from "./FeedView.js";
|
|
11
|
+
async function postPrompt(baseUrl, issueKey, text, bearerToken) {
|
|
12
|
+
const headers = { "content-type": "application/json" };
|
|
13
|
+
if (bearerToken)
|
|
14
|
+
headers.authorization = `Bearer ${bearerToken}`;
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/prompt`, baseUrl), {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers,
|
|
19
|
+
body: JSON.stringify({ text }),
|
|
20
|
+
signal: AbortSignal.timeout(5000),
|
|
21
|
+
});
|
|
22
|
+
return await response.json();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { reason: "Request failed" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
11
28
|
async function postRetry(baseUrl, issueKey, bearerToken) {
|
|
12
29
|
const headers = { "content-type": "application/json" };
|
|
13
30
|
if (bearerToken)
|
|
@@ -28,12 +45,60 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
28
45
|
useWatchStream({ baseUrl, bearerToken, dispatch });
|
|
29
46
|
useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
|
|
30
47
|
useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
|
|
48
|
+
const [promptMode, setPromptMode] = useState(false);
|
|
49
|
+
const [promptBuffer, setPromptBuffer] = useState("");
|
|
31
50
|
const handleRetry = useCallback(() => {
|
|
32
51
|
if (state.activeDetailKey) {
|
|
33
52
|
void postRetry(baseUrl, state.activeDetailKey, bearerToken);
|
|
34
53
|
}
|
|
35
54
|
}, [baseUrl, bearerToken, state.activeDetailKey]);
|
|
55
|
+
const [promptStatus, setPromptStatus] = useState(null);
|
|
56
|
+
const handlePromptSubmit = useCallback(() => {
|
|
57
|
+
const text = promptBuffer.trim();
|
|
58
|
+
if (!state.activeDetailKey || !text) {
|
|
59
|
+
setPromptMode(false);
|
|
60
|
+
setPromptBuffer("");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Add synthetic userMessage to timeline immediately
|
|
64
|
+
dispatch({
|
|
65
|
+
type: "codex-notification",
|
|
66
|
+
method: "item/started",
|
|
67
|
+
params: { item: { id: `prompt-${Date.now()}`, type: "userMessage", status: "completed", text } },
|
|
68
|
+
});
|
|
69
|
+
setPromptMode(false);
|
|
70
|
+
setPromptBuffer("");
|
|
71
|
+
setPromptStatus("sending...");
|
|
72
|
+
void postPrompt(baseUrl, state.activeDetailKey, text, bearerToken).then((result) => {
|
|
73
|
+
if (result.delivered) {
|
|
74
|
+
setPromptStatus("delivered");
|
|
75
|
+
}
|
|
76
|
+
else if (result.queued) {
|
|
77
|
+
setPromptStatus("queued for next run");
|
|
78
|
+
}
|
|
79
|
+
else if (result.reason) {
|
|
80
|
+
setPromptStatus(`failed: ${result.reason}`);
|
|
81
|
+
}
|
|
82
|
+
setTimeout(() => setPromptStatus(null), 3000);
|
|
83
|
+
});
|
|
84
|
+
}, [baseUrl, bearerToken, state.activeDetailKey, promptBuffer]);
|
|
36
85
|
useInput((input, key) => {
|
|
86
|
+
if (promptMode) {
|
|
87
|
+
if (key.escape) {
|
|
88
|
+
setPromptMode(false);
|
|
89
|
+
setPromptBuffer("");
|
|
90
|
+
}
|
|
91
|
+
else if (key.return) {
|
|
92
|
+
handlePromptSubmit();
|
|
93
|
+
}
|
|
94
|
+
else if (key.backspace || key.delete) {
|
|
95
|
+
setPromptBuffer((b) => b.slice(0, -1));
|
|
96
|
+
}
|
|
97
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
98
|
+
setPromptBuffer((b) => b + input);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
37
102
|
if (input === "q") {
|
|
38
103
|
exit();
|
|
39
104
|
return;
|
|
@@ -68,6 +133,9 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
68
133
|
else if (input === "r") {
|
|
69
134
|
handleRetry();
|
|
70
135
|
}
|
|
136
|
+
else if (input === "p") {
|
|
137
|
+
setPromptMode(true);
|
|
138
|
+
}
|
|
71
139
|
else if (input === "j" || key.downArrow) {
|
|
72
140
|
dispatch({ type: "detail-navigate", direction: "next", filtered });
|
|
73
141
|
}
|
|
@@ -81,5 +149,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
81
149
|
}
|
|
82
150
|
}
|
|
83
151
|
});
|
|
84
|
-
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
152
|
+
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
85
153
|
}
|
|
@@ -7,7 +7,7 @@ const HELP_TEXT = {
|
|
|
7
7
|
};
|
|
8
8
|
export function HelpBar({ view, follow }) {
|
|
9
9
|
const text = view === "detail"
|
|
10
|
-
? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} r: retry q: quit`
|
|
10
|
+
? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt r: retry q: quit`
|
|
11
11
|
: HELP_TEXT[view];
|
|
12
12
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
|
|
13
13
|
}
|
|
@@ -64,6 +64,9 @@ export function ItemLine({ item, isLast }) {
|
|
|
64
64
|
case "plan":
|
|
65
65
|
content = renderPlan(item);
|
|
66
66
|
break;
|
|
67
|
+
case "userMessage":
|
|
68
|
+
content = (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "you: " }), _jsx(Text, { children: truncate(item.text ?? "", 120) })] }));
|
|
69
|
+
break;
|
|
67
70
|
default:
|
|
68
71
|
content = renderDefault(item);
|
|
69
72
|
break;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Format ISO timestamp as HH:MM:SS (24h, en-GB). */
|
|
2
|
+
export function formatTime(iso) {
|
|
3
|
+
return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
|
|
4
|
+
}
|
|
5
|
+
/** Format ISO timestamp as compact relative time: "3s", "12m", "2h", "5d". */
|
|
6
|
+
export function relativeTime(iso) {
|
|
7
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
8
|
+
if (ms < 0)
|
|
9
|
+
return "now";
|
|
10
|
+
const seconds = Math.floor(ms / 1000);
|
|
11
|
+
if (seconds < 60)
|
|
12
|
+
return `${seconds}s`;
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
if (minutes < 60)
|
|
15
|
+
return `${minutes}m`;
|
|
16
|
+
const hours = Math.floor(minutes / 60);
|
|
17
|
+
if (hours < 24)
|
|
18
|
+
return `${hours}h`;
|
|
19
|
+
const days = Math.floor(hours / 24);
|
|
20
|
+
return `${days}d`;
|
|
21
|
+
}
|
|
22
|
+
/** Format millisecond duration as "2m 30s" or "45s". */
|
|
23
|
+
export function formatDuration(ms) {
|
|
24
|
+
const seconds = Math.floor(ms / 1000);
|
|
25
|
+
if (seconds < 60)
|
|
26
|
+
return `${seconds}s`;
|
|
27
|
+
const minutes = Math.floor(seconds / 60);
|
|
28
|
+
const remainingSeconds = seconds % 60;
|
|
29
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
30
|
+
}
|
|
31
|
+
/** Format token count with k/M suffix. */
|
|
32
|
+
export function formatTokens(n) {
|
|
33
|
+
if (n >= 1_000_000)
|
|
34
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
35
|
+
if (n >= 1_000)
|
|
36
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
37
|
+
return String(n);
|
|
38
|
+
}
|
|
39
|
+
/** Truncate text to max length with ellipsis. Collapses newlines. */
|
|
40
|
+
export function truncate(text, max) {
|
|
41
|
+
const line = text.replace(/\n/g, " ").trim();
|
|
42
|
+
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSE (Server-Sent Events) stream parser.
|
|
3
|
+
* Extracts event type + data from a ReadableStream, calls onEvent for each complete event.
|
|
4
|
+
*/
|
|
5
|
+
export async function readSSEStream(body, onEvent) {
|
|
6
|
+
const reader = body.getReader();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
let buffer = "";
|
|
9
|
+
let eventType = "";
|
|
10
|
+
let dataLines = [];
|
|
11
|
+
while (true) {
|
|
12
|
+
const { done, value } = await reader.read();
|
|
13
|
+
if (done)
|
|
14
|
+
break;
|
|
15
|
+
buffer += decoder.decode(value, { stream: true });
|
|
16
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
17
|
+
while (newlineIndex !== -1) {
|
|
18
|
+
const rawLine = buffer.slice(0, newlineIndex);
|
|
19
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
20
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
21
|
+
if (!line) {
|
|
22
|
+
if (dataLines.length > 0) {
|
|
23
|
+
onEvent(eventType, dataLines.join("\n"));
|
|
24
|
+
dataLines = [];
|
|
25
|
+
eventType = "";
|
|
26
|
+
}
|
|
27
|
+
newlineIndex = buffer.indexOf("\n");
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (line.startsWith(":")) {
|
|
31
|
+
newlineIndex = buffer.indexOf("\n");
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (line.startsWith("event:")) {
|
|
35
|
+
eventType = line.slice(6).trim();
|
|
36
|
+
}
|
|
37
|
+
else if (line.startsWith("data:")) {
|
|
38
|
+
dataLines.push(line.slice(5).trimStart());
|
|
39
|
+
}
|
|
40
|
+
newlineIndex = buffer.indexOf("\n");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ─── Factory State Colors ─────────────────────────────────────────
|
|
2
|
+
export const FACTORY_STATE_COLORS = {
|
|
3
|
+
delegated: "blue",
|
|
4
|
+
preparing: "blue",
|
|
5
|
+
implementing: "yellow",
|
|
6
|
+
awaiting_input: "yellow",
|
|
7
|
+
pr_open: "cyan",
|
|
8
|
+
changes_requested: "magenta",
|
|
9
|
+
repairing_ci: "magenta",
|
|
10
|
+
repairing_queue: "magenta",
|
|
11
|
+
awaiting_queue: "green",
|
|
12
|
+
done: "green",
|
|
13
|
+
failed: "red",
|
|
14
|
+
escalated: "red",
|
|
15
|
+
};
|
|
16
|
+
// ─── Item Status Symbols & Colors ─────────────────────────────────
|
|
17
|
+
export const ITEM_STATUS_SYMBOLS = {
|
|
18
|
+
completed: "\u2713",
|
|
19
|
+
failed: "\u2717",
|
|
20
|
+
declined: "\u2717",
|
|
21
|
+
inProgress: "\u25cf",
|
|
22
|
+
};
|
|
23
|
+
export const ITEM_STATUS_COLORS = {
|
|
24
|
+
completed: "green",
|
|
25
|
+
failed: "red",
|
|
26
|
+
declined: "red",
|
|
27
|
+
inProgress: "yellow",
|
|
28
|
+
};
|
|
29
|
+
// ─── CI Check Symbols & Colors ────────────────────────────────────
|
|
30
|
+
export const CHECK_SYMBOLS = {
|
|
31
|
+
passed: "\u2713",
|
|
32
|
+
failed: "\u2717",
|
|
33
|
+
pending: "\u25cf",
|
|
34
|
+
};
|
|
35
|
+
export const CHECK_COLORS = {
|
|
36
|
+
passed: "green",
|
|
37
|
+
failed: "red",
|
|
38
|
+
pending: "yellow",
|
|
39
|
+
};
|
|
40
|
+
// ─── Feed Event Colors ────────────────────────────────────────────
|
|
41
|
+
export const FEED_LEVEL_COLORS = {
|
|
42
|
+
info: "white",
|
|
43
|
+
warn: "yellow",
|
|
44
|
+
error: "red",
|
|
45
|
+
};
|
|
46
|
+
export const FEED_KIND_COLORS = {
|
|
47
|
+
stage: "cyan",
|
|
48
|
+
turn: "yellow",
|
|
49
|
+
github: "green",
|
|
50
|
+
webhook: "blue",
|
|
51
|
+
agent: "magenta",
|
|
52
|
+
service: "white",
|
|
53
|
+
workflow: "cyan",
|
|
54
|
+
linear: "blue",
|
|
55
|
+
};
|
|
@@ -303,7 +303,7 @@ export function appendCodexItemToTimeline(timeline, params, activeRunId) {
|
|
|
303
303
|
const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
|
|
304
304
|
const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
|
|
305
305
|
const item = { id, type, status };
|
|
306
|
-
if (type === "agentMessage" && typeof itemObj.text === "string")
|
|
306
|
+
if ((type === "agentMessage" || type === "userMessage") && typeof itemObj.text === "string")
|
|
307
307
|
item.text = itemObj.text;
|
|
308
308
|
if (type === "commandExecution") {
|
|
309
309
|
const cmd = itemObj.command;
|
package/dist/db.js
CHANGED
|
@@ -233,6 +233,10 @@ export class PatchRelayDatabase {
|
|
|
233
233
|
const row = this.connection.prepare("SELECT * FROM issues WHERE branch_name = ?").get(branchName);
|
|
234
234
|
return row ? mapIssueRow(row) : undefined;
|
|
235
235
|
}
|
|
236
|
+
getIssueByPrNumber(prNumber) {
|
|
237
|
+
const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
|
|
238
|
+
return row ? mapIssueRow(row) : undefined;
|
|
239
|
+
}
|
|
236
240
|
listIssuesReadyForExecution() {
|
|
237
241
|
const rows = this.connection
|
|
238
242
|
.prepare("SELECT project_id, linear_issue_id FROM issues WHERE (pending_run_type IS NOT NULL OR pending_merge_prep = 1) AND active_run_id IS NULL")
|
|
@@ -25,14 +25,16 @@ export class GitHubWebhookHandler {
|
|
|
25
25
|
enqueueIssue;
|
|
26
26
|
mergeQueue;
|
|
27
27
|
logger;
|
|
28
|
+
codex;
|
|
28
29
|
feed;
|
|
29
|
-
constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, feed) {
|
|
30
|
+
constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, codex, feed) {
|
|
30
31
|
this.config = config;
|
|
31
32
|
this.db = db;
|
|
32
33
|
this.linearProvider = linearProvider;
|
|
33
34
|
this.enqueueIssue = enqueueIssue;
|
|
34
35
|
this.mergeQueue = mergeQueue;
|
|
35
36
|
this.logger = logger;
|
|
37
|
+
this.codex = codex;
|
|
36
38
|
this.feed = feed;
|
|
37
39
|
}
|
|
38
40
|
async acceptGitHubWebhook(params) {
|
|
@@ -100,6 +102,10 @@ export class GitHubWebhookHandler {
|
|
|
100
102
|
}
|
|
101
103
|
return;
|
|
102
104
|
}
|
|
105
|
+
if (params.eventType === "issue_comment") {
|
|
106
|
+
await this.handlePrComment(payload);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
103
109
|
const event = normalizeGitHubWebhook({
|
|
104
110
|
eventType: params.eventType,
|
|
105
111
|
payload: payload,
|
|
@@ -282,6 +288,56 @@ export class GitHubWebhookHandler {
|
|
|
282
288
|
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
|
|
283
289
|
}
|
|
284
290
|
}
|
|
291
|
+
async handlePrComment(payload) {
|
|
292
|
+
if (payload.action !== "created")
|
|
293
|
+
return;
|
|
294
|
+
const issuePayload = payload.issue;
|
|
295
|
+
const comment = payload.comment;
|
|
296
|
+
if (!issuePayload || !comment)
|
|
297
|
+
return;
|
|
298
|
+
if (!issuePayload.pull_request)
|
|
299
|
+
return; // only PR comments
|
|
300
|
+
const body = typeof comment.body === "string" ? comment.body : "";
|
|
301
|
+
if (!body.trim())
|
|
302
|
+
return;
|
|
303
|
+
const user = comment.user;
|
|
304
|
+
const author = typeof user?.login === "string" ? user.login : "unknown";
|
|
305
|
+
if (typeof user?.type === "string" && user.type === "Bot")
|
|
306
|
+
return;
|
|
307
|
+
const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
|
|
308
|
+
if (!prNumber)
|
|
309
|
+
return;
|
|
310
|
+
const issue = this.db.getIssueByPrNumber(prNumber);
|
|
311
|
+
if (!issue)
|
|
312
|
+
return;
|
|
313
|
+
this.feed?.publish({
|
|
314
|
+
level: "info",
|
|
315
|
+
kind: "comment",
|
|
316
|
+
issueKey: issue.issueKey,
|
|
317
|
+
projectId: issue.projectId,
|
|
318
|
+
stage: issue.factoryState,
|
|
319
|
+
status: "pr_comment",
|
|
320
|
+
summary: `GitHub PR comment from ${author}`,
|
|
321
|
+
detail: body.slice(0, 200),
|
|
322
|
+
});
|
|
323
|
+
if (issue.activeRunId) {
|
|
324
|
+
const run = this.db.getRun(issue.activeRunId);
|
|
325
|
+
if (run?.threadId && run.turnId) {
|
|
326
|
+
try {
|
|
327
|
+
await this.codex.steerTurn({
|
|
328
|
+
threadId: run.threadId,
|
|
329
|
+
turnId: run.turnId,
|
|
330
|
+
input: `GitHub PR comment from ${author}:\n\n${body}`,
|
|
331
|
+
});
|
|
332
|
+
this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
336
|
+
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to forward GitHub PR comment");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
285
341
|
}
|
|
286
342
|
function resolveCheckClass(checkName, project) {
|
|
287
343
|
if (!checkName || !project)
|
package/dist/http.js
CHANGED
|
@@ -309,6 +309,22 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
309
309
|
}
|
|
310
310
|
return reply.send({ ok: true, ...result });
|
|
311
311
|
});
|
|
312
|
+
app.post("/api/issues/:issueKey/prompt", async (request, reply) => {
|
|
313
|
+
const issueKey = request.params.issueKey;
|
|
314
|
+
const body = request.body;
|
|
315
|
+
const text = body?.text;
|
|
316
|
+
if (!text || typeof text !== "string") {
|
|
317
|
+
return reply.code(400).send({ ok: false, reason: "missing text field" });
|
|
318
|
+
}
|
|
319
|
+
const result = await service.promptIssue(issueKey, text);
|
|
320
|
+
if (!result) {
|
|
321
|
+
return reply.code(404).send({ ok: false, reason: "issue_not_found" });
|
|
322
|
+
}
|
|
323
|
+
if ("error" in result) {
|
|
324
|
+
return reply.code(409).send({ ok: false, reason: result.error });
|
|
325
|
+
}
|
|
326
|
+
return reply.send({ ok: true, ...result });
|
|
327
|
+
});
|
|
312
328
|
app.get("/api/feed", async (request, reply) => {
|
|
313
329
|
const feedQuery = {
|
|
314
330
|
limit: getPositiveIntegerQueryParam(request, "limit") ?? 50,
|
package/dist/service.js
CHANGED
|
@@ -57,7 +57,7 @@ export class PatchRelayService {
|
|
|
57
57
|
})();
|
|
58
58
|
});
|
|
59
59
|
this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
|
|
60
|
-
this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, this.feed);
|
|
60
|
+
this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, codex, this.feed);
|
|
61
61
|
const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
|
|
62
62
|
processIssue: async (item) => {
|
|
63
63
|
const issue = db.getIssue(item.projectId, item.issueId);
|
|
@@ -218,6 +218,60 @@ export class PatchRelayService {
|
|
|
218
218
|
this.codex.on("notification", handler);
|
|
219
219
|
return () => { this.codex.off("notification", handler); };
|
|
220
220
|
}
|
|
221
|
+
async promptIssue(issueKey, text, source = "watch") {
|
|
222
|
+
const issue = this.db.getIssueByKey(issueKey);
|
|
223
|
+
if (!issue)
|
|
224
|
+
return undefined;
|
|
225
|
+
// Publish to operator feed so all clients see the prompt
|
|
226
|
+
this.feed.publish({
|
|
227
|
+
level: "info",
|
|
228
|
+
kind: "comment",
|
|
229
|
+
issueKey: issue.issueKey,
|
|
230
|
+
projectId: issue.projectId,
|
|
231
|
+
stage: issue.factoryState,
|
|
232
|
+
status: "operator_prompt",
|
|
233
|
+
summary: `Operator prompt (${source})`,
|
|
234
|
+
detail: text.slice(0, 200),
|
|
235
|
+
});
|
|
236
|
+
// If no active run, queue as pending context for the next run
|
|
237
|
+
if (!issue.activeRunId) {
|
|
238
|
+
const existing = issue.pendingRunContextJson
|
|
239
|
+
? JSON.parse(issue.pendingRunContextJson)
|
|
240
|
+
: {};
|
|
241
|
+
this.db.upsertIssue({
|
|
242
|
+
projectId: issue.projectId,
|
|
243
|
+
linearIssueId: issue.linearIssueId,
|
|
244
|
+
pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
|
|
245
|
+
});
|
|
246
|
+
return { delivered: false, queued: true };
|
|
247
|
+
}
|
|
248
|
+
const run = this.db.getRun(issue.activeRunId);
|
|
249
|
+
if (!run?.threadId || !run.turnId) {
|
|
250
|
+
return { error: "Active run has no thread or turn yet" };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await this.codex.steerTurn({
|
|
254
|
+
threadId: run.threadId,
|
|
255
|
+
turnId: run.turnId,
|
|
256
|
+
input: `Operator prompt (${source}):\n\n${text}`,
|
|
257
|
+
});
|
|
258
|
+
return { delivered: true };
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
// Turn may have completed between check and steer — queue for next run
|
|
262
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
263
|
+
this.logger.warn({ issueKey, error: msg }, "steerTurn failed, queuing prompt for next run");
|
|
264
|
+
const existing = issue.pendingRunContextJson
|
|
265
|
+
? JSON.parse(issue.pendingRunContextJson)
|
|
266
|
+
: {};
|
|
267
|
+
this.db.upsertIssue({
|
|
268
|
+
projectId: issue.projectId,
|
|
269
|
+
linearIssueId: issue.linearIssueId,
|
|
270
|
+
pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
|
|
271
|
+
});
|
|
272
|
+
return { delivered: false, queued: true };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
221
275
|
retryIssue(issueKey) {
|
|
222
276
|
const issue = this.db.getIssueByKey(issueKey);
|
|
223
277
|
if (!issue)
|