mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
Ship Cursor-only MCP admin and modular Admin/Chat UI.
Plugins/Skills install and edit via mcpServers JSON, with clearer row actions, soft reloads, and instant loading placeholders; also drop MCP market and harden related scheduler/logging/exec guards. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2aff012b48
commit
f6f129931f
61 changed files with 18057 additions and 16327 deletions
|
|
@ -89,6 +89,13 @@ class AdminScheduledJobsApiTests(unittest.TestCase):
|
|||
runs = self.client.get(f"/admin/api/scheduled-jobs/{job_id}/runs", headers=headers)
|
||||
self.assertTrue(runs.json().get("ok"))
|
||||
|
||||
stats = self.client.get("/admin/api/scheduled-jobs/stats", headers=headers)
|
||||
self.assertEqual(stats.status_code, 200, stats.text)
|
||||
body = stats.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
self.assertIn("recent_fail_rate_pct", body)
|
||||
self.assertIn("jobs_by_status", body)
|
||||
|
||||
patch = self.client.patch(
|
||||
f"/admin/api/scheduled-jobs/{job_id}",
|
||||
headers=headers,
|
||||
|
|
|
|||
30
tests/test_exec_managed_ne_guard.py
Normal file
30
tests/test_exec_managed_ne_guard.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for execManagedNe arg normalize / detect helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from runtime.chat.exec_managed_ne_guard import (
|
||||
is_batch_exec_args,
|
||||
is_exec_managed_ne_tool,
|
||||
normalize_exec_managed_ne_args,
|
||||
)
|
||||
|
||||
|
||||
def test_is_exec_managed_ne_tool_aliases() -> None:
|
||||
assert is_exec_managed_ne_tool("mcp__netx__execManagedNe")
|
||||
assert is_exec_managed_ne_tool("netx_exec_managed_ne")
|
||||
assert not is_exec_managed_ne_tool("mcp__netx__listCliTargets")
|
||||
|
||||
|
||||
def test_is_batch_exec_args() -> None:
|
||||
assert is_batch_exec_args({"ne_ids": ["a", "b"], "commands": ["show version"]})
|
||||
assert is_batch_exec_args({"ume_ne_ids": ["u1"]})
|
||||
assert is_batch_exec_args({"targets": [{"ne_id": "x"}]})
|
||||
assert not is_batch_exec_args({"ne_id": "x", "commands": ["show version"]})
|
||||
assert not is_batch_exec_args({"ne_ids": []})
|
||||
|
||||
|
||||
def test_normalize_exec_managed_ne_args_defaults_and_clamps() -> None:
|
||||
assert normalize_exec_managed_ne_args({})["read_timeout_sec"] == 60
|
||||
assert normalize_exec_managed_ne_args({"read_timeout_sec": 5})["read_timeout_sec"] == 10
|
||||
assert normalize_exec_managed_ne_args({"read_timeout_sec": 999})["read_timeout_sec"] == 120
|
||||
assert normalize_exec_managed_ne_args({"read_timeout_sec": 90})["read_timeout_sec"] == 90
|
||||
|
|
@ -130,15 +130,16 @@ def test_retry_forbidden_blocks_same_tool_different_args(tmp_path: Path) -> None
|
|||
assert blocked.get("retry_forbidden") is True
|
||||
|
||||
|
||||
def test_distinct_exec_managed_ne_calls_not_capped_by_count(tmp_path: Path) -> None:
|
||||
"""Field ops may legitimately CLI many NEs; only identical-arg loops are blocked."""
|
||||
def test_distinct_exec_managed_ne_calls_respect_single_budget(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Single-NE loops are capped; batch ne_ids remains allowed after the budget."""
|
||||
monkeypatch.setenv("AIA_EXEC_MANAGED_NE_SINGLE_BUDGET", "3")
|
||||
store = SqliteStore(str(tmp_path / "cli.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
calls = {"n": 0}
|
||||
|
||||
def _handler(args):
|
||||
calls["n"] += 1
|
||||
return {"ok": True, "data": {"ne_id": args.get("ne_id"), "n": calls["n"]}}
|
||||
return {"ok": True, "data": {"ne_id": args.get("ne_id") or args.get("ne_ids"), "n": calls["n"]}}
|
||||
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
|
|
@ -158,7 +159,7 @@ def test_distinct_exec_managed_ne_calls_not_capped_by_count(tmp_path: Path) -> N
|
|||
turn_uuid="turn-cli-many",
|
||||
lang="en",
|
||||
)
|
||||
for i in range(8):
|
||||
for i in range(3):
|
||||
ToolExecutor().execute_tool_uses(
|
||||
ctx=ctx,
|
||||
assistant_msg_id=i + 1,
|
||||
|
|
@ -171,4 +172,96 @@ def test_distinct_exec_managed_ne_calls_not_capped_by_count(tmp_path: Path) -> N
|
|||
],
|
||||
signature_budget=2,
|
||||
)
|
||||
assert calls["n"] == 8
|
||||
assert calls["n"] == 3
|
||||
|
||||
_, blocked_results = ToolExecutor().execute_tool_uses(
|
||||
ctx=ctx,
|
||||
assistant_msg_id=99,
|
||||
tool_uses=[
|
||||
LLMToolCall(
|
||||
id="blocked",
|
||||
name="mcp__netx__execManagedNe",
|
||||
arguments={"ne_id": "ne-x", "commands": ["disp"]},
|
||||
)
|
||||
],
|
||||
signature_budget=2,
|
||||
)
|
||||
blocked, _ = blocked_results["blocked"]
|
||||
assert calls["n"] == 3
|
||||
assert blocked.get("error_code") == "cli_call_budget_exceeded"
|
||||
|
||||
_, batch_results = ToolExecutor().execute_tool_uses(
|
||||
ctx=ctx,
|
||||
assistant_msg_id=100,
|
||||
tool_uses=[
|
||||
LLMToolCall(
|
||||
id="batch",
|
||||
name="mcp__netx__execManagedNe",
|
||||
arguments={"ne_ids": ["a", "b", "c"], "commands": ["show version"]},
|
||||
)
|
||||
],
|
||||
signature_budget=2,
|
||||
)
|
||||
batch_out, _ = batch_results["batch"]
|
||||
assert calls["n"] == 4
|
||||
assert batch_out.get("ok") is True
|
||||
|
||||
|
||||
def test_exec_managed_ne_fail_budget_blocks_further_single(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIA_EXEC_MANAGED_NE_FAIL_BUDGET", "2")
|
||||
monkeypatch.setenv("AIA_EXEC_MANAGED_NE_SINGLE_BUDGET", "10")
|
||||
store = SqliteStore(str(tmp_path / "cli-fail.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
calls = {"n": 0}
|
||||
|
||||
def _handler(_args):
|
||||
calls["n"] += 1
|
||||
return {"ok": False, "error_code": "tool_timeout_or_failed", "error": "timeout"}
|
||||
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
ToolSpec(
|
||||
name="mcp__netx__execManagedNe",
|
||||
description="exec",
|
||||
parameters={"type": "object", "properties": {"ne_id": {"type": "string"}}},
|
||||
handler=_handler,
|
||||
read_only=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
ctx = ToolExecutionContext(
|
||||
store=store,
|
||||
tools=reg,
|
||||
session_id=sess.id,
|
||||
turn_uuid="turn-cli-fail",
|
||||
lang="en",
|
||||
)
|
||||
for i in range(2):
|
||||
ToolExecutor().execute_tool_uses(
|
||||
ctx=ctx,
|
||||
assistant_msg_id=i + 1,
|
||||
tool_uses=[
|
||||
LLMToolCall(
|
||||
id=f"f{i}",
|
||||
name="mcp__netx__execManagedNe",
|
||||
arguments={"ne_id": f"ne-{i}", "commands": ["disp"]},
|
||||
)
|
||||
],
|
||||
signature_budget=2,
|
||||
)
|
||||
assert calls["n"] == 2
|
||||
_, results = ToolExecutor().execute_tool_uses(
|
||||
ctx=ctx,
|
||||
assistant_msg_id=9,
|
||||
tool_uses=[
|
||||
LLMToolCall(
|
||||
id="f3",
|
||||
name="mcp__netx__execManagedNe",
|
||||
arguments={"ne_id": "ne-9", "commands": ["disp"]},
|
||||
)
|
||||
],
|
||||
signature_budget=2,
|
||||
)
|
||||
blocked, _ = results["f3"]
|
||||
assert calls["n"] == 2
|
||||
assert blocked.get("error_code") == "cli_fail_budget_exceeded"
|
||||
|
|
|
|||
|
|
@ -89,3 +89,50 @@ def test_oclaw_hooks_log_dir_legacy_when_env_set(
|
|||
state.mkdir()
|
||||
d = log_paths.oclaw_hooks_log_dir(state_dir_if_legacy=state)
|
||||
assert d == (state / "logs").resolve()
|
||||
|
||||
|
||||
def test_safe_rotating_handler_swallows_permission_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import logging.handlers
|
||||
|
||||
from svc.observability.logging_setup import SafeRotatingFileHandler
|
||||
|
||||
path = tmp_path / "oclaw.log"
|
||||
path.write_text("seed\n", encoding="utf-8")
|
||||
handler = SafeRotatingFileHandler(str(path), maxBytes=1_000_000, backupCount=1, encoding="utf-8")
|
||||
|
||||
def _boom(self) -> None: # noqa: ANN001
|
||||
raise PermissionError("WinError 32 simulated")
|
||||
|
||||
monkeypatch.setattr(logging.handlers.RotatingFileHandler, "doRollover", _boom)
|
||||
handler.doRollover() # must not raise
|
||||
handler.close()
|
||||
|
||||
|
||||
def test_safe_rotating_handler_swallows_winerror_32(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import logging.handlers
|
||||
|
||||
from svc.observability.logging_setup import SafeRotatingFileHandler
|
||||
|
||||
path = tmp_path / "oclaw2.log"
|
||||
path.write_text("seed\n", encoding="utf-8")
|
||||
handler = SafeRotatingFileHandler(str(path), maxBytes=1_000_000, backupCount=1, encoding="utf-8")
|
||||
|
||||
def _boom(self) -> None: # noqa: ANN001
|
||||
err = OSError("locked")
|
||||
err.winerror = 32 # type: ignore[attr-defined]
|
||||
raise err
|
||||
|
||||
monkeypatch.setattr(logging.handlers.RotatingFileHandler, "doRollover", _boom)
|
||||
handler.doRollover()
|
||||
handler.close()
|
||||
|
||||
|
||||
def test_worker_dict_config_uses_safe_rotating_handler(tmp_path: Path) -> None:
|
||||
cfg = logging_setup.build_worker_logging_dict_config(log_root=tmp_path / "logs")
|
||||
assert cfg["handlers"]["oclaw_file"]["class"] == "svc.observability.logging_setup.SafeRotatingFileHandler"
|
||||
|
|
|
|||
|
|
@ -93,11 +93,18 @@ class McpAdminApiTests(unittest.TestCase):
|
|||
|
||||
ins = self.client.post(
|
||||
"/admin/api/mcp/install",
|
||||
json={"source_type": "npm", "source_ref": "demo-mcp", "server_id": "demo-mcp", "entry_command": "python", "entry_args": ["-V"], "dry_run": True},
|
||||
json={
|
||||
"mcpServers": {
|
||||
"demo-mcp": {"command": "python", "args": ["-V"]},
|
||||
},
|
||||
"dry_run": True,
|
||||
},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(ins.status_code, 200)
|
||||
self.assertTrue(ins.json().get("ok"), ins.json())
|
||||
data = ins.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertIn("demo-mcp", data.get("installed") or [])
|
||||
|
||||
toggle = self.client.post(
|
||||
"/admin/api/mcp/toggle",
|
||||
|
|
@ -120,6 +127,52 @@ class McpAdminApiTests(unittest.TestCase):
|
|||
items = failures.json().get("items") or []
|
||||
self.assertTrue(any(str(x.get("server_id") or "") == "demo-mcp" for x in items))
|
||||
|
||||
def test_cursor_install_config_and_export(self) -> None:
|
||||
ins = self.client.post(
|
||||
"/admin/api/mcp/install",
|
||||
json={
|
||||
"mcpServers": {
|
||||
"remote-demo": {
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {"Authorization": "Bearer ${DEMO_TOKEN}"},
|
||||
}
|
||||
},
|
||||
"dry_run": True,
|
||||
},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(ins.status_code, 200)
|
||||
self.assertTrue(ins.json().get("ok"), ins.json())
|
||||
|
||||
cfg = self.client.post(
|
||||
"/admin/api/mcp/config",
|
||||
json={
|
||||
"server_id": "remote-demo",
|
||||
"url": "https://example.com/mcp-v2",
|
||||
"headers": {"Authorization": "Bearer ${DEMO_TOKEN}"},
|
||||
"enabled": True,
|
||||
"timeout_s": 45,
|
||||
},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(cfg.status_code, 200)
|
||||
cfg_data = cfg.json()
|
||||
self.assertTrue(cfg_data.get("ok"), cfg_data)
|
||||
self.assertEqual(str((cfg_data.get("cursor") or {}).get("url") or ""), "https://example.com/mcp-v2")
|
||||
|
||||
servers = self.client.get("/admin/api/mcp/servers", headers=self._headers())
|
||||
self.assertEqual(servers.status_code, 200)
|
||||
rows = servers.json().get("servers") or []
|
||||
row = next((x for x in rows if str(x.get("server_id") or "") == "remote-demo"), None)
|
||||
self.assertTrue(isinstance(row, dict), rows)
|
||||
self.assertEqual(str((row.get("cursor") or {}).get("url") or ""), "https://example.com/mcp-v2")
|
||||
|
||||
export = self.client.get("/admin/api/mcp/export", headers=self._headers())
|
||||
self.assertEqual(export.status_code, 200)
|
||||
doc = export.json().get("document") or {}
|
||||
self.assertIn("mcpServers", doc)
|
||||
self.assertIn("remote-demo", doc.get("mcpServers") or {})
|
||||
self.assertNotIn("servers", doc)
|
||||
def test_healthcheck_and_tools_sync(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = get_assistant_store()
|
||||
|
|
|
|||
78
tests/test_mcp_cursor_config.py
Normal file
78
tests/test_mcp_cursor_config.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from runtime.tools.mcp.cursor_config import (
|
||||
build_cursor_mcp_export,
|
||||
config_payload_to_upsert_fields,
|
||||
parse_cursor_mcp_document,
|
||||
registry_row_to_cursor_server,
|
||||
)
|
||||
|
||||
|
||||
class CursorConfigTests(unittest.TestCase):
|
||||
def test_parse_stdio_and_remote(self) -> None:
|
||||
items = parse_cursor_mcp_document(
|
||||
{
|
||||
"mcpServers": {
|
||||
"fetch": {"command": "npx", "args": ["-y", "mcp-fetch-server"], "env": {"A": "1"}},
|
||||
"remote": {
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {"Authorization": "Bearer ${TOKEN}"},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
by_id = {x["server_id"]: x for x in items}
|
||||
self.assertEqual(by_id["fetch"]["source_type"], "local")
|
||||
self.assertEqual(by_id["fetch"]["entry_command"], "npx")
|
||||
self.assertEqual(by_id["fetch"]["entry_args"], ["-y", "mcp-fetch-server"])
|
||||
self.assertIn("A", by_id["fetch"]["env_schema"])
|
||||
self.assertEqual(by_id["remote"]["source_type"], "npm")
|
||||
self.assertEqual(by_id["remote"]["source_ref"], "mcp-remote")
|
||||
self.assertEqual(by_id["remote"]["entry_command"], "npx")
|
||||
self.assertIn("mcp-remote", by_id["remote"]["entry_args"])
|
||||
self.assertIn("https://example.com/mcp", by_id["remote"]["entry_args"])
|
||||
self.assertIn("TOKEN", by_id["remote"]["env_schema"])
|
||||
|
||||
def test_url_only_without_type_is_remote(self) -> None:
|
||||
items = parse_cursor_mcp_document({"mcpServers": {"x": {"url": "https://example.com/sse"}}})
|
||||
self.assertEqual(items[0]["source_ref"], "mcp-remote")
|
||||
|
||||
def test_roundtrip_mcp_remote(self) -> None:
|
||||
row = {
|
||||
"server_id": "web",
|
||||
"entry_command": "npx",
|
||||
"entry_args": ["-y", "mcp-remote", "https://example.com/sse", "--header", "Authorization: Bearer ${K}"],
|
||||
"env_schema": {"K": {"default": "", "type": "string"}},
|
||||
}
|
||||
cursor = registry_row_to_cursor_server(row)
|
||||
self.assertEqual(cursor.get("url"), "https://example.com/sse")
|
||||
self.assertEqual((cursor.get("headers") or {}).get("Authorization"), "Bearer ${K}")
|
||||
export = build_cursor_mcp_export([row])
|
||||
self.assertIn("web", export["mcpServers"])
|
||||
|
||||
def test_config_payload_preserves_mcp_remote(self) -> None:
|
||||
existing = {
|
||||
"server_id": "web",
|
||||
"source_type": "npm",
|
||||
"source_ref": "mcp-remote",
|
||||
"entry_command": "npx",
|
||||
"entry_args": ["-y", "mcp-remote", "https://old.example/sse"],
|
||||
"env_schema": {},
|
||||
"enabled": True,
|
||||
"timeout_s": 30.0,
|
||||
"required_permissions": [],
|
||||
"risk_level": "high",
|
||||
"version": "",
|
||||
}
|
||||
fields = config_payload_to_upsert_fields(
|
||||
{"server_id": "web", "url": "https://new.example/sse", "headers": {"X": "1"}},
|
||||
existing=existing,
|
||||
)
|
||||
self.assertEqual(fields["source_ref"], "mcp-remote")
|
||||
self.assertIn("https://new.example/sse", fields["entry_args"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from runtime.tools.mcp.market import infer_install_template, search_mcp_market, trending_mcp_market
|
||||
|
||||
|
||||
class McpMarketTests(unittest.TestCase):
|
||||
@patch("runtime.tools.mcp.market.search_github_repos")
|
||||
@patch("runtime.tools.mcp.market.search_npm_packages")
|
||||
@patch("runtime.tools.mcp.market.search_pypi_packages")
|
||||
def test_aggregate_results(self, pypi_mock, npm_mock, gh_mock) -> None:
|
||||
gh_mock.return_value = [{"source_type": "github", "name": "a"}]
|
||||
npm_mock.return_value = [{"source_type": "npm", "name": "b"}]
|
||||
pypi_mock.return_value = [{"source_type": "pypi", "name": "c"}]
|
||||
rows = search_mcp_market("demo", per_source_limit=3)
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0]["source_type"], "github")
|
||||
self.assertEqual(rows[1]["source_type"], "npm")
|
||||
self.assertEqual(rows[2]["source_type"], "pypi")
|
||||
|
||||
def test_infer_install_template(self) -> None:
|
||||
t1 = infer_install_template("npm", "@acme/mcp-demo")
|
||||
self.assertEqual(t1["entry_command"], "npx")
|
||||
self.assertTrue(isinstance(t1["entry_args"], list))
|
||||
t2 = infer_install_template("pypi", "mcp-demo")
|
||||
self.assertEqual(t2["entry_command"], "python")
|
||||
self.assertEqual(t2["entry_args"][:1], ["-m"])
|
||||
|
||||
@patch("runtime.tools.mcp.market.search_mcp_market")
|
||||
def test_trending_cache(self, search_mock) -> None:
|
||||
search_mock.return_value = [{"source_type": "github", "name": "a", "stars": 9}]
|
||||
rows = trending_mcp_market(force_refresh=True, per_source_limit=2)
|
||||
self.assertEqual(len(rows), 1)
|
||||
rows2 = trending_mcp_market(force_refresh=False, per_source_limit=2)
|
||||
self.assertEqual(len(rows2), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ from runtime.tools.tool_validation import format_invalid_arguments_error, valida
|
|||
|
||||
class McpTimeoutAndCacheTests(unittest.TestCase):
|
||||
def test_exec_managed_ne_timeout_override(self) -> None:
|
||||
self.assertEqual(mcp_timeout_for_tool("execManagedNe", 30.0), 320.0)
|
||||
self.assertEqual(mcp_timeout_for_tool("execManagedNe", 30.0), 620.0)
|
||||
self.assertEqual(mcp_timeout_for_tool("ping", 30.0), 30.0)
|
||||
self.assertGreaterEqual(mcp_timeout_for_tool("sqlQueryUme", 30.0), 90.0)
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ class McpTimeoutAndCacheTests(unittest.TestCase):
|
|||
],
|
||||
)
|
||||
specs = {s.name: s for s in materialize_mcp_tools(store)}
|
||||
self.assertEqual(specs["mcp__netx__execManagedNe"].timeout_s, 320.0)
|
||||
self.assertEqual(specs["mcp__netx__execManagedNe"].timeout_s, 620.0)
|
||||
self.assertEqual(specs["mcp__netx__listCliTargets"].timeout_s, 30.0)
|
||||
|
||||
def test_list_cli_targets_ttl_cache(self) -> None:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ def test_ops_short_intent_suppresses_inventory_cli_tools() -> None:
|
|||
assert is_ops_short_intent_suppressed_tool("mcp__netx__listCliTargets", intent="fiber_cut")
|
||||
assert is_ops_short_intent_suppressed_tool("mcp__netx__execManagedNe", intent="offline")
|
||||
assert is_ops_short_intent_suppressed_tool("run_command", intent="excel_export")
|
||||
assert is_ops_short_intent_suppressed_tool("write_xlsx", intent="fiber_cut")
|
||||
assert is_ops_short_intent_suppressed_tool("write_xlsx", intent="excel_export")
|
||||
assert not is_ops_short_intent_suppressed_tool("ume_alarm_xlsx_report", intent="fiber_cut")
|
||||
assert not is_ops_short_intent_suppressed_tool("mcp__netx__queryUmeAlarmsRaw", intent="alarm_tally")
|
||||
assert not is_ops_short_intent_suppressed_tool("mcp__netx__execManagedNe", intent="continue")
|
||||
|
|
@ -50,7 +52,7 @@ def test_filter_tool_specs_for_ops_short_intent_keeps_report_path() -> None:
|
|||
names = {t.name for t in kept}
|
||||
assert "ume_alarm_xlsx_report" in names
|
||||
assert "mcp__netx__aggregateUmeAlarms" in names
|
||||
assert "write_xlsx" in names
|
||||
assert "write_xlsx" not in names
|
||||
assert "mcp__netx__listCliTargets" not in names
|
||||
assert "mcp__netx__execManagedNe" not in names
|
||||
assert "run_command" not in names
|
||||
|
|
|
|||
73
tests/test_scheduled_job_failure_board.py
Normal file
73
tests/test_scheduled_job_failure_board.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Tests for scheduled job failure classification + summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.scheduler.failure_class import classify_scheduled_job_error, enrich_scheduled_job_run_dict
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def test_classify_scheduled_job_error_classes() -> None:
|
||||
assert classify_scheduled_job_error("overlapping_run", status="skipped") == "overlap"
|
||||
assert classify_scheduled_job_error("read_timeout after 60s", status="failed") == "timeout"
|
||||
assert classify_scheduled_job_error("WhatsApp Connection Closed", status="failed") == "delivery"
|
||||
assert classify_scheduled_job_error("insufficient_scope:sql:query", status="failed") == "auth"
|
||||
assert classify_scheduled_job_error("Unregistered tool: mcp__netx__x", status="failed") == "mcp"
|
||||
assert classify_scheduled_job_error("stale_running_cleared", status="failed") == "stale"
|
||||
assert classify_scheduled_job_error("boom", status="failed") == "runtime"
|
||||
assert classify_scheduled_job_error("", status="success") == ""
|
||||
|
||||
|
||||
def test_enrich_run_dict_adds_failure_class() -> None:
|
||||
out = enrich_scheduled_job_run_dict({"status": "failed", "error": "tool timeout"})
|
||||
assert out["failure_class"] == "timeout"
|
||||
|
||||
|
||||
def test_scheduled_job_failure_summary(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "jobs.sqlite"))
|
||||
# Ensure tenant user exists if required by store create paths.
|
||||
job = store.scheduled_job_create(
|
||||
tenant_id="default",
|
||||
name="fail-board",
|
||||
description="",
|
||||
schedule_kind="interval",
|
||||
schedule_expr="3600",
|
||||
timezone_name="UTC",
|
||||
prompt_text="ping",
|
||||
specialist="ops",
|
||||
lang="en",
|
||||
created_by_user_id="u1",
|
||||
source="test",
|
||||
)
|
||||
run_ok = store.scheduled_job_run_create(
|
||||
job_id=job.id, tenant_id="default", scheduled_at="2026-08-01T00:00:00+00:00", status="queued"
|
||||
)
|
||||
store.scheduled_job_run_update(
|
||||
run_id=run_ok.id,
|
||||
tenant_id="default",
|
||||
patch={"status": "success", "finished_at": "2026-08-01T00:01:00+00:00", "error": ""},
|
||||
)
|
||||
run_fail = store.scheduled_job_run_create(
|
||||
job_id=job.id, tenant_id="default", scheduled_at="2026-08-01T01:00:00+00:00", status="queued"
|
||||
)
|
||||
store.scheduled_job_run_update(
|
||||
run_id=run_fail.id,
|
||||
tenant_id="default",
|
||||
patch={
|
||||
"status": "failed",
|
||||
"finished_at": "2026-08-01T01:01:00+00:00",
|
||||
"error": "read_timeout after 90s",
|
||||
},
|
||||
)
|
||||
store.scheduled_job_mark_run(
|
||||
job_id=job.id, tenant_id="default", last_run_status="failed", pause_after=False
|
||||
)
|
||||
summary = store.scheduled_job_failure_summary(tenant_id="default", recent_limit=50)
|
||||
assert summary["recent_failed"] >= 1
|
||||
assert summary["recent_success"] >= 1
|
||||
assert "timeout" in (summary.get("recent_fail_classes") or {})
|
||||
assert any(x.get("id") == job.id for x in summary.get("jobs_last_failed") or [])
|
||||
d = store.scheduled_job_run_to_dict(store.scheduled_job_run_get(run_id=run_fail.id, tenant_id="default"))
|
||||
assert d.get("failure_class") == "timeout"
|
||||
Loading…
Add table
Add a link
Reference in a new issue