bosun 0.31.2 → 0.31.4
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/error-detector.mjs +60 -10
- package/monitor.mjs +3 -0
- package/package.json +1 -1
- package/task-executor.mjs +29 -1
- package/ui/app.js +114 -19
- package/ui/modules/api.js +5 -0
- package/ui/styles/components.css +203 -0
- package/ui/tabs/dashboard.js +128 -24
- package/utils.mjs +47 -0
package/error-detector.mjs
CHANGED
|
@@ -132,6 +132,7 @@ const GIT_CONFLICT_PATTERNS = [
|
|
|
132
132
|
/rebase.*conflict/i,
|
|
133
133
|
/cannot.*merge|unable to merge/i,
|
|
134
134
|
/both modified/i,
|
|
135
|
+
/cannot rebase|rebase failed/i,
|
|
135
136
|
];
|
|
136
137
|
|
|
137
138
|
export const PUSH_FAILURE_PATTERNS = [
|
|
@@ -173,6 +174,20 @@ export const LINT_FAILURE_PATTERNS = [
|
|
|
173
174
|
/gofmt.*differ|goimports.*differ/i,
|
|
174
175
|
];
|
|
175
176
|
|
|
177
|
+
// ── OOM / SIGKILL patterns ──────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export const OOM_KILL_PATTERNS = [
|
|
180
|
+
/SIGKILL/,
|
|
181
|
+
/killed.*out.?of.?memory|oom.?kill/i,
|
|
182
|
+
/out of memory: kill process/i,
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
export const OOM_PATTERNS = [
|
|
186
|
+
/heap out of memory|javascript heap/i,
|
|
187
|
+
/fatal error.*allocation failed|allocation failure/i,
|
|
188
|
+
/process out of memory/i,
|
|
189
|
+
];
|
|
190
|
+
|
|
176
191
|
/**
|
|
177
192
|
* Ordered list of pattern groups to check. Earlier entries win on ties.
|
|
178
193
|
* Each entry: [patternName, regexArray, baseConfidence]
|
|
@@ -187,6 +202,8 @@ const PATTERN_GROUPS = [
|
|
|
187
202
|
["request_error", REQUEST_ERROR_PATTERNS, 0.91], // Client errors (400/404/422) — needs prompt fix
|
|
188
203
|
["api_error", API_ERROR_PATTERNS, 0.9],
|
|
189
204
|
["session_expired", SESSION_EXPIRED_PATTERNS, 0.9],
|
|
205
|
+
["oom_kill", OOM_KILL_PATTERNS, 0.97], // SIGKILL / OS-level OOM kill — critical
|
|
206
|
+
["oom", OOM_PATTERNS, 0.95], // JavaScript heap OOM — critical
|
|
190
207
|
["codex_sandbox", CODEX_SANDBOX_PATTERNS, 0.88], // Codex sandbox failures
|
|
191
208
|
["push_failure", PUSH_FAILURE_PATTERNS, 0.85],
|
|
192
209
|
["test_failure", TEST_FAILURE_PATTERNS, 0.83],
|
|
@@ -195,6 +212,34 @@ const PATTERN_GROUPS = [
|
|
|
195
212
|
["git_conflict", GIT_CONFLICT_PATTERNS, 0.85],
|
|
196
213
|
];
|
|
197
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Severity level for each error pattern type.
|
|
217
|
+
* 'low' | 'medium' | 'high' | 'critical'
|
|
218
|
+
* @type {Record<string, 'low'|'medium'|'high'|'critical'>}
|
|
219
|
+
*/
|
|
220
|
+
export const PATTERN_SEVERITY = {
|
|
221
|
+
auth_error: "high",
|
|
222
|
+
content_policy: "high",
|
|
223
|
+
plan_stuck: "low",
|
|
224
|
+
rate_limit: "medium",
|
|
225
|
+
token_overflow: "medium",
|
|
226
|
+
model_error: "high",
|
|
227
|
+
request_error: "medium",
|
|
228
|
+
api_error: "medium",
|
|
229
|
+
session_expired: "medium",
|
|
230
|
+
oom_kill: "critical",
|
|
231
|
+
oom: "critical",
|
|
232
|
+
codex_sandbox: "high",
|
|
233
|
+
push_failure: "medium",
|
|
234
|
+
test_failure: "medium",
|
|
235
|
+
lint_failure: "low",
|
|
236
|
+
build_failure: "medium",
|
|
237
|
+
git_conflict: "medium",
|
|
238
|
+
permission_wait: "low",
|
|
239
|
+
empty_response: "low",
|
|
240
|
+
unknown: "low",
|
|
241
|
+
};
|
|
242
|
+
|
|
198
243
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
199
244
|
|
|
200
245
|
/** Safely truncate a string for logging / details. */
|
|
@@ -224,12 +269,14 @@ const PATTERN_DESCRIPTIONS = {
|
|
|
224
269
|
test_failure: "Unit or integration test failure",
|
|
225
270
|
lint_failure: "Lint or code formatting failure",
|
|
226
271
|
push_failure: "Git push or pre-push hook failure",
|
|
227
|
-
git_conflict: "Git merge conflict detected",
|
|
272
|
+
git_conflict: "Git merge or rebase conflict detected",
|
|
228
273
|
auth_error: "API key invalid, expired, or missing — NOT retryable",
|
|
229
274
|
model_error: "Model not found, deprecated, or unavailable",
|
|
230
275
|
request_error: "Bad request (400/404/422) — invalid payload or endpoint",
|
|
231
276
|
content_policy: "Content policy / safety filter violation — NOT retryable",
|
|
232
277
|
codex_sandbox: "Codex CLI sandbox or permission error",
|
|
278
|
+
oom_kill: "Process killed by OS due to out-of-memory (SIGKILL)",
|
|
279
|
+
oom: "JavaScript heap out-of-memory error",
|
|
233
280
|
permission_wait: "Agent waiting for human input/permission",
|
|
234
281
|
empty_response: "Agent produced no meaningful output",
|
|
235
282
|
unknown: "Unclassified error",
|
|
@@ -271,7 +318,7 @@ export class ErrorDetector {
|
|
|
271
318
|
*
|
|
272
319
|
* @param {string} output Agent stdout / response text
|
|
273
320
|
* @param {string} [error] Agent stderr or error message
|
|
274
|
-
* @returns {{ pattern: string, confidence: number, details: string, rawMatch: string|null }}
|
|
321
|
+
* @returns {{ pattern: string, confidence: number, details: string, rawMatch: string|null, severity: 'low'|'medium'|'high'|'critical' }}
|
|
275
322
|
*/
|
|
276
323
|
classify(output, error) {
|
|
277
324
|
const combined = [output, error].filter(Boolean).join("\n");
|
|
@@ -281,6 +328,7 @@ export class ErrorDetector {
|
|
|
281
328
|
confidence: 0,
|
|
282
329
|
details: "No output to analyse",
|
|
283
330
|
rawMatch: null,
|
|
331
|
+
severity: PATTERN_SEVERITY.unknown ?? "low",
|
|
284
332
|
};
|
|
285
333
|
}
|
|
286
334
|
|
|
@@ -307,14 +355,16 @@ export class ErrorDetector {
|
|
|
307
355
|
}
|
|
308
356
|
}
|
|
309
357
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
358
|
+
const result = best || {
|
|
359
|
+
pattern: "unknown",
|
|
360
|
+
confidence: 0.3,
|
|
361
|
+
details: PATTERN_DESCRIPTIONS.unknown,
|
|
362
|
+
rawMatch: null,
|
|
363
|
+
};
|
|
364
|
+
return {
|
|
365
|
+
...result,
|
|
366
|
+
severity: PATTERN_SEVERITY[result.pattern] ?? "low",
|
|
367
|
+
};
|
|
318
368
|
}
|
|
319
369
|
|
|
320
370
|
// ── recordError ─────────────────────────────────────────────────────────
|
package/monitor.mjs
CHANGED
|
@@ -9372,6 +9372,7 @@ function formatCodexResult(result) {
|
|
|
9372
9372
|
}
|
|
9373
9373
|
if (typeof result === "object") {
|
|
9374
9374
|
const candidates = [
|
|
9375
|
+
result.finalResponse,
|
|
9375
9376
|
result.output,
|
|
9376
9377
|
result.text,
|
|
9377
9378
|
result.message,
|
|
@@ -12748,6 +12749,8 @@ export {
|
|
|
12748
12749
|
appendKnowledgeEntry,
|
|
12749
12750
|
buildKnowledgeEntry,
|
|
12750
12751
|
formatKnowledgeSummary,
|
|
12752
|
+
extractPlannerTasksFromOutput,
|
|
12753
|
+
formatCodexResult,
|
|
12751
12754
|
// Container runner re-exports
|
|
12752
12755
|
getContainerStatus,
|
|
12753
12756
|
isContainerEnabled,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.4",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/task-executor.mjs
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
collectDiffStats,
|
|
69
69
|
} from "./diff-stats.mjs";
|
|
70
70
|
import { createAnomalyDetector } from "./anomaly-detector.mjs";
|
|
71
|
-
import { normalizeDedupKey } from "./utils.mjs";
|
|
71
|
+
import { normalizeDedupKey, yieldToEventLoop } from "./utils.mjs";
|
|
72
72
|
import {
|
|
73
73
|
resolveExecutorForTask,
|
|
74
74
|
executorToSdk,
|
|
@@ -114,6 +114,23 @@ const CODEX_TASK_LABELS = (() => {
|
|
|
114
114
|
/** Watchdog interval: how often to check for stalled agent slots */
|
|
115
115
|
const WATCHDOG_INTERVAL_MS = 60_000; // 1 minute
|
|
116
116
|
|
|
117
|
+
// ── Error categorization ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Categorize an error for smarter retry/backoff decisions.
|
|
121
|
+
* @param {unknown} err
|
|
122
|
+
* @returns {'transient'|'auth'|'network'|'conflict'|'permanent'|'unknown'}
|
|
123
|
+
*/
|
|
124
|
+
function categorizeError(err) {
|
|
125
|
+
const msg = (err?.message || err?.stderr || String(err ?? "")).toLowerCase();
|
|
126
|
+
if (/rate.?limit|too many requests|429/i.test(msg)) return "transient";
|
|
127
|
+
if (/auth|token|unauthorized|forbidden|403|401/i.test(msg)) return "auth";
|
|
128
|
+
if (/network|econnrefused|enotfound|etimedout|econnreset|socket/i.test(msg)) return "network";
|
|
129
|
+
if (/conflict|merge conflict|rebase conflict|CONFLICT/i.test(msg)) return "conflict";
|
|
130
|
+
if (/fatal|crash|exit code 1|exit code 127|SIGKILL/i.test(msg)) return "permanent";
|
|
131
|
+
return "unknown";
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
/**
|
|
118
135
|
* Returns the Co-authored-by trailer for bosun-botswain[bot], or empty string
|
|
119
136
|
* if the GitHub App ID is not configured. Used to attribute agent commits to
|
|
@@ -3241,6 +3258,8 @@ class TaskExecutor {
|
|
|
3241
3258
|
`${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
|
|
3242
3259
|
);
|
|
3243
3260
|
});
|
|
3261
|
+
// Yield between slot dispatches so WebSocket/HTTP work can proceed
|
|
3262
|
+
await yieldToEventLoop();
|
|
3244
3263
|
}
|
|
3245
3264
|
} catch (err) {
|
|
3246
3265
|
console.error(`${TAG} poll loop error: ${err.message}`);
|
|
@@ -4795,6 +4814,15 @@ class TaskExecutor {
|
|
|
4795
4814
|
/* best-effort */
|
|
4796
4815
|
}
|
|
4797
4816
|
|
|
4817
|
+
// Category-specific backoff to avoid hammering services under stress
|
|
4818
|
+
const errCategory = categorizeError(result.error || "");
|
|
4819
|
+
console.log(`${TAG} error category for "${task.title}": ${errCategory}`);
|
|
4820
|
+
if (errCategory === "transient") {
|
|
4821
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
4822
|
+
} else if (errCategory === "network") {
|
|
4823
|
+
await new Promise((r) => setTimeout(r, 15_000));
|
|
4824
|
+
}
|
|
4825
|
+
|
|
4798
4826
|
// If plan-stuck, use recovery prompt instead of generic retry
|
|
4799
4827
|
if (
|
|
4800
4828
|
classification.pattern === "plan_stuck" &&
|
package/ui/app.js
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
connectWebSocket,
|
|
44
44
|
disconnectWebSocket,
|
|
45
45
|
wsConnected,
|
|
46
|
+
loadingCount,
|
|
46
47
|
} from "./modules/api.js";
|
|
47
48
|
import {
|
|
48
49
|
connected,
|
|
@@ -124,17 +125,48 @@ if (typeof document !== "undefined" && !document.getElementById("offline-banner-
|
|
|
124
125
|
gap: 12px;
|
|
125
126
|
padding: 12px 16px;
|
|
126
127
|
margin: 8px 16px;
|
|
127
|
-
background: rgba(239, 68, 68, 0.08);
|
|
128
128
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
129
129
|
border-radius: 14px;
|
|
130
130
|
box-shadow: var(--shadow-sm);
|
|
131
131
|
backdrop-filter: blur(6px);
|
|
132
132
|
animation: slideDown 0.3s ease-out;
|
|
133
|
+
transition: background 0.4s ease, border-color 0.4s ease;
|
|
134
|
+
}
|
|
135
|
+
.offline-banner.tone-orange {
|
|
136
|
+
background: rgba(249, 115, 22, 0.08);
|
|
137
|
+
border-color: rgba(249, 115, 22, 0.25);
|
|
138
|
+
}
|
|
139
|
+
.offline-banner.tone-red {
|
|
140
|
+
background: rgba(239, 68, 68, 0.12);
|
|
141
|
+
border-color: rgba(239, 68, 68, 0.3);
|
|
133
142
|
}
|
|
134
143
|
.offline-banner-icon { font-size: 20px; }
|
|
135
144
|
.offline-banner-content { flex: 1; }
|
|
136
|
-
.offline-banner-title { font-weight: 600; font-size: 13px;
|
|
145
|
+
.offline-banner-title { font-weight: 600; font-size: 13px; }
|
|
146
|
+
.tone-orange .offline-banner-title { color: #f97316; }
|
|
147
|
+
.tone-red .offline-banner-title { color: #ef4444; }
|
|
137
148
|
.offline-banner-meta { font-size: 12px; opacity: 0.7; margin-top: 2px; }
|
|
149
|
+
.offline-reconnect-bar {
|
|
150
|
+
height: 2px; border-radius: 2px; margin-top: 6px;
|
|
151
|
+
background: rgba(249,115,22,0.18);
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
}
|
|
154
|
+
.offline-reconnect-fill {
|
|
155
|
+
height: 100%; border-radius: 2px;
|
|
156
|
+
background: #f97316;
|
|
157
|
+
transition: width 1s linear;
|
|
158
|
+
}
|
|
159
|
+
.tone-red .offline-reconnect-fill { background: #ef4444; }
|
|
160
|
+
.offline-dot {
|
|
161
|
+
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
|
162
|
+
animation: offlinePulse 1.6s ease-in-out infinite;
|
|
163
|
+
}
|
|
164
|
+
.offline-dot.orange { background: #f97316; }
|
|
165
|
+
.offline-dot.red { background: #ef4444; }
|
|
166
|
+
@keyframes offlinePulse {
|
|
167
|
+
0%,100% { opacity:1; transform:scale(1); }
|
|
168
|
+
50% { opacity:0.5; transform:scale(1.3); }
|
|
169
|
+
}
|
|
138
170
|
`;
|
|
139
171
|
document.head.appendChild(style);
|
|
140
172
|
}
|
|
@@ -203,16 +235,38 @@ function OfflineBanner() {
|
|
|
203
235
|
}
|
|
204
236
|
} catch { /* handled by signal */ }
|
|
205
237
|
}, []);
|
|
238
|
+
|
|
239
|
+
const retryCount = backendRetryCount.value;
|
|
240
|
+
const isPersistent = retryCount > 3;
|
|
241
|
+
const tone = isPersistent ? "red" : "orange";
|
|
242
|
+
const title = isPersistent
|
|
243
|
+
? "Persistent connection failure"
|
|
244
|
+
: "Backend Unreachable";
|
|
245
|
+
|
|
246
|
+
// Reconnect countdown drives the progress bar
|
|
247
|
+
const countdown = wsReconnectIn.value;
|
|
248
|
+
const maxWait = 15; // max backoff seconds
|
|
249
|
+
const reconnectPct = countdown != null && countdown > 0
|
|
250
|
+
? Math.round(((maxWait - Math.min(countdown, maxWait)) / maxWait) * 100)
|
|
251
|
+
: 100;
|
|
252
|
+
|
|
206
253
|
return html`
|
|
207
|
-
<div class="offline-banner">
|
|
208
|
-
<div class="offline-
|
|
254
|
+
<div class="offline-banner tone-${tone}">
|
|
255
|
+
<div class="offline-dot ${tone}"></div>
|
|
209
256
|
<div class="offline-banner-content">
|
|
210
|
-
<div class="offline-banner-title"
|
|
257
|
+
<div class="offline-banner-title">${title}</div>
|
|
211
258
|
<div class="offline-banner-meta">${backendError.value || "Connection lost"}</div>
|
|
212
259
|
${backendLastSeen.value
|
|
213
260
|
? html`<div class="offline-banner-meta">Last connected: ${formatTimeAgo(backendLastSeen.value)}</div>`
|
|
214
261
|
: null}
|
|
215
|
-
<div class="offline-banner-meta">
|
|
262
|
+
<div class="offline-banner-meta">
|
|
263
|
+
${countdown != null && countdown > 0
|
|
264
|
+
? `Reconnecting in ${countdown}s…`
|
|
265
|
+
: `Retry attempt #${retryCount}`}
|
|
266
|
+
</div>
|
|
267
|
+
<div class="offline-reconnect-bar">
|
|
268
|
+
<div class="offline-reconnect-fill" style="width:${reconnectPct}%"></div>
|
|
269
|
+
</div>
|
|
216
270
|
</div>
|
|
217
271
|
<button class="btn btn-ghost btn-sm" onClick=${manualRetry}>Retry</button>
|
|
218
272
|
</div>
|
|
@@ -224,7 +278,7 @@ import { Component } from "preact";
|
|
|
224
278
|
class TabErrorBoundary extends Component {
|
|
225
279
|
constructor(props) {
|
|
226
280
|
super(props);
|
|
227
|
-
this.state = { error: null };
|
|
281
|
+
this.state = { error: null, showStack: false };
|
|
228
282
|
}
|
|
229
283
|
static getDerivedStateFromError(error) {
|
|
230
284
|
return { error };
|
|
@@ -234,23 +288,43 @@ class TabErrorBoundary extends Component {
|
|
|
234
288
|
}
|
|
235
289
|
render() {
|
|
236
290
|
if (this.state.error) {
|
|
237
|
-
const retry = () => this.setState({ error: null });
|
|
291
|
+
const retry = () => this.setState({ error: null, showStack: false });
|
|
292
|
+
const err = this.state.error;
|
|
293
|
+
const tabName = this.props.tabName || "";
|
|
294
|
+
const errorMsg = err?.message || "An unexpected error occurred while rendering this tab.";
|
|
295
|
+
const stack = err?.stack || "";
|
|
296
|
+
const copyError = () => {
|
|
297
|
+
const text = `${errorMsg}\n\n${stack}`;
|
|
298
|
+
navigator?.clipboard?.writeText(text).catch(() => {});
|
|
299
|
+
};
|
|
300
|
+
const toggleStack = () => this.setState((s) => ({ showStack: !s.showStack }));
|
|
238
301
|
return html`
|
|
239
|
-
<div
|
|
240
|
-
<div
|
|
241
|
-
|
|
242
|
-
Something went wrong
|
|
302
|
+
<div class="tab-error-boundary">
|
|
303
|
+
<div class="tab-error-pulse">
|
|
304
|
+
<span style="font-size:20px;color:#ef4444;">⚠</span>
|
|
243
305
|
</div>
|
|
244
|
-
<div
|
|
245
|
-
|
|
306
|
+
<div>
|
|
307
|
+
<div style="font-size:14px;font-weight:600;margin-bottom:4px;color:var(--text-primary);">
|
|
308
|
+
Something went wrong${tabName ? ` in ${tabName}` : ""}
|
|
309
|
+
</div>
|
|
310
|
+
<div style="font-size:12px;color:var(--text-secondary);max-width:400px;">
|
|
311
|
+
${errorMsg}
|
|
312
|
+
</div>
|
|
246
313
|
</div>
|
|
247
|
-
<
|
|
248
|
-
Retry
|
|
249
|
-
|
|
314
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;">
|
|
315
|
+
<button class="btn btn-primary btn-sm" onClick=${retry}>Retry</button>
|
|
316
|
+
<button class="btn btn-ghost btn-sm" onClick=${copyError}>Copy Error</button>
|
|
317
|
+
${stack ? html`<button class="btn btn-ghost btn-sm" onClick=${toggleStack}>
|
|
318
|
+
${this.state.showStack ? "Hide Stack" : "Stack Trace"}
|
|
319
|
+
</button>` : null}
|
|
320
|
+
</div>
|
|
321
|
+
${this.state.showStack && stack ? html`
|
|
322
|
+
<div class="tab-error-stack">${stack}</div>
|
|
323
|
+
` : null}
|
|
250
324
|
</div>
|
|
251
325
|
`;
|
|
252
326
|
}
|
|
253
|
-
return this.props.children
|
|
327
|
+
return html`<div class="tab-content-enter">${this.props.children}</div>`;
|
|
254
328
|
}
|
|
255
329
|
}
|
|
256
330
|
|
|
@@ -712,6 +786,26 @@ function App() {
|
|
|
712
786
|
const mainRef = useRef(null);
|
|
713
787
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
|
714
788
|
const scrollVisibilityRef = useRef(false);
|
|
789
|
+
|
|
790
|
+
// ── Top loading bar state ──
|
|
791
|
+
const [loadingPct, setLoadingPct] = useState(0);
|
|
792
|
+
const [loadingVisible, setLoadingVisible] = useState(false);
|
|
793
|
+
const loadingTimerRef = useRef(null);
|
|
794
|
+
const isLoading = loadingCount.value > 0;
|
|
795
|
+
useEffect(() => {
|
|
796
|
+
if (isLoading) {
|
|
797
|
+
if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current);
|
|
798
|
+
setLoadingVisible(true);
|
|
799
|
+
setLoadingPct(70);
|
|
800
|
+
} else {
|
|
801
|
+
setLoadingPct(100);
|
|
802
|
+
loadingTimerRef.current = setTimeout(() => {
|
|
803
|
+
setLoadingVisible(false);
|
|
804
|
+
setLoadingPct(0);
|
|
805
|
+
}, 500);
|
|
806
|
+
}
|
|
807
|
+
return () => {};
|
|
808
|
+
}, [isLoading]);
|
|
715
809
|
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
|
716
810
|
const resizeRef = useRef(null);
|
|
717
811
|
const [isCompactNav, setIsCompactNav] = useState(() => {
|
|
@@ -1055,6 +1149,7 @@ function App() {
|
|
|
1055
1149
|
}, []);
|
|
1056
1150
|
|
|
1057
1151
|
return html`
|
|
1152
|
+
<div class="top-loading-bar" style="width: ${loadingPct}%; opacity: ${loadingVisible ? 1 : 0}"></div>
|
|
1058
1153
|
<div
|
|
1059
1154
|
class="app-shell"
|
|
1060
1155
|
style=${shellStyle}
|
|
@@ -1130,7 +1225,7 @@ function App() {
|
|
|
1130
1225
|
<${CommandPalette} open=${paletteOpen} onClose=${paletteClose} />
|
|
1131
1226
|
<${PullToRefresh} onRefresh=${() => refreshTab(activeTab.value)}>
|
|
1132
1227
|
<main class="main-content" ref=${mainRef}>
|
|
1133
|
-
<${TabErrorBoundary} key=${activeTab.value}>
|
|
1228
|
+
<${TabErrorBoundary} key=${activeTab.value} tabName=${activeTab.value}>
|
|
1134
1229
|
<${CurrentTab} />
|
|
1135
1230
|
<//>
|
|
1136
1231
|
</main>
|
package/ui/modules/api.js
CHANGED
|
@@ -14,6 +14,8 @@ export const wsLatency = signal(null);
|
|
|
14
14
|
export const wsReconnectIn = signal(null);
|
|
15
15
|
/** Reactive signal: number of reconnections since last user-initiated action */
|
|
16
16
|
export const wsReconnectCount = signal(0);
|
|
17
|
+
/** Reactive signal: count of in-flight apiFetch calls (drives top loading bar) */
|
|
18
|
+
export const loadingCount = signal(0);
|
|
17
19
|
|
|
18
20
|
/* ─── REST API Client ─── */
|
|
19
21
|
|
|
@@ -37,6 +39,7 @@ export async function apiFetch(path, options = {}) {
|
|
|
37
39
|
const silent = options._silent;
|
|
38
40
|
delete options._silent;
|
|
39
41
|
|
|
42
|
+
loadingCount.value += 1;
|
|
40
43
|
try {
|
|
41
44
|
const res = await fetch(path, { ...options, headers });
|
|
42
45
|
if (!res.ok) {
|
|
@@ -57,6 +60,8 @@ export async function apiFetch(path, options = {}) {
|
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
throw err;
|
|
63
|
+
} finally {
|
|
64
|
+
loadingCount.value = Math.max(0, loadingCount.value - 1);
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
|
package/ui/styles/components.css
CHANGED
|
@@ -3876,3 +3876,206 @@ select.input {
|
|
|
3876
3876
|
vertical-align: baseline;
|
|
3877
3877
|
font-size: inherit;
|
|
3878
3878
|
}
|
|
3879
|
+
|
|
3880
|
+
/* ═══════════════════════════════════════════════
|
|
3881
|
+
* Tab transition animations
|
|
3882
|
+
* ═══════════════════════════════════════════════ */
|
|
3883
|
+
|
|
3884
|
+
@keyframes tabFadeSlideIn {
|
|
3885
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
3886
|
+
to { opacity: 1; transform: translateY(0); }
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
.tab-content-enter {
|
|
3890
|
+
animation: tabFadeSlideIn 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
/* ═══════════════════════════════════════════════
|
|
3894
|
+
* Top loading bar
|
|
3895
|
+
* ═══════════════════════════════════════════════ */
|
|
3896
|
+
|
|
3897
|
+
.top-loading-bar {
|
|
3898
|
+
position: fixed;
|
|
3899
|
+
top: 0;
|
|
3900
|
+
left: 0;
|
|
3901
|
+
height: 3px;
|
|
3902
|
+
z-index: 99999;
|
|
3903
|
+
background: linear-gradient(90deg, var(--accent), #8b5cf6);
|
|
3904
|
+
transition: width 0.3s ease, opacity 0.4s ease;
|
|
3905
|
+
pointer-events: none;
|
|
3906
|
+
border-radius: 0 2px 2px 0;
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
/* ═══════════════════════════════════════════════
|
|
3910
|
+
* Dashboard headline variants
|
|
3911
|
+
* ═══════════════════════════════════════════════ */
|
|
3912
|
+
|
|
3913
|
+
.dashboard-headline-ok { color: var(--color-done); }
|
|
3914
|
+
.dashboard-headline-warn { color: var(--color-inreview); }
|
|
3915
|
+
.dashboard-headline-error { color: var(--color-error); }
|
|
3916
|
+
.dashboard-headline-idle { color: var(--text-secondary); }
|
|
3917
|
+
|
|
3918
|
+
/* ═══════════════════════════════════════════════
|
|
3919
|
+
* Fleet ticker
|
|
3920
|
+
* ═══════════════════════════════════════════════ */
|
|
3921
|
+
|
|
3922
|
+
.fleet-ticker-wrap {
|
|
3923
|
+
overflow: hidden;
|
|
3924
|
+
position: relative;
|
|
3925
|
+
height: 20px;
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
@keyframes tickerScroll {
|
|
3929
|
+
0% { transform: translateY(0); }
|
|
3930
|
+
33% { transform: translateY(-20px); }
|
|
3931
|
+
66% { transform: translateY(-40px); }
|
|
3932
|
+
100% { transform: translateY(0); }
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
.fleet-ticker-inner {
|
|
3936
|
+
display: flex;
|
|
3937
|
+
flex-direction: column;
|
|
3938
|
+
animation: tickerScroll 6s steps(1) infinite;
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
.fleet-ticker-item {
|
|
3942
|
+
height: 20px;
|
|
3943
|
+
display: flex;
|
|
3944
|
+
align-items: center;
|
|
3945
|
+
gap: 6px;
|
|
3946
|
+
font-size: 12px;
|
|
3947
|
+
color: var(--text-secondary);
|
|
3948
|
+
white-space: nowrap;
|
|
3949
|
+
overflow: hidden;
|
|
3950
|
+
text-overflow: ellipsis;
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
/* ═══════════════════════════════════════════════
|
|
3954
|
+
* Tab Error Boundary improvements
|
|
3955
|
+
* ═══════════════════════════════════════════════ */
|
|
3956
|
+
|
|
3957
|
+
.tab-error-boundary {
|
|
3958
|
+
padding: 32px 24px;
|
|
3959
|
+
display: flex;
|
|
3960
|
+
flex-direction: column;
|
|
3961
|
+
align-items: center;
|
|
3962
|
+
gap: 16px;
|
|
3963
|
+
text-align: center;
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
.tab-error-pulse {
|
|
3967
|
+
width: 48px;
|
|
3968
|
+
height: 48px;
|
|
3969
|
+
border-radius: 50%;
|
|
3970
|
+
background: rgba(239, 68, 68, 0.15);
|
|
3971
|
+
display: flex;
|
|
3972
|
+
align-items: center;
|
|
3973
|
+
justify-content: center;
|
|
3974
|
+
animation: errorPulse 2s ease-in-out infinite;
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
@keyframes errorPulse {
|
|
3978
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); }
|
|
3979
|
+
50% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
.tab-error-stack {
|
|
3983
|
+
max-height: 120px;
|
|
3984
|
+
overflow-y: auto;
|
|
3985
|
+
background: rgba(0, 0, 0, 0.3);
|
|
3986
|
+
border-radius: 8px;
|
|
3987
|
+
padding: 8px 12px;
|
|
3988
|
+
font-family: monospace;
|
|
3989
|
+
font-size: 11px;
|
|
3990
|
+
color: var(--text-secondary);
|
|
3991
|
+
text-align: left;
|
|
3992
|
+
width: 100%;
|
|
3993
|
+
box-sizing: border-box;
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
/* ═══════════════════════════════════════════════
|
|
3997
|
+
* Offline banner — pulsing reconnect dot
|
|
3998
|
+
* ═══════════════════════════════════════════════ */
|
|
3999
|
+
|
|
4000
|
+
.offline-dot {
|
|
4001
|
+
width: 10px;
|
|
4002
|
+
height: 10px;
|
|
4003
|
+
border-radius: 50%;
|
|
4004
|
+
flex-shrink: 0;
|
|
4005
|
+
animation: offlinePulse 1.6s ease-in-out infinite;
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
.offline-dot.orange { background: #f97316; }
|
|
4009
|
+
.offline-dot.red { background: #ef4444; }
|
|
4010
|
+
|
|
4011
|
+
@keyframes offlinePulse {
|
|
4012
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
4013
|
+
50% { opacity: 0.5; transform: scale(1.3); }
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
/* ═══════════════════════════════════════════════
|
|
4017
|
+
* Dashboard — hero "Fleet at rest" badge
|
|
4018
|
+
* ═══════════════════════════════════════════════ */
|
|
4019
|
+
|
|
4020
|
+
.fleet-rest-badge {
|
|
4021
|
+
display: flex;
|
|
4022
|
+
flex-direction: column;
|
|
4023
|
+
align-items: center;
|
|
4024
|
+
gap: 8px;
|
|
4025
|
+
padding: 20px 0 8px;
|
|
4026
|
+
text-align: center;
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
.fleet-rest-icon {
|
|
4030
|
+
width: 44px;
|
|
4031
|
+
height: 44px;
|
|
4032
|
+
border-radius: 50%;
|
|
4033
|
+
background: rgba(34, 197, 94, 0.15);
|
|
4034
|
+
display: flex;
|
|
4035
|
+
align-items: center;
|
|
4036
|
+
justify-content: center;
|
|
4037
|
+
font-size: 22px;
|
|
4038
|
+
color: var(--color-done);
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
.fleet-rest-label {
|
|
4042
|
+
font-size: 15px;
|
|
4043
|
+
font-weight: 700;
|
|
4044
|
+
color: var(--color-done);
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
.fleet-rest-sub {
|
|
4048
|
+
font-size: 12px;
|
|
4049
|
+
color: var(--text-secondary);
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
/* ═══════════════════════════════════════════════
|
|
4053
|
+
* Dashboard welcome card
|
|
4054
|
+
* ═══════════════════════════════════════════════ */
|
|
4055
|
+
|
|
4056
|
+
.dashboard-welcome-card {
|
|
4057
|
+
text-align: center;
|
|
4058
|
+
padding: 40px 24px;
|
|
4059
|
+
display: flex;
|
|
4060
|
+
flex-direction: column;
|
|
4061
|
+
align-items: center;
|
|
4062
|
+
gap: 12px;
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
.dashboard-welcome-icon {
|
|
4066
|
+
font-size: 52px;
|
|
4067
|
+
margin-bottom: 8px;
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
.dashboard-welcome-title {
|
|
4071
|
+
font-size: 22px;
|
|
4072
|
+
font-weight: 700;
|
|
4073
|
+
color: var(--text-primary);
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
.dashboard-welcome-desc {
|
|
4077
|
+
font-size: 14px;
|
|
4078
|
+
color: var(--text-secondary);
|
|
4079
|
+
max-width: 340px;
|
|
4080
|
+
line-height: 1.6;
|
|
4081
|
+
}
|
package/ui/tabs/dashboard.js
CHANGED
|
@@ -189,6 +189,7 @@ export function CreateTaskModal({ onClose }) {
|
|
|
189
189
|
export function DashboardTab() {
|
|
190
190
|
const [showCreate, setShowCreate] = useState(false);
|
|
191
191
|
const [showStartModal, setShowStartModal] = useState(false);
|
|
192
|
+
const [uptime, setUptime] = useState(null);
|
|
192
193
|
const status = statusData.value;
|
|
193
194
|
const executor = executorData.value;
|
|
194
195
|
const project = projectSummary.value;
|
|
@@ -219,6 +220,44 @@ export function DashboardTab() {
|
|
|
219
220
|
blocked ? ` · ${blocked} blocked` : ""
|
|
220
221
|
}`;
|
|
221
222
|
|
|
223
|
+
// ── Dynamic headline ──
|
|
224
|
+
const headline =
|
|
225
|
+
totalActive === 0
|
|
226
|
+
? "Fleet idle"
|
|
227
|
+
: blocked > 0
|
|
228
|
+
? "Needs attention"
|
|
229
|
+
: "All systems running";
|
|
230
|
+
const headlineClass =
|
|
231
|
+
totalActive === 0
|
|
232
|
+
? "dashboard-headline-idle"
|
|
233
|
+
: blocked > 0
|
|
234
|
+
? "dashboard-headline-error"
|
|
235
|
+
: "dashboard-headline-ok";
|
|
236
|
+
|
|
237
|
+
// ── Hero badge: all tasks done and nothing pending ──
|
|
238
|
+
const fleetAtRest = totalTasks > 0 && done > 0 && backlog === 0 && totalActive === 0;
|
|
239
|
+
|
|
240
|
+
// ── Uptime fetch on mount ──
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
let active = true;
|
|
243
|
+
apiFetch("/api/health", { _silent: true })
|
|
244
|
+
.then((res) => {
|
|
245
|
+
if (!active) return;
|
|
246
|
+
const secs = Number(res?.uptime || 0);
|
|
247
|
+
if (!secs) return;
|
|
248
|
+
const d = Math.floor(secs / 86400);
|
|
249
|
+
const h = Math.floor((secs % 86400) / 3600);
|
|
250
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
251
|
+
const parts = [];
|
|
252
|
+
if (d > 0) parts.push(`${d}d`);
|
|
253
|
+
if (h > 0) parts.push(`${h}h`);
|
|
254
|
+
if (m > 0 && d === 0) parts.push(`${m}m`);
|
|
255
|
+
setUptime(parts.length ? `up ${parts.join(" ")}` : "up < 1m");
|
|
256
|
+
})
|
|
257
|
+
.catch(() => {});
|
|
258
|
+
return () => { active = false; };
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
222
261
|
const overviewMetrics = [
|
|
223
262
|
{
|
|
224
263
|
label: "Total tasks",
|
|
@@ -226,6 +265,7 @@ export function DashboardTab() {
|
|
|
226
265
|
color: "var(--text-primary)",
|
|
227
266
|
trend: getTrend("total"),
|
|
228
267
|
spark: "total",
|
|
268
|
+
tab: "tasks",
|
|
229
269
|
},
|
|
230
270
|
{
|
|
231
271
|
label: "In progress",
|
|
@@ -233,6 +273,7 @@ export function DashboardTab() {
|
|
|
233
273
|
color: "var(--color-inprogress)",
|
|
234
274
|
trend: getTrend("running"),
|
|
235
275
|
spark: "running",
|
|
276
|
+
tab: "tasks",
|
|
236
277
|
},
|
|
237
278
|
{
|
|
238
279
|
label: "Done",
|
|
@@ -240,6 +281,7 @@ export function DashboardTab() {
|
|
|
240
281
|
color: "var(--color-done)",
|
|
241
282
|
trend: getTrend("done"),
|
|
242
283
|
spark: "done",
|
|
284
|
+
tab: "tasks",
|
|
243
285
|
},
|
|
244
286
|
{
|
|
245
287
|
label: "Error rate",
|
|
@@ -247,9 +289,13 @@ export function DashboardTab() {
|
|
|
247
289
|
color: "var(--color-error)",
|
|
248
290
|
trend: -getTrend("errors"),
|
|
249
291
|
spark: "errors",
|
|
292
|
+
tab: "tasks",
|
|
250
293
|
},
|
|
251
294
|
];
|
|
252
295
|
|
|
296
|
+
// ── Live fleet ticker data (3 most recent tasks) ──
|
|
297
|
+
const tickerTasks = (tasksData.value || []).slice(0, 3);
|
|
298
|
+
|
|
253
299
|
const workItems = [
|
|
254
300
|
{ label: "Running", value: running, color: "var(--color-inprogress)" },
|
|
255
301
|
{ label: "Review", value: review, color: "var(--color-inreview)" },
|
|
@@ -416,17 +462,40 @@ export function DashboardTab() {
|
|
|
416
462
|
if (!status && !executor)
|
|
417
463
|
return html`<${Card} title="Loading…"><${SkeletonCard} count=${4} /><//>`;
|
|
418
464
|
|
|
465
|
+
/* ── Welcome empty state ── */
|
|
466
|
+
if (totalTasks === 0 && !executor) {
|
|
467
|
+
return html`
|
|
468
|
+
<div class="dashboard-shell">
|
|
469
|
+
<${Card} className="dashboard-card">
|
|
470
|
+
<div class="dashboard-welcome-card">
|
|
471
|
+
<div class="dashboard-welcome-icon">🎛️</div>
|
|
472
|
+
<div class="dashboard-welcome-title">Welcome to VirtEngine Control Center</div>
|
|
473
|
+
<div class="dashboard-welcome-desc">
|
|
474
|
+
Your AI development fleet is ready. Create your first task to get started.
|
|
475
|
+
</div>
|
|
476
|
+
<button class="btn btn-primary" onClick=${() => setShowCreate(true)}>
|
|
477
|
+
➕ Create your first task
|
|
478
|
+
</button>
|
|
479
|
+
</div>
|
|
480
|
+
<//>
|
|
481
|
+
${showCreate &&
|
|
482
|
+
html`<${CreateTaskModal} onClose=${() => setShowCreate(false)} />`}
|
|
483
|
+
</div>
|
|
484
|
+
`;
|
|
485
|
+
}
|
|
486
|
+
|
|
419
487
|
return html`
|
|
420
488
|
<div class="dashboard-shell">
|
|
421
489
|
<div class="dashboard-header">
|
|
422
490
|
<div class="dashboard-header-text">
|
|
423
491
|
<div class="dashboard-eyebrow">Pulse</div>
|
|
424
|
-
<div class="dashboard-title"
|
|
492
|
+
<div class="dashboard-title ${headlineClass}">${headline}</div>
|
|
425
493
|
<div class="dashboard-subtitle">${headerLine}</div>
|
|
426
494
|
</div>
|
|
427
495
|
<div class="dashboard-header-meta">
|
|
428
496
|
<span class="dashboard-chip">Mode ${mode}</span>
|
|
429
497
|
<span class="dashboard-chip">SDK ${defaultSdk}</span>
|
|
498
|
+
${uptime ? html`<span class="dashboard-chip">${uptime}</span>` : null}
|
|
430
499
|
${executor
|
|
431
500
|
? executor.paused
|
|
432
501
|
? html`<${Badge} status="error" text="Paused" />`
|
|
@@ -490,6 +559,24 @@ export function DashboardTab() {
|
|
|
490
559
|
Resume
|
|
491
560
|
</button>
|
|
492
561
|
</div>
|
|
562
|
+
${tickerTasks.length > 0 ? html`
|
|
563
|
+
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border)">
|
|
564
|
+
<div style="font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-secondary);margin-bottom:6px;display:flex;align-items:center;gap:5px;">
|
|
565
|
+
<span style="width:6px;height:6px;border-radius:50%;background:var(--color-done);animation:errorPulse 2s ease-in-out infinite;display:inline-block;"></span>
|
|
566
|
+
Live
|
|
567
|
+
</div>
|
|
568
|
+
<div class="fleet-ticker-wrap">
|
|
569
|
+
<div class="fleet-ticker-inner">
|
|
570
|
+
${tickerTasks.map((task) => html`
|
|
571
|
+
<div class="fleet-ticker-item">
|
|
572
|
+
<${Badge} status=${task.status} text=${task.status} />
|
|
573
|
+
<span>${truncate(task.title || "(untitled)", 38)}</span>
|
|
574
|
+
</div>
|
|
575
|
+
`)}
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
` : null}
|
|
493
580
|
<//>
|
|
494
581
|
|
|
495
582
|
<${Card}
|
|
@@ -498,29 +585,46 @@ export function DashboardTab() {
|
|
|
498
585
|
>`}
|
|
499
586
|
className="dashboard-card dashboard-overview"
|
|
500
587
|
>
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
<div class="
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
588
|
+
${fleetAtRest
|
|
589
|
+
? html`
|
|
590
|
+
<div class="fleet-rest-badge">
|
|
591
|
+
<div class="fleet-rest-icon">✓</div>
|
|
592
|
+
<div class="fleet-rest-label">Fleet at rest</div>
|
|
593
|
+
<div class="fleet-rest-sub">${done} task${done !== 1 ? "s" : ""} completed · zero pending</div>
|
|
594
|
+
</div>
|
|
595
|
+
`
|
|
596
|
+
: html`
|
|
597
|
+
<div class="dashboard-metric-grid">
|
|
598
|
+
${overviewMetrics.map(
|
|
599
|
+
(metric) => html`
|
|
600
|
+
<div
|
|
601
|
+
class="dashboard-metric"
|
|
602
|
+
style="cursor:pointer;"
|
|
603
|
+
role="button"
|
|
604
|
+
tabindex="0"
|
|
605
|
+
onClick=${() => navigateTo(metric.tab || "tasks")}
|
|
606
|
+
onKeyDown=${(e) => e.key === "Enter" && navigateTo(metric.tab || "tasks")}
|
|
607
|
+
>
|
|
608
|
+
<div class="dashboard-metric-label">${metric.label}</div>
|
|
609
|
+
<div
|
|
610
|
+
class="dashboard-metric-value"
|
|
611
|
+
style="color: ${metric.color}"
|
|
612
|
+
>
|
|
613
|
+
${metric.value} ${trend(metric.trend)}
|
|
614
|
+
</div>
|
|
615
|
+
<div class="dashboard-metric-spark">
|
|
616
|
+
<${MiniSparkline}
|
|
617
|
+
data=${sparkData(metric.spark)}
|
|
618
|
+
color=${metric.color}
|
|
619
|
+
height=${20}
|
|
620
|
+
width=${90}
|
|
621
|
+
/>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
`,
|
|
625
|
+
)}
|
|
626
|
+
</div>
|
|
627
|
+
`}
|
|
524
628
|
${segments.length > 0 && html`
|
|
525
629
|
<div class="dashboard-work-layout" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
|
526
630
|
<div class="dashboard-work-list">
|
package/utils.mjs
CHANGED
|
@@ -170,3 +170,50 @@ export function parsePrNumberFromUrl(url) {
|
|
|
170
170
|
const num = Number(match[1]);
|
|
171
171
|
return Number.isFinite(num) ? num : null;
|
|
172
172
|
}
|
|
173
|
+
|
|
174
|
+
// ── Async process helpers ─────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* spawnAsync — Promise-based alternative to spawnSync.
|
|
178
|
+
* Doesn't block the event loop. Resolves { stdout, stderr, status }
|
|
179
|
+
* or rejects with an Error enriched with those same properties.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} cmd
|
|
182
|
+
* @param {string[]} args
|
|
183
|
+
* @param {import('node:child_process').SpawnOptions} options
|
|
184
|
+
* @returns {Promise<{ stdout: string, stderr: string, status: number }>}
|
|
185
|
+
*/
|
|
186
|
+
export async function spawnAsync(cmd, args = [], options = {}) {
|
|
187
|
+
const { spawn } = await import("node:child_process");
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const stdoutChunks = [];
|
|
190
|
+
const stderrChunks = [];
|
|
191
|
+
const proc = spawn(cmd, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
192
|
+
proc.stdout.on("data", (d) => stdoutChunks.push(d));
|
|
193
|
+
proc.stderr.on("data", (d) => stderrChunks.push(d));
|
|
194
|
+
proc.on("close", (code) => {
|
|
195
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
196
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
197
|
+
if (code !== 0) {
|
|
198
|
+
const err = Object.assign(
|
|
199
|
+
new Error(`spawnAsync: ${cmd} ${args.join(" ")} exited ${code}\n${stderr || stdout}`),
|
|
200
|
+
{ stdout, stderr, status: code },
|
|
201
|
+
);
|
|
202
|
+
reject(err);
|
|
203
|
+
} else {
|
|
204
|
+
resolve({ stdout, stderr, status: code });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
proc.on("error", reject);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* yieldToEventLoop — allow other microtasks/macrotasks to proceed.
|
|
213
|
+
* Use between iterations of long synchronous-ish loops to prevent
|
|
214
|
+
* event loop starvation when many agent slots are active.
|
|
215
|
+
* @returns {Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
export function yieldToEventLoop() {
|
|
218
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
219
|
+
}
|