mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
commit
ca8f2c7907
182 changed files with 9843 additions and 974 deletions
|
|
@ -43,7 +43,7 @@ class NodeServer:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 18789,
|
||||
token_path: Optional[Path] = None,
|
||||
display_name: str = "hermes-meet-node",
|
||||
|
|
@ -76,6 +76,13 @@ class NodeServer:
|
|||
json.dumps({"token": tok, "generated_at": time.time()}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Restrict to owner-read-write only — the token grants full RPC
|
||||
# access to the meet bot (start, transcribe, speak in meetings).
|
||||
try:
|
||||
tmp.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
# Best-effort on non-POSIX filesystems; mode is set on POSIX.
|
||||
pass
|
||||
tmp.replace(self.token_path)
|
||||
self._token = tok
|
||||
return tok
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ Achievement system for the Hermes Dashboard: collectible, tiered badges generate
|
|||
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
|
||||
|
||||
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
|
||||
>
|
||||
> **Share cards (2026-05-04, vendored in hermes-agent v0.4.0):** Unlocked achievement cards now have a "Share" button that renders a 1200×630 PNG share card (client-side canvas, no backend, no network) with Download + Copy-to-clipboard actions. Fits X/Twitter, Discord, LinkedIn, Bluesky link-preview dimensions.
|
||||
|
||||
## What it does
|
||||
|
||||
|
|
|
|||
305
plugins/hermes-achievements/dashboard/dist/index.js
vendored
305
plugins/hermes-achievements/dashboard/dist/index.js
vendored
|
|
@ -66,6 +66,296 @@
|
|||
});
|
||||
}
|
||||
|
||||
const TIER_HEX = {
|
||||
"Copper": "#b87333",
|
||||
"Silver": "#c0c7d2",
|
||||
"Gold": "#f2c94c",
|
||||
"Diamond": "#67e8f9",
|
||||
"Olympian": "#c084fc",
|
||||
};
|
||||
|
||||
function tierHex(tier) {
|
||||
return TIER_HEX[tier] || "#67e8f9";
|
||||
}
|
||||
|
||||
// Render a LUCIDE icon path fragment into a standalone SVG string with an
|
||||
// explicit stroke color so it can be rasterized onto a <canvas> via Image.
|
||||
// The normal render path uses stroke="currentColor" which browsers honor in
|
||||
// DOM but NOT when the SVG is drawn to a canvas from a data URL.
|
||||
function iconSvgForCanvas(iconKey, strokeColor) {
|
||||
const paths = LUCIDE[iconKey] || LUCIDE.secret;
|
||||
return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" " +
|
||||
"stroke=\"" + strokeColor + "\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">" +
|
||||
paths + "</svg>";
|
||||
}
|
||||
|
||||
function loadSvgImage(svgString) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = function (e) { URL.revokeObjectURL(url); reject(e); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function wrapText(ctx, text, maxWidth) {
|
||||
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||
const lines = [];
|
||||
let current = "";
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const candidate = current ? current + " " + words[i] : words[i];
|
||||
if (ctx.measureText(candidate).width <= maxWidth) {
|
||||
current = candidate;
|
||||
} else {
|
||||
if (current) lines.push(current);
|
||||
current = words[i];
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Build a 1200x630 share card PNG for a single achievement. Returns a Blob.
|
||||
// Pure client-side render via Canvas2D — no external deps, no network.
|
||||
async function buildShareImage(achievement) {
|
||||
const W = 1200;
|
||||
const H = 630;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const tier = achievement.tier || achievement.next_tier || "Copper";
|
||||
const color = tierHex(tier);
|
||||
|
||||
// Background: dark charcoal with a tier-tinted radial highlight on the
|
||||
// top-left, echoing the card visual language.
|
||||
ctx.fillStyle = "#0b0d11";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
const bgGrad = ctx.createRadialGradient(260, 220, 60, 260, 220, 820);
|
||||
bgGrad.addColorStop(0, color + "33");
|
||||
bgGrad.addColorStop(0.55, color + "0a");
|
||||
bgGrad.addColorStop(1, "#0b0d1100");
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Outer border
|
||||
ctx.strokeStyle = color + "66";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||||
|
||||
// Icon block — 380x380 on the left
|
||||
try {
|
||||
const svg = iconSvgForCanvas(achievement.icon || "secret", color);
|
||||
const iconImg = await loadSvgImage(svg);
|
||||
const ix = 90;
|
||||
const iy = 125;
|
||||
const isize = 380;
|
||||
// Icon glow
|
||||
ctx.save();
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 40;
|
||||
ctx.drawImage(iconImg, ix, iy, isize, isize);
|
||||
ctx.restore();
|
||||
} catch (_) {
|
||||
// Icon render failure is non-fatal; card still useful without it.
|
||||
}
|
||||
|
||||
// Right column text layout
|
||||
const rx = 520;
|
||||
const rMaxWidth = W - rx - 70;
|
||||
|
||||
// Category label (kicker)
|
||||
ctx.fillStyle = "#8b95a8";
|
||||
ctx.font = "600 22px ui-monospace, 'SF Mono', Menlo, monospace";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText((achievement.category || "").toUpperCase(), rx, 112);
|
||||
|
||||
// Achievement name — wrap to 2 lines if needed
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "780 68px system-ui, -apple-system, 'Segoe UI', sans-serif";
|
||||
const nameLines = wrapText(ctx, achievement.name || "Achievement", rMaxWidth).slice(0, 2);
|
||||
let cursorY = 150;
|
||||
for (let i = 0; i < nameLines.length; i++) {
|
||||
ctx.fillText(nameLines[i], rx, cursorY);
|
||||
cursorY += 76;
|
||||
}
|
||||
|
||||
// Tier badge pill
|
||||
const badgeLabel = tier.toUpperCase() + " TIER";
|
||||
ctx.font = "700 22px ui-monospace, 'SF Mono', Menlo, monospace";
|
||||
const badgeWidth = ctx.measureText(badgeLabel).width + 32;
|
||||
const badgeX = rx;
|
||||
const badgeY = cursorY + 14;
|
||||
const badgeH = 40;
|
||||
ctx.fillStyle = color + "1f";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.rect(badgeX, badgeY, badgeWidth, badgeH);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = color;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(badgeLabel, badgeX + 16, badgeY + badgeH / 2 + 1);
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
// Description — wrap up to 3 lines
|
||||
ctx.fillStyle = "#c3cad6";
|
||||
ctx.font = "400 26px system-ui, -apple-system, 'Segoe UI', sans-serif";
|
||||
const descLines = wrapText(ctx, achievement.description || "", rMaxWidth).slice(0, 3);
|
||||
let descY = badgeY + badgeH + 28;
|
||||
for (let i = 0; i < descLines.length; i++) {
|
||||
ctx.fillText(descLines[i], rx, descY);
|
||||
descY += 34;
|
||||
}
|
||||
|
||||
// Progress / stat line (if meaningful)
|
||||
const progressValue = achievement.progress;
|
||||
const threshold = achievement.next_threshold;
|
||||
let statLine = null;
|
||||
if (progressValue && threshold) {
|
||||
statLine = progressValue.toLocaleString() + " / " + threshold.toLocaleString();
|
||||
} else if (progressValue) {
|
||||
statLine = progressValue.toLocaleString();
|
||||
}
|
||||
if (statLine) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = "700 28px ui-monospace, 'SF Mono', Menlo, monospace";
|
||||
ctx.fillText(statLine, rx, descY + 14);
|
||||
}
|
||||
|
||||
// Footer watermark
|
||||
ctx.fillStyle = "#8b95a8";
|
||||
ctx.font = "600 20px ui-monospace, 'SF Mono', Menlo, monospace";
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.fillText("HERMES AGENT · hermes-agent.nousresearch.com", 70, H - 40);
|
||||
|
||||
// "UNLOCKED" stamp upper-right
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = "800 24px ui-monospace, 'SF Mono', Menlo, monospace";
|
||||
const stamp = "◆ UNLOCKED";
|
||||
const stampW = ctx.measureText(stamp).width;
|
||||
ctx.fillText(stamp, W - 70 - stampW, 70);
|
||||
|
||||
return await new Promise(function (resolve, reject) {
|
||||
canvas.toBlob(function (blob) {
|
||||
if (blob) resolve(blob); else reject(new Error("canvas.toBlob returned null"));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
function ShareDialog({ achievement, onClose }) {
|
||||
const [status, setStatus] = hooks.useState("rendering"); // rendering | ready | copied | error
|
||||
const [errorMsg, setErrorMsg] = hooks.useState(null);
|
||||
const [previewUrl, setPreviewUrl] = hooks.useState(null);
|
||||
const blobRef = React.useRef(null);
|
||||
|
||||
hooks.useEffect(function () {
|
||||
let cancelled = false;
|
||||
let createdUrl = null;
|
||||
buildShareImage(achievement).then(function (blob) {
|
||||
if (cancelled) return;
|
||||
blobRef.current = blob;
|
||||
createdUrl = URL.createObjectURL(blob);
|
||||
setPreviewUrl(createdUrl);
|
||||
setStatus("ready");
|
||||
}).catch(function (err) {
|
||||
if (cancelled) return;
|
||||
setErrorMsg(String(err && err.message || err));
|
||||
setStatus("error");
|
||||
});
|
||||
return function () {
|
||||
cancelled = true;
|
||||
if (createdUrl) URL.revokeObjectURL(createdUrl);
|
||||
};
|
||||
}, [achievement.id]);
|
||||
|
||||
function download() {
|
||||
if (!blobRef.current) return;
|
||||
const url = URL.createObjectURL(blobRef.current);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "hermes-achievement-" + (achievement.id || "badge") + ".png";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!blobRef.current) return;
|
||||
try {
|
||||
if (!navigator.clipboard || !window.ClipboardItem) {
|
||||
throw new Error("Clipboard image copy not supported in this browser — use Download instead.");
|
||||
}
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ "image/png": blobRef.current }),
|
||||
]);
|
||||
setStatus("copied");
|
||||
setTimeout(function () { setStatus("ready"); }, 1800);
|
||||
} catch (err) {
|
||||
setErrorMsg(String(err && err.message || err));
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
// Build the pre-filled tweet text. Keep it short so X doesn't truncate
|
||||
// when the user hasn't attached the PNG yet — they'll copy-image and
|
||||
// paste in the same flow.
|
||||
function tweetText() {
|
||||
const tierPart = achievement.tier ? (achievement.tier + " tier ") : "";
|
||||
return "Just unlocked " + tierPart + "\"" + achievement.name + "\" in Hermes Agent ☤\n\n" +
|
||||
"@NousResearch · https://hermes-agent.nousresearch.com";
|
||||
}
|
||||
|
||||
function shareOnX() {
|
||||
const url = "https://x.com/intent/post?text=" + encodeURIComponent(tweetText());
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
return React.createElement("div", {
|
||||
className: "ha-share-backdrop",
|
||||
onClick: function (e) { if (e.target === e.currentTarget) onClose(); },
|
||||
},
|
||||
React.createElement("div", { className: "ha-share-dialog", role: "dialog", "aria-label": "Share achievement" },
|
||||
React.createElement("div", { className: "ha-share-head" },
|
||||
React.createElement("strong", null, "Share: " + achievement.name),
|
||||
React.createElement("button", { className: "ha-share-close", onClick: onClose, "aria-label": "Close" }, "×")
|
||||
),
|
||||
React.createElement("div", { className: "ha-share-preview" },
|
||||
status === "rendering" && React.createElement("div", { className: "ha-share-placeholder" }, "Rendering…"),
|
||||
previewUrl && React.createElement("img", { src: previewUrl, alt: achievement.name + " share card" })
|
||||
),
|
||||
status === "error" && React.createElement("div", { className: "ha-share-error" }, errorMsg || "Something went wrong."),
|
||||
React.createElement("div", { className: "ha-share-actions" },
|
||||
React.createElement("button", {
|
||||
className: "ha-share-btn ha-share-btn-primary",
|
||||
onClick: shareOnX,
|
||||
title: "Opens X with a pre-filled post",
|
||||
}, "Share on X"),
|
||||
React.createElement("button", {
|
||||
className: "ha-share-btn",
|
||||
onClick: copyToClipboard,
|
||||
disabled: status !== "ready" && status !== "copied",
|
||||
title: "Copy the image to paste into your post",
|
||||
}, status === "copied" ? "Copied ✓" : "Copy image"),
|
||||
React.createElement("button", {
|
||||
className: "ha-share-btn",
|
||||
onClick: download,
|
||||
disabled: status !== "ready" && status !== "copied",
|
||||
}, "Download PNG")
|
||||
),
|
||||
React.createElement("p", { className: "ha-share-hint" },
|
||||
"Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere."
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard(props) {
|
||||
return React.createElement(C.Card, { className: "ha-stat" },
|
||||
React.createElement(C.CardContent, { className: "ha-stat-content" },
|
||||
|
|
@ -170,6 +460,7 @@
|
|||
const targetTier = achievement.next_tier || achievement.tier;
|
||||
const tierLabel = achievement.tier ? achievement.tier : (targetTier ? "Target " + targetTier : (state === "secret" ? "Hidden" : (unlocked ? "Complete" : "Objective")));
|
||||
const progressText = state === "secret" ? "hidden" : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : ""));
|
||||
const [shareOpen, setShareOpen] = hooks.useState(false);
|
||||
return React.createElement(C.Card, { className: cn("ha-card", "ha-state-" + state, tierClass(achievement.tier || achievement.next_tier)) },
|
||||
React.createElement(C.CardContent, { className: "ha-card-content" },
|
||||
React.createElement("div", { className: "ha-card-head" },
|
||||
|
|
@ -180,7 +471,13 @@
|
|||
),
|
||||
React.createElement("div", { className: "ha-badges" },
|
||||
React.createElement("span", { className: "ha-state-badge" }, stateLabel),
|
||||
React.createElement("span", { className: "ha-tier-badge" }, tierLabel)
|
||||
React.createElement("span", { className: "ha-tier-badge" }, tierLabel),
|
||||
state === "unlocked" && React.createElement("button", {
|
||||
className: "ha-share-trigger",
|
||||
onClick: function () { setShareOpen(true); },
|
||||
title: "Share this achievement",
|
||||
"aria-label": "Share " + achievement.name,
|
||||
}, "Share")
|
||||
)
|
||||
),
|
||||
React.createElement("p", { className: "ha-description" }, achievement.description),
|
||||
|
|
@ -200,7 +497,11 @@
|
|||
),
|
||||
React.createElement("span", { className: "ha-progress-text" }, progressText)
|
||||
)
|
||||
)
|
||||
),
|
||||
shareOpen && React.createElement(ShareDialog, {
|
||||
achievement: achievement,
|
||||
onClose: function () { setShareOpen(false); },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,3 +118,29 @@
|
|||
.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; }
|
||||
.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
|
||||
.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; }
|
||||
|
||||
/* Share achievement — trigger button on unlocked cards + modal dialog.
|
||||
* Added to the vendored bundle (on top of the upstream PCinkusz base).
|
||||
* Canvas rendering is pure client-side, no backend, no network.
|
||||
*/
|
||||
.ha-share-trigger { border: 1px solid color-mix(in srgb, var(--ha-tier) 58%, var(--color-border)); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .18rem .42rem; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); cursor: pointer; margin-top: .05rem; transition: background .12s ease, border-color .12s ease; }
|
||||
.ha-share-trigger:hover { background: color-mix(in srgb, var(--ha-tier) 20%, transparent); border-color: var(--ha-tier); }
|
||||
.ha-share-trigger:focus-visible { outline: 2px solid var(--ha-tier); outline-offset: 2px; }
|
||||
|
||||
.ha-share-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgba(4,6,10,.72); backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; padding: 1.5rem; animation: ha-fade-in .14s ease-out; }
|
||||
.ha-share-dialog { width: min(760px, 100%); max-height: calc(100vh - 3rem); overflow: auto; border: 1px solid color-mix(in srgb, var(--color-border) 70%, var(--color-ring)); background: color-mix(in srgb, var(--color-card) 94%, #000); box-shadow: 0 24px 60px rgba(0,0,0,.55); display: flex; flex-direction: column; gap: .9rem; padding: 1rem 1.1rem 1.1rem; }
|
||||
.ha-share-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.ha-share-head strong { font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
|
||||
.ha-share-close { width: 1.9rem; height: 1.9rem; display: grid; place-items: center; border: 1px solid var(--color-border); background: transparent; color: var(--color-muted-foreground); font-size: 1.1rem; cursor: pointer; line-height: 1; }
|
||||
.ha-share-close:hover { color: var(--color-foreground); border-color: var(--color-ring); }
|
||||
.ha-share-preview { position: relative; border: 1px solid var(--color-border); background: #0b0d11; overflow: hidden; aspect-ratio: 1200 / 630; }
|
||||
.ha-share-preview img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.ha-share-placeholder { position: absolute; inset: 0; display: grid; place-items: center; color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; animation: ha-pulse 1.4s ease-in-out infinite; border-radius: 0; }
|
||||
.ha-share-error { border: 1px solid #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 10%, transparent); padding: .55rem .7rem; font-size: .78rem; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-share-actions { display: flex; gap: .55rem; flex-wrap: wrap; }
|
||||
.ha-share-btn { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-foreground); padding: .5rem .85rem; font-size: .82rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .08em; cursor: pointer; transition: border-color .12s ease, background .12s ease; }
|
||||
.ha-share-btn:hover:not(:disabled) { border-color: var(--color-ring); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
|
||||
.ha-share-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.ha-share-btn-primary { border-color: #ffffff; color: #ffffff; background: #000000; }
|
||||
.ha-share-btn-primary:hover:not(:disabled) { background: #1a1a1a; border-color: #67e8f9; color: #67e8f9; }
|
||||
.ha-share-hint { margin: 0; color: var(--color-muted-foreground); font-size: .76rem; line-height: 1.45; }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"label": "Achievements",
|
||||
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
|
||||
"icon": "Star",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"tab": { "path": "/achievements", "position": "after:analytics" },
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
|
|
|
|||
|
|
@ -203,11 +203,12 @@ class XAIImageGenProvider(ImageGenProvider):
|
|||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
status = exc.response.status_code if exc.response else 0
|
||||
response = exc.response
|
||||
status = response.status_code if response is not None else 0
|
||||
try:
|
||||
err_msg = exc.response.json().get("error", {}).get("message", exc.response.text[:300])
|
||||
err_msg = response.json().get("error", {}).get("message", response.text[:300])
|
||||
except Exception:
|
||||
err_msg = exc.response.text[:300] if exc.response else str(exc)
|
||||
err_msg = response.text[:300] if response is not None else str(exc)
|
||||
logger.error("xAI image gen failed (%d): %s", status, err_msg)
|
||||
return error_response(
|
||||
error=f"xAI image generation failed ({status}): {err_msg}",
|
||||
|
|
|
|||
451
plugins/kanban/dashboard/dist/index.js
vendored
451
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -63,6 +63,53 @@
|
|||
const API = "/api/plugins/kanban";
|
||||
const MIME_TASK = "text/x-hermes-task";
|
||||
|
||||
// localStorage key for the user's selected board. Independent of the
|
||||
// CLI's on-disk ``<root>/kanban/current`` pointer so browser users
|
||||
// can inspect any board without shifting the CLI's active board out
|
||||
// from under a terminal they left open.
|
||||
const LS_BOARD_KEY = "hermes.kanban.selectedBoard";
|
||||
|
||||
function readSelectedBoard() {
|
||||
try {
|
||||
const v = window.localStorage.getItem(LS_BOARD_KEY);
|
||||
return (v || "").trim() || null;
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
function writeSelectedBoard(slug) {
|
||||
try {
|
||||
if (slug && slug !== "default") window.localStorage.setItem(LS_BOARD_KEY, slug);
|
||||
else window.localStorage.removeItem(LS_BOARD_KEY);
|
||||
} catch (_e) { /* ignore quota / private mode */ }
|
||||
}
|
||||
|
||||
function withBoard(url, board) {
|
||||
// Append ?board=<slug> when a non-default board is active. Omitted
|
||||
// for default so the URL stays clean and the backend falls through
|
||||
// to its own resolution chain (env var → ``current`` file →
|
||||
// default) which is already correct.
|
||||
if (!board || board === "default") return url;
|
||||
const sep = url.indexOf("?") >= 0 ? "&" : "?";
|
||||
return `${url}${sep}board=${encodeURIComponent(board)}`;
|
||||
}
|
||||
|
||||
// The SDK's Select component fires ``onValueChange(value)`` directly
|
||||
// (it's a shadcn-style popup, not a native <select>). Older plugin
|
||||
// code calls ``onChange({target: {value}})`` which silently never
|
||||
// fires. This helper wires both signatures so a setter works with
|
||||
// either API — use it as:
|
||||
//
|
||||
// h(Select, {..., ...selectChangeHandler(setState), ...})
|
||||
function selectChangeHandler(setter) {
|
||||
return {
|
||||
onValueChange: function (v) { setter(v == null ? "" : v); },
|
||||
onChange: function (e) {
|
||||
const v = e && e.target ? e.target.value : e;
|
||||
setter(v == null ? "" : v);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Minimal safe markdown renderer.
|
||||
//
|
||||
|
|
@ -245,7 +292,19 @@
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
function KanbanPage() {
|
||||
const [board, setBoard] = useState(null);
|
||||
const [board, setBoard] = useState(() => readSelectedBoard() || "default");
|
||||
const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}]
|
||||
const [showNewBoard, setShowNewBoard] = useState(false);
|
||||
|
||||
const [kanbanBoard, setKanbanBoard] = useState(null); // the grid data
|
||||
// Alias so the rest of the function can keep using `board` semantically
|
||||
// for the grid data (card columns + tenants + assignees) without
|
||||
// colliding with the selected-board slug above. History: the old
|
||||
// component had `const [board, setBoard]` for the grid data. We
|
||||
// renamed the grid data to `kanbanBoard` so the more useful name
|
||||
// (`board`) belongs to the selected slug.
|
||||
const boardData = kanbanBoard;
|
||||
const setBoardData = setKanbanBoard;
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
|
@ -292,9 +351,9 @@
|
|||
if (tenantFilter) qs.set("tenant", tenantFilter);
|
||||
if (includeArchived) qs.set("include_archived", "true");
|
||||
const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`;
|
||||
return SDK.fetchJSON(url)
|
||||
return SDK.fetchJSON(withBoard(url, board))
|
||||
.then(function (data) {
|
||||
setBoard(data);
|
||||
setBoardData(data);
|
||||
cursorRef.current = data.latest_event_id || 0;
|
||||
setError(null);
|
||||
})
|
||||
|
|
@ -302,7 +361,26 @@
|
|||
setError(String(err && err.message ? err.message : err));
|
||||
})
|
||||
.finally(function () { setLoading(false); });
|
||||
}, [tenantFilter, includeArchived]);
|
||||
}, [tenantFilter, includeArchived, board]);
|
||||
|
||||
// --- load list of boards for the switcher ------------------------------
|
||||
const loadBoardList = useCallback(function () {
|
||||
return SDK.fetchJSON(`${API}/boards`)
|
||||
.then(function (data) {
|
||||
const boards = (data && data.boards) || [];
|
||||
setBoardList(boards);
|
||||
// If the stored slug isn't in the list any longer (board was
|
||||
// deleted in the CLI while dashboard was open), fall back to
|
||||
// default so the UI doesn't hang on a 404.
|
||||
if (board !== "default" && !boards.find(function (b) { return b.slug === board; })) {
|
||||
setBoard("default");
|
||||
writeSelectedBoard("default");
|
||||
}
|
||||
})
|
||||
.catch(function () { /* non-fatal */ });
|
||||
}, [board]);
|
||||
|
||||
useEffect(function () { loadBoardList(); }, [loadBoardList]);
|
||||
|
||||
const scheduleReload = useCallback(function () {
|
||||
if (reloadTimerRef.current) return;
|
||||
|
|
@ -324,16 +402,21 @@
|
|||
|
||||
// --- WebSocket ---------------------------------------------------------
|
||||
useEffect(function () {
|
||||
if (!board) return undefined;
|
||||
if (!boardData) return undefined;
|
||||
wsClosedRef.current = false;
|
||||
function openWs() {
|
||||
if (wsClosedRef.current) return;
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({
|
||||
const qsParams = {
|
||||
since: String(cursorRef.current || 0),
|
||||
token: token,
|
||||
});
|
||||
};
|
||||
// Pin the WS stream to the currently-selected board so events
|
||||
// from other boards don't bleed in. Only set for non-default so
|
||||
// single-board installs keep the cleaner URL.
|
||||
if (board && board !== "default") qsParams.board = board;
|
||||
const qs = new URLSearchParams(qsParams);
|
||||
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||
|
|
@ -372,11 +455,11 @@
|
|||
wsClosedRef.current = true;
|
||||
try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ }
|
||||
};
|
||||
}, [!!board, scheduleReload]);
|
||||
}, [!!boardData, board, scheduleReload]);
|
||||
|
||||
// --- filtering ----------------------------------------------------------
|
||||
const filteredBoard = useMemo(function () {
|
||||
if (!board) return null;
|
||||
if (!boardData) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
const filterTask = function (t) {
|
||||
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
|
||||
|
|
@ -386,18 +469,18 @@
|
|||
}
|
||||
return true;
|
||||
};
|
||||
return Object.assign({}, board, {
|
||||
columns: board.columns.map(function (col) {
|
||||
return Object.assign({}, boardData, {
|
||||
columns: boardData.columns.map(function (col) {
|
||||
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
|
||||
}),
|
||||
});
|
||||
}, [board, assigneeFilter, search]);
|
||||
}, [boardData, assigneeFilter, search]);
|
||||
|
||||
// --- actions ------------------------------------------------------------
|
||||
const moveTask = useCallback(function (taskId, newStatus) {
|
||||
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
setBoard(function (b) {
|
||||
setBoardData(function (b) {
|
||||
if (!b) return b;
|
||||
let moved = null;
|
||||
const columns = b.columns.map(function (col) {
|
||||
|
|
@ -413,7 +496,7 @@
|
|||
}
|
||||
return Object.assign({}, b, { columns });
|
||||
});
|
||||
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
|
|
@ -421,10 +504,10 @@
|
|||
setError(`Move failed: ${err.message || err}`);
|
||||
loadBoard();
|
||||
});
|
||||
}, [loadBoard]);
|
||||
}, [loadBoard, board]);
|
||||
|
||||
const createTask = useCallback(function (body) {
|
||||
return SDK.fetchJSON(`${API}/tasks`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
|
|
@ -437,9 +520,10 @@
|
|||
setError("Task created, but: " + res.warning);
|
||||
}
|
||||
loadBoard();
|
||||
loadBoardList(); // refresh counts in the switcher
|
||||
return res;
|
||||
});
|
||||
}, [loadBoard]);
|
||||
}, [loadBoard, loadBoardList, board]);
|
||||
|
||||
const toggleSelected = useCallback(function (id, additive) {
|
||||
setSelectedIds(function (prev) {
|
||||
|
|
@ -455,7 +539,7 @@
|
|||
if (selectedIds.size === 0) return;
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
|
||||
SDK.fetchJSON(`${API}/tasks/bulk`, {
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
|
|
@ -470,14 +554,50 @@
|
|||
loadBoard();
|
||||
})
|
||||
.catch(function (e) { setError(String(e.message || e)); });
|
||||
}, [selectedIds, loadBoard, clearSelected]);
|
||||
}, [selectedIds, loadBoard, clearSelected, board]);
|
||||
|
||||
// --- board switching ----------------------------------------------------
|
||||
const switchBoard = useCallback(function (nextSlug) {
|
||||
if (!nextSlug || nextSlug === board) return;
|
||||
// Optimistic UI: clear the current grid + show loading, reset the
|
||||
// event cursor so the WS reopens aligned to the new board's
|
||||
// latest_event_id on the next loadBoard.
|
||||
setBoardData(null);
|
||||
cursorRef.current = 0;
|
||||
setLoading(true);
|
||||
setBoard(nextSlug);
|
||||
writeSelectedBoard(nextSlug);
|
||||
}, [board]);
|
||||
|
||||
const createNewBoard = useCallback(function (payload) {
|
||||
return SDK.fetchJSON(`${API}/boards`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(function (res) {
|
||||
loadBoardList();
|
||||
const slug = res && res.board && res.board.slug;
|
||||
if (slug && payload.switch) switchBoard(slug);
|
||||
return res;
|
||||
});
|
||||
}, [loadBoardList, switchBoard]);
|
||||
|
||||
const deleteBoard = useCallback(function (slug) {
|
||||
if (!slug || slug === "default") return Promise.resolve();
|
||||
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
}).then(function () {
|
||||
loadBoardList();
|
||||
if (board === slug) switchBoard("default");
|
||||
});
|
||||
}, [board, loadBoardList, switchBoard]);
|
||||
|
||||
// --- render -------------------------------------------------------------
|
||||
if (loading && !board) {
|
||||
if (loading && !boardData) {
|
||||
return h("div", { className: "p-8 text-sm text-muted-foreground" },
|
||||
"Loading Kanban board…");
|
||||
}
|
||||
if (error && !board) {
|
||||
if (error && !boardData) {
|
||||
return h(Card, null,
|
||||
h(CardContent, { className: "p-6" },
|
||||
h("div", { className: "text-sm text-destructive" },
|
||||
|
|
@ -493,15 +613,28 @@
|
|||
|
||||
return h(ErrorBoundary, null,
|
||||
h("div", { className: "hermes-kanban flex flex-col gap-4" },
|
||||
h(BoardToolbar, {
|
||||
h(BoardSwitcher, {
|
||||
board: board,
|
||||
boardList: boardList,
|
||||
onSwitch: switchBoard,
|
||||
onNewClick: function () { setShowNewBoard(true); },
|
||||
onDeleteBoard: deleteBoard,
|
||||
}),
|
||||
showNewBoard ? h(NewBoardDialog, {
|
||||
onCancel: function () { setShowNewBoard(false); },
|
||||
onCreate: function (payload) {
|
||||
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
|
||||
},
|
||||
}) : null,
|
||||
h(BoardToolbar, {
|
||||
board: boardData,
|
||||
tenantFilter, setTenantFilter,
|
||||
assigneeFilter, setAssigneeFilter,
|
||||
includeArchived, setIncludeArchived,
|
||||
laneByProfile, setLaneByProfile,
|
||||
search, setSearch,
|
||||
onNudgeDispatch: function () {
|
||||
SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" })
|
||||
SDK.fetchJSON(withBoard(`${API}/dispatch?max=8`, board), { method: "POST" })
|
||||
.then(loadBoard)
|
||||
.catch(function (e) { setError(String(e.message || e)); });
|
||||
},
|
||||
|
|
@ -509,7 +642,7 @@
|
|||
}),
|
||||
selectedIds.size > 0 ? h(BulkActionBar, {
|
||||
count: selectedIds.size,
|
||||
assignees: (board && board.assignees) || [],
|
||||
assignees: (boardData && boardData.assignees) || [],
|
||||
onApply: applyBulk,
|
||||
onClear: clearSelected,
|
||||
}) : null,
|
||||
|
|
@ -522,20 +655,215 @@
|
|||
onMove: moveTask,
|
||||
onOpen: setSelectedTaskId,
|
||||
onCreate: createTask,
|
||||
allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
}),
|
||||
selectedTaskId ? h(TaskDrawer, {
|
||||
taskId: selectedTaskId,
|
||||
boardSlug: board,
|
||||
onClose: function () { setSelectedTaskId(null); },
|
||||
onRefresh: loadBoard,
|
||||
renderMarkdown: renderMd,
|
||||
allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
eventTick: taskEventTick[selectedTaskId] || 0,
|
||||
}) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Board switcher (multi-project)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function BoardSwitcher(props) {
|
||||
const list = props.boardList || [];
|
||||
const current = list.find(function (b) { return b.slug === props.board; });
|
||||
const currentName = current && current.name ? current.name : props.board;
|
||||
const currentTotal = current ? current.total : 0;
|
||||
const hasMultipleBoards = list.length > 1;
|
||||
|
||||
// Hide entirely when only the default board exists AND it's empty —
|
||||
// single-project users never see boards UI unless they ask for it.
|
||||
// We show the [+ New board] affordance as soon as any board has a
|
||||
// task (so the user can discover multi-project before they need it)
|
||||
// OR when any non-default board exists.
|
||||
const totalAcrossAllBoards = list.reduce(function (n, b) { return n + (b.total || 0); }, 0);
|
||||
const shouldShow = hasMultipleBoards || totalAcrossAllBoards > 0;
|
||||
if (!shouldShow) {
|
||||
return h("div", {
|
||||
className: "hermes-kanban-boardswitcher-compact",
|
||||
title: "Boards let you separate unrelated streams of work",
|
||||
},
|
||||
h(Button, {
|
||||
onClick: props.onNewClick,
|
||||
size: "sm",
|
||||
className: "h-7 text-xs",
|
||||
}, "+ New board"),
|
||||
);
|
||||
}
|
||||
|
||||
return h("div", { className: "hermes-kanban-boardswitcher" },
|
||||
h("div", { className: "hermes-kanban-boardswitcher-inner" },
|
||||
h("div", { className: "flex flex-col gap-0.5" },
|
||||
h("div", { className: "text-[11px] uppercase tracking-wider text-muted-foreground" },
|
||||
"Board"),
|
||||
h("div", { className: "flex items-center gap-2" },
|
||||
h(Select, Object.assign({
|
||||
value: props.board,
|
||||
className: "h-8 min-w-[220px]",
|
||||
"aria-label": "Switch kanban board",
|
||||
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
|
||||
list.map(function (b) {
|
||||
const label = b.total > 0
|
||||
? `${b.name || b.slug} · ${b.total}`
|
||||
: (b.name || b.slug);
|
||||
return h(SelectOption, { key: b.slug, value: b.slug }, label);
|
||||
}),
|
||||
),
|
||||
h("span", { className: "text-xs text-muted-foreground" },
|
||||
`${currentTotal || 0} task${currentTotal === 1 ? "" : "s"}`),
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex-1" }),
|
||||
h(Button, {
|
||||
onClick: props.onNewClick,
|
||||
size: "sm",
|
||||
className: "h-8",
|
||||
}, "+ New board"),
|
||||
props.board !== "default"
|
||||
? h(Button, {
|
||||
onClick: function () {
|
||||
const msg =
|
||||
`Archive board '${currentName}'? ` +
|
||||
`It will be moved to boards/_archived/ so you can recover it later. ` +
|
||||
`Tasks on this board will no longer appear anywhere in the UI.`;
|
||||
if (window.confirm(msg)) props.onDeleteBoard(props.board);
|
||||
},
|
||||
size: "sm",
|
||||
className: "h-8",
|
||||
title: "Archive this board",
|
||||
}, "Archive")
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function NewBoardDialog(props) {
|
||||
const [slug, setSlug] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [switchTo, setSwitchTo] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
// Auto-derive a name from the slug if the user hasn't typed one.
|
||||
const autoName = useMemo(function () {
|
||||
if (!slug) return "";
|
||||
return slug.replace(/[-_]+/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map(function (w) { return w[0].toUpperCase() + w.slice(1); })
|
||||
.join(" ");
|
||||
}, [slug]);
|
||||
|
||||
function onSubmit(ev) {
|
||||
if (ev) ev.preventDefault();
|
||||
if (!slug.trim()) { setErr("slug is required"); return; }
|
||||
setSubmitting(true);
|
||||
setErr(null);
|
||||
props.onCreate({
|
||||
slug: slug.trim(),
|
||||
name: name.trim() || autoName || undefined,
|
||||
description: description.trim() || undefined,
|
||||
icon: icon.trim() || undefined,
|
||||
switch: switchTo,
|
||||
}).catch(function (e) {
|
||||
setErr(String(e && e.message ? e.message : e));
|
||||
setSubmitting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return h("div", {
|
||||
className: "hermes-kanban-dialog-backdrop",
|
||||
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
|
||||
},
|
||||
h("form", {
|
||||
className: "hermes-kanban-dialog",
|
||||
onSubmit: onSubmit,
|
||||
},
|
||||
h("div", { className: "hermes-kanban-dialog-title" }, "New board"),
|
||||
h("div", { className: "text-xs text-muted-foreground mb-2" },
|
||||
"Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks."),
|
||||
h("div", { className: "flex flex-col gap-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, "Slug ",
|
||||
h("span", { className: "text-muted-foreground" },
|
||||
"— lowercase, hyphens, e.g. atm10-server")),
|
||||
h(Input, {
|
||||
value: slug,
|
||||
onChange: function (e) { setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9\-_]/g, "-")); },
|
||||
placeholder: "atm10-server",
|
||||
autoFocus: true,
|
||||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, "Display name ",
|
||||
h("span", { className: "text-muted-foreground" }, "(optional)")),
|
||||
h(Input, {
|
||||
value: name,
|
||||
onChange: function (e) { setName(e.target.value); },
|
||||
placeholder: autoName || "Display name",
|
||||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, "Description ",
|
||||
h("span", { className: "text-muted-foreground" }, "(optional)")),
|
||||
h(Input, {
|
||||
value: description,
|
||||
onChange: function (e) { setDescription(e.target.value); },
|
||||
placeholder: "What goes on this board?",
|
||||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, "Icon ",
|
||||
h("span", { className: "text-muted-foreground" }, "(single character or emoji)")),
|
||||
h(Input, {
|
||||
value: icon,
|
||||
onChange: function (e) { setIcon(e.target.value.slice(0, 4)); },
|
||||
placeholder: "📦",
|
||||
className: "h-8 w-24",
|
||||
}),
|
||||
),
|
||||
h("label", { className: "flex items-center gap-2 text-xs" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: switchTo,
|
||||
onChange: function (e) { setSwitchTo(e.target.checked); },
|
||||
}),
|
||||
"Switch to this board after creating it",
|
||||
),
|
||||
),
|
||||
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
|
||||
h("div", { className: "hermes-kanban-dialog-actions" },
|
||||
h(Button, {
|
||||
type: "button",
|
||||
onClick: props.onCancel,
|
||||
size: "sm",
|
||||
disabled: submitting,
|
||||
}, "Cancel"),
|
||||
h(Button, {
|
||||
type: "submit",
|
||||
size: "sm",
|
||||
disabled: submitting || !slug.trim(),
|
||||
}, submitting ? "Creating…" : "Create board"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toolbar
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -555,11 +883,10 @@
|
|||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
||||
h(Select, {
|
||||
h(Select, Object.assign({
|
||||
value: props.tenantFilter,
|
||||
onChange: function (e) { props.setTenantFilter(e.target.value); },
|
||||
className: "h-8",
|
||||
},
|
||||
}, selectChangeHandler(props.setTenantFilter)),
|
||||
h(SelectOption, { value: "" }, "All tenants"),
|
||||
tenants.map(function (t) {
|
||||
return h(SelectOption, { key: t, value: t }, t);
|
||||
|
|
@ -568,11 +895,10 @@
|
|||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
||||
h(Select, {
|
||||
h(Select, Object.assign({
|
||||
value: props.assigneeFilter,
|
||||
onChange: function (e) { props.setAssigneeFilter(e.target.value); },
|
||||
className: "h-8",
|
||||
},
|
||||
}, selectChangeHandler(props.setAssigneeFilter)),
|
||||
h(SelectOption, { value: "" }, "All profiles"),
|
||||
assignees.map(function (a) {
|
||||
return h(SelectOption, { key: a, value: a }, a);
|
||||
|
|
@ -919,6 +1245,12 @@
|
|||
const [priority, setPriority] = useState(0);
|
||||
const [parent, setParent] = useState("");
|
||||
const [skills, setSkills] = useState("");
|
||||
// Workspace controls. `scratch` (default) ignores path; `worktree` optionally
|
||||
// takes a path (dispatcher derives one from the assignee profile otherwise);
|
||||
// `dir` requires a path. Backend enforces the rule — we only hide/show the
|
||||
// input here to save vertical space in the common `scratch` case.
|
||||
const [workspaceKind, setWorkspaceKind] = useState("scratch");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
|
||||
const submit = function () {
|
||||
const trimmed = title.trim();
|
||||
|
|
@ -938,10 +1270,23 @@
|
|||
.map(function (s) { return s.trim(); })
|
||||
.filter(function (s) { return s.length > 0; });
|
||||
if (skillList.length > 0) body.skills = skillList;
|
||||
// Only send workspace_kind when it's non-default. Keeps the request
|
||||
// shape small and interoperable with older dispatcher versions.
|
||||
if (workspaceKind && workspaceKind !== "scratch") {
|
||||
body.workspace_kind = workspaceKind;
|
||||
}
|
||||
const wpTrim = workspacePath.trim();
|
||||
if (wpTrim) body.workspace_path = wpTrim;
|
||||
props.onSubmit(body);
|
||||
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
|
||||
setWorkspaceKind("scratch"); setWorkspacePath("");
|
||||
};
|
||||
|
||||
const showPathInput = workspaceKind !== "scratch";
|
||||
const pathPlaceholder = workspaceKind === "dir"
|
||||
? "workspace path (required, e.g. ~/projects/my-app)"
|
||||
: "workspace path (optional, derived from assignee if blank)";
|
||||
|
||||
return h("div", { className: "hermes-kanban-inline-create" },
|
||||
h(Input, {
|
||||
value: title,
|
||||
|
|
@ -978,6 +1323,24 @@
|
|||
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
||||
className: "h-7 text-xs",
|
||||
}),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Select, {
|
||||
value: workspaceKind,
|
||||
onChange: function (e) { setWorkspaceKind(e.target.value); },
|
||||
title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).",
|
||||
className: "h-7 text-xs w-28",
|
||||
},
|
||||
h(SelectOption, { value: "scratch" }, "scratch"),
|
||||
h(SelectOption, { value: "worktree" }, "worktree"),
|
||||
h(SelectOption, { value: "dir" }, "dir"),
|
||||
),
|
||||
showPathInput ? h(Input, {
|
||||
value: workspacePath,
|
||||
onChange: function (e) { setWorkspacePath(e.target.value); },
|
||||
placeholder: pathPlaceholder,
|
||||
className: "h-7 text-xs flex-1",
|
||||
}) : null,
|
||||
),
|
||||
h(Select, {
|
||||
value: parent,
|
||||
onChange: function (e) { setParent(e.target.value); },
|
||||
|
|
@ -1012,13 +1375,14 @@
|
|||
const [err, setErr] = useState(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const boardSlug = props.boardSlug;
|
||||
|
||||
const load = useCallback(function () {
|
||||
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`)
|
||||
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug))
|
||||
.then(function (d) { setData(d); setErr(null); })
|
||||
.catch(function (e) { setErr(String(e.message || e)); })
|
||||
.finally(function () { setLoading(false); });
|
||||
}, [props.taskId]);
|
||||
}, [props.taskId, boardSlug]);
|
||||
|
||||
// Reload when the WS stream reports new events for this task id
|
||||
// (completion, block, crash, etc. — anything that'd make the drawer
|
||||
|
|
@ -1033,7 +1397,7 @@
|
|||
const handleComment = function () {
|
||||
const body = newComment.trim();
|
||||
if (!body) return;
|
||||
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, {
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, boardSlug), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
|
|
@ -1048,7 +1412,7 @@
|
|||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
|
|
@ -1056,7 +1420,7 @@
|
|||
};
|
||||
|
||||
const addLink = function (parentId) {
|
||||
return SDK.fetchJSON(`${API}/links`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }),
|
||||
|
|
@ -1065,12 +1429,12 @@
|
|||
};
|
||||
const removeLink = function (parentId) {
|
||||
const qs = new URLSearchParams({ parent_id: parentId, child_id: props.taskId });
|
||||
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
|
||||
return SDK.fetchJSON(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
|
||||
.then(function () { load(); props.onRefresh(); })
|
||||
.catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
const addChild = function (childId) {
|
||||
return SDK.fetchJSON(`${API}/links`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent_id: props.taskId, child_id: childId }),
|
||||
|
|
@ -1079,7 +1443,7 @@
|
|||
};
|
||||
const removeChild = function (childId) {
|
||||
const qs = new URLSearchParams({ parent_id: props.taskId, child_id: childId });
|
||||
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
|
||||
return SDK.fetchJSON(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
|
||||
.then(function () { load(); props.onRefresh(); })
|
||||
.catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
|
|
@ -1104,6 +1468,7 @@
|
|||
data, editing, setEditing,
|
||||
renderMarkdown: props.renderMarkdown,
|
||||
allTasks: props.allTasks,
|
||||
boardSlug: boardSlug,
|
||||
onPatch: doPatch,
|
||||
onAddParent: addLink,
|
||||
onRemoveParent: removeLink,
|
||||
|
|
@ -1216,7 +1581,7 @@
|
|||
);
|
||||
}),
|
||||
),
|
||||
h(WorkerLogSection, { taskId: t.id }),
|
||||
h(WorkerLogSection, { taskId: t.id, boardSlug: props.boardSlug }),
|
||||
h(RunHistorySection, { runs: props.data.runs || [] }),
|
||||
);
|
||||
}
|
||||
|
|
@ -1287,10 +1652,10 @@
|
|||
const [state, setState] = useState({ loading: false, data: null, err: null });
|
||||
const load = useCallback(function () {
|
||||
setState({ loading: true, data: null, err: null });
|
||||
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`)
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`, props.boardSlug))
|
||||
.then(function (d) { setState({ loading: false, data: d, err: null }); })
|
||||
.catch(function (e) { setState({ loading: false, data: null, err: String(e.message || e) }); });
|
||||
}, [props.taskId]);
|
||||
}, [props.taskId, props.boardSlug]);
|
||||
|
||||
// Auto-load when the section mounts; the user opened the drawer so the
|
||||
// cost is one small HTTP round-trip.
|
||||
|
|
|
|||
93
plugins/kanban/dashboard/dist/style.css
vendored
93
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -268,7 +268,7 @@
|
|||
}
|
||||
|
||||
.hermes-kanban-drawer {
|
||||
width: min(480px, 92vw);
|
||||
width: min(var(--hermes-kanban-drawer-width, 640px), 92vw);
|
||||
height: 100vh;
|
||||
background: var(--color-card);
|
||||
border-left: 1px solid var(--color-border);
|
||||
|
|
@ -334,7 +334,7 @@
|
|||
.hermes-kanban-meta-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.hermes-kanban-meta-label {
|
||||
width: 92px;
|
||||
|
|
@ -367,14 +367,15 @@
|
|||
|
||||
.hermes-kanban-pre {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: color-mix(in srgb, var(--color-foreground) 4%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
|
|
@ -605,8 +606,8 @@
|
|||
/* ---- Markdown rendering -------------------------------------------- */
|
||||
|
||||
.hermes-kanban-md {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.hermes-kanban-md p { margin: 0.25rem 0; }
|
||||
|
|
@ -632,15 +633,22 @@
|
|||
}
|
||||
.hermes-kanban-md code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
}
|
||||
/* Fenced code block. Set a visible background even when --color-foreground
|
||||
* is empty (color-mix falls through to transparent in that case), and force
|
||||
* color: inherit so the text tracks the drawer foreground rather than the
|
||||
* UA default on <code> elements — otherwise themes that don't set
|
||||
* --color-foreground leave code text rendering near-black on dark themes
|
||||
* (see issue #18576). */
|
||||
.hermes-kanban-md-code {
|
||||
margin: 0.35rem 0;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: color-mix(in srgb, var(--color-foreground) 5%, transparent);
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
overflow-x: auto;
|
||||
|
|
@ -648,8 +656,9 @@
|
|||
.hermes-kanban-md-code code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre;
|
||||
color: inherit;
|
||||
}
|
||||
.hermes-kanban-md strong { font-weight: 600; }
|
||||
|
||||
|
|
@ -684,11 +693,11 @@
|
|||
/* ---- Worker log pane ------------------------------------------------ */
|
||||
|
||||
.hermes-kanban-log {
|
||||
max-height: 340px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -739,7 +748,8 @@
|
|||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.hermes-kanban-run-summary {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.2rem 0 0;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
|
@ -751,10 +761,65 @@
|
|||
}
|
||||
.hermes-kanban-run-meta {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.15rem 0 0;
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Multi-project: board switcher + create-board dialog
|
||||
------------------------------------------------------------------------- */
|
||||
.hermes-kanban-boardswitcher {
|
||||
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: var(--color-card-subtle, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
.hermes-kanban-boardswitcher-inner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hermes-kanban-boardswitcher-compact {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.hermes-kanban-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 10, 16, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hermes-kanban-dialog {
|
||||
background: var(--color-card, #121421);
|
||||
color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.1rem 1.2rem 1rem;
|
||||
width: 28rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 3rem);
|
||||
overflow: auto;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.hermes-kanban-dialog-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.hermes-kanban-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,19 +72,45 @@ def _check_ws_token(provided: Optional[str]) -> bool:
|
|||
return hmac.compare_digest(str(provided), str(expected))
|
||||
|
||||
|
||||
def _conn():
|
||||
def _resolve_board(board: Optional[str]) -> Optional[str]:
|
||||
"""Validate and normalise a board slug from a query param.
|
||||
|
||||
Raises :class:`HTTPException` 400 on malformed slugs so the browser
|
||||
sees a clean error instead of a 500. Returns the normalised slug,
|
||||
or ``None`` when the caller omitted the param (which then falls
|
||||
through to the active board inside ``kb.connect()``).
|
||||
"""
|
||||
if board is None or board == "":
|
||||
return None
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(board)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if normed and normed != kanban_db.DEFAULT_BOARD and not kanban_db.board_exists(normed):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"board {normed!r} does not exist",
|
||||
)
|
||||
return normed
|
||||
|
||||
|
||||
def _conn(board: Optional[str] = None):
|
||||
"""Open a kanban_db connection, creating the schema on first use.
|
||||
|
||||
Every handler that mutates the DB goes through this so the plugin
|
||||
self-heals on a fresh install (no user-visible "no such table"
|
||||
error if somebody hits POST /tasks before GET /board).
|
||||
``init_db`` is idempotent.
|
||||
|
||||
``board`` is the query-param slug (already normalised by
|
||||
:func:`_resolve_board`). When ``None`` the active board is used
|
||||
via the resolution chain (env var → ``current`` file → ``default``).
|
||||
"""
|
||||
try:
|
||||
kanban_db.init_db()
|
||||
kanban_db.init_db(board=board)
|
||||
except Exception as exc:
|
||||
log.warning("kanban init_db failed: %s", exc)
|
||||
return kanban_db.connect()
|
||||
return kanban_db.connect(board=board)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -177,13 +203,19 @@ def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
|
|||
def get_board(
|
||||
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
|
||||
include_archived: bool = Query(False),
|
||||
board: Optional[str] = Query(None, description="Kanban board slug (omit for current)"),
|
||||
):
|
||||
"""Return the full board grouped by status column.
|
||||
|
||||
``_conn()`` auto-initializes ``kanban.db`` on first call so a fresh
|
||||
install doesn't surface a "failed to load" error on the plugin tab.
|
||||
|
||||
``board`` selects which board to read from. Omitting it falls
|
||||
through to the active board (``HERMES_KANBAN_BOARD`` env → on-disk
|
||||
``current`` pointer → ``default``).
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
tasks = kanban_db.list_tasks(
|
||||
conn, tenant=tenant, include_archived=include_archived
|
||||
|
|
@ -274,8 +306,9 @@ def get_board(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def get_task(task_id: str):
|
||||
conn = _conn()
|
||||
def get_task(task_id: str, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
|
|
@ -311,8 +344,9 @@ class CreateTaskBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/tasks")
|
||||
def create_task(payload: CreateTaskBody):
|
||||
conn = _conn()
|
||||
def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task_id = kanban_db.create_task(
|
||||
conn,
|
||||
|
|
@ -373,8 +407,9 @@ class UpdateTaskBody(BaseModel):
|
|||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
def update_task(task_id: str, payload: UpdateTaskBody):
|
||||
conn = _conn()
|
||||
def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
|
|
@ -414,7 +449,12 @@ def update_task(task_id: str, payload: UpdateTaskBody):
|
|||
ok = _set_status_direct(conn, task_id, "ready")
|
||||
elif s == "archived":
|
||||
ok = kanban_db.archive_task(conn, task_id)
|
||||
elif s in ("todo", "running", "triage"):
|
||||
elif s == "running":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot set status to 'running' directly; use the dispatcher/claim path",
|
||||
)
|
||||
elif s in ("todo", "triage"):
|
||||
ok = _set_status_direct(conn, task_id, s)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
|
||||
|
|
@ -527,10 +567,11 @@ class CommentBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/tasks/{task_id}/comments")
|
||||
def add_comment(task_id: str, payload: CommentBody):
|
||||
def add_comment(task_id: str, payload: CommentBody, board: Optional[str] = Query(None)):
|
||||
if not payload.body.strip():
|
||||
raise HTTPException(status_code=400, detail="body is required")
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
|
|
@ -552,8 +593,9 @@ class LinkBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/links")
|
||||
def add_link(payload: LinkBody):
|
||||
conn = _conn()
|
||||
def add_link(payload: LinkBody, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
kanban_db.link_tasks(conn, payload.parent_id, payload.child_id)
|
||||
return {"ok": True}
|
||||
|
|
@ -564,8 +606,13 @@ def add_link(payload: LinkBody):
|
|||
|
||||
|
||||
@router.delete("/links")
|
||||
def delete_link(parent_id: str = Query(...), child_id: str = Query(...)):
|
||||
conn = _conn()
|
||||
def delete_link(
|
||||
parent_id: str = Query(...),
|
||||
child_id: str = Query(...),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
ok = kanban_db.unlink_tasks(conn, parent_id, child_id)
|
||||
return {"ok": bool(ok)}
|
||||
|
|
@ -586,7 +633,7 @@ class BulkTaskBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/tasks/bulk")
|
||||
def bulk_update(payload: BulkTaskBody):
|
||||
def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
|
||||
"""Apply the same patch to every id in ``payload.ids``.
|
||||
|
||||
This is an *independent* iteration — per-task failures don't abort
|
||||
|
|
@ -596,7 +643,8 @@ def bulk_update(payload: BulkTaskBody):
|
|||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="ids is required")
|
||||
results: list[dict] = []
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
for tid in ids:
|
||||
entry: dict[str, Any] = {"id": tid, "ok": True}
|
||||
|
|
@ -690,14 +738,15 @@ def get_config():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats():
|
||||
def get_stats(board: Optional[str] = Query(None)):
|
||||
"""Per-status + per-assignee counts + oldest-ready age.
|
||||
|
||||
Designed for the dashboard HUD and for router profiles that need to
|
||||
answer "is this specialist overloaded?" without scanning the whole
|
||||
board themselves.
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
return kanban_db.board_stats(conn)
|
||||
finally:
|
||||
|
|
@ -705,7 +754,7 @@ def get_stats():
|
|||
|
||||
|
||||
@router.get("/assignees")
|
||||
def get_assignees():
|
||||
def get_assignees(board: Optional[str] = Query(None)):
|
||||
"""Known profiles + per-profile task counts.
|
||||
|
||||
Returns the union of ``~/.hermes/profiles/*`` on disk and every
|
||||
|
|
@ -713,7 +762,8 @@ def get_assignees():
|
|||
this to populate its assignee dropdown so a freshly-created profile
|
||||
appears in the picker before it's been given any task.
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
return {"assignees": kanban_db.known_assignees(conn)}
|
||||
finally:
|
||||
|
|
@ -725,7 +775,11 @@ def get_assignees():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/tasks/{task_id}/log")
|
||||
def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_000)):
|
||||
def get_task_log(
|
||||
task_id: str,
|
||||
tail: Optional[int] = Query(None, ge=1, le=2_000_000),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""Return the worker's stdout/stderr log.
|
||||
|
||||
``tail`` caps the response size (bytes) so the dashboard drawer
|
||||
|
|
@ -734,15 +788,16 @@ def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_
|
|||
``_rotate_worker_log`` — a single ``.log.1`` is kept, no further
|
||||
generations, so disk usage per task is bounded at ~4 MiB.
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
content = kanban_db.read_worker_log(task_id, tail_bytes=tail)
|
||||
log_path = kanban_db.worker_log_path(task_id)
|
||||
content = kanban_db.read_worker_log(task_id, tail_bytes=tail, board=board)
|
||||
log_path = kanban_db.worker_log_path(task_id, board=board)
|
||||
size = log_path.stat().st_size if log_path.exists() else 0
|
||||
return {
|
||||
"task_id": task_id,
|
||||
|
|
@ -760,11 +815,16 @@ def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/dispatch")
|
||||
def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
|
||||
conn = _conn()
|
||||
def dispatch(
|
||||
dry_run: bool = Query(False),
|
||||
max_n: int = Query(8, alias="max"),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
result = kanban_db.dispatch_once(
|
||||
conn, dry_run=dry_run, max_spawn=max_n,
|
||||
conn, dry_run=dry_run, max_spawn=max_n, board=board,
|
||||
)
|
||||
# DispatchResult is a dataclass.
|
||||
try:
|
||||
|
|
@ -775,6 +835,124 @@ def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boards CRUD (multi-project support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateBoardBody(BaseModel):
|
||||
slug: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
switch: bool = False
|
||||
|
||||
|
||||
class RenameBoardBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
def _board_counts(slug: str) -> dict[str, int]:
|
||||
"""Return ``{status: count}`` for a board. Safe on an empty DB."""
|
||||
try:
|
||||
path = kanban_db.kanban_db_path(board=slug)
|
||||
if not path.exists():
|
||||
return {}
|
||||
conn = kanban_db.connect(board=slug)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
|
||||
).fetchall()
|
||||
return {r["status"]: int(r["n"]) for r in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/boards")
|
||||
def list_boards(include_archived: bool = Query(False)):
|
||||
"""Return every board on disk with task counts and the active slug."""
|
||||
boards = kanban_db.list_boards(include_archived=include_archived)
|
||||
current = kanban_db.get_current_board()
|
||||
for b in boards:
|
||||
b["is_current"] = (b["slug"] == current)
|
||||
b["counts"] = _board_counts(b["slug"])
|
||||
b["total"] = sum(b["counts"].values())
|
||||
return {"boards": boards, "current": current}
|
||||
|
||||
|
||||
@router.post("/boards")
|
||||
def create_board_endpoint(payload: CreateBoardBody):
|
||||
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
|
||||
try:
|
||||
meta = kanban_db.create_board(
|
||||
payload.slug,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if payload.switch:
|
||||
try:
|
||||
kanban_db.set_current_board(meta["slug"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"board": meta, "current": kanban_db.get_current_board()}
|
||||
|
||||
|
||||
@router.patch("/boards/{slug}")
|
||||
def rename_board(slug: str, payload: RenameBoardBody):
|
||||
"""Update a board's display metadata (slug is immutable — create a new one to rename the directory)."""
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(slug)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not normed or not kanban_db.board_exists(normed):
|
||||
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
|
||||
meta = kanban_db.write_board_metadata(
|
||||
normed,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
)
|
||||
return {"board": meta}
|
||||
|
||||
|
||||
@router.delete("/boards/{slug}")
|
||||
def delete_board(slug: str, delete: bool = Query(False, description="Hard-delete instead of archive")):
|
||||
"""Archive (default) or hard-delete a board."""
|
||||
try:
|
||||
res = kanban_db.remove_board(slug, archive=not delete)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"result": res, "current": kanban_db.get_current_board()}
|
||||
|
||||
|
||||
@router.post("/boards/{slug}/switch")
|
||||
def switch_board(slug: str):
|
||||
"""Persist ``slug`` as the active board for subsequent CLI / slash calls.
|
||||
|
||||
Dashboard users pick boards via a client-side ``localStorage`` — this
|
||||
endpoint is for ``/kanban boards switch`` parity so gateway slash
|
||||
commands and the CLI share the same current-board pointer.
|
||||
"""
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(slug)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not normed or not kanban_db.board_exists(normed):
|
||||
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
|
||||
kanban_db.set_current_board(normed)
|
||||
return {"current": normed}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket: /events?since=<event_id>
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -802,8 +980,18 @@ async def stream_events(ws: WebSocket):
|
|||
except ValueError:
|
||||
cursor = 0
|
||||
|
||||
# Board selection — pinned at the WS handshake; re-subscribe to
|
||||
# switch boards. Changing boards mid-stream would require
|
||||
# reconciling two cursors, so the UI just opens a new WS on
|
||||
# board change.
|
||||
ws_board_raw = ws.query_params.get("board")
|
||||
try:
|
||||
ws_board = kanban_db._normalize_board_slug(ws_board_raw) if ws_board_raw else None
|
||||
except ValueError:
|
||||
ws_board = None
|
||||
|
||||
def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]:
|
||||
conn = kanban_db.connect()
|
||||
conn = kanban_db.connect(board=ws_board)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, task_id, run_id, kind, payload, created_at "
|
||||
|
|
|
|||
|
|
@ -592,6 +592,8 @@ def interactive_setup() -> None:
|
|||
from hermes_cli.config import (
|
||||
get_env_value,
|
||||
save_env_value,
|
||||
)
|
||||
from hermes_cli.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_info,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue