mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
feat(kanban): multi-project boards — one install, many kanbans (#19653)
Adds first-class board support to kanban so users can separate unrelated
streams of work (projects, repos, domains) into isolated queues. Single-
project users stay on the 'default' board and see no UI change.
Isolation model
---------------
- Each board is a directory at `~/.hermes/kanban/boards/<slug>/` with
its own `kanban.db`, `workspaces/`, and `logs/`. The 'default' board
keeps its legacy path (`~/.hermes/kanban.db`) for back-compat — fresh
installs and pre-boards users get zero migration.
- Workers spawned by the dispatcher have `HERMES_KANBAN_BOARD` pinned in
their env alongside the existing `HERMES_KANBAN_DB` /
`HERMES_KANBAN_WORKSPACES_ROOT` pins, so workers physically cannot see
other boards' tasks.
- The gateway's single dispatcher loop now sweeps every board per tick;
per-tick cost is a few extra filesystem stats.
- CAS concurrency guarantees are preserved per-board (each board is its
own SQLite DB, same WAL+IMMEDIATE machinery as before).
CLI
---
hermes kanban boards list|create|switch|show|rename|rm
hermes kanban --board <slug> <any-subcommand>
Board resolution order: `--board` flag → `HERMES_KANBAN_BOARD` env →
`~/.hermes/kanban/current` file → `default`. Slug validation is strict:
lowercase alphanumerics + hyphens + underscores, 1-64 chars, starts with
alphanumeric. Uppercase is auto-downcased; slashes / dots / `..` /
control chars are rejected so boards can't name their way out of the
boards/ directory.
Passive discoverability: when more than one board exists, `hermes kanban
list` prints a one-line header ("Board: foo (2 other boards …)") so
users who stumble across multi-project never have to hunt for the
feature. Invisible for single-board installs.
Dashboard
---------
- New `BoardSwitcher` component at the top of the Kanban tab: dropdown
with all boards + task counts, `+ New board` button, `Archive`
button (non-default only). Hidden entirely when only `default` exists
and is empty — single-project users never see it.
- New `NewBoardDialog` modal: slug / display name / description / icon
+ "switch to this board after creating" checkbox.
- Selected board persists to `localStorage` so browser users don't
shift the CLI's active board out from under a terminal they left open.
- New `?board=<slug>` query param on every existing endpoint plus a
new `/boards` CRUD surface (`GET /boards`, `POST /boards`,
`PATCH /boards/<slug>`, `DELETE /boards/<slug>`,
`POST /boards/<slug>/switch`).
- Events WebSocket is pinned to a board at connection time; switching
opens a fresh WS against the new board.
Also fixes a pre-existing bug in the plugin's tenant / assignee
filters: the SDK's `Select` uses `onValueChange(value)`, not
native `onChange(event)`, so those filters silently didn't work.
New `selectChangeHandler` helper wires both signatures.
Tests
-----
49 new tests in `tests/hermes_cli/test_kanban_boards.py` covering:
slug validation (valid / invalid / auto-downcase), path resolution
(default = legacy path, named = `boards/<slug>/`, env var override),
current-board resolution chain (env > file > default), board CRUD +
archive / hard-delete, per-board connection isolation (tasks don't
leak), worker spawn env injection (`HERMES_KANBAN_BOARD`,
`HERMES_KANBAN_DB`, `HERMES_KANBAN_WORKSPACES_ROOT` all point at the
right board), and end-to-end CLI surface.
Regression surface: all 264 pre-existing kanban tests continue to pass.
Live-tested via the dashboard: created 3 boards (default,
hermes-agent, atm10-server), created tasks on each via both CLI
(`--board <slug> create`) and dashboard (inline create on the Ready
column), confirmed zero cross-board leakage, confirmed `BoardSwitcher`
+ `NewBoardDialog` work end-to-end in the browser.
This commit is contained in:
parent
135b4c8b35
commit
5ec6baa400
8 changed files with 2191 additions and 212 deletions
414
plugins/kanban/dashboard/dist/index.js
vendored
414
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);
|
||||
|
|
@ -1049,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
|
||||
|
|
@ -1070,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 }),
|
||||
|
|
@ -1085,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),
|
||||
|
|
@ -1093,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 }),
|
||||
|
|
@ -1102,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 }),
|
||||
|
|
@ -1116,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)); });
|
||||
};
|
||||
|
|
@ -1141,6 +1468,7 @@
|
|||
data, editing, setEditing,
|
||||
renderMarkdown: props.renderMarkdown,
|
||||
allTasks: props.allTasks,
|
||||
boardSlug: boardSlug,
|
||||
onPatch: doPatch,
|
||||
onAddParent: addLink,
|
||||
onRemoveParent: removeLink,
|
||||
|
|
@ -1253,7 +1581,7 @@
|
|||
);
|
||||
}),
|
||||
),
|
||||
h(WorkerLogSection, { taskId: t.id }),
|
||||
h(WorkerLogSection, { taskId: t.id, boardSlug: props.boardSlug }),
|
||||
h(RunHistorySection, { runs: props.data.runs || [] }),
|
||||
);
|
||||
}
|
||||
|
|
@ -1324,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.
|
||||
|
|
|
|||
54
plugins/kanban/dashboard/dist/style.css
vendored
54
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -769,3 +769,57 @@
|
|||
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:
|
||||
|
|
@ -527,10 +562,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 +588,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 +601,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 +628,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 +638,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 +733,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 +749,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 +757,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 +770,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 +783,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 +810,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 +830,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 +975,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 "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue