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
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# PatchRelay
|
|
2
2
|
|
|
3
|
-
PatchRelay is a self-hosted harness for Linear
|
|
3
|
+
PatchRelay is a self-hosted harness for running a controlled coding loop per Linear issue on your own machine.
|
|
4
4
|
|
|
5
|
-
It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the whole
|
|
5
|
+
It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the whole issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge queue failures.
|
|
6
6
|
|
|
7
7
|
PatchRelay is the system around the model:
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ PatchRelay is the system around the model:
|
|
|
10
10
|
- Linear OAuth and workspace installations
|
|
11
11
|
- issue-to-repo routing
|
|
12
12
|
- issue worktree and branch lifecycle
|
|
13
|
-
- run orchestration and thread continuity
|
|
13
|
+
- context packaging, run orchestration, and thread continuity
|
|
14
14
|
- reactive CI repair, review fix, and merge queue repair loops
|
|
15
15
|
- native Linear agent input forwarding into active runs
|
|
16
16
|
- read-only inspection and run reporting
|
|
@@ -21,7 +21,7 @@ If you want Codex to work inside your real repos with your real tools, secrets,
|
|
|
21
21
|
|
|
22
22
|
- Keep the agent in the real environment instead of rebuilding that environment in a hosted sandbox.
|
|
23
23
|
- Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
|
|
24
|
-
- Keep deterministic workflow logic outside the model: routing, run orchestration, worktree ownership, and reporting.
|
|
24
|
+
- Keep deterministic workflow logic outside the model: context packaging, routing, run orchestration, worktree ownership, verification, and reporting.
|
|
25
25
|
- Choose the Codex approval and sandbox settings that match your risk tolerance.
|
|
26
26
|
- Let Linear drive the loop through delegation and native agent sessions.
|
|
27
27
|
- Let GitHub drive reactive loops through PR reviews and CI check events.
|
|
@@ -33,6 +33,7 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
|
|
|
33
33
|
|
|
34
34
|
- verifies and deduplicates Linear and GitHub webhooks
|
|
35
35
|
- maps issue events to the correct local project
|
|
36
|
+
- packages the right issue, repo, review, and failure context for each loop
|
|
36
37
|
- creates and reuses one durable worktree and branch per issue lifecycle
|
|
37
38
|
- starts Codex threads for implementation runs
|
|
38
39
|
- triggers reactive runs for CI failures, review feedback, and merge queue failures
|
|
@@ -50,7 +51,7 @@ PatchRelay works best when read as five layers with clear ownership:
|
|
|
50
51
|
- integration layer: Linear webhooks, GitHub webhooks, OAuth, project routing, and state sync
|
|
51
52
|
- observability layer: CLI inspection, reports, event trails, and operator endpoints
|
|
52
53
|
|
|
53
|
-
That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps
|
|
54
|
+
That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps context, action, verification, and repair coordinated in a real repository with real operational state.
|
|
54
55
|
|
|
55
56
|
## Runtime Model
|
|
56
57
|
|
|
@@ -75,10 +76,10 @@ You will also need:
|
|
|
75
76
|
## How It Works
|
|
76
77
|
|
|
77
78
|
1. A human delegates PatchRelay on an issue to start automation.
|
|
78
|
-
2. PatchRelay verifies the webhook
|
|
79
|
+
2. PatchRelay verifies the webhook, routes the issue to the right local project, and packages the issue context for the first loop.
|
|
79
80
|
3. Delegated issues create or reuse the issue worktree and launch an implementation run through `codex app-server`.
|
|
80
81
|
4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
|
|
81
|
-
5. GitHub webhooks drive reactive loops: CI repair on check failures, review fix on changes requested, and merge queue repair on queue failures.
|
|
82
|
+
5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures, review fix on changes requested, and merge queue repair on queue failures.
|
|
82
83
|
6. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
|
|
83
84
|
|
|
84
85
|
## Factory State Machine
|
|
@@ -101,6 +102,8 @@ Run types:
|
|
|
101
102
|
- `ci_repair` — fix failing CI checks
|
|
102
103
|
- `queue_repair` — fix merge queue failures
|
|
103
104
|
|
|
105
|
+
PatchRelay treats these as distinct loop types with different context, entry conditions, and success criteria rather than as one generic "ask the agent again" workflow.
|
|
106
|
+
|
|
104
107
|
## Restart And Reconciliation
|
|
105
108
|
|
|
106
109
|
PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
|
|
@@ -122,7 +125,7 @@ PatchRelay uses repo-local workflow files as prompts for Codex runs:
|
|
|
122
125
|
- `IMPLEMENTATION_WORKFLOW.md` — used for implementation, CI repair, and queue repair runs
|
|
123
126
|
- `REVIEW_WORKFLOW.md` — used for review fix runs
|
|
124
127
|
|
|
125
|
-
These files define how the agent should work in that repository. Keep them short and
|
|
128
|
+
These files define how the agent should work in that repository. Keep them short, action-oriented, and human-authored.
|
|
126
129
|
|
|
127
130
|
## Access Control
|
|
128
131
|
|
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useReducer, useMemo } from "react";
|
|
2
|
+
import { useReducer, useMemo, useCallback } from "react";
|
|
3
3
|
import { Box, 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";
|
|
7
7
|
import { IssueListView } from "./IssueListView.js";
|
|
8
8
|
import { IssueDetailView } from "./IssueDetailView.js";
|
|
9
|
+
async function postRetry(baseUrl, issueKey, bearerToken) {
|
|
10
|
+
const headers = { "content-type": "application/json" };
|
|
11
|
+
if (bearerToken)
|
|
12
|
+
headers.authorization = `Bearer ${bearerToken}`;
|
|
13
|
+
await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/retry`, baseUrl), {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers,
|
|
16
|
+
signal: AbortSignal.timeout(5000),
|
|
17
|
+
}).catch(() => { });
|
|
18
|
+
}
|
|
9
19
|
export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
10
20
|
const { exit } = useApp();
|
|
11
21
|
const [state, dispatch] = useReducer(watchReducer, {
|
|
@@ -15,6 +25,11 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
15
25
|
const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
|
|
16
26
|
useWatchStream({ baseUrl, bearerToken, dispatch });
|
|
17
27
|
useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
|
|
28
|
+
const handleRetry = useCallback(() => {
|
|
29
|
+
if (state.activeDetailKey) {
|
|
30
|
+
void postRetry(baseUrl, state.activeDetailKey, bearerToken);
|
|
31
|
+
}
|
|
32
|
+
}, [baseUrl, bearerToken, state.activeDetailKey]);
|
|
18
33
|
useInput((input, key) => {
|
|
19
34
|
if (input === "q") {
|
|
20
35
|
exit();
|
|
@@ -44,7 +59,10 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
44
59
|
else if (input === "f") {
|
|
45
60
|
dispatch({ type: "toggle-follow" });
|
|
46
61
|
}
|
|
62
|
+
else if (input === "r") {
|
|
63
|
+
handleRetry();
|
|
64
|
+
}
|
|
47
65
|
}
|
|
48
66
|
});
|
|
49
|
-
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey),
|
|
67
|
+
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_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 })) }));
|
|
50
68
|
}
|
|
@@ -3,5 +3,5 @@ import { Box, Text } from "ink";
|
|
|
3
3
|
export function HelpBar({ view, follow }) {
|
|
4
4
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: view === "list"
|
|
5
5
|
? "j/k: navigate Enter: detail Tab: filter q: quit"
|
|
6
|
-
: `Esc: back f: follow ${follow ? "on" : "off"} q: quit` }) }));
|
|
6
|
+
: `Esc: back f: follow ${follow ? "on" : "off"} r: retry q: quit` }) }));
|
|
7
7
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer } from "react";
|
|
2
3
|
import { Box, Text } from "ink";
|
|
3
|
-
import {
|
|
4
|
+
import { Timeline } from "./Timeline.js";
|
|
4
5
|
import { HelpBar } from "./HelpBar.js";
|
|
5
|
-
function truncate(text, max) {
|
|
6
|
-
const line = text.replace(/\n/g, " ").trim();
|
|
7
|
-
return line.length > max ? `${line.slice(0, max - 3)}...` : line;
|
|
8
|
-
}
|
|
9
6
|
function formatTokens(n) {
|
|
10
7
|
if (n >= 1_000_000)
|
|
11
8
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
@@ -13,16 +10,35 @@ function formatTokens(n) {
|
|
|
13
10
|
return `${(n / 1_000).toFixed(1)}k`;
|
|
14
11
|
return String(n);
|
|
15
12
|
}
|
|
16
|
-
function
|
|
17
|
-
|
|
13
|
+
function ElapsedTime({ startedAt }) {
|
|
14
|
+
const [, tick] = useReducer((c) => c + 1, 0);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const id = setInterval(tick, 1000);
|
|
17
|
+
return () => clearInterval(id);
|
|
18
|
+
}, []);
|
|
19
|
+
const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
|
|
20
|
+
const minutes = Math.floor(elapsed / 60);
|
|
21
|
+
const seconds = elapsed % 60;
|
|
22
|
+
return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
|
|
23
|
+
}
|
|
24
|
+
function planStepSymbol(status) {
|
|
25
|
+
if (status === "completed")
|
|
26
|
+
return "\u2713";
|
|
27
|
+
if (status === "inProgress")
|
|
28
|
+
return "\u25b8";
|
|
29
|
+
return " ";
|
|
18
30
|
}
|
|
19
|
-
function
|
|
20
|
-
|
|
31
|
+
function planStepColor(status) {
|
|
32
|
+
if (status === "completed")
|
|
33
|
+
return "green";
|
|
34
|
+
if (status === "inProgress")
|
|
35
|
+
return "yellow";
|
|
36
|
+
return "white";
|
|
21
37
|
}
|
|
22
|
-
export function IssueDetailView({ issue,
|
|
38
|
+
export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, }) {
|
|
23
39
|
if (!issue) {
|
|
24
40
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
|
|
25
41
|
}
|
|
26
42
|
const key = issue.issueKey ?? issue.projectId;
|
|
27
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }),
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsxs(Box, { gap: 2, children: [tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] })), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", diffSummary.filesChanged, " file", diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(Timeline, { entries: timeline, follow: follow }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
|
|
28
44
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { TimelineRow } from "./TimelineRow.js";
|
|
4
|
+
const FOLLOW_TAIL_SIZE = 20;
|
|
5
|
+
export function Timeline({ entries, follow }) {
|
|
6
|
+
const visible = follow && entries.length > FOLLOW_TAIL_SIZE
|
|
7
|
+
? entries.slice(-FOLLOW_TAIL_SIZE)
|
|
8
|
+
: entries;
|
|
9
|
+
const skipped = entries.length - visible.length;
|
|
10
|
+
if (entries.length === 0) {
|
|
11
|
+
return _jsx(Text, { dimColor: true, children: "No timeline events yet." });
|
|
12
|
+
}
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", children: [skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier events"] }), visible.map((entry) => (_jsx(TimelineRow, { entry: entry }, entry.id)))] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { ItemLine } from "./ItemLine.js";
|
|
4
|
+
function formatTime(iso) {
|
|
5
|
+
return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
|
|
6
|
+
}
|
|
7
|
+
function formatDuration(startedAt, endedAt) {
|
|
8
|
+
const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
|
|
9
|
+
const seconds = Math.floor(ms / 1000);
|
|
10
|
+
if (seconds < 60)
|
|
11
|
+
return `${seconds}s`;
|
|
12
|
+
const minutes = Math.floor(seconds / 60);
|
|
13
|
+
const remainingSeconds = seconds % 60;
|
|
14
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
15
|
+
}
|
|
16
|
+
const CHECK_SYMBOLS = {
|
|
17
|
+
passed: "\u2713",
|
|
18
|
+
failed: "\u2717",
|
|
19
|
+
pending: "\u25cf",
|
|
20
|
+
};
|
|
21
|
+
const CHECK_COLORS = {
|
|
22
|
+
passed: "green",
|
|
23
|
+
failed: "red",
|
|
24
|
+
pending: "yellow",
|
|
25
|
+
};
|
|
26
|
+
function FeedRow({ entry }) {
|
|
27
|
+
const feed = entry.feed;
|
|
28
|
+
const statusLabel = feed.status ?? feed.feedKind;
|
|
29
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: "cyan", children: statusLabel.padEnd(16) }), _jsx(Text, { children: feed.summary })] }));
|
|
30
|
+
}
|
|
31
|
+
function RunStartRow({ entry }) {
|
|
32
|
+
const run = entry.run;
|
|
33
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, children: "run started" })] }));
|
|
34
|
+
}
|
|
35
|
+
function RunEndRow({ entry }) {
|
|
36
|
+
const run = entry.run;
|
|
37
|
+
const color = run.status === "completed" ? "green" : "red";
|
|
38
|
+
const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : "";
|
|
39
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
|
|
40
|
+
}
|
|
41
|
+
function ItemRow({ entry }) {
|
|
42
|
+
const item = entry.item;
|
|
43
|
+
return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: item, isLast: false }) }));
|
|
44
|
+
}
|
|
45
|
+
function CIChecksRow({ entry }) {
|
|
46
|
+
const ci = entry.ciChecks;
|
|
47
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: CHECK_COLORS[ci.overall] ?? "white", children: "ci_checks".padEnd(16) }), ci.checks.map((check, i) => (_jsxs(Text, { children: [_jsx(Text, { color: CHECK_COLORS[check.status] ?? "white", children: CHECK_SYMBOLS[check.status] ?? " " }), _jsxs(Text, { dimColor: true, children: [" ", check.name, " "] })] }, `check-${i}`)))] }));
|
|
48
|
+
}
|
|
49
|
+
export function TimelineRow({ entry }) {
|
|
50
|
+
switch (entry.kind) {
|
|
51
|
+
case "feed":
|
|
52
|
+
return _jsx(FeedRow, { entry: entry });
|
|
53
|
+
case "run-start":
|
|
54
|
+
return _jsx(RunStartRow, { entry: entry });
|
|
55
|
+
case "run-end":
|
|
56
|
+
return _jsx(RunEndRow, { entry: entry });
|
|
57
|
+
case "item":
|
|
58
|
+
return _jsx(ItemRow, { entry: entry });
|
|
59
|
+
case "ci-checks":
|
|
60
|
+
return _jsx(CIChecksRow, { entry: entry });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// ─── Build Timeline from Rehydration Data ─────────────────────────
|
|
2
|
+
export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId) {
|
|
3
|
+
const entries = [];
|
|
4
|
+
// 1. Add run boundaries and items from reports
|
|
5
|
+
for (const run of runs) {
|
|
6
|
+
entries.push({
|
|
7
|
+
id: `run-start-${run.id}`,
|
|
8
|
+
at: run.startedAt,
|
|
9
|
+
kind: "run-start",
|
|
10
|
+
runId: run.id,
|
|
11
|
+
run: { runType: run.runType, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt },
|
|
12
|
+
});
|
|
13
|
+
if (run.endedAt) {
|
|
14
|
+
entries.push({
|
|
15
|
+
id: `run-end-${run.id}`,
|
|
16
|
+
at: run.endedAt,
|
|
17
|
+
kind: "run-end",
|
|
18
|
+
runId: run.id,
|
|
19
|
+
run: { runType: run.runType, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// Items from completed run reports
|
|
23
|
+
if (run.report && run.id !== activeRunId) {
|
|
24
|
+
entries.push(...itemsFromReport(run.id, run.report, run.startedAt, run.endedAt));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// 2. Items from live thread (active run)
|
|
28
|
+
if (liveThread && activeRunId) {
|
|
29
|
+
entries.push(...itemsFromThread(activeRunId, liveThread));
|
|
30
|
+
}
|
|
31
|
+
// 3. Feed events → feed entries + CI check aggregation
|
|
32
|
+
entries.push(...feedEventsToEntries(feedEvents));
|
|
33
|
+
// 4. Sort by timestamp, then by entry order for stability
|
|
34
|
+
entries.sort((a, b) => {
|
|
35
|
+
const cmp = a.at.localeCompare(b.at);
|
|
36
|
+
if (cmp !== 0)
|
|
37
|
+
return cmp;
|
|
38
|
+
// Within same timestamp: run-start before items, items before run-end
|
|
39
|
+
return kindOrder(a.kind) - kindOrder(b.kind);
|
|
40
|
+
});
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
function kindOrder(kind) {
|
|
44
|
+
switch (kind) {
|
|
45
|
+
case "run-start": return 0;
|
|
46
|
+
case "feed": return 1;
|
|
47
|
+
case "ci-checks": return 2;
|
|
48
|
+
case "item": return 3;
|
|
49
|
+
case "run-end": return 4;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ─── Items from Report ────────────────────────────────────────────
|
|
53
|
+
function itemsFromReport(runId, report, startedAt, endedAt) {
|
|
54
|
+
const entries = [];
|
|
55
|
+
const start = new Date(startedAt).getTime();
|
|
56
|
+
const end = endedAt ? new Date(endedAt).getTime() : start + 60000;
|
|
57
|
+
let idx = 0;
|
|
58
|
+
const total = report.commands.length + report.assistantMessages.length + report.toolCalls.length;
|
|
59
|
+
for (const msg of report.assistantMessages) {
|
|
60
|
+
entries.push({
|
|
61
|
+
id: `report-${runId}-msg-${idx}`,
|
|
62
|
+
at: syntheticTimestamp(start, end, idx, total),
|
|
63
|
+
kind: "item",
|
|
64
|
+
runId,
|
|
65
|
+
item: { id: `report-${runId}-msg-${idx}`, type: "agentMessage", status: "completed", text: msg },
|
|
66
|
+
});
|
|
67
|
+
idx++;
|
|
68
|
+
}
|
|
69
|
+
for (const cmd of report.commands) {
|
|
70
|
+
entries.push({
|
|
71
|
+
id: `report-${runId}-cmd-${idx}`,
|
|
72
|
+
at: syntheticTimestamp(start, end, idx, total),
|
|
73
|
+
kind: "item",
|
|
74
|
+
runId,
|
|
75
|
+
item: {
|
|
76
|
+
id: `report-${runId}-cmd-${idx}`,
|
|
77
|
+
type: "commandExecution",
|
|
78
|
+
status: "completed",
|
|
79
|
+
command: cmd.command,
|
|
80
|
+
...(typeof cmd.exitCode === "number" ? { exitCode: cmd.exitCode } : {}),
|
|
81
|
+
...(typeof cmd.durationMs === "number" ? { durationMs: cmd.durationMs } : {}),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
idx++;
|
|
85
|
+
}
|
|
86
|
+
for (const tool of report.toolCalls) {
|
|
87
|
+
entries.push({
|
|
88
|
+
id: `report-${runId}-tool-${idx}`,
|
|
89
|
+
at: syntheticTimestamp(start, end, idx, total),
|
|
90
|
+
kind: "item",
|
|
91
|
+
runId,
|
|
92
|
+
item: {
|
|
93
|
+
id: `report-${runId}-tool-${idx}`,
|
|
94
|
+
type: tool.type === "mcp" ? "mcpToolCall" : "dynamicToolCall",
|
|
95
|
+
status: "completed",
|
|
96
|
+
toolName: tool.name,
|
|
97
|
+
...(typeof tool.durationMs === "number" ? { durationMs: tool.durationMs } : {}),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
idx++;
|
|
101
|
+
}
|
|
102
|
+
if (report.fileChanges.length > 0) {
|
|
103
|
+
entries.push({
|
|
104
|
+
id: `report-${runId}-files`,
|
|
105
|
+
at: syntheticTimestamp(start, end, idx, total),
|
|
106
|
+
kind: "item",
|
|
107
|
+
runId,
|
|
108
|
+
item: {
|
|
109
|
+
id: `report-${runId}-files`,
|
|
110
|
+
type: "fileChange",
|
|
111
|
+
status: "completed",
|
|
112
|
+
changes: report.fileChanges,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
function syntheticTimestamp(startMs, endMs, index, total) {
|
|
119
|
+
if (total <= 1)
|
|
120
|
+
return new Date(startMs).toISOString();
|
|
121
|
+
const fraction = index / (total - 1);
|
|
122
|
+
return new Date(startMs + fraction * (endMs - startMs)).toISOString();
|
|
123
|
+
}
|
|
124
|
+
// ─── Items from Live Thread ───────────────────────────────────────
|
|
125
|
+
function itemsFromThread(runId, thread) {
|
|
126
|
+
const entries = [];
|
|
127
|
+
for (const turn of thread.turns) {
|
|
128
|
+
for (const item of turn.items) {
|
|
129
|
+
entries.push({
|
|
130
|
+
id: `live-${item.id}`,
|
|
131
|
+
at: new Date().toISOString(), // live items don't have timestamps; they'll sort to the end
|
|
132
|
+
kind: "item",
|
|
133
|
+
runId,
|
|
134
|
+
item: materializeItem(item),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return entries;
|
|
139
|
+
}
|
|
140
|
+
function materializeItem(item) {
|
|
141
|
+
const r = item;
|
|
142
|
+
const id = String(r.id ?? "unknown");
|
|
143
|
+
const type = String(r.type ?? "unknown");
|
|
144
|
+
const base = { id, type, status: "completed" };
|
|
145
|
+
switch (type) {
|
|
146
|
+
case "agentMessage":
|
|
147
|
+
return { ...base, text: String(r.text ?? "") };
|
|
148
|
+
case "commandExecution":
|
|
149
|
+
return {
|
|
150
|
+
...base,
|
|
151
|
+
command: String(r.command ?? ""),
|
|
152
|
+
status: String(r.status ?? "completed"),
|
|
153
|
+
...(typeof r.exitCode === "number" ? { exitCode: r.exitCode } : {}),
|
|
154
|
+
...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
|
|
155
|
+
...(typeof r.aggregatedOutput === "string" ? { output: r.aggregatedOutput } : {}),
|
|
156
|
+
};
|
|
157
|
+
case "fileChange":
|
|
158
|
+
return { ...base, status: String(r.status ?? "completed"), changes: Array.isArray(r.changes) ? r.changes : [] };
|
|
159
|
+
case "mcpToolCall":
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
status: String(r.status ?? "completed"),
|
|
163
|
+
toolName: `${String(r.server ?? "")}/${String(r.tool ?? "")}`,
|
|
164
|
+
...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
|
|
165
|
+
};
|
|
166
|
+
case "dynamicToolCall":
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
status: String(r.status ?? "completed"),
|
|
170
|
+
toolName: String(r.tool ?? ""),
|
|
171
|
+
...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
|
|
172
|
+
};
|
|
173
|
+
case "plan":
|
|
174
|
+
return { ...base, text: String(r.text ?? "") };
|
|
175
|
+
case "reasoning":
|
|
176
|
+
return { ...base, text: Array.isArray(r.summary) ? r.summary.join("\n") : "" };
|
|
177
|
+
default:
|
|
178
|
+
return base;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ─── Feed Events to Timeline Entries ──────────────────────────────
|
|
182
|
+
function feedEventsToEntries(feedEvents) {
|
|
183
|
+
const entries = [];
|
|
184
|
+
const ciAggregator = new CICheckAggregator();
|
|
185
|
+
for (const event of feedEvents) {
|
|
186
|
+
// GitHub check events get aggregated
|
|
187
|
+
if (event.kind === "github" && (event.status === "check_passed" || event.status === "check_failed") && event.detail) {
|
|
188
|
+
const ciEntry = ciAggregator.add(event);
|
|
189
|
+
if (ciEntry) {
|
|
190
|
+
// Replace the last ci-checks entry if it was updated
|
|
191
|
+
const lastIdx = entries.findLastIndex((e) => e.kind === "ci-checks" && e.id === ciEntry.id);
|
|
192
|
+
if (lastIdx >= 0) {
|
|
193
|
+
entries[lastIdx] = ciEntry;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
entries.push(ciEntry);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
entries.push({
|
|
202
|
+
id: `feed-${event.id}`,
|
|
203
|
+
at: event.at,
|
|
204
|
+
kind: "feed",
|
|
205
|
+
feed: {
|
|
206
|
+
feedKind: event.kind,
|
|
207
|
+
...(event.status ? { status: event.status } : {}),
|
|
208
|
+
summary: event.summary,
|
|
209
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return entries;
|
|
214
|
+
}
|
|
215
|
+
// ─── CI Check Aggregation ─────────────────────────────────────────
|
|
216
|
+
const CI_CHECK_WINDOW_MS = 60_000;
|
|
217
|
+
class CICheckAggregator {
|
|
218
|
+
currentGroup = null;
|
|
219
|
+
groupCounter = 0;
|
|
220
|
+
add(event) {
|
|
221
|
+
const name = event.detail ?? "unknown";
|
|
222
|
+
const status = event.status === "check_passed" ? "passed" : "failed";
|
|
223
|
+
const eventMs = new Date(event.at).getTime();
|
|
224
|
+
if (this.currentGroup && eventMs - this.currentGroup.windowStart < CI_CHECK_WINDOW_MS) {
|
|
225
|
+
this.currentGroup.checks.set(name, status);
|
|
226
|
+
return this.toEntry();
|
|
227
|
+
}
|
|
228
|
+
this.groupCounter++;
|
|
229
|
+
this.currentGroup = {
|
|
230
|
+
id: `ci-checks-${this.groupCounter}`,
|
|
231
|
+
at: event.at,
|
|
232
|
+
checks: new Map([[name, status]]),
|
|
233
|
+
windowStart: eventMs,
|
|
234
|
+
};
|
|
235
|
+
return this.toEntry();
|
|
236
|
+
}
|
|
237
|
+
toEntry() {
|
|
238
|
+
const group = this.currentGroup;
|
|
239
|
+
const checks = [...group.checks.entries()].map(([name, status]) => ({ name, status }));
|
|
240
|
+
const overall = checks.every((c) => c.status === "passed") ? "passed"
|
|
241
|
+
: checks.some((c) => c.status === "failed") ? "failed"
|
|
242
|
+
: "pending";
|
|
243
|
+
return {
|
|
244
|
+
id: group.id,
|
|
245
|
+
at: group.at,
|
|
246
|
+
kind: "ci-checks",
|
|
247
|
+
ciChecks: { checks, overall },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// ─── Live Append Helpers ──────────────────────────────────────────
|
|
252
|
+
export function appendFeedToTimeline(timeline, event) {
|
|
253
|
+
// GitHub check events: aggregate into existing ci-checks entry
|
|
254
|
+
if (event.kind === "github" && (event.status === "check_passed" || event.status === "check_failed") && event.detail) {
|
|
255
|
+
return aggregateCICheckIntoTimeline(timeline, event);
|
|
256
|
+
}
|
|
257
|
+
return [...timeline, {
|
|
258
|
+
id: `feed-${event.id}`,
|
|
259
|
+
at: event.at,
|
|
260
|
+
kind: "feed",
|
|
261
|
+
feed: {
|
|
262
|
+
feedKind: event.kind,
|
|
263
|
+
...(event.status ? { status: event.status } : {}),
|
|
264
|
+
summary: event.summary,
|
|
265
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
266
|
+
},
|
|
267
|
+
}];
|
|
268
|
+
}
|
|
269
|
+
function aggregateCICheckIntoTimeline(timeline, event) {
|
|
270
|
+
const name = event.detail ?? "unknown";
|
|
271
|
+
const status = event.status === "check_passed" ? "passed" : "failed";
|
|
272
|
+
const eventMs = new Date(event.at).getTime();
|
|
273
|
+
// Find the most recent ci-checks entry within the window
|
|
274
|
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
275
|
+
const entry = timeline[i];
|
|
276
|
+
if (entry.kind === "ci-checks" && entry.ciChecks) {
|
|
277
|
+
const entryMs = new Date(entry.at).getTime();
|
|
278
|
+
if (eventMs - entryMs < CI_CHECK_WINDOW_MS) {
|
|
279
|
+
const updatedChecks = [...entry.ciChecks.checks.filter((c) => c.name !== name), { name, status }];
|
|
280
|
+
const overall = updatedChecks.every((c) => c.status === "passed") ? "passed"
|
|
281
|
+
: updatedChecks.some((c) => c.status === "failed") ? "failed"
|
|
282
|
+
: "pending";
|
|
283
|
+
const updated = [...timeline];
|
|
284
|
+
updated[i] = { ...entry, ciChecks: { checks: updatedChecks, overall } };
|
|
285
|
+
return updated;
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// No recent ci-checks entry; create new one
|
|
291
|
+
return [...timeline, {
|
|
292
|
+
id: `ci-checks-live-${event.id}`,
|
|
293
|
+
at: event.at,
|
|
294
|
+
kind: "ci-checks",
|
|
295
|
+
ciChecks: { checks: [{ name, status }], overall: status },
|
|
296
|
+
}];
|
|
297
|
+
}
|
|
298
|
+
export function appendCodexItemToTimeline(timeline, params, activeRunId) {
|
|
299
|
+
const itemObj = params.item;
|
|
300
|
+
if (!itemObj)
|
|
301
|
+
return timeline;
|
|
302
|
+
const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
|
|
303
|
+
const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
|
|
304
|
+
const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
|
|
305
|
+
const item = { id, type, status };
|
|
306
|
+
if (type === "agentMessage" && typeof itemObj.text === "string")
|
|
307
|
+
item.text = itemObj.text;
|
|
308
|
+
if (type === "commandExecution") {
|
|
309
|
+
const cmd = itemObj.command;
|
|
310
|
+
item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
|
|
311
|
+
}
|
|
312
|
+
if (type === "mcpToolCall") {
|
|
313
|
+
item.toolName = `${String(itemObj.server ?? "")}/${String(itemObj.tool ?? "")}`;
|
|
314
|
+
}
|
|
315
|
+
if (type === "dynamicToolCall") {
|
|
316
|
+
item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
|
|
317
|
+
}
|
|
318
|
+
return [...timeline, {
|
|
319
|
+
id: `live-${id}`,
|
|
320
|
+
at: new Date().toISOString(),
|
|
321
|
+
kind: "item",
|
|
322
|
+
runId: activeRunId ?? undefined,
|
|
323
|
+
item,
|
|
324
|
+
}];
|
|
325
|
+
}
|
|
326
|
+
export function completeCodexItemInTimeline(timeline, params) {
|
|
327
|
+
const itemObj = params.item;
|
|
328
|
+
if (!itemObj)
|
|
329
|
+
return timeline;
|
|
330
|
+
const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
|
|
331
|
+
if (!id)
|
|
332
|
+
return timeline;
|
|
333
|
+
const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
|
|
334
|
+
const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
|
|
335
|
+
const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
|
|
336
|
+
const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
|
|
337
|
+
const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
|
|
338
|
+
return timeline.map((entry) => {
|
|
339
|
+
if (entry.kind !== "item" || entry.item?.id !== id)
|
|
340
|
+
return entry;
|
|
341
|
+
return {
|
|
342
|
+
...entry,
|
|
343
|
+
item: {
|
|
344
|
+
...entry.item,
|
|
345
|
+
status,
|
|
346
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
347
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
348
|
+
...(text !== undefined ? { text } : {}),
|
|
349
|
+
...(changes !== undefined ? { changes } : {}),
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
export function appendDeltaToTimelineItem(timeline, itemId, field, delta) {
|
|
355
|
+
return timeline.map((entry) => {
|
|
356
|
+
if (entry.kind !== "item" || entry.item?.id !== itemId)
|
|
357
|
+
return entry;
|
|
358
|
+
return {
|
|
359
|
+
...entry,
|
|
360
|
+
item: { ...entry.item, [field]: (entry.item[field] ?? "") + delta },
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
}
|