From f67aae323010e32c592a185984d36b20e9fa474a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:15:59 -0700 Subject: [PATCH] fix(kanban): make scratch cleanup explicit in dashboard (#63123) --- plugins/kanban/dashboard/dist/index.js | 41 +++++++++++------ plugins/kanban/dashboard/plugin_api.py | 12 +++++ tests/plugins/test_kanban_dashboard_plugin.py | 45 +++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index a801a8732..af6ba426c 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -1072,6 +1072,7 @@ error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, h(BoardColumns, { board: filteredBoard, + boardMeta: boardList.find(function (item) { return item.slug === board; }) || null, laneByProfile, selectedIds, failedIds, @@ -2384,6 +2385,7 @@ return h(Column, { key: col.name, column: col, + boardMeta: props.boardMeta, laneByProfile: props.laneByProfile, selectedIds: props.selectedIds, failedIds: props.failedIds, @@ -2504,6 +2506,8 @@ showCreate ? h(InlineCreate, { columnName: props.column.name, allTasks: props.allTasks, + defaultWorkspaceKind: (props.boardMeta && props.boardMeta.default_workspace_kind) || "scratch", + defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || "", onSubmit: function (body) { props.onCreate(body).then(function () { setShowCreate(false); }); }, @@ -2745,12 +2749,13 @@ 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(""); + // A board with a configured workdir defaults to a persistent workspace: + // worktree for git repositories, dir for ordinary directories. Boards + // without one keep scratch for disposable research and ops tasks. + const defaultWorkspaceKind = props.defaultWorkspaceKind || "scratch"; + const defaultWorkspacePath = props.defaultWorkspacePath || ""; + const [workspaceKind, setWorkspaceKind] = useState(defaultWorkspaceKind); + const [workspacePath, setWorkspacePath] = useState(defaultWorkspacePath); // Goal-mode: when on, the dispatched worker runs the Ralph-style /goal // loop — a judge re-checks the card after each turn and the worker keeps // going in the same session until done, or the turn budget runs out @@ -2793,15 +2798,15 @@ } props.onSubmit(body); setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills(""); - setWorkspaceKind("scratch"); setWorkspacePath(""); + setWorkspaceKind(defaultWorkspaceKind); setWorkspacePath(defaultWorkspacePath); setGoalMode(false); setGoalMaxTurns(""); }; const showPathInput = workspaceKind !== "scratch"; const pathPlaceholder = workspaceKind === "dir" - ? tx(t, "workspacePathDir", "workspace path (required, e.g. ~/projects/my-app)") + ? tx(t, "workspacePathDir", "workspace path (required without a board workdir)") : tx(t, "workspacePathOptional", - "workspace path (optional, derived from assignee if blank)"); + "repository path (optional when the board has a workdir)"); return h("div", { className: "hermes-kanban-inline-create" }, h("textarea", { @@ -2877,12 +2882,15 @@ h("div", { className: "flex gap-2" }, h(Select, Object.assign({ value: workspaceKind, - 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", + title: "Choose whether task files are temporary or preserved after completion.", + className: "h-7 text-xs flex-1", }, selectChangeHandler(setWorkspaceKind)), - h(SelectOption, { value: "scratch" }, "scratch"), - h(SelectOption, { value: "worktree" }, "worktree"), - h(SelectOption, { value: "dir" }, "dir"), + h(SelectOption, { value: "scratch" }, + tx(t, "workspaceScratch", "Temporary — deleted on completion")), + h(SelectOption, { value: "worktree" }, + tx(t, "workspaceWorktree", "Git worktree — preserved")), + h(SelectOption, { value: "dir" }, + tx(t, "workspaceDir", "Directory — preserved")), ), showPathInput ? h(Input, { value: workspacePath, @@ -2891,6 +2899,11 @@ className: "h-7 text-xs flex-1", }) : null, ), + workspaceKind === "scratch" ? h("div", { + className: "text-xs text-destructive", + role: "alert", + }, tx(t, "workspaceScratchWarning", + "This workspace and any files left in it are deleted when the task completes.")) : null, h(Select, Object.assign({ value: parent, className: "h-7 text-xs", diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index e340aba97..b2fc468de 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -2010,6 +2010,17 @@ def _board_counts(slug: str) -> dict[str, int]: return {} +def _default_workspace_kind(board: dict[str, Any]) -> str: + """Recommend a non-destructive task workspace from board metadata.""" + workdir = str(board.get("default_workdir") or "").strip() + if not workdir: + return "scratch" + try: + return "worktree" if kanban_db._git_toplevel(Path(workdir)) else "dir" + except (OSError, ValueError): + return "dir" + + @router.get("/boards") def list_boards(include_archived: bool = Query(False)): """Return every board on disk with task counts and the active slug.""" @@ -2019,6 +2030,7 @@ def list_boards(include_archived: bool = Query(False)): b["is_current"] = (b["slug"] == current) b["counts"] = _board_counts(b["slug"]) b["total"] = sum(b["counts"].values()) + b["default_workspace_kind"] = _default_workspace_kind(b) return {"boards": boards, "current": current} diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 9833ea210..1ec04e8db 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -9,6 +9,7 @@ from __future__ import annotations import importlib.util import os +import subprocess import sys import time from pathlib import Path @@ -114,6 +115,50 @@ def test_create_task_appears_on_board(client): assert "researcher" in data["assignees"] +def test_board_list_recommends_persistent_workspace_for_configured_workdir( + client, tmp_path +): + """Board metadata should tell the UI which safe task default to use.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + kb.write_board_metadata("default", default_workdir=str(repo)) + + plain_dir = tmp_path / "notes" + plain_dir.mkdir() + kb.create_board("notes", default_workdir=str(plain_dir)) + kb.create_board("disposable") + + response = client.get("/api/plugins/kanban/boards") + + assert response.status_code == 200 + boards = {board["slug"]: board for board in response.json()["boards"]} + assert boards["default"]["default_workspace_kind"] == "worktree" + assert boards["notes"]["default_workspace_kind"] == "dir" + assert boards["disposable"]["default_workspace_kind"] == "scratch" + + +def test_dashboard_workspace_picker_explains_persistence_contract(): + """Task creation must make scratch deletion visible without a hover.""" + bundle = ( + Path(__file__).resolve().parents[2] + / "plugins" + / "kanban" + / "dashboard" + / "dist" + / "index.js" + ).read_text(encoding="utf-8") + + assert "Temporary — deleted on completion" in bundle + assert "Git worktree — preserved" in bundle + assert "Directory — preserved" in bundle + assert "defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || \"\"" in bundle + assert ( + "This workspace and any files left in it are deleted when the task completes." + in bundle + ) + + def test_scheduled_tasks_have_their_own_column_not_todo(client): """Scheduled/time-delay tasks must not be silently bucketed into todo."""