thepopebot 1.2.46 → 1.2.48
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/lib/chat/actions.js +1 -33
- package/lib/chat/components/swarm-page.js +41 -146
- package/lib/chat/components/swarm-page.jsx +61 -188
- package/lib/tools/github.js +17 -118
- package/package.json +1 -1
- package/templates/.github/workflows/upgrade-event-handler.yml +11 -6
- package/templates/docker-compose.yml +1 -0
package/lib/chat/actions.js
CHANGED
|
@@ -245,7 +245,7 @@ export async function getSwarmStatus(page = 1) {
|
|
|
245
245
|
return await fetchStatus(page);
|
|
246
246
|
} catch (err) {
|
|
247
247
|
console.error('Failed to get swarm status:', err);
|
|
248
|
-
return { error: 'Failed to get swarm status',
|
|
248
|
+
return { error: 'Failed to get swarm status', runs: [], hasMore: false, counts: { running: 0, queued: 0 } };
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
@@ -264,35 +264,3 @@ export async function getSwarmConfig() {
|
|
|
264
264
|
return { crons, triggers };
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
/**
|
|
268
|
-
* Cancel a running swarm job.
|
|
269
|
-
* @param {number} runId - Workflow run ID
|
|
270
|
-
* @returns {Promise<object>}
|
|
271
|
-
*/
|
|
272
|
-
export async function cancelSwarmJob(runId) {
|
|
273
|
-
await requireAuth();
|
|
274
|
-
try {
|
|
275
|
-
const { cancelWorkflowRun } = await import('../tools/github.js');
|
|
276
|
-
return await cancelWorkflowRun(runId);
|
|
277
|
-
} catch (err) {
|
|
278
|
-
console.error('Failed to cancel workflow run:', err);
|
|
279
|
-
return { error: err.message || 'Failed to cancel workflow run' };
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Rerun a swarm job (all or failed only).
|
|
285
|
-
* @param {number} runId - Workflow run ID
|
|
286
|
-
* @param {boolean} [failedOnly=false]
|
|
287
|
-
* @returns {Promise<object>}
|
|
288
|
-
*/
|
|
289
|
-
export async function rerunSwarmJob(runId, failedOnly = false) {
|
|
290
|
-
await requireAuth();
|
|
291
|
-
try {
|
|
292
|
-
const { rerunWorkflowRun } = await import('../tools/github.js');
|
|
293
|
-
return await rerunWorkflowRun(runId, !!failedOnly);
|
|
294
|
-
} catch (err) {
|
|
295
|
-
console.error('Failed to rerun workflow:', err);
|
|
296
|
-
return { error: err.message || 'Failed to rerun workflow' };
|
|
297
|
-
}
|
|
298
|
-
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useEffect, useCallback } from "react";
|
|
4
4
|
import { PageLayout } from "./page-layout.js";
|
|
5
|
-
import {
|
|
6
|
-
import { getSwarmStatus
|
|
5
|
+
import { SpinnerIcon, RefreshIcon } from "./icons.js";
|
|
6
|
+
import { getSwarmStatus } from "../actions.js";
|
|
7
7
|
function formatDuration(seconds) {
|
|
8
8
|
if (seconds < 60) return `${seconds}s`;
|
|
9
9
|
const minutes = Math.floor(seconds / 60);
|
|
@@ -29,7 +29,7 @@ function LoadingSkeleton() {
|
|
|
29
29
|
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4", children: [
|
|
30
30
|
/* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-3", children: [...Array(2)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-20 animate-pulse rounded-md bg-border/50" }, i)) }),
|
|
31
31
|
/* @__PURE__ */ jsx("div", { className: "h-8 w-32 animate-pulse rounded-md bg-border/50" }),
|
|
32
|
-
[...Array(3)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-
|
|
32
|
+
[...Array(3)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-14 animate-pulse rounded-md bg-border/50" }, i))
|
|
33
33
|
] });
|
|
34
34
|
}
|
|
35
35
|
function SwarmSummaryCards({ counts }) {
|
|
@@ -49,119 +49,48 @@ function SwarmSummaryCards({ counts }) {
|
|
|
49
49
|
card.label
|
|
50
50
|
)) });
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
62
|
-
/* @__PURE__ */ jsx(
|
|
63
|
-
"span",
|
|
64
|
-
{
|
|
65
|
-
className: `inline-block h-2.5 w-2.5 rounded-full ${isRunning ? "bg-green-500 animate-pulse" : "bg-yellow-500"}`
|
|
66
|
-
}
|
|
67
|
-
),
|
|
68
|
-
/* @__PURE__ */ jsx("span", { className: "font-mono text-sm font-medium", children: job.job_id.slice(0, 8) }),
|
|
69
|
-
job.workflow_name && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: job.workflow_name })
|
|
70
|
-
] }),
|
|
71
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
72
|
-
/* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: formatDuration(job.duration_seconds) }),
|
|
73
|
-
job.html_url && /* @__PURE__ */ jsx(
|
|
74
|
-
"a",
|
|
75
|
-
{
|
|
76
|
-
href: job.html_url,
|
|
77
|
-
target: "_blank",
|
|
78
|
-
rel: "noopener noreferrer",
|
|
79
|
-
className: "text-xs text-blue-500 hover:underline",
|
|
80
|
-
children: "View"
|
|
81
|
-
}
|
|
82
|
-
),
|
|
83
|
-
/* @__PURE__ */ jsx(
|
|
84
|
-
"button",
|
|
85
|
-
{
|
|
86
|
-
onClick: () => onCancel(job.run_id),
|
|
87
|
-
className: "inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive",
|
|
88
|
-
title: "Cancel job",
|
|
89
|
-
children: /* @__PURE__ */ jsx(StopIcon, { size: 14 })
|
|
90
|
-
}
|
|
91
|
-
)
|
|
92
|
-
] })
|
|
93
|
-
] }),
|
|
94
|
-
job.current_step && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mb-2 truncate", children: job.current_step }),
|
|
95
|
-
job.steps_total > 0 && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
96
|
-
/* @__PURE__ */ jsx("div", { className: "flex-1 h-1.5 rounded-full bg-border overflow-hidden", children: /* @__PURE__ */ jsx(
|
|
97
|
-
"div",
|
|
98
|
-
{
|
|
99
|
-
className: "h-full rounded-full bg-green-500 transition-all duration-500",
|
|
100
|
-
style: { width: `${progress}%` }
|
|
101
|
-
}
|
|
102
|
-
) }),
|
|
103
|
-
/* @__PURE__ */ jsxs("span", { className: "text-xs text-muted-foreground shrink-0", children: [
|
|
104
|
-
job.steps_completed,
|
|
105
|
-
"/",
|
|
106
|
-
job.steps_total
|
|
107
|
-
] })
|
|
108
|
-
] })
|
|
109
|
-
] }, job.run_id);
|
|
110
|
-
}) });
|
|
111
|
-
}
|
|
112
|
-
function SwarmJobHistory({ jobs, onRerun }) {
|
|
113
|
-
if (!jobs || jobs.length === 0) {
|
|
114
|
-
return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground py-4 text-center", children: "No completed jobs." });
|
|
52
|
+
const conclusionBadgeStyles = {
|
|
53
|
+
success: "bg-green-500/10 text-green-500",
|
|
54
|
+
failure: "bg-red-500/10 text-red-500",
|
|
55
|
+
cancelled: "bg-yellow-500/10 text-yellow-500",
|
|
56
|
+
skipped: "bg-muted text-muted-foreground"
|
|
57
|
+
};
|
|
58
|
+
function SwarmWorkflowList({ runs }) {
|
|
59
|
+
if (!runs || runs.length === 0) {
|
|
60
|
+
return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground py-4 text-center", children: "No workflow runs." });
|
|
115
61
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
62
|
+
return /* @__PURE__ */ jsx("div", { className: "flex flex-col divide-y divide-border", children: runs.map((run) => {
|
|
63
|
+
const isActive = run.status === "in_progress" || run.status === "queued";
|
|
64
|
+
const isRunning = run.status === "in_progress";
|
|
65
|
+
const isQueued = run.status === "queued";
|
|
66
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 py-3 px-1", children: [
|
|
67
|
+
isRunning && /* @__PURE__ */ jsx("span", { className: "inline-block h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 animate-pulse" }),
|
|
68
|
+
isQueued && /* @__PURE__ */ jsx("span", { className: "inline-block h-2.5 w-2.5 shrink-0 rounded-full bg-yellow-500" }),
|
|
69
|
+
!isActive && /* @__PURE__ */ jsx(
|
|
70
|
+
"span",
|
|
71
|
+
{
|
|
72
|
+
className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase shrink-0 ${conclusionBadgeStyles[run.conclusion] || "bg-muted text-muted-foreground"}`,
|
|
73
|
+
children: run.conclusion || "unknown"
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium truncate", children: run.workflow_name || run.branch }),
|
|
77
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground shrink-0", children: isActive ? formatDuration(run.duration_seconds) : timeAgo(run.updated_at || run.started_at) }),
|
|
78
|
+
/* @__PURE__ */ jsx("div", { className: "flex-1" }),
|
|
79
|
+
run.html_url && /* @__PURE__ */ jsx(
|
|
134
80
|
"a",
|
|
135
81
|
{
|
|
136
|
-
href:
|
|
82
|
+
href: run.html_url,
|
|
137
83
|
target: "_blank",
|
|
138
84
|
rel: "noopener noreferrer",
|
|
139
|
-
className: "text-xs text-blue-500 hover:underline",
|
|
85
|
+
className: "text-xs text-blue-500 hover:underline shrink-0",
|
|
140
86
|
children: "View"
|
|
141
87
|
}
|
|
142
|
-
),
|
|
143
|
-
/* @__PURE__ */ jsx(
|
|
144
|
-
"button",
|
|
145
|
-
{
|
|
146
|
-
onClick: () => onRerun(job.run_id, false),
|
|
147
|
-
className: "text-xs rounded-md px-2 py-1 border hover:bg-accent",
|
|
148
|
-
children: "Rerun"
|
|
149
|
-
}
|
|
150
|
-
),
|
|
151
|
-
job.conclusion === "failure" && /* @__PURE__ */ jsx(
|
|
152
|
-
"button",
|
|
153
|
-
{
|
|
154
|
-
onClick: () => onRerun(job.run_id, true),
|
|
155
|
-
className: "text-xs rounded-md px-2 py-1 border text-red-500 hover:bg-red-500/10",
|
|
156
|
-
children: "Rerun failed"
|
|
157
|
-
}
|
|
158
88
|
)
|
|
159
|
-
] })
|
|
160
|
-
|
|
89
|
+
] }, run.run_id);
|
|
90
|
+
}) });
|
|
161
91
|
}
|
|
162
92
|
function SwarmPage({ session }) {
|
|
163
|
-
const [
|
|
164
|
-
const [completed, setCompleted] = useState([]);
|
|
93
|
+
const [runs, setRuns] = useState([]);
|
|
165
94
|
const [counts, setCounts] = useState({ running: 0, queued: 0 });
|
|
166
95
|
const [hasMore, setHasMore] = useState(false);
|
|
167
96
|
const [page, setPage] = useState(1);
|
|
@@ -171,11 +100,10 @@ function SwarmPage({ session }) {
|
|
|
171
100
|
const fetchStatus = useCallback(async () => {
|
|
172
101
|
try {
|
|
173
102
|
const data = await getSwarmStatus();
|
|
174
|
-
setActive(data.active || []);
|
|
175
103
|
setCounts(data.counts || { running: 0, queued: 0 });
|
|
176
104
|
setHasMore(data.hasMore || false);
|
|
177
|
-
|
|
178
|
-
const firstPage = data.
|
|
105
|
+
setRuns((prev) => {
|
|
106
|
+
const firstPage = data.runs || [];
|
|
179
107
|
if (page <= 1) return firstPage;
|
|
180
108
|
const beyondPage1 = prev.slice(firstPage.length);
|
|
181
109
|
return [...firstPage, ...beyondPage1];
|
|
@@ -197,9 +125,8 @@ function SwarmPage({ session }) {
|
|
|
197
125
|
setPage(1);
|
|
198
126
|
try {
|
|
199
127
|
const data = await getSwarmStatus();
|
|
200
|
-
setActive(data.active || []);
|
|
201
128
|
setCounts(data.counts || { running: 0, queued: 0 });
|
|
202
|
-
|
|
129
|
+
setRuns(data.runs || []);
|
|
203
130
|
setHasMore(data.hasMore || false);
|
|
204
131
|
} catch (err) {
|
|
205
132
|
console.error("Failed to fetch swarm status:", err);
|
|
@@ -211,7 +138,7 @@ function SwarmPage({ session }) {
|
|
|
211
138
|
setLoadingMore(true);
|
|
212
139
|
try {
|
|
213
140
|
const data = await getSwarmStatus(nextPage);
|
|
214
|
-
|
|
141
|
+
setRuns((prev) => [...prev, ...data.runs || []]);
|
|
215
142
|
setHasMore(data.hasMore || false);
|
|
216
143
|
setPage(nextPage);
|
|
217
144
|
} catch (err) {
|
|
@@ -219,22 +146,6 @@ function SwarmPage({ session }) {
|
|
|
219
146
|
}
|
|
220
147
|
setLoadingMore(false);
|
|
221
148
|
};
|
|
222
|
-
const handleCancel = async (runId) => {
|
|
223
|
-
try {
|
|
224
|
-
await cancelSwarmJob(runId);
|
|
225
|
-
await fetchStatus();
|
|
226
|
-
} catch (err) {
|
|
227
|
-
console.error("Failed to cancel job:", err);
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
const handleRerun = async (runId, failedOnly) => {
|
|
231
|
-
try {
|
|
232
|
-
await rerunSwarmJob(runId, failedOnly);
|
|
233
|
-
await fetchStatus();
|
|
234
|
-
} catch (err) {
|
|
235
|
-
console.error("Failed to rerun job:", err);
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
149
|
return /* @__PURE__ */ jsxs(PageLayout, { session, children: [
|
|
239
150
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-6", children: [
|
|
240
151
|
/* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold", children: "Swarm" }),
|
|
@@ -257,24 +168,8 @@ function SwarmPage({ session }) {
|
|
|
257
168
|
loading ? /* @__PURE__ */ jsx(LoadingSkeleton, {}) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-6", children: [
|
|
258
169
|
/* @__PURE__ */ jsx(SwarmSummaryCards, { counts }),
|
|
259
170
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
260
|
-
/* @__PURE__ */ jsx("h2", { className: "text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3", children: "
|
|
261
|
-
/* @__PURE__ */ jsx(
|
|
262
|
-
SwarmActiveJobs,
|
|
263
|
-
{
|
|
264
|
-
jobs: active,
|
|
265
|
-
onCancel: handleCancel
|
|
266
|
-
}
|
|
267
|
-
)
|
|
268
|
-
] }),
|
|
269
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
270
|
-
/* @__PURE__ */ jsx("h2", { className: "text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3", children: "Job History" }),
|
|
271
|
-
/* @__PURE__ */ jsx(
|
|
272
|
-
SwarmJobHistory,
|
|
273
|
-
{
|
|
274
|
-
jobs: completed,
|
|
275
|
-
onRerun: handleRerun
|
|
276
|
-
}
|
|
277
|
-
),
|
|
171
|
+
/* @__PURE__ */ jsx("h2", { className: "text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3", children: "Workflow Runs" }),
|
|
172
|
+
/* @__PURE__ */ jsx(SwarmWorkflowList, { runs }),
|
|
278
173
|
hasMore && /* @__PURE__ */ jsx("div", { className: "flex justify-center mt-4", children: /* @__PURE__ */ jsx(
|
|
279
174
|
"button",
|
|
280
175
|
{
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import { PageLayout } from './page-layout.js';
|
|
5
|
-
import {
|
|
6
|
-
import { getSwarmStatus
|
|
5
|
+
import { SpinnerIcon, RefreshIcon } from './icons.js';
|
|
6
|
+
import { getSwarmStatus } from '../actions.js';
|
|
7
7
|
|
|
8
8
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
9
|
// Utilities
|
|
@@ -42,7 +42,7 @@ function LoadingSkeleton() {
|
|
|
42
42
|
</div>
|
|
43
43
|
<div className="h-8 w-32 animate-pulse rounded-md bg-border/50" />
|
|
44
44
|
{[...Array(3)].map((_, i) => (
|
|
45
|
-
<div key={i} className="h-
|
|
45
|
+
<div key={i} className="h-14 animate-pulse rounded-md bg-border/50" />
|
|
46
46
|
))}
|
|
47
47
|
</div>
|
|
48
48
|
);
|
|
@@ -74,169 +74,80 @@ function SwarmSummaryCards({ counts }) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
-
//
|
|
77
|
+
// Unified Workflow List
|
|
78
78
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const conclusionBadgeStyles = {
|
|
81
|
+
success: 'bg-green-500/10 text-green-500',
|
|
82
|
+
failure: 'bg-red-500/10 text-red-500',
|
|
83
|
+
cancelled: 'bg-yellow-500/10 text-yellow-500',
|
|
84
|
+
skipped: 'bg-muted text-muted-foreground',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function SwarmWorkflowList({ runs }) {
|
|
88
|
+
if (!runs || runs.length === 0) {
|
|
82
89
|
return (
|
|
83
90
|
<div className="text-sm text-muted-foreground py-4 text-center">
|
|
84
|
-
No
|
|
91
|
+
No workflow runs.
|
|
85
92
|
</div>
|
|
86
93
|
);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
return (
|
|
90
|
-
<div className="flex flex-col
|
|
91
|
-
{
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
: 0;
|
|
97
|
+
<div className="flex flex-col divide-y divide-border">
|
|
98
|
+
{runs.map((run) => {
|
|
99
|
+
const isActive = run.status === 'in_progress' || run.status === 'queued';
|
|
100
|
+
const isRunning = run.status === 'in_progress';
|
|
101
|
+
const isQueued = run.status === 'queued';
|
|
96
102
|
|
|
97
103
|
return (
|
|
98
|
-
<div key={
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
<span
|
|
103
|
-
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
|
104
|
-
isRunning ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'
|
|
105
|
-
}`}
|
|
106
|
-
/>
|
|
107
|
-
<span className="font-mono text-sm font-medium">
|
|
108
|
-
{job.job_id.slice(0, 8)}
|
|
109
|
-
</span>
|
|
110
|
-
{job.workflow_name && (
|
|
111
|
-
<span className="text-xs text-muted-foreground">
|
|
112
|
-
{job.workflow_name}
|
|
113
|
-
</span>
|
|
114
|
-
)}
|
|
115
|
-
</div>
|
|
116
|
-
<div className="flex items-center gap-2">
|
|
117
|
-
<span className="text-xs text-muted-foreground">
|
|
118
|
-
{formatDuration(job.duration_seconds)}
|
|
119
|
-
</span>
|
|
120
|
-
{job.html_url && (
|
|
121
|
-
<a
|
|
122
|
-
href={job.html_url}
|
|
123
|
-
target="_blank"
|
|
124
|
-
rel="noopener noreferrer"
|
|
125
|
-
className="text-xs text-blue-500 hover:underline"
|
|
126
|
-
>
|
|
127
|
-
View
|
|
128
|
-
</a>
|
|
129
|
-
)}
|
|
130
|
-
<button
|
|
131
|
-
onClick={() => onCancel(job.run_id)}
|
|
132
|
-
className="inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
|
133
|
-
title="Cancel job"
|
|
134
|
-
>
|
|
135
|
-
<StopIcon size={14} />
|
|
136
|
-
</button>
|
|
137
|
-
</div>
|
|
138
|
-
</div>
|
|
139
|
-
|
|
140
|
-
{/* Current step */}
|
|
141
|
-
{job.current_step && (
|
|
142
|
-
<p className="text-xs text-muted-foreground mb-2 truncate">
|
|
143
|
-
{job.current_step}
|
|
144
|
-
</p>
|
|
104
|
+
<div key={run.run_id} className="flex items-center gap-3 py-3 px-1">
|
|
105
|
+
{/* Status indicator */}
|
|
106
|
+
{isRunning && (
|
|
107
|
+
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 animate-pulse" />
|
|
145
108
|
)}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{job.steps_completed}/{job.steps_total}
|
|
158
|
-
</span>
|
|
159
|
-
</div>
|
|
109
|
+
{isQueued && (
|
|
110
|
+
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full bg-yellow-500" />
|
|
111
|
+
)}
|
|
112
|
+
{!isActive && (
|
|
113
|
+
<span
|
|
114
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase shrink-0 ${
|
|
115
|
+
conclusionBadgeStyles[run.conclusion] || 'bg-muted text-muted-foreground'
|
|
116
|
+
}`}
|
|
117
|
+
>
|
|
118
|
+
{run.conclusion || 'unknown'}
|
|
119
|
+
</span>
|
|
160
120
|
)}
|
|
161
|
-
</div>
|
|
162
|
-
);
|
|
163
|
-
})}
|
|
164
|
-
</div>
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
-
// Job History
|
|
170
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
function SwarmJobHistory({ jobs, onRerun }) {
|
|
173
|
-
if (!jobs || jobs.length === 0) {
|
|
174
|
-
return (
|
|
175
|
-
<div className="text-sm text-muted-foreground py-4 text-center">
|
|
176
|
-
No completed jobs.
|
|
177
|
-
</div>
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const badgeStyles = {
|
|
182
|
-
success: 'bg-green-500/10 text-green-500',
|
|
183
|
-
failure: 'bg-red-500/10 text-red-500',
|
|
184
|
-
cancelled: 'bg-yellow-500/10 text-yellow-500',
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
return (
|
|
188
|
-
<div className="flex flex-col divide-y divide-border">
|
|
189
|
-
{jobs.map((job) => (
|
|
190
|
-
<div key={job.run_id} className="flex items-center gap-3 py-3 px-1">
|
|
191
|
-
{/* Conclusion badge */}
|
|
192
|
-
<span
|
|
193
|
-
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase ${
|
|
194
|
-
badgeStyles[job.conclusion] || 'bg-muted text-muted-foreground'
|
|
195
|
-
}`}
|
|
196
|
-
>
|
|
197
|
-
{job.conclusion || 'unknown'}
|
|
198
|
-
</span>
|
|
199
121
|
|
|
200
|
-
|
|
201
|
-
|
|
122
|
+
{/* Workflow name */}
|
|
123
|
+
<span className="text-sm font-medium truncate">
|
|
124
|
+
{run.workflow_name || run.branch}
|
|
125
|
+
</span>
|
|
202
126
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
127
|
+
{/* Duration or time ago */}
|
|
128
|
+
<span className="text-xs text-muted-foreground shrink-0">
|
|
129
|
+
{isActive
|
|
130
|
+
? formatDuration(run.duration_seconds)
|
|
131
|
+
: timeAgo(run.updated_at || run.started_at)}
|
|
132
|
+
</span>
|
|
207
133
|
|
|
208
|
-
|
|
209
|
-
|
|
134
|
+
{/* Spacer */}
|
|
135
|
+
<div className="flex-1" />
|
|
210
136
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
{job.html_url && (
|
|
137
|
+
{/* Link */}
|
|
138
|
+
{run.html_url && (
|
|
214
139
|
<a
|
|
215
|
-
href={
|
|
140
|
+
href={run.html_url}
|
|
216
141
|
target="_blank"
|
|
217
142
|
rel="noopener noreferrer"
|
|
218
|
-
className="text-xs text-blue-500 hover:underline"
|
|
143
|
+
className="text-xs text-blue-500 hover:underline shrink-0"
|
|
219
144
|
>
|
|
220
145
|
View
|
|
221
146
|
</a>
|
|
222
147
|
)}
|
|
223
|
-
<button
|
|
224
|
-
onClick={() => onRerun(job.run_id, false)}
|
|
225
|
-
className="text-xs rounded-md px-2 py-1 border hover:bg-accent"
|
|
226
|
-
>
|
|
227
|
-
Rerun
|
|
228
|
-
</button>
|
|
229
|
-
{job.conclusion === 'failure' && (
|
|
230
|
-
<button
|
|
231
|
-
onClick={() => onRerun(job.run_id, true)}
|
|
232
|
-
className="text-xs rounded-md px-2 py-1 border text-red-500 hover:bg-red-500/10"
|
|
233
|
-
>
|
|
234
|
-
Rerun failed
|
|
235
|
-
</button>
|
|
236
|
-
)}
|
|
237
148
|
</div>
|
|
238
|
-
|
|
239
|
-
)
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
240
151
|
</div>
|
|
241
152
|
);
|
|
242
153
|
}
|
|
@@ -246,8 +157,7 @@ function SwarmJobHistory({ jobs, onRerun }) {
|
|
|
246
157
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
247
158
|
|
|
248
159
|
export function SwarmPage({ session }) {
|
|
249
|
-
const [
|
|
250
|
-
const [completed, setCompleted] = useState([]);
|
|
160
|
+
const [runs, setRuns] = useState([]);
|
|
251
161
|
const [counts, setCounts] = useState({ running: 0, queued: 0 });
|
|
252
162
|
const [hasMore, setHasMore] = useState(false);
|
|
253
163
|
const [page, setPage] = useState(1);
|
|
@@ -258,15 +168,12 @@ export function SwarmPage({ session }) {
|
|
|
258
168
|
const fetchStatus = useCallback(async () => {
|
|
259
169
|
try {
|
|
260
170
|
const data = await getSwarmStatus();
|
|
261
|
-
setActive(data.active || []);
|
|
262
171
|
setCounts(data.counts || { running: 0, queued: 0 });
|
|
263
172
|
setHasMore(data.hasMore || false);
|
|
264
|
-
// On auto-refresh, replace page 1
|
|
265
|
-
|
|
266
|
-
const firstPage = data.
|
|
267
|
-
// If user hasn't loaded beyond page 1, just use page 1 results
|
|
173
|
+
// On auto-refresh, replace page 1 runs but keep any loaded beyond page 1
|
|
174
|
+
setRuns((prev) => {
|
|
175
|
+
const firstPage = data.runs || [];
|
|
268
176
|
if (page <= 1) return firstPage;
|
|
269
|
-
// Keep accumulated jobs beyond page 1
|
|
270
177
|
const beyondPage1 = prev.slice(firstPage.length);
|
|
271
178
|
return [...firstPage, ...beyondPage1];
|
|
272
179
|
});
|
|
@@ -286,13 +193,11 @@ export function SwarmPage({ session }) {
|
|
|
286
193
|
|
|
287
194
|
const handleRefresh = async () => {
|
|
288
195
|
setRefreshing(true);
|
|
289
|
-
// Reset to page 1
|
|
290
196
|
setPage(1);
|
|
291
197
|
try {
|
|
292
198
|
const data = await getSwarmStatus();
|
|
293
|
-
setActive(data.active || []);
|
|
294
199
|
setCounts(data.counts || { running: 0, queued: 0 });
|
|
295
|
-
|
|
200
|
+
setRuns(data.runs || []);
|
|
296
201
|
setHasMore(data.hasMore || false);
|
|
297
202
|
} catch (err) {
|
|
298
203
|
console.error('Failed to fetch swarm status:', err);
|
|
@@ -305,7 +210,7 @@ export function SwarmPage({ session }) {
|
|
|
305
210
|
setLoadingMore(true);
|
|
306
211
|
try {
|
|
307
212
|
const data = await getSwarmStatus(nextPage);
|
|
308
|
-
|
|
213
|
+
setRuns((prev) => [...prev, ...(data.runs || [])]);
|
|
309
214
|
setHasMore(data.hasMore || false);
|
|
310
215
|
setPage(nextPage);
|
|
311
216
|
} catch (err) {
|
|
@@ -314,24 +219,6 @@ export function SwarmPage({ session }) {
|
|
|
314
219
|
setLoadingMore(false);
|
|
315
220
|
};
|
|
316
221
|
|
|
317
|
-
const handleCancel = async (runId) => {
|
|
318
|
-
try {
|
|
319
|
-
await cancelSwarmJob(runId);
|
|
320
|
-
await fetchStatus();
|
|
321
|
-
} catch (err) {
|
|
322
|
-
console.error('Failed to cancel job:', err);
|
|
323
|
-
}
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
const handleRerun = async (runId, failedOnly) => {
|
|
327
|
-
try {
|
|
328
|
-
await rerunSwarmJob(runId, failedOnly);
|
|
329
|
-
await fetchStatus();
|
|
330
|
-
} catch (err) {
|
|
331
|
-
console.error('Failed to rerun job:', err);
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
|
|
335
222
|
return (
|
|
336
223
|
<PageLayout session={session}>
|
|
337
224
|
{/* Header */}
|
|
@@ -365,26 +252,12 @@ export function SwarmPage({ session }) {
|
|
|
365
252
|
{/* Summary Cards */}
|
|
366
253
|
<SwarmSummaryCards counts={counts} />
|
|
367
254
|
|
|
368
|
-
{/*
|
|
369
|
-
<div>
|
|
370
|
-
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
|
371
|
-
Active Jobs
|
|
372
|
-
</h2>
|
|
373
|
-
<SwarmActiveJobs
|
|
374
|
-
jobs={active}
|
|
375
|
-
onCancel={handleCancel}
|
|
376
|
-
/>
|
|
377
|
-
</div>
|
|
378
|
-
|
|
379
|
-
{/* Job History */}
|
|
255
|
+
{/* Workflow Runs */}
|
|
380
256
|
<div>
|
|
381
257
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
|
382
|
-
|
|
258
|
+
Workflow Runs
|
|
383
259
|
</h2>
|
|
384
|
-
<
|
|
385
|
-
jobs={completed}
|
|
386
|
-
onRerun={handleRerun}
|
|
387
|
-
/>
|
|
260
|
+
<SwarmWorkflowList runs={runs} />
|
|
388
261
|
{hasMore && (
|
|
389
262
|
<div className="flex justify-center mt-4">
|
|
390
263
|
<button
|
package/lib/tools/github.js
CHANGED
|
@@ -30,7 +30,7 @@ async function githubApi(endpoint, options = {}) {
|
|
|
30
30
|
* @param {string} [workflow] - Workflow filename to scope to (e.g., 'run-job.yml')
|
|
31
31
|
* @returns {Promise<object>} - Workflow runs response
|
|
32
32
|
*/
|
|
33
|
-
async function getWorkflowRuns(status, workflow,
|
|
33
|
+
async function getWorkflowRuns(status, { workflow, page = 1, perPage = 100 } = {}) {
|
|
34
34
|
const { GH_OWNER, GH_REPO } = process.env;
|
|
35
35
|
const params = new URLSearchParams();
|
|
36
36
|
if (status) params.set('status', status);
|
|
@@ -62,8 +62,8 @@ async function getWorkflowRunJobs(runId) {
|
|
|
62
62
|
async function getJobStatus(jobId) {
|
|
63
63
|
// Fetch both in_progress and queued runs (scoped to run-job.yml)
|
|
64
64
|
const [inProgress, queued] = await Promise.all([
|
|
65
|
-
getWorkflowRuns('in_progress', 'run-job.yml'),
|
|
66
|
-
getWorkflowRuns('queued', 'run-job.yml'),
|
|
65
|
+
getWorkflowRuns('in_progress', { workflow: 'run-job.yml' }),
|
|
66
|
+
getWorkflowRuns('queued', { workflow: 'run-job.yml' }),
|
|
67
67
|
]);
|
|
68
68
|
|
|
69
69
|
const allRuns = [...(inProgress.workflow_runs || []), ...(queued.workflow_runs || [])];
|
|
@@ -125,138 +125,39 @@ async function getJobStatus(jobId) {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
|
-
* Get full swarm status:
|
|
129
|
-
* @
|
|
128
|
+
* Get full swarm status: unified list of all workflow runs with counts
|
|
129
|
+
* @param {number} [page=1] - Page number for pagination
|
|
130
|
+
* @returns {Promise<object>} - { runs, hasMore, counts }
|
|
130
131
|
*/
|
|
131
132
|
async function getSwarmStatus(page = 1) {
|
|
132
|
-
const [
|
|
133
|
-
getWorkflowRuns('in_progress',
|
|
134
|
-
getWorkflowRuns('queued',
|
|
135
|
-
getWorkflowRuns(
|
|
133
|
+
const [inProgressData, queuedData, allRuns] = await Promise.all([
|
|
134
|
+
getWorkflowRuns('in_progress', { perPage: 1 }),
|
|
135
|
+
getWorkflowRuns('queued', { perPage: 1 }),
|
|
136
|
+
getWorkflowRuns(null, { page, perPage: 25 }),
|
|
136
137
|
]);
|
|
137
138
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
...(queued.workflow_runs || []),
|
|
141
|
-
].filter(run => run.head_branch?.startsWith('job/'));
|
|
142
|
-
|
|
143
|
-
const completedRuns = (completed.workflow_runs || [])
|
|
144
|
-
.filter(run => run.head_branch?.startsWith('job/'));
|
|
145
|
-
|
|
146
|
-
// Get step details for active jobs
|
|
147
|
-
const active = await Promise.all(
|
|
148
|
-
activeRuns.map(async (run) => {
|
|
149
|
-
const jobId = run.head_branch.slice(4);
|
|
150
|
-
const startedAt = new Date(run.created_at);
|
|
151
|
-
const durationSeconds = Math.round((Date.now() - startedAt.getTime()) / 1000);
|
|
152
|
-
|
|
153
|
-
let currentStep = null;
|
|
154
|
-
let stepsCompleted = 0;
|
|
155
|
-
let stepsTotal = 0;
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const jobsData = await getWorkflowRunJobs(run.id);
|
|
159
|
-
if (jobsData.jobs?.length > 0) {
|
|
160
|
-
const job = jobsData.jobs[0];
|
|
161
|
-
stepsTotal = job.steps?.length || 0;
|
|
162
|
-
stepsCompleted = job.steps?.filter(s => s.status === 'completed').length || 0;
|
|
163
|
-
currentStep = job.steps?.find(s => s.status === 'in_progress')?.name || null;
|
|
164
|
-
}
|
|
165
|
-
} catch (err) {
|
|
166
|
-
// Jobs endpoint may fail if run hasn't started yet
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
job_id: jobId,
|
|
171
|
-
branch: run.head_branch,
|
|
172
|
-
status: run.status,
|
|
173
|
-
workflow_name: run.name,
|
|
174
|
-
started_at: run.created_at,
|
|
175
|
-
duration_seconds: durationSeconds,
|
|
176
|
-
current_step: currentStep,
|
|
177
|
-
steps_completed: stepsCompleted,
|
|
178
|
-
steps_total: stepsTotal,
|
|
179
|
-
run_id: run.id,
|
|
180
|
-
html_url: run.html_url,
|
|
181
|
-
};
|
|
182
|
-
})
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
// Completed jobs are lighter — no step detail needed
|
|
186
|
-
const completedJobs = completedRuns.map((run) => ({
|
|
187
|
-
job_id: run.head_branch.slice(4),
|
|
139
|
+
const runs = (allRuns.workflow_runs || []).map(run => ({
|
|
140
|
+
run_id: run.id,
|
|
188
141
|
branch: run.head_branch,
|
|
189
142
|
status: run.status,
|
|
190
143
|
conclusion: run.conclusion,
|
|
191
144
|
workflow_name: run.name,
|
|
192
145
|
started_at: run.created_at,
|
|
193
146
|
updated_at: run.updated_at,
|
|
194
|
-
|
|
147
|
+
duration_seconds: Math.round((Date.now() - new Date(run.created_at).getTime()) / 1000),
|
|
195
148
|
html_url: run.html_url,
|
|
196
149
|
}));
|
|
197
150
|
|
|
198
|
-
const totalCompleted = completed.total_count || 0;
|
|
199
|
-
|
|
200
151
|
return {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
hasMore: page * 100 < totalCompleted,
|
|
152
|
+
runs,
|
|
153
|
+
hasMore: page * 25 < (allRuns.total_count || 0),
|
|
204
154
|
counts: {
|
|
205
|
-
running:
|
|
206
|
-
queued:
|
|
155
|
+
running: inProgressData.total_count || 0,
|
|
156
|
+
queued: queuedData.total_count || 0,
|
|
207
157
|
},
|
|
208
158
|
};
|
|
209
159
|
}
|
|
210
160
|
|
|
211
|
-
/**
|
|
212
|
-
* Cancel a workflow run
|
|
213
|
-
* @param {number} runId - Workflow run ID
|
|
214
|
-
*/
|
|
215
|
-
async function cancelWorkflowRun(runId) {
|
|
216
|
-
const { GH_OWNER, GH_REPO } = process.env;
|
|
217
|
-
const res = await fetch(
|
|
218
|
-
`https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/actions/runs/${runId}/cancel`,
|
|
219
|
-
{
|
|
220
|
-
method: 'POST',
|
|
221
|
-
headers: {
|
|
222
|
-
'Authorization': `Bearer ${process.env.GH_TOKEN}`,
|
|
223
|
-
'Accept': 'application/vnd.github+json',
|
|
224
|
-
'X-GitHub-Api-Version': '2022-11-28',
|
|
225
|
-
},
|
|
226
|
-
}
|
|
227
|
-
);
|
|
228
|
-
if (!res.ok && res.status !== 202) {
|
|
229
|
-
const error = await res.text();
|
|
230
|
-
throw new Error(`GitHub API error: ${res.status} ${error}`);
|
|
231
|
-
}
|
|
232
|
-
return { success: true };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Re-run a workflow run (all jobs or failed only)
|
|
237
|
-
* @param {number} runId - Workflow run ID
|
|
238
|
-
* @param {boolean} [failedOnly=false] - Only rerun failed jobs
|
|
239
|
-
*/
|
|
240
|
-
async function rerunWorkflowRun(runId, failedOnly = false) {
|
|
241
|
-
const { GH_OWNER, GH_REPO } = process.env;
|
|
242
|
-
const endpoint = failedOnly
|
|
243
|
-
? `https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/actions/runs/${runId}/rerun-failed-jobs`
|
|
244
|
-
: `https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/actions/runs/${runId}/rerun`;
|
|
245
|
-
const res = await fetch(endpoint, {
|
|
246
|
-
method: 'POST',
|
|
247
|
-
headers: {
|
|
248
|
-
'Authorization': `Bearer ${process.env.GH_TOKEN}`,
|
|
249
|
-
'Accept': 'application/vnd.github+json',
|
|
250
|
-
'X-GitHub-Api-Version': '2022-11-28',
|
|
251
|
-
},
|
|
252
|
-
});
|
|
253
|
-
if (!res.ok && res.status !== 201) {
|
|
254
|
-
const error = await res.text();
|
|
255
|
-
throw new Error(`GitHub API error: ${res.status} ${error}`);
|
|
256
|
-
}
|
|
257
|
-
return { success: true };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
161
|
/**
|
|
261
162
|
* Trigger a workflow via workflow_dispatch
|
|
262
163
|
* @param {string} workflowId - Workflow file name (e.g., 'upgrade-event-handler.yml')
|
|
@@ -291,7 +192,5 @@ export {
|
|
|
291
192
|
getWorkflowRunJobs,
|
|
292
193
|
getJobStatus,
|
|
293
194
|
getSwarmStatus,
|
|
294
|
-
cancelWorkflowRun,
|
|
295
|
-
rerunWorkflowRun,
|
|
296
195
|
triggerWorkflowDispatch,
|
|
297
196
|
};
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ jobs:
|
|
|
13
13
|
steps:
|
|
14
14
|
- name: Upgrade thepopebot
|
|
15
15
|
run: |
|
|
16
|
+
# Phase 1: Update package and build (but don't swap yet)
|
|
16
17
|
docker exec thepopebot-event-handler bash -c '
|
|
17
18
|
export GH_TOKEN=$(grep "^GH_TOKEN=" /app/.env | cut -d= -f2-)
|
|
18
19
|
echo "${GH_TOKEN}" | gh auth login --with-token
|
|
@@ -21,19 +22,23 @@ jobs:
|
|
|
21
22
|
git fetch origin main
|
|
22
23
|
git reset --hard origin/main
|
|
23
24
|
|
|
24
|
-
# Update thepopebot package
|
|
25
25
|
npm update thepopebot
|
|
26
|
+
npx thepopebot init
|
|
26
27
|
npm install --omit=dev
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
rm -rf .next-new .next-old
|
|
29
|
+
rm -rf .next-new
|
|
30
30
|
NEXT_BUILD_DIR=.next-new npm run build
|
|
31
|
+
'
|
|
32
|
+
|
|
33
|
+
# Phase 2: Pull new image and restart container
|
|
34
|
+
cd /project
|
|
35
|
+
docker compose pull event-handler
|
|
36
|
+
docker compose up -d event-handler
|
|
31
37
|
|
|
38
|
+
# Phase 3: Swap build and reload inside new container
|
|
39
|
+
docker exec thepopebot-event-handler bash -c '
|
|
32
40
|
mv .next .next-old 2>/dev/null || true
|
|
33
41
|
mv .next-new .next
|
|
34
|
-
|
|
35
|
-
echo "Upgrade complete, reloading..."
|
|
36
42
|
npx pm2 reload all
|
|
37
|
-
|
|
38
43
|
rm -rf .next-old
|
|
39
44
|
'
|