Merge PR #4299: feat(cron): bind scheduled automations to sessions

feat(cron): bind scheduled automations to sessions
This commit is contained in:
Xubin Ren
2026-06-13 00:07:55 +08:00
committed by GitHub
58 changed files with 2451 additions and 412 deletions
+46
View File
@@ -2866,3 +2866,49 @@ def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
"legacy question",
"legacy answer",
]
def test_handle_webui_thread_get_does_not_backfill_cron_internal_prompt(
tmp_path,
monkeypatch,
) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.webui.transcript import append_transcript_object
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
workspace = tmp_path / "workspace"
sessions = SessionManager(workspace)
key = "websocket:c-cron"
session = sessions.get_or_create(key)
session.add_message(
"user",
"Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt",
**{CRON_HISTORY_META: True},
)
session.add_message("assistant", "提醒已经到期。")
sessions.save(session)
append_transcript_object(
key,
{"event": "message", "chat_id": "c-cron", "text": "提醒已经到期。"},
)
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=workspace),
)
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
enc = quote(key, safe="")
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert [message["role"] for message in body["messages"]] == ["assistant"]
assert [message["content"] for message in body["messages"]] == ["提醒已经到期。"]
+212 -4
View File
@@ -14,6 +14,7 @@ import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@@ -29,6 +30,7 @@ def _make_handler(
workspace_path: Path | None = None,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
cron_pending_job_ids: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -43,6 +45,7 @@ def _make_handler(
runtime_surface="browser",
runtime_capabilities_overrides=None,
cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
)
@@ -55,6 +58,7 @@ def _ch(
port: int = _PORT,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
cron_pending_job_ids: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -73,6 +77,7 @@ def _ch(
workspace_path=workspace_path,
runtime_model_name=runtime_model_name,
cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -176,18 +181,30 @@ async def test_session_automations_route_filters_by_webui_session(
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000)
pending_job_id = ""
for name, message, to in (
("Morning check", "Check the project status", "abc"),
("Other session", "Do not show", "other"),
):
cron.add_job(
job = cron.add_job(
name=name,
schedule=hourly,
message=message,
channel="websocket",
to=to,
session_key=f"websocket:{to}",
origin_channel="websocket",
origin_chat_id=to,
)
if name == "Morning check":
pending_job_id = job.id
cron.add_job(
name="Legacy same target",
schedule=hourly,
message="Legacy job should be migrated",
deliver=True,
channel="websocket",
to="abc",
session_key="websocket:abc",
)
cron.register_system_job(
CronJob(
id="heartbeat",
@@ -200,6 +217,7 @@ async def test_session_automations_route_filters_by_webui_session(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron,
cron_pending_job_ids=lambda key: {pending_job_id} if key == "websocket:abc" else set(),
port=29914,
)
server_task = asyncio.create_task(channel.start())
@@ -220,11 +238,66 @@ async def test_session_automations_route_filters_by_webui_session(
assert resp.status_code == 200
body = resp.json()
assert [job["name"] for job in body["jobs"]] == ["Morning check"]
assert [job["name"] for job in body["jobs"]] == ["Morning check", "Legacy same target"]
job = body["jobs"][0]
assert job["schedule"]["kind"] == "every"
assert job["schedule"]["every_ms"] == 3_600_000
assert job["payload"]["message"] == "Check the project status"
assert job["state"]["pending"] is True
assert body["jobs"][1]["state"]["pending"] is False
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_automations_route_ignores_unified_owner(
bus: MagicMock, tmp_path: Path
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000)
cron.add_job(
name="Unified check",
schedule=hourly,
message="Check the shared session",
session_key=UNIFIED_SESSION_KEY,
origin_channel="websocket",
origin_chat_id="abc",
)
cron.add_job(
name="Visible chat job",
schedule=hourly,
message="Show for this chat",
session_key="websocket:abc",
origin_channel="websocket",
origin_chat_id="abc",
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron,
port=29917,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29917/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
"http://127.0.0.1:29917/api/sessions/websocket%3Aabc/automations",
headers=auth,
)
assert resp.status_code == 200
assert [job["name"] for job in resp.json()["jobs"]] == ["Visible chat job"]
resp = await _http_get(
"http://127.0.0.1:29917/api/sessions/websocket%3Aother/automations",
headers=auth,
)
assert resp.status_code == 200
assert resp.json()["jobs"] == []
finally:
await channel.stop()
await server_task
@@ -659,6 +732,141 @@ async def test_session_delete_removes_file(
await server_task
@pytest.mark.asyncio
async def test_session_delete_blocks_when_bound_automation_exists(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
cron = CronService(tmp_path / "cron" / "jobs.json")
cron.add_job(
name="Daily check",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Check the repo",
session_key="websocket:doomed",
origin_channel="websocket",
origin_chat_id="doomed",
)
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29915/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
path = sm._get_session_path("websocket:doomed")
resp = await _http_get(
"http://127.0.0.1:29915/api/sessions/websocket:doomed/delete",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is False
assert body["blocked_by_automations"] is True
assert [job["name"] for job in body["automations"]] == ["Daily check"]
assert path.exists()
assert cron.list_bound_cron_jobs_for_session("websocket:doomed")
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_can_cascade_bound_automations(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
cron = CronService(tmp_path / "cron" / "jobs.json")
cron.add_job(
name="Daily check",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Check the repo",
session_key="websocket:doomed",
origin_channel="websocket",
origin_chat_id="doomed",
)
cron.add_job(
name="Legacy same target",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Legacy job remains",
channel="websocket",
to="doomed",
)
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
path = sm._get_session_path("websocket:doomed")
resp = await _http_get(
"http://127.0.0.1:29916/api/sessions/websocket:doomed/delete?delete_automations=true",
headers=auth,
)
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert not path.exists()
assert cron.list_bound_cron_jobs_for_session("websocket:doomed") == []
assert cron.list_jobs(include_disabled=True) == []
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_blocks_origin_automation_when_unified_enabled(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
cron = CronService(tmp_path / "cron" / "jobs.json")
cron.add_job(
name="Chat daily check",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Check this chat",
session_key="websocket:doomed",
origin_channel="websocket",
origin_chat_id="doomed",
)
channel = _ch(
bus,
session_manager=sm,
cron_service=cron,
port=29918,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29918/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
path = sm._get_session_path("websocket:doomed")
resp = await _http_get(
"http://127.0.0.1:29918/api/sessions/websocket:doomed/delete",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is False
assert body["blocked_by_automations"] is True
assert [job["name"] for job in body["automations"]] == ["Chat daily check"]
assert path.exists()
assert [job.name for job in cron.list_bound_cron_jobs_for_session("websocket:doomed")] == [
"Chat daily check"
]
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_routes_accept_percent_encoded_websocket_keys(
bus: MagicMock, tmp_path: Path