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:
2026-06-03 07:34:43 +00:00
parent f04fef916f
commit a53ffebe0e
57 changed files with 2804 additions and 271 deletions
+156
View File
@@ -0,0 +1,156 @@
"""组装层 / 端到端 smoke 测试。
这一层补 ``tests/unit`` 的盲区:单测全是 mock 出来的纯逻辑,抓不到
"import 错误 / 路由冲突 / 真实节点拓扑串联不上" 这类组装层 bug。
设计原则:
- **不依赖真 ray / 真 postgres**sandbox 里 ``ray.init`` 有 psutil PID 问题,
真 postgres 要 docker。这里只验证"组件能正确组装 + 真实拓扑能端到端跑通"
- app 装配:用真实的 ``KiloStarGateway`` 内部 ``app``(触发所有 router import +
注册),打 health 探针。
- workflow:用真实的 6 节点 graph 拓扑端到端跑,只 mock 最外层 IO(DB 写 / SSE /
执行器),不 mock 任何节点逻辑。
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.integration
# ─── 组装层:整个 FastAPI app 能 import + 路由注册无冲突 ────────────────────
@pytest.mark.asyncio
async def test_app_imports_and_health_live_ok():
"""导入生产 ``app`` 不报错,且 /health/live 返回 alive。
这一步能抓到的真实 bug:任一 router 模块 import 失败、include_router
路由前缀撞车、middleware 装配异常——这些单测都看不到。
"""
from kilostar.api import app
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/health/live")
assert resp.status_code == 200
assert resp.json() == {"status": "alive"}
@pytest.mark.asyncio
async def test_app_route_table_has_expected_endpoints():
"""关键路由都已注册(拓扑回归保护)。"""
from kilostar.api import app
paths = {getattr(r, "path", None) for r in app.router.routes}
assert "/health/live" in paths
assert "/health/ready" in paths
assert "/api/v1/workflow" in paths
# 阶段九/十 新增的 resume / graph 端点
assert "/api/v1/workflow/{trace_id}/resume" in paths
assert "/api/v1/workflow/{trace_id}/graph" in paths
# ─── 端到端:真实 6 节点 graph 拓扑跑通 ────────────────────────────────────
def _make_real_deps(skill_outputs, consciousness_outputs=None, replies=None):
"""构造 WorkflowDeps:只 mock 最外层 IO,节点逻辑全用真实实现。"""
from kilostar.core.work.workflow.workflow_engine import WorkflowDeps
skill_q = list(skill_outputs or [])
consc_q = list(consciousness_outputs or [])
reply_q = list(replies or [])
sink = {"pending": [], "skill": [], "consc": []}
async def _get_received(tid):
return reply_q.pop(0) if reply_q else ""
async def _run_skill(step, state):
sink["skill"].append(step.get("name"))
return skill_q.pop(0) if skill_q else ("(none)", True)
async def _run_consciousness(step, state):
sink["consc"].append(step.get("name"))
return consc_q.pop(0) if consc_q else ("(none)", True)
deps = WorkflowDeps(
upsert_workflow_context=AsyncMock(),
update_workflow_status=AsyncMock(),
put_pending=AsyncMock(side_effect=lambda t, m: sink["pending"].append(m)),
get_received=_get_received,
run_skill=_run_skill,
run_consciousness=_run_consciousness,
)
return deps, sink
@pytest.mark.asyncio
async def test_end_to_end_mixed_workflow_runs_to_completion():
"""混合 skill + consciousness + HITL 的多步 workflow 端到端跑通。
这是最贴近"真实一次 workflow"的 smoke:3 步分别走不同节点类型 + 一步
需要人工审批,全程用真实 Dispatch 派发逻辑。
"""
from kilostar.core.work.workflow.workflow_engine import run_workflow_graph
from kilostar.core.work.workflow.model import WorkflowStatus
deps, sink = _make_real_deps(
skill_outputs=[("s-ok", True), ("s2-ok", True)],
consciousness_outputs=[("c-ok", True)],
replies=["approve"],
)
workflow_data = {
"work_link": [
{"step": 1, "name": "research", "action": "do",
"node": "skill_individual", "agent_id": "a1"},
{"step": 2, "name": "plan", "action": "do",
"node": "consciousness_node"},
{"step": 3, "name": "review", "action": "do",
"node": "skill_individual", "agent_id": "a1",
"require_approval": True},
]
}
final = await run_workflow_graph(workflow_data, "smoke-mixed", deps=deps)
assert final == WorkflowStatus.COMPLETED.value
# 真实派发:skill 跑了 research + review(审批通过后),consciousness 跑了 plan
assert sink["skill"] == ["research", "review"]
assert sink["consc"] == ["plan"]
# 审批提示发过
assert any("人工审批" in m for m in sink["pending"])
@pytest.mark.asyncio
async def test_end_to_end_empty_workflow_completes_immediately():
"""空 work_link 直接 COMPLETED(不卡死、不报错)。"""
from kilostar.core.work.workflow.workflow_engine import run_workflow_graph
from kilostar.core.work.workflow.model import WorkflowStatus
deps, _ = _make_real_deps(skill_outputs=[])
final = await run_workflow_graph({"work_link": []}, "smoke-empty", deps=deps)
assert final == WorkflowStatus.COMPLETED.value
@pytest.mark.asyncio
async def test_end_to_end_failed_step_aborts_workflow():
"""某步执行失败 → 工作流终态 FAILED(真实 logic gate 行为)。"""
from kilostar.core.work.workflow.workflow_engine import run_workflow_graph
from kilostar.core.work.workflow.model import WorkflowStatus
deps, sink = _make_real_deps(skill_outputs=[("boom", False)])
workflow_data = {
"work_link": [
{"step": 1, "name": "will-fail", "action": "do",
"node": "skill_individual", "agent_id": "a1"},
]
}
final = await run_workflow_graph(workflow_data, "smoke-fail", deps=deps)
assert final == WorkflowStatus.FAILED.value
assert sink["skill"] == ["will-fail"]
+3 -3
View File
@@ -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
+113
View File
@@ -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
+80
View File
@@ -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) == []
+78
View File
@@ -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
+3 -3
View File
@@ -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():
+61
View File
@@ -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
+34
View File
@@ -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