Compare commits

...

7 Commits

Author SHA1 Message Date
LeonSGP43
a712947966 fix(telegram): expose kanban blocked notifier helper 2026-05-06 05:00:56 -07:00
maciekczech
8d7ba19f71 fix(kanban): filter dashboard board by selected tenant 2026-05-06 04:59:45 -07:00
maciekczech
485a1d06b3 test(kanban): cover dashboard select filter wiring 2026-05-06 04:59:45 -07:00
Kenny Wang
6f8dd5844f fix(kanban): preflight missing Codex profile auth 2026-05-06 04:59:15 -07:00
Teknium
e66e1d0417 chore(release): map maciekczech@users.noreply -> maciekczech 2026-05-06 04:58:10 -07:00
liuguangyong
c01a050eac fix(kanban): reset code element background inside board
The Nous DS globals.css applies a global rule:
  code { background: var(--midground); color: var(--background); }

This paints an opaque cream/yellow fill on every <code> element,
which hides text in the kanban drawer's event-payload, run-meta,
and worker-log panes (all rendered as <code>).

Fix: scope a reset inside .hermes-kanban so <code> elements inherit
their parent's color and stay transparent.
2026-05-06 04:08:02 -07:00
Teknium
244cd59c73 chore(release): map liuguangyong@hellobike -> liuguangyong93 2026-05-06 04:08:02 -07:00
8 changed files with 323 additions and 1 deletions

View File

@@ -1340,6 +1340,47 @@ class TelegramAdapter(BasePlatformAdapter):
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
return SendResult(success=False, error=str(e), retryable=not is_timeout)
async def send_kanban_blocked(
self,
chat_id: str,
task_id: Any,
reason: Optional[str] = None,
*,
assignee: Optional[str] = None,
title: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> SendResult:
"""Send a kanban blocked notification through the standard Telegram sender."""
task = task_id if isinstance(task_id, dict) else {}
resolved_task_id = (
task.get("task_id")
or task.get("id")
or kwargs.get("task_id")
or kwargs.get("id")
or task_id
)
resolved_reason = (
reason
or task.get("blocked_reason")
or task.get("block_reason")
or task.get("reason")
or kwargs.get("blocked_reason")
or kwargs.get("block_reason")
or kwargs.get("reason")
or ""
)
resolved_assignee = assignee or task.get("assignee") or kwargs.get("assignee") or ""
resolved_title = title or task.get("title") or kwargs.get("title") or ""
tag = f"@{resolved_assignee} " if resolved_assignee else ""
reason_suffix = f": {str(resolved_reason)[:160]}" if resolved_reason else ""
title_suffix = f" - {str(resolved_title)[:120]}" if resolved_title else ""
message = f"{tag}Kanban {resolved_task_id} blocked{reason_suffix}{title_suffix}"
return await self.send(chat_id, message, reply_to=reply_to, metadata=metadata)
async def edit_message(
self,
chat_id: str,

View File

@@ -2542,6 +2542,16 @@ DEFAULT_FAILURE_LIMIT = 5
# Legacy alias — callers / tests still reference the old name.
DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT
# OpenAI Codex OAuth credentials live in profile-local auth.json rather than
# only in config.yaml / .env. Kanban workers run under their assignee's profile,
# so a cloned profile can have a valid model provider but still exit immediately
# if its auth store was not intentionally initialized. Keep this non-secret: the
# preflight checks only config metadata plus auth.json presence/size, never token
# values.
_AUTH_STORE_REQUIRED_PROVIDERS = {
"openai-codex": "OpenAI Codex",
}
# Max bytes to keep in a single worker log file. The dispatcher truncates
# and rotates on spawn if the file is larger than this at spawn time.
DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB
@@ -3107,6 +3117,79 @@ def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
return False
def _worker_profile_dir(profile: str) -> Path:
"""Resolve a kanban assignee profile to its HERMES_HOME directory."""
from hermes_cli.profiles import get_profile_dir
return get_profile_dir(profile)
def _profile_configured_provider(profile_dir: Path) -> Optional[str]:
"""Read the profile's configured model provider without loading secrets."""
config_path = profile_dir / "config.yaml"
if not config_path.is_file():
return None
try:
import yaml
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception:
return None
if not isinstance(raw, dict):
return None
provider: Any = None
model_cfg = raw.get("model")
if isinstance(model_cfg, dict):
provider = model_cfg.get("provider")
if not provider:
provider = raw.get("provider")
if not isinstance(provider, str):
return None
provider = provider.strip().lower()
return provider or None
def _preflight_worker_profile_auth(task: Task) -> None:
"""Fail early when a profile-local OAuth auth store is plainly absent.
This intentionally does NOT read or copy auth.json contents. It only checks
the assignee profile's non-secret configured provider and whether an auth
store file exists with nonzero size. The worker still performs the
authoritative credential validation when it starts; this preflight catches
the common cloned-profile footgun before spawning a process that can only
exit immediately.
"""
profile = (task.assignee or "").strip()
if not profile:
return
try:
profile_dir = _worker_profile_dir(profile)
except Exception:
return
provider = _profile_configured_provider(profile_dir)
display_name = _AUTH_STORE_REQUIRED_PROVIDERS.get(provider or "")
if not display_name:
return
auth_path = profile_dir / "auth.json"
try:
has_auth_store = auth_path.is_file() and auth_path.stat().st_size > 0
except OSError:
has_auth_store = False
if has_auth_store:
return
raise RuntimeError(
f"Kanban worker profile {profile!r} is configured for {display_name} "
f"but has no profile-local auth store at {auth_path}. "
f"Authenticate this profile intentionally with `hermes -p {profile} auth` "
f"or `hermes -p {profile} model` before dispatching. Hermes will not "
"copy OAuth credentials from another profile automatically."
)
def dispatch_once(
conn: sqlite3.Connection,
*,
@@ -3274,6 +3357,7 @@ def _default_spawn(
import subprocess
if not task.assignee:
raise ValueError(f"task {task.id} has no assignee")
_preflight_worker_profile_auth(task)
from hermes_cli.profiles import normalize_profile_name

View File

@@ -496,6 +496,7 @@
if (!boardData) return null;
const q = search.trim().toLowerCase();
const filterTask = function (t) {
if (tenantFilter && t.tenant !== tenantFilter) return false;
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
if (q) {
const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase();
@@ -508,7 +509,7 @@
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
}),
});
}, [boardData, assigneeFilter, search]);
}, [boardData, tenantFilter, assigneeFilter, search]);
// --- actions ------------------------------------------------------------
const moveTask = useCallback(function (taskId, newStatus) {

View File

@@ -9,6 +9,15 @@
width: 100%;
}
/* Override the Nous DS global `code { background: var(--midground) }` rule
which paints an opaque cream/yellow fill on every <code> inside the board,
hiding the text underneath. Kanban uses <code> for event payloads, run-meta,
and log panes — those need transparent backgrounds. */
.hermes-kanban code {
background: transparent;
color: inherit;
}
/* ---- Columns layout -------------------------------------------------- */
.hermes-kanban-columns {

View File

@@ -50,6 +50,8 @@ AUTHOR_MAP = {
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
"aludwin+gh@gmail.com": "adamludwin",
"ngusev@astralinux.ru": "NikolayGusev-astra",
"liuguangyong201@hellobike.com": "liuguangyong93",
"maciekczech@users.noreply.github.com": "maciekczech",
"2093036+exiao@users.noreply.github.com": "exiao",
"rylen.anil@gmail.com": "rylena",
"godnanijatin@gmail.com": "jatingodnani",

View File

@@ -13,6 +13,7 @@ WITHOUT message_thread_id so the message still reaches the chat.
import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
@@ -273,6 +274,55 @@ async def test_send_without_thread_id_unaffected():
assert call_log[0]["message_thread_id"] is None
@pytest.mark.asyncio
async def test_send_kanban_blocked_delegates_to_send_with_metadata():
"""The blocked-notifier compatibility API should use the normal sender."""
adapter = _make_adapter()
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="kanban-msg"))
result = await adapter.send_kanban_blocked(
"123",
"KAN-7",
"waiting for owner",
assignee="ada",
metadata={"thread_id": "42"},
)
assert result.success is True
assert result.message_id == "kanban-msg"
adapter.send.assert_awaited_once_with(
"123",
"⏸ @ada Kanban KAN-7 blocked: waiting for owner",
reply_to=None,
metadata={"thread_id": "42"},
)
@pytest.mark.asyncio
async def test_send_kanban_blocked_accepts_task_mapping():
"""Downstream guardrails may pass task payloads instead of separate fields."""
adapter = _make_adapter()
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="kanban-msg"))
result = await adapter.send_kanban_blocked(
"123",
{
"task_id": "KAN-8",
"blocked_reason": "needs maintainer answer",
"assignee": "leon",
"title": "Clarify gateway hook contract",
},
)
assert result.success is True
adapter.send.assert_awaited_once_with(
"123",
"⏸ @leon Kanban KAN-8 blocked: needs maintainer answer - Clarify gateway hook contract",
reply_to=None,
metadata=None,
)
@pytest.mark.asyncio
async def test_send_retries_network_errors_normally():
"""Real transient network errors (not BadRequest) should still be retried."""

View File

@@ -474,6 +474,104 @@ def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawna
assert kb.get_task(conn, t).claim_lock is None
def test_default_spawn_preflights_missing_profile_oauth_auth_store(
kanban_home, monkeypatch, tmp_path
):
profile_home = kanban_home / "profiles" / "dogfood"
profile_home.mkdir(parents=True)
(profile_home / "config.yaml").write_text(
"model:\n"
" default: gpt-5.5\n"
" provider: openai-codex\n",
encoding="utf-8",
)
popen_called = False
class _FakePopen:
def __init__(self, cmd, **kwargs):
nonlocal popen_called
popen_called = True
self.pid = 4242
monkeypatch.setattr("subprocess.Popen", _FakePopen)
task = kb.Task(
id="t_missing_auth",
title="x",
body=None,
assignee="dogfood",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
with pytest.raises(RuntimeError, match="dogfood.*OpenAI Codex.*auth"):
kb._default_spawn(task, str(tmp_path / "ws"))
assert not popen_called
def test_default_spawn_allows_profile_with_oauth_auth_store(
kanban_home, monkeypatch, tmp_path
):
profile_home = kanban_home / "profiles" / "dogfood"
profile_home.mkdir(parents=True)
(profile_home / "config.yaml").write_text(
"model:\n"
" default: gpt-5.5\n"
" provider: openai-codex\n",
encoding="utf-8",
)
(profile_home / "auth.json").write_text("{}", encoding="utf-8")
popen_calls = []
class _FakePopen:
def __init__(self, cmd, **kwargs):
popen_calls.append((cmd, kwargs))
stdout = kwargs.get("stdout")
if stdout is not None:
stdout.close()
self.pid = 4242
monkeypatch.setattr("subprocess.Popen", _FakePopen)
task = kb.Task(
id="t_has_auth",
title="x",
body=None,
assignee="dogfood",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
workspace = tmp_path / "ws"
workspace.mkdir()
assert kb._default_spawn(task, str(workspace)) == 4242
assert len(popen_calls) == 1
cmd, kwargs = popen_calls[0]
assert cmd[:3] == ["hermes", "-p", "dogfood"]
assert kwargs["env"]["HERMES_PROFILE"] == "dogfood"
def test_dispatch_reclaims_stale_before_spawning(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="x", assignee="alice")

View File

@@ -127,6 +127,43 @@ def test_tenant_filter(client):
assert total == 1
def test_dashboard_select_filters_use_sdk_value_change_handler():
"""Tenant/assignee filters must work with the dashboard SDK Select API.
The dashboard Select component is shadcn-like and calls
``onValueChange(value)`` instead of native ``onChange(event)``. A native-only
handler leaves the tenant dropdown visually selectable but never updates the
filtered board query.
"""
repo_root = Path(__file__).resolve().parents[2]
bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
js = bundle.read_text()
assert "function selectChangeHandler(setter)" in js
assert "onValueChange: function (v)" in js
assert "onChange: function (e)" in js
assert "selectChangeHandler(props.setTenantFilter)" in js
assert "selectChangeHandler(props.setAssigneeFilter)" in js
def test_dashboard_client_side_filtering_includes_tenant_filter():
"""The rendered board must also filter by tenant.
The API request includes ``?tenant=...``, but the dashboard also filters the
locally cached board for search/assignee changes. Without checking
``tenantFilter`` here, switching tenants can leave stale cards visible until a
full reload finishes.
"""
repo_root = Path(__file__).resolve().parents[2]
bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
js = bundle.read_text()
assert "if (tenantFilter && t.tenant !== tenantFilter) return false;" in js
assert "[boardData, tenantFilter, assigneeFilter, search]" in js
# ---------------------------------------------------------------------------
# GET /tasks/:id returns body + comments + events + links
# ---------------------------------------------------------------------------