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.
@@ -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', active: [], completed: [], hasMore: false, counts: { running: 0, queued: 0 } };
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 { StopIcon, SpinnerIcon, RefreshIcon } from "./icons.js";
6
- import { getSwarmStatus, cancelSwarmJob, rerunSwarmJob } from "../actions.js";
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-24 animate-pulse rounded-md bg-border/50" }, i))
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
- function SwarmActiveJobs({ jobs, onCancel }) {
53
- if (!jobs || jobs.length === 0) {
54
- return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground py-4 text-center", children: "No active jobs." });
55
- }
56
- return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-3", children: jobs.map((job) => {
57
- const isRunning = job.status === "in_progress";
58
- const progress = job.steps_total > 0 ? Math.round(job.steps_completed / job.steps_total * 100) : 0;
59
- return /* @__PURE__ */ jsxs("div", { className: "rounded-md border bg-card p-4", children: [
60
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-2", children: [
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
- const badgeStyles = {
117
- success: "bg-green-500/10 text-green-500",
118
- failure: "bg-red-500/10 text-red-500",
119
- cancelled: "bg-yellow-500/10 text-yellow-500"
120
- };
121
- return /* @__PURE__ */ jsx("div", { className: "flex flex-col divide-y divide-border", children: jobs.map((job) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 py-3 px-1", children: [
122
- /* @__PURE__ */ jsx(
123
- "span",
124
- {
125
- className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase ${badgeStyles[job.conclusion] || "bg-muted text-muted-foreground"}`,
126
- children: job.conclusion || "unknown"
127
- }
128
- ),
129
- /* @__PURE__ */ jsx("span", { className: "font-mono text-sm", children: job.job_id.slice(0, 8) }),
130
- /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: timeAgo(job.updated_at || job.started_at) }),
131
- /* @__PURE__ */ jsx("div", { className: "flex-1" }),
132
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
133
- job.html_url && /* @__PURE__ */ jsx(
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: job.html_url,
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
- ] }, job.run_id)) });
89
+ ] }, run.run_id);
90
+ }) });
161
91
  }
162
92
  function SwarmPage({ session }) {
163
- const [active, setActive] = useState([]);
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
- setCompleted((prev) => {
178
- const firstPage = data.completed || [];
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
- setCompleted(data.completed || []);
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
- setCompleted((prev) => [...prev, ...data.completed || []]);
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: "Active Jobs" }),
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 { StopIcon, SpinnerIcon, RefreshIcon } from './icons.js';
6
- import { getSwarmStatus, cancelSwarmJob, rerunSwarmJob } from '../actions.js';
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-24 animate-pulse rounded-md bg-border/50" />
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
- // Active Jobs
77
+ // Unified Workflow List
78
78
  // ─────────────────────────────────────────────────────────────────────────────
79
79
 
80
- function SwarmActiveJobs({ jobs, onCancel }) {
81
- if (!jobs || jobs.length === 0) {
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 active jobs.
91
+ No workflow runs.
85
92
  </div>
86
93
  );
87
94
  }
88
95
 
89
96
  return (
90
- <div className="flex flex-col gap-3">
91
- {jobs.map((job) => {
92
- const isRunning = job.status === 'in_progress';
93
- const progress = job.steps_total > 0
94
- ? Math.round((job.steps_completed / job.steps_total) * 100)
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={job.run_id} className="rounded-md border bg-card p-4">
99
- <div className="flex items-center justify-between mb-2">
100
- <div className="flex items-center gap-2">
101
- {/* Status dot */}
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
- {/* Progress bar */}
148
- {job.steps_total > 0 && (
149
- <div className="flex items-center gap-2">
150
- <div className="flex-1 h-1.5 rounded-full bg-border overflow-hidden">
151
- <div
152
- className="h-full rounded-full bg-green-500 transition-all duration-500"
153
- style={{ width: `${progress}%` }}
154
- />
155
- </div>
156
- <span className="text-xs text-muted-foreground shrink-0">
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
- {/* Job ID */}
201
- <span className="font-mono text-sm">{job.job_id.slice(0, 8)}</span>
122
+ {/* Workflow name */}
123
+ <span className="text-sm font-medium truncate">
124
+ {run.workflow_name || run.branch}
125
+ </span>
202
126
 
203
- {/* Time ago */}
204
- <span className="text-xs text-muted-foreground">
205
- {timeAgo(job.updated_at || job.started_at)}
206
- </span>
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
- {/* Spacer */}
209
- <div className="flex-1" />
134
+ {/* Spacer */}
135
+ <div className="flex-1" />
210
136
 
211
- {/* Actions */}
212
- <div className="flex items-center gap-2">
213
- {job.html_url && (
137
+ {/* Link */}
138
+ {run.html_url && (
214
139
  <a
215
- href={job.html_url}
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
- </div>
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 [active, setActive] = useState([]);
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 completed jobs but keep any loaded beyond page 1
265
- setCompleted((prev) => {
266
- const firstPage = data.completed || [];
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
- setCompleted(data.completed || []);
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
- setCompleted((prev) => [...prev, ...(data.completed || [])]);
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
- {/* Active Jobs */}
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
- Job History
258
+ Workflow Runs
383
259
  </h2>
384
- <SwarmJobHistory
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
@@ -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, { page = 1, perPage = 100 } = {}) {
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: active + completed jobs with counts
129
- * @returns {Promise<object>} - { active, completed, counts }
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 [inProgress, queued, completed] = await Promise.all([
133
- getWorkflowRuns('in_progress', 'run-job.yml'),
134
- getWorkflowRuns('queued', 'run-job.yml'),
135
- getWorkflowRuns('completed', 'run-job.yml', { page }),
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 activeRuns = [
139
- ...(inProgress.workflow_runs || []),
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
- run_id: run.id,
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
- active,
202
- completed: completedJobs,
203
- hasMore: page * 100 < totalCompleted,
152
+ runs,
153
+ hasMore: page * 25 < (allRuns.total_count || 0),
204
154
  counts: {
205
- running: active.filter(j => j.status === 'in_progress').length,
206
- queued: active.filter(j => j.status === 'queued').length,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.46",
3
+ "version": "1.2.48",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -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
- # Atomic swap build (same pattern as rebuild-event-handler)
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
  '
@@ -54,6 +54,7 @@ services:
54
54
  LABELS: self-hosted
55
55
  volumes:
56
56
  - /var/run/docker.sock:/var/run/docker.sock
57
+ - .:/project:ro
57
58
  restart: unless-stopped
58
59
 
59
60
  volumes: