feat: 新增工具插件、系统日志、workflow配置及前端优化
1. 新增工具插件(edit_file, python_executor, search_file, shell_executor, write_file) 2. 新增系统事件日志模块和API 3. 新增workflow配置文件和详情API 4. 前端增加SSE、错误边界、设置引导等组件 5. 优化认证加密、速率限制、配置加载等工具模块 6. 删除废弃的cluster和health API 7. 补充单元测试和集成测试 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""``api/health.py`` 健康探针端点。"""
|
||||
"""``api/system.py`` 健康探针端点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,13 +9,13 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from kilostar.api.health import health_router
|
||||
from kilostar.api.system import system_router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def health_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(health_router)
|
||||
app.include_router(system_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""workflow 路由鉴权测试:SSE / reply / resume / detail / graph 端点归属校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from kilostar.api.workflow import workflow_router
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
|
||||
|
||||
def _fake_user(user_id: str = "alice"):
|
||||
return TokenData(user_id=user_id, username=user_id)
|
||||
|
||||
|
||||
def _make_workflow(owner: str = "alice"):
|
||||
return types.SimpleNamespace(
|
||||
trace_id="trace-1",
|
||||
user_id=owner,
|
||||
title="test",
|
||||
status="running",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_alice():
|
||||
app = FastAPI()
|
||||
app.include_router(workflow_router)
|
||||
app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user("alice")
|
||||
return app
|
||||
|
||||
|
||||
def _register_pg(fake_actors, owner: str = "alice"):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=_make_workflow(owner)))
|
||||
pg.get_workflow_context = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
pg.get_workflow_graph_state = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
return pg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/trace-1")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_not_found(app_alice, fake_actors):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/trace-nonexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.post("/api/v1/workflow/reply/trace-1", json={"message": "hi"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.post("/api/v1/workflow/trace-1/resume")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_not_found(app_alice, fake_actors):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.post("/api/v1/workflow/trace-nonexist/resume")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/trace-1/graph")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/sse/trace-1")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_not_found(app_alice, fake_actors):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/sse/trace-nonexist")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,80 @@
|
||||
"""``api/workflow.py`` 读侧拼装:运行期状态 merge 到静态 step。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from kilostar.api.workflow import _merge_runtime_status
|
||||
|
||||
|
||||
def test_merge_marks_pending_when_no_log():
|
||||
"""没有任何运行日志时,所有 step 默认 pending。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
{"step": 2, "name": "s2", "node": "consciousness_node", "action": "b"},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, [])
|
||||
assert [s["status"] for s in merged] == ["pending", "pending"]
|
||||
# 静态字段保留
|
||||
assert merged[0]["name"] == "s1"
|
||||
assert merged[1]["node"] == "consciousness_node"
|
||||
|
||||
|
||||
def test_merge_uses_latest_status_per_step():
|
||||
"""同一 step 多条日志时取最后一条(working → completed)。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
]
|
||||
workflow_log = [
|
||||
{"0": ["2026-01-01T00:00:00", "working", "开始"]},
|
||||
{"0": ["2026-01-01T00:00:05", "completed", "成功"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert merged[0]["status"] == "completed"
|
||||
|
||||
|
||||
def test_merge_mixed_statuses():
|
||||
"""多 step 各自取自己最新状态;无日志的保持 pending。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
{"step": 2, "name": "s2", "node": "skill_individual", "action": "b"},
|
||||
{"step": 3, "name": "s3", "node": "skill_individual", "action": "c"},
|
||||
]
|
||||
workflow_log = [
|
||||
{"0": ["t", "completed", "ok"]},
|
||||
{"1": ["t", "failed", "boom"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert [s["status"] for s in merged] == ["completed", "failed", "pending"]
|
||||
|
||||
|
||||
def test_merge_falls_back_to_position_index_without_step_field():
|
||||
"""step 没有 step 字段时按位置索引匹配日志。"""
|
||||
work_link = [
|
||||
{"name": "s1", "node": "skill_individual", "action": "a"},
|
||||
{"name": "s2", "node": "skill_individual", "action": "b"},
|
||||
]
|
||||
workflow_log = [
|
||||
{"1": ["t", "completed", "ok"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert merged[0]["status"] == "pending"
|
||||
assert merged[1]["status"] == "completed"
|
||||
|
||||
|
||||
def test_merge_ignores_malformed_log_entries():
|
||||
"""脏日志(非 dict / payload 不是数组 / key 不是数字)不应炸。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
]
|
||||
workflow_log = [
|
||||
"not-a-dict",
|
||||
{"not-an-int": ["t", "completed", "x"]},
|
||||
{"0": "not-a-list"},
|
||||
{"0": ["t", "working"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert merged[0]["status"] == "working"
|
||||
|
||||
|
||||
def test_merge_handles_empty_work_link():
|
||||
assert _merge_runtime_status([], []) == []
|
||||
assert _merge_runtime_status(None, None) == []
|
||||
@@ -0,0 +1,78 @@
|
||||
"""``utils/config_loader.py``:workflow.yaml 读/写/热重载。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_yaml(tmp_path, monkeypatch):
|
||||
"""每个用例用独立的临时 yaml,避免污染真实 config/workflow.yaml。"""
|
||||
from kilostar.utils import config_loader
|
||||
|
||||
fake_yaml = tmp_path / "workflow.yaml"
|
||||
monkeypatch.setattr(config_loader, "_WORKFLOW_YAML", fake_yaml)
|
||||
monkeypatch.setattr(config_loader, "_current", None)
|
||||
return fake_yaml
|
||||
|
||||
|
||||
def test_get_workflow_config_returns_default_when_file_absent():
|
||||
from kilostar.utils.config_loader import get_workflow_config
|
||||
|
||||
config = get_workflow_config()
|
||||
assert config.retry.max_attempts == 5
|
||||
|
||||
|
||||
def test_get_workflow_config_reads_from_disk(isolated_yaml):
|
||||
isolated_yaml.write_text("retry:\n max_attempts: 12\n", encoding="utf-8")
|
||||
|
||||
from kilostar.utils.config_loader import reload_workflow_config
|
||||
|
||||
config = reload_workflow_config()
|
||||
assert config.retry.max_attempts == 12
|
||||
|
||||
|
||||
def test_save_workflow_config_writes_yaml_and_reloads(isolated_yaml):
|
||||
from kilostar.utils.config_loader import (
|
||||
save_workflow_config,
|
||||
get_workflow_config,
|
||||
WorkflowConfig,
|
||||
RetryConfig,
|
||||
)
|
||||
|
||||
new_config = WorkflowConfig(retry=RetryConfig(max_attempts=20))
|
||||
save_workflow_config(new_config)
|
||||
|
||||
on_disk = yaml.safe_load(isolated_yaml.read_text(encoding="utf-8"))
|
||||
assert on_disk == {"retry": {"max_attempts": 20}}
|
||||
|
||||
# 热重载:再次 get 应直接拿到新值
|
||||
assert get_workflow_config().retry.max_attempts == 20
|
||||
|
||||
|
||||
def test_max_attempts_validation_rejects_out_of_range():
|
||||
from kilostar.utils.config_loader import RetryConfig
|
||||
|
||||
with pytest.raises(Exception):
|
||||
RetryConfig(max_attempts=0)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
RetryConfig(max_attempts=200)
|
||||
|
||||
|
||||
def test_reload_picks_up_external_file_changes(isolated_yaml):
|
||||
"""模拟运维直接改 yaml 文件,reload 后引擎能拿到新值。"""
|
||||
isolated_yaml.write_text("retry:\n max_attempts: 3\n", encoding="utf-8")
|
||||
|
||||
from kilostar.utils.config_loader import (
|
||||
get_workflow_config,
|
||||
reload_workflow_config,
|
||||
)
|
||||
|
||||
assert reload_workflow_config().retry.max_attempts == 3
|
||||
|
||||
isolated_yaml.write_text("retry:\n max_attempts: 30\n", encoding="utf-8")
|
||||
assert reload_workflow_config().retry.max_attempts == 30
|
||||
# get_workflow_config 也读到最新
|
||||
assert get_workflow_config().retry.max_attempts == 30
|
||||
@@ -13,15 +13,15 @@ def test_approval_metadata():
|
||||
data = ApprovalToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
assert "control_node" in data.action_scope
|
||||
assert "consciousness_node" in data.action_scope
|
||||
# action_scope 为空表示分配给 default 组(所有节点可用)
|
||||
assert data.action_scope == []
|
||||
|
||||
|
||||
def test_file_reader_metadata():
|
||||
data = FileReaderToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
assert "control_node" in data.action_scope
|
||||
assert data.action_scope == []
|
||||
|
||||
|
||||
def test_tavily_search_metadata():
|
||||
|
||||
@@ -44,3 +44,64 @@ def test_ray_actor_hook_unknown_actor_raises(fake_actors):
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ray_actor_hook("does_not_exist")
|
||||
|
||||
|
||||
def test_wait_for_actor_returns_immediately_when_ready(fake_actors):
|
||||
"""actor 已就绪时 wait_for_actor 立刻返回,不进入轮询等待。"""
|
||||
handle = MagicMock()
|
||||
fake_actors.register("postgres_database", handle)
|
||||
|
||||
from kilostar.utils.ray_hook import wait_for_actor
|
||||
|
||||
got = wait_for_actor("postgres_database", timeout=5.0)
|
||||
assert got is handle
|
||||
|
||||
|
||||
def test_wait_for_actor_times_out_with_clear_error(fake_actors):
|
||||
"""超时仍未就绪时抛 TimeoutError,并在 message 里带 actor 名。"""
|
||||
from kilostar.utils.ray_hook import wait_for_actor
|
||||
|
||||
with pytest.raises(TimeoutError) as exc_info:
|
||||
wait_for_actor("never_ready", timeout=0.2, interval=0.05)
|
||||
assert "never_ready" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_wait_for_actor_succeeds_after_delayed_registration(fake_actors):
|
||||
"""actor 在第 N 次轮询时才注册,wait_for_actor 应在它就绪后返回。"""
|
||||
from kilostar.utils.ray_hook import wait_for_actor
|
||||
|
||||
handle = MagicMock()
|
||||
calls = {"n": 0}
|
||||
original_get = fake_actors.get
|
||||
|
||||
def delayed_get(name, namespace="kilostar"):
|
||||
calls["n"] += 1
|
||||
if calls["n"] >= 3:
|
||||
return handle
|
||||
raise ValueError("not ready yet")
|
||||
|
||||
fake_actors.get = delayed_get
|
||||
try:
|
||||
got = wait_for_actor("late_actor", timeout=2.0, interval=0.05)
|
||||
assert got is handle
|
||||
assert calls["n"] >= 3
|
||||
finally:
|
||||
fake_actors.get = original_get
|
||||
|
||||
|
||||
def test_ray_actor_hook_with_timeout_waits(fake_actors):
|
||||
"""ray_actor_hook(timeout>0) 会走 wait_for_actor 等待路径。"""
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
handle = MagicMock()
|
||||
calls = {"n": 0}
|
||||
|
||||
def delayed_get(name, namespace="kilostar"):
|
||||
calls["n"] += 1
|
||||
if calls["n"] >= 2:
|
||||
return handle
|
||||
raise ValueError("not ready yet")
|
||||
|
||||
fake_actors.get = delayed_get
|
||||
actors = ray_actor_hook("slow_actor", timeout=2.0, interval=0.05)
|
||||
assert actors.slow_actor is handle
|
||||
|
||||
@@ -288,3 +288,37 @@ def test_workflow_graph_state_defaults():
|
||||
assert state.final_status == WorkflowStatus.RUNNING.value
|
||||
assert state.logs == []
|
||||
assert state.original_command == ""
|
||||
assert state.jump_counts == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_retry_exceeds_max_attempts_fails(monkeypatch):
|
||||
"""环跳转超过 max_attempts 后,工作流应直接 FAILED 而非无限重试。"""
|
||||
from kilostar.utils import config_loader
|
||||
from kilostar.utils.config_loader import WorkflowConfig, RetryConfig
|
||||
|
||||
monkeypatch.setattr(config_loader, "_current", WorkflowConfig(retry=RetryConfig(max_attempts=2)))
|
||||
|
||||
deps, sink = _make_deps(
|
||||
skill_outputs=[
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
]
|
||||
)
|
||||
workflow_data = {
|
||||
"work_link": [
|
||||
{"step": 1, "name": "s1", "action": "do",
|
||||
"node": "skill_individual", "agent_id": "a1"},
|
||||
{"step": 2, "name": "s2", "action": "do",
|
||||
"node": "skill_individual", "agent_id": "a2",
|
||||
"logic_gate": {"if_fail": "jump_to_step_1", "if_pass": "continue"}},
|
||||
]
|
||||
}
|
||||
final = await run_workflow_graph(workflow_data, "trace-loop-limit", deps=deps)
|
||||
assert final == WorkflowStatus.FAILED.value
|
||||
|
||||
Reference in New Issue
Block a user