mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 14:24:47 -06:00
42b9f89988
* Add scheduled task system with cron/at scheduling, admin API, and console UI Console-integrated background scheduler dispatches workstreams on recurring cron expressions or one-shot ISO8601 timestamps. Four target modes: auto (best node by headroom), pool (shared queue), all (fan-out), or specific node. Redis distributed lock with unique owner + Lua conditional release prevents duplicate dispatch in multi-console deployments. Storage: scheduled_tasks + scheduled_task_runs tables (migration 004), 9 protocol methods on both SQLite and PostgreSQL backends, field allowlist on updates, run history auto-pruned at 90 days. API: 6 CRUD endpoints under /v1/api/admin/schedules with croniter validation, ISO8601 future-time checks, field length bounds, schedule count cap (200), and OpenAPI spec entries with Pydantic models. UI: Schedules tab in admin panel with create/edit/delete modals, run history modal, cron/at type toggle, target mode select, status dots for accessibility, responsive grid, keyboard navigation, and focus management. Security: fan-out capped at 20 nodes/task/tick, auto-approve dispatches logged at WARNING with created_by attribution, user_id propagated in CreateWorkstreamMessage for audit trail. 46 tests across storage, scheduler engine, and API endpoints. * Add croniter to test extras for CI compatibility CI installs [test] extras but not [console], so croniter was missing when schedule API tests import console/server.py validation functions. * Address Copilot review: timezone validation, focus trap, enabled flag - Reject naive at_time timestamps — require timezone offset (e.g. +00:00 or Z) - UI appends +00:00 to datetime-local values for explicit UTC - Fix datetime-local normalization: check length before appending seconds - Add textarea to modal focus trap selector (prevents focus escape) - Fix _normalize_task_dict not called in update response - Persist enabled=false on create (storage defaults to enabled=1) - Validate at_time is still in future when re-enabling a one-shot task - broker._redis coupling acknowledged as tracked tech debt
265 lines
9.0 KiB
Python
265 lines
9.0 KiB
Python
"""Tests for scheduled task admin API endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from turnstone.console.server import (
|
|
admin_create_schedule,
|
|
admin_delete_schedule,
|
|
admin_get_schedule,
|
|
admin_list_schedule_runs,
|
|
admin_list_schedules,
|
|
admin_update_schedule,
|
|
)
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
"""Fresh SQLite backend for each test."""
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def client(storage):
|
|
"""TestClient with storage and auth bypassed."""
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/admin/schedules", admin_list_schedules),
|
|
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
|
|
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
|
|
Route(
|
|
"/api/admin/schedules/{task_id}",
|
|
admin_update_schedule,
|
|
methods=["PUT"],
|
|
),
|
|
Route(
|
|
"/api/admin/schedules/{task_id}",
|
|
admin_delete_schedule,
|
|
methods=["DELETE"],
|
|
),
|
|
Route(
|
|
"/api/admin/schedules/{task_id}/runs",
|
|
admin_list_schedule_runs,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
app.state.auth_storage = storage
|
|
return TestClient(app)
|
|
|
|
|
|
def _cron_payload(**overrides):
|
|
"""Build default cron schedule creation payload."""
|
|
defaults = {
|
|
"name": "Daily report",
|
|
"description": "Generate the summary",
|
|
"schedule_type": "cron",
|
|
"cron_expr": "0 9 * * *",
|
|
"target_mode": "auto",
|
|
"model": "gpt-5",
|
|
"initial_message": "Generate the daily report",
|
|
}
|
|
defaults.update(overrides)
|
|
return defaults
|
|
|
|
|
|
def _at_payload(**overrides):
|
|
"""Build default at-time schedule creation payload."""
|
|
defaults = {
|
|
"name": "One-shot task",
|
|
"description": "Run once",
|
|
"schedule_type": "at",
|
|
"at_time": "2099-01-01T00:00:00+00:00",
|
|
"target_mode": "auto",
|
|
"model": "gpt-5",
|
|
"initial_message": "Do the thing",
|
|
}
|
|
defaults.update(overrides)
|
|
return defaults
|
|
|
|
|
|
class TestScheduleAPI:
|
|
"""Tests for the 6 admin schedule endpoints."""
|
|
|
|
def test_list_empty(self, client):
|
|
resp = client.get("/v1/api/admin/schedules")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["schedules"] == []
|
|
|
|
def test_create_cron(self, client):
|
|
resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
assert resp.status_code == 200
|
|
task = resp.json()
|
|
assert task["name"] == "Daily report"
|
|
assert task["schedule_type"] == "cron"
|
|
assert task["cron_expr"] == "0 9 * * *"
|
|
assert task["enabled"] is True
|
|
assert "task_id" in task
|
|
assert "created" in task
|
|
assert "next_run" in task
|
|
assert task["next_run"] != ""
|
|
|
|
def test_create_at(self, client):
|
|
resp = client.post("/v1/api/admin/schedules", json=_at_payload())
|
|
assert resp.status_code == 200
|
|
task = resp.json()
|
|
assert task["schedule_type"] == "at"
|
|
assert task["at_time"] == "2099-01-01T00:00:00+00:00"
|
|
assert task["next_run"] == "2099-01-01T00:00:00+00:00"
|
|
|
|
def test_create_missing_name(self, client):
|
|
payload = _cron_payload()
|
|
del payload["name"]
|
|
resp = client.post("/v1/api/admin/schedules", json=payload)
|
|
assert resp.status_code == 400
|
|
assert "name" in resp.json()["error"].lower()
|
|
|
|
def test_create_invalid_cron(self, client):
|
|
resp = client.post(
|
|
"/v1/api/admin/schedules",
|
|
json=_cron_payload(cron_expr="not a cron"),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "cron" in resp.json()["error"].lower()
|
|
|
|
def test_create_naive_at_time(self, client):
|
|
"""Naive timestamps (no timezone) should be rejected."""
|
|
resp = client.post(
|
|
"/v1/api/admin/schedules",
|
|
json=_at_payload(at_time="2099-01-01T00:00:00"),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "timezone" in resp.json()["error"].lower()
|
|
|
|
def test_create_past_at_time(self, client):
|
|
resp = client.post(
|
|
"/v1/api/admin/schedules",
|
|
json=_at_payload(at_time="2000-01-01T00:00:00+00:00"),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "future" in resp.json()["error"].lower()
|
|
|
|
def test_get_schedule(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["task_id"] == task_id
|
|
assert resp.json()["name"] == "Daily report"
|
|
|
|
def test_get_nonexistent(self, client):
|
|
resp = client.get("/v1/api/admin/schedules/nonexistent_id")
|
|
assert resp.status_code == 404
|
|
|
|
def test_update_schedule(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.put(
|
|
f"/v1/api/admin/schedules/{task_id}",
|
|
json={"name": "Weekly report"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["name"] == "Weekly report"
|
|
|
|
# Verify via GET
|
|
get_resp = client.get(f"/v1/api/admin/schedules/{task_id}")
|
|
assert get_resp.json()["name"] == "Weekly report"
|
|
|
|
def test_update_nonexistent(self, client):
|
|
resp = client.put(
|
|
"/v1/api/admin/schedules/nonexistent_id",
|
|
json={"name": "Nope"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_delete_schedule(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.delete(f"/v1/api/admin/schedules/{task_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ok"
|
|
|
|
# Verify gone
|
|
get_resp = client.get(f"/v1/api/admin/schedules/{task_id}")
|
|
assert get_resp.status_code == 404
|
|
|
|
def test_delete_nonexistent(self, client):
|
|
resp = client.delete("/v1/api/admin/schedules/nonexistent_id")
|
|
assert resp.status_code == 404
|
|
|
|
def test_list_runs_empty(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["runs"] == []
|
|
|
|
def test_list_runs_nonexistent(self, client):
|
|
resp = client.get("/v1/api/admin/schedules/nonexistent_id/runs")
|
|
assert resp.status_code == 404
|
|
|
|
def test_create_specific_node_target(self, client):
|
|
payload = _cron_payload(target_mode="node-custom-001")
|
|
resp = client.post("/v1/api/admin/schedules", json=payload)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["target_mode"] == "node-custom-001"
|
|
|
|
def test_list_runs_with_data(self, client, storage):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
# Record runs directly in storage
|
|
storage.record_task_run(
|
|
run_id="run_001",
|
|
task_id=task_id,
|
|
node_id="node-1",
|
|
ws_id="ws_abc",
|
|
correlation_id="corr_001",
|
|
started="2025-06-01T09:00:00",
|
|
status="dispatched",
|
|
error="",
|
|
)
|
|
storage.record_task_run(
|
|
run_id="run_002",
|
|
task_id=task_id,
|
|
node_id="node-2",
|
|
ws_id="",
|
|
correlation_id="corr_002",
|
|
started="2025-06-01T09:01:00",
|
|
status="failed",
|
|
error="No reachable nodes",
|
|
)
|
|
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs")
|
|
assert resp.status_code == 200
|
|
runs = resp.json()["runs"]
|
|
assert len(runs) == 2
|
|
# Most recent first
|
|
assert runs[0]["run_id"] == "run_002"
|
|
assert runs[0]["status"] == "failed"
|
|
assert runs[1]["run_id"] == "run_001"
|
|
|
|
def test_list_runs_invalid_limit(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
# Invalid limit should not crash — falls back to 50
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs?limit=abc")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["runs"] == []
|