From 22081d0131a6e7c019f4a8e19e64b4f8f167a731 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Wed, 3 Jun 2026 22:09:46 +0100 Subject: [PATCH 01/19] Add OpenClaw workflow dashboard --- migrations/0018_openclaw_workflow.sql | 15 + scripts/generate-assets.mjs | 10 +- src/app.html | 220 ++++++++++ src/app/main.jsx | 524 ++++++++++++++++++++++- src/index.ts | 591 ++++++++++++++++++++++++++ src/openclaw-workflow.ts | 421 ++++++++++++++++++ tests/openclaw-workflow.test.ts | 173 ++++++++ 7 files changed, 1942 insertions(+), 12 deletions(-) create mode 100644 migrations/0018_openclaw_workflow.sql create mode 100644 src/openclaw-workflow.ts create mode 100644 tests/openclaw-workflow.test.ts diff --git a/migrations/0018_openclaw_workflow.sql b/migrations/0018_openclaw_workflow.sql new file mode 100644 index 00000000..eb9e4da8 --- /dev/null +++ b/migrations/0018_openclaw_workflow.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS openclaw_workflow_preferences ( + subject TEXT PRIMARY KEY, + role_mode TEXT NOT NULL DEFAULT 'trial_maintainer', + target_repo TEXT NOT NULL DEFAULT 'openclaw/openclaw', + github_login TEXT, + active_open_pr_limit INTEGER NOT NULL DEFAULT 10, + hard_open_pr_cap INTEGER NOT NULL DEFAULT 20, + daily_usage_drop_limit INTEGER NOT NULL DEFAULT 5, + max_parallel_workers INTEGER NOT NULL DEFAULT 2, + weekly_remaining_baseline INTEGER, + weekly_remaining_current INTEGER, + usage_window_started_at INTEGER, + updated_at INTEGER NOT NULL, + FOREIGN KEY (subject) REFERENCES users(subject) +); diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index 208e6907..8e0c4ef9 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { css, js, preThemeScript, themeToggleHtml } from "./docs-site-assets.mjs"; @@ -27,10 +28,10 @@ const distDocs = new URL("../dist/docs/", import.meta.url); const appOrigin = "https://clawfleet.openclaw.ai"; await run(process.execPath, [ - new URL("../node_modules/vite/bin/vite.js", import.meta.url).pathname, + fileURLToPath(new URL("../node_modules/vite/bin/vite.js", import.meta.url)), "build", "--config", - new URL("../vite.config.mjs", import.meta.url).pathname, + fileURLToPath(new URL("../vite.config.mjs", import.meta.url)), ]); const [ @@ -136,10 +137,15 @@ function buildLucideIconScript(iconNodes) { const names = [ "book-open", "copy", + "external-link", "git-pull-request", "layout-grid", + "list-checks", + "message-square", "moon", + "refresh-cw", "settings", + "shield-check", "square-terminal", "sun", "terminal", diff --git a/src/app.html b/src/app.html index 7afbe5a4..d296233b 100644 --- a/src/app.html +++ b/src/app.html @@ -1906,6 +1906,205 @@ font-size: 13px; } + .openclaw-page { + display: grid; + gap: 18px; + } + .openclaw-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 16px 18px; + } + .openclaw-toolbar h2, + .workflow-section h2 { + margin: 5px 0 0; + color: var(--ink); + font-size: 18px; + font-weight: 720; + } + .openclaw-toolbar button, + .pr-row button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + } + .openclaw-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + } + .workflow-banner { + border: 1px solid rgb(245 158 11 / 0.35); + border-radius: 8px; + background: rgb(245 158 11 / 0.08); + color: var(--ink); + padding: 10px 12px; + font-size: 13px; + } + .workflow-banner.error { + border-color: rgb(239 68 68 / 0.34); + background: rgb(239 68 68 / 0.1); + } + .openclaw-settings { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + gap: 10px; + align-items: end; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 14px; + } + .openclaw-settings.skeleton { + color: var(--muted); + font-size: 13px; + } + .openclaw-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 0.42fr); + gap: 16px; + align-items: start; + } + .openclaw-main, + .openclaw-side, + .workflow-section { + min-width: 0; + } + .workflow-section { + display: grid; + gap: 12px; + } + .workflow-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .queue-block, + .pr-row { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 14px; + } + .queue-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + } + .queue-head h3 { + margin: 0; + color: var(--ink); + font-size: 15px; + font-weight: 720; + } + .queue-head p, + .pr-detail { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .query-line { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-2); + color: var(--muted); + padding: 7px 9px; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + } + .candidate-list, + .pr-list { + display: grid; + gap: 8px; + } + .candidate-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 74px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-2); + padding: 10px; + } + .candidate-main { + min-width: 0; + display: grid; + gap: 6px; + } + .candidate-main a, + .pr-row > a { + min-width: 0; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + } + .candidate-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + .candidate-actions { + display: inline-flex; + gap: 8px; + align-items: center; + } + .candidate-actions button:first-child { + width: 36px; + padding: 0; + } + .chip.ok { + border-color: rgb(16 185 129 / 0.28); + background: rgb(16 185 129 / 0.12); + color: var(--success); + } + .chip.warn { + border-color: rgb(245 158 11 / 0.32); + background: rgb(245 158 11 / 0.1); + color: var(--warning); + } + .chip.danger { + border-color: rgb(239 68 68 / 0.34); + background: rgb(239 68 68 / 0.1); + color: #f87171; + } + .pr-monitor { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 14px; + } + .pr-row { + background: var(--panel); + } + .handoff-box { + margin-top: 4px; + } + .handoff-box textarea { + min-height: 150px; + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + } + @keyframes infraDrift { 0%, 100% { @@ -1954,6 +2153,15 @@ .dashboard-grid { grid-template-columns: 1fr; } + .openclaw-grid { + grid-template-columns: 1fr; + } + .openclaw-settings { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .openclaw-settings button { + grid-column: 1 / -1; + } .fleet-control-panel { grid-template-columns: 1fr; } @@ -2011,6 +2219,10 @@ } .toolbar, .status-strip, + .openclaw-toolbar, + .openclaw-summary, + .openclaw-settings, + .candidate-row, .fleet-control-grid, .dev-identity-panel, .board, @@ -2019,6 +2231,14 @@ .session-head { grid-template-columns: 1fr; } + .candidate-actions { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + width: 100%; + } + .candidate-actions button { + width: 100%; + } .session-grid { grid-template-columns: 1fr; --session-tile-height: 430px; diff --git a/src/app/main.jsx b/src/app/main.jsx index f2c76ffd..5a440416 100644 --- a/src/app/main.jsx +++ b/src/app/main.jsx @@ -97,6 +97,12 @@ function App() { ); const [sessionLayout, setSessionLayout] = useState(loadSessionLayout); const [terminalStatus, setTerminalStatus] = useState({}); + const [openClawState, setOpenClawState] = useState({ + loading: false, + data: null, + error: "", + handoffText: "", + }); const stateRef = useRef(state); const authMethodsRef = useRef(authMethods); const signedInRef = useRef(signedIn); @@ -107,6 +113,7 @@ function App() { const stateRetryTimer = useRef(null); const refPreviewTimer = useRef(null); const refPreviewSeq = useRef(0); + const openClawSeq = useRef(0); const draggedSessionId = useRef(null); const autoLoginStarted = useRef(false); @@ -190,6 +197,11 @@ function App() { void openInitialSessionLink(); }, [sharedSessionId, signedIn, state.interactiveSessions]); + useEffect(() => { + if (!signedIn || appView !== "openclaw") return; + void loadOpenClawWorkflow(); + }, [signedIn, appView]); + async function loadState() { try { const nextState = await api("/api/state", { authOptional: true }); @@ -418,12 +430,13 @@ function App() { } function setAppView(value) { - const next = value === "board" ? "board" : "fleet"; + const next = value === "board" ? "board" : value === "openclaw" ? "openclaw" : "fleet"; setAppViewState(next); closeAllDrawers(); if (!history.pushState) return; const url = new URL(location.href); - url.pathname = next === "board" ? "/app/board" : "/app/fleet"; + url.pathname = + next === "board" ? "/app/board" : next === "openclaw" ? "/app/openclaw" : "/app/fleet"; url.search = ""; history.pushState(null, "", url); } @@ -468,11 +481,77 @@ function App() { return; } const url = new URL(location.href); - url.pathname = options.grid ? "/sessions" : appView === "board" ? "/app/board" : "/app/fleet"; + url.pathname = options.grid + ? "/sessions" + : appView === "board" + ? "/app/board" + : appView === "openclaw" + ? "/app/openclaw" + : "/app/fleet"; url.search = ""; history.replaceState(null, "", url); } + async function loadOpenClawWorkflow(params = {}) { + const seq = ++openClawSeq.current; + const query = new URLSearchParams(); + if (params.repo) query.set("repo", params.repo); + if (params.login) query.set("login", params.login); + const queryText = query.toString(); + setOpenClawState((current) => ({ ...current, loading: true, error: "" })); + try { + const data = await api(`/api/openclaw/workflow${queryText ? `?${queryText}` : ""}`); + if (seq !== openClawSeq.current) return; + setOpenClawState((current) => ({ ...current, loading: false, data, error: "" })); + } catch (error) { + if (seq !== openClawSeq.current) return; + setOpenClawState((current) => ({ + ...current, + loading: false, + error: error.message || "OpenClaw workflow failed to load", + })); + } + } + + async function updateOpenClawPreferences(preferences) { + const result = await api("/api/openclaw/preferences", { + method: "PUT", + body: preferences, + }); + setOpenClawState((current) => ({ + ...current, + data: current.data ? { ...current.data, preferences: result.preferences } : current.data, + })); + await loadOpenClawWorkflow({ + repo: result.preferences?.targetRepo, + login: result.preferences?.githubLogin, + }); + } + + async function createOpenClawCandidateCard(candidate) { + await api("/api/cards", { + method: "POST", + body: { + title: `OpenClaw #${candidate.number}: ${candidate.title}`, + prompt: candidate.workPrompt || `${candidate.url}\n\n${candidate.title}`, + repo: openClawState.data?.repo || "openclaw/openclaw", + source: "Issue", + runtime: "auto", + policy: "open_pr", + }, + }); + await loadState(); + } + + async function createOpenClawHandoff(input) { + const result = await api("/api/openclaw/handoff", { + method: "POST", + body: input, + }); + setOpenClawState((current) => ({ ...current, handoffText: result.text || "" })); + return result.text || ""; + } + function setTheme(value) { setThemeState(value === "light" ? "light" : "dark"); } @@ -863,6 +942,11 @@ function App() { removeRepo, refreshWorkflow, updatePolicy, + openClawState, + loadOpenClawWorkflow, + updateOpenClawPreferences, + createOpenClawCandidateCard, + createOpenClawHandoff, }; return ; @@ -1065,6 +1149,15 @@ function AppShell(props) { Board + + + {props.openClawState.error ? ( +
{props.openClawState.error}
+ ) : null} + {actionError ?
{actionError}
: null} +
+ + + + +
+ {governor?.reasons?.length ? ( +
{governor.reasons.join(" ")}
+ ) : null} + +
+
+
+
+
+
QUEUES
+

ClawSweeper-screened issues

+
+
+ {queues.length ? ( + queues.map((queue) => ( + { + setActionError(""); + setBusyIssue(candidate.number); + try { + await props.createOpenClawCandidateCard(candidate); + } catch (error) { + setActionError(error.message || "Could not create card"); + } finally { + setBusyIssue(null); + } + }} + /> + )) + ) : ( +
No queue data loaded.
+ )} +
+
+ +
+ + ); +} + +function OpenClawPreferencesPanel({ preferences, loading, onSave }) { + const [draft, setDraft] = useState(() => openClawPreferenceDraft(preferences)); + const [busy, setBusy] = useState(false); + useEffect(() => { + setDraft(openClawPreferenceDraft(preferences)); + }, [preferences?.updatedAt, preferences?.targetRepo, preferences?.githubLogin]); + if (!preferences) return
Loading settings...
; + async function submit(event) { + event.preventDefault(); + setBusy(true); + try { + const now = Date.now(); + const weeklyRemainingBaseline = nullableFormNumber(draft.weeklyRemainingBaseline); + const weeklyRemainingCurrent = nullableFormNumber(draft.weeklyRemainingCurrent); + const usageWindowActive = + preferences.usageWindowStartedAt && + now - preferences.usageWindowStartedAt <= 24 * 60 * 60 * 1000; + await onSave({ + roleMode: draft.roleMode, + targetRepo: draft.targetRepo, + githubLogin: draft.githubLogin, + activeOpenPrLimit: Number(draft.activeOpenPrLimit), + dailyUsageDropLimit: Number(draft.dailyUsageDropLimit), + maxParallelWorkers: Number(draft.maxParallelWorkers), + weeklyRemainingBaseline, + weeklyRemainingCurrent, + usageWindowStartedAt: + weeklyRemainingBaseline === null && weeklyRemainingCurrent === null + ? null + : usageWindowActive + ? preferences.usageWindowStartedAt + : now, + }); + } finally { + setBusy(false); + } + } + const disabled = busy || loading; + return ( +
+ + + + + + + + + +
+ ); +} + +function OpenClawQueue({ queue, canCreate, busyIssue, onCreate }) { + return ( +
+
+
+

{queue.definition.title}

+

{queue.definition.why}

+
+ {queue.totalCount} +
+ {queue.query} + {queue.error ?
{queue.error}
: null} +
+ {queue.candidates.length ? ( + queue.candidates.map((candidate) => ( +
+
+ + #{candidate.number} {candidate.title} + +
+ {candidate.author ? @{candidate.author} : null} + + {candidate.signals.readyForPickup ? "ready" : candidate.signals.ageGate} + + {candidate.signals.sourceRepro ? source repro : null} + {candidate.signals.currentMainRepro ? ( + current main + ) : null} + {candidate.signals.needsLiveValidation ? ( + live proof + ) : null} +
+
+
+ + +
+
+ )) + ) : ( +
No matching issues.
+ )} +
+
+ ); +} + +function OpenClawPullRequests({ pullRequests, error, handoffText, onHandoff }) { + const [busyPr, setBusyPr] = useState(null); + return ( +
+
+
+
PR MONITOR
+

Authored open PRs

+
+
+ {error ?
{error}
: null} +
+ {pullRequests.length ? ( + pullRequests.map((pr) => ( +
+ + #{pr.number} {pr.title} + +
+ + {pr.signals.statusLabel || + (pr.signals.readyForMaintainer ? "ready" : "needs action")} + + + CI {pr.checks.state} + + {pr.signals.proofSufficient ? proof sufficient : null} + {pr.checks.mantis ? Mantis {pr.checks.mantis} : null} +
+ {pr.checks.failing.length ? ( +

Failing: {pr.checks.failing.slice(0, 3).join(", ")}

+ ) : pr.checks.pending.length ? ( +

Pending: {pr.checks.pending.slice(0, 3).join(", ")}

+ ) : null} + +
+ )) + ) : ( +
No authored open PRs found.
+ )} +
+