diff --git a/tests/test_notify_completion.py b/tests/test_notify_completion.py new file mode 100644 index 00000000..ca536a58 --- /dev/null +++ b/tests/test_notify_completion.py @@ -0,0 +1,530 @@ +"""Tests for scheduled task completion notification feature. + +Covers: target validation, content extraction, notification delivery +(mock gateway), scheduler dispatch passthrough, schedule API CRUD +with notify_targets. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.routing import Mount, Route +from starlette.testclient import TestClient + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + +from turnstone.console.server import ( + admin_create_schedule, + admin_get_schedule, + admin_update_schedule, +) +from turnstone.core.auth import AuthResult +from turnstone.core.storage._sqlite import SQLiteBackend +from turnstone.server import ( + _deliver_notification, + _extract_last_assistant_content, + _fire_notify_targets, + _validate_notify_targets, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +class _InjectAuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Any) -> Response: + request.state.auth_result = AuthResult( + user_id="test-admin", + scopes=frozenset({"approve"}), + token_source="config", + permissions=frozenset({"admin.schedules"}), + ) + return await call_next(request) + + +@pytest.fixture +def storage(tmp_path): + return SQLiteBackend(str(tmp_path / "test.db")) + + +@pytest.fixture +def client(storage): + app = Starlette( + routes=[ + Mount( + "/v1", + routes=[ + 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"], + ), + ], + ), + ], + middleware=[Middleware(_InjectAuthMiddleware)], + ) + app.state.auth_storage = storage + return TestClient(app) + + +def _cron_payload(**overrides): + defaults = { + "name": "Notify test", + "description": "Test schedule", + "schedule_type": "cron", + "cron_expr": "0 9 * * *", + "target_mode": "auto", + "model": "gpt-5", + "initial_message": "Run the tests", + } + defaults.update(overrides) + return defaults + + +# --------------------------------------------------------------------------- +# Target validation +# --------------------------------------------------------------------------- + + +class TestValidateNotifyTargets: + def test_empty_string(self): + result, err = _validate_notify_targets("") + assert result == "[]" + assert err == "" + + def test_none(self): + result, err = _validate_notify_targets(None) + assert result == "[]" + assert err == "" + + def test_valid_channel_id(self): + targets = [{"channel_type": "discord", "channel_id": "123456"}] + result, err = _validate_notify_targets(json.dumps(targets)) + assert err == "" + assert json.loads(result) == targets + + def test_valid_user_id(self): + targets = [{"channel_type": "discord", "user_id": "789"}] + result, err = _validate_notify_targets(json.dumps(targets)) + assert err == "" + assert json.loads(result) == targets + + def test_valid_list_input(self): + targets = [{"channel_type": "discord", "channel_id": "123"}] + result, err = _validate_notify_targets(targets) + assert err == "" + assert json.loads(result) == targets + + def test_multiple_targets(self): + targets = [ + {"channel_type": "discord", "channel_id": "111"}, + {"channel_type": "discord", "user_id": "222"}, + ] + result, err = _validate_notify_targets(json.dumps(targets)) + assert err == "" + assert len(json.loads(result)) == 2 + + def test_invalid_json(self): + _, err = _validate_notify_targets("{not json") + assert "valid JSON" in err + + def test_not_array(self): + _, err = _validate_notify_targets('{"key": "val"}') + assert "array" in err + + def test_missing_channel_type(self): + targets = [{"channel_id": "123"}] + _, err = _validate_notify_targets(json.dumps(targets)) + assert "channel_type" in err + + def test_missing_id_field(self): + targets = [{"channel_type": "discord"}] + _, err = _validate_notify_targets(json.dumps(targets)) + assert "channel_id or user_id" in err + + def test_non_object_element(self): + _, err = _validate_notify_targets('["string"]') + assert "object" in err + + def test_exceeds_max_targets(self): + targets = [{"channel_type": "discord", "channel_id": str(i)} for i in range(11)] + _, err = _validate_notify_targets(json.dumps(targets)) + assert "limited to" in err + + def test_max_targets_at_limit(self): + targets = [{"channel_type": "discord", "channel_id": str(i)} for i in range(10)] + result, err = _validate_notify_targets(json.dumps(targets)) + assert err == "" + assert len(json.loads(result)) == 10 + + def test_field_too_long(self): + targets = [{"channel_type": "discord", "channel_id": "x" * 257}] + _, err = _validate_notify_targets(json.dumps(targets)) + assert "256 chars" in err + + def test_non_string_field_value(self): + _, err = _validate_notify_targets('[{"channel_type": 123, "channel_id": "1"}]') + assert "string" in err + + def test_empty_string_channel_type(self): + targets = [{"channel_type": "", "channel_id": "123"}] + _, err = _validate_notify_targets(json.dumps(targets)) + assert "non-empty" in err + + def test_empty_string_channel_id(self): + targets = [{"channel_type": "discord", "channel_id": ""}] + _, err = _validate_notify_targets(json.dumps(targets)) + assert "non-empty" in err + + def test_whitespace_only_values_stripped(self): + targets = [{"channel_type": "discord", "channel_id": " 123 "}] + result, err = _validate_notify_targets(json.dumps(targets)) + assert err == "" + parsed = json.loads(result) + assert parsed[0]["channel_id"] == "123" + + def test_both_channel_id_and_user_id_rejected(self): + targets = [{"channel_type": "discord", "channel_id": "1", "user_id": "2"}] + _, err = _validate_notify_targets(json.dumps(targets)) + assert "only one of" in err + + +# --------------------------------------------------------------------------- +# Content extraction +# --------------------------------------------------------------------------- + + +class TestExtractLastAssistantContent: + def test_string_content(self): + session = MagicMock() + session.messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "world"}, + ] + assert _extract_last_assistant_content(session) == "world" + + def test_structured_content(self): + session = MagicMock() + session.messages = [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "part one"}, + {"type": "text", "text": "part two"}, + ], + }, + ] + assert _extract_last_assistant_content(session) == "part one\npart two" + + def test_empty_messages(self): + session = MagicMock() + session.messages = [] + assert _extract_last_assistant_content(session) == "" + + def test_no_assistant_messages(self): + session = MagicMock() + session.messages = [{"role": "user", "content": "hello"}] + assert _extract_last_assistant_content(session) == "" + + def test_picks_last_assistant(self): + session = MagicMock() + session.messages = [ + {"role": "assistant", "content": "first"}, + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "second"}, + ] + assert _extract_last_assistant_content(session) == "second" + + def test_skips_non_text_blocks(self): + session = MagicMock() + session.messages = [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "123"}, + {"type": "text", "text": "result"}, + ], + }, + ] + assert _extract_last_assistant_content(session) == "result" + + +# --------------------------------------------------------------------------- +# Notification delivery (mock gateway) +# --------------------------------------------------------------------------- + + +class TestDeliverNotification: + @patch("httpx.post") + def test_successful_delivery(self, mock_post): + mock_resp = MagicMock(status_code=200) + mock_resp.json.return_value = {"results": [{"status": "sent"}]} + mock_post.return_value = mock_resp + + storage = MagicMock() + storage.list_services.return_value = [{"url": "http://gateway:8080"}] + + payload = { + "target": {"channel_type": "discord", "channel_id": "123"}, + "message": "Hello", + "title": "Schedule: test", + "ws_id": "ws_001", + } + _deliver_notification(storage, payload, {"Authorization": "Bearer tok"}) + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args.kwargs + assert call_kwargs["json"] == payload + assert "Authorization" in call_kwargs["headers"] + + def test_no_services_retries(self): + storage = MagicMock() + storage.list_services.return_value = [] + + with patch("time.sleep"): + _deliver_notification(storage, {"ws_id": "ws_001"}, {}) + + assert storage.list_services.call_count == 3 + + @patch("httpx.post", side_effect=ConnectionError("refused")) + def test_http_error_continues(self, mock_post): + storage = MagicMock() + storage.list_services.return_value = [{"url": "http://gw:8080"}] + + with patch("time.sleep"): + _deliver_notification(storage, {"ws_id": "ws_001"}, {}) + + assert mock_post.call_count >= 1 + + +class TestFireNotifyTargets: + @patch("turnstone.server._deliver_notification") + @patch( + "turnstone.core.session._notify_auth_headers", + return_value={"Authorization": "Bearer x"}, + ) + def test_fires_for_each_target(self, mock_auth, mock_deliver): + ws = MagicMock() + ws.id = "ws_test" + ws.name = "My Task" + ws.notify_targets = json.dumps( + [ + {"channel_type": "discord", "channel_id": "111"}, + {"channel_type": "discord", "user_id": "222"}, + ] + ) + + with patch("turnstone.core.storage.get_storage") as mock_storage: + mock_storage.return_value = MagicMock() + _fire_notify_targets(ws, "Task completed successfully") + + assert mock_deliver.call_count == 2 + # First call — channel_id target + first_payload = mock_deliver.call_args_list[0][0][1] + assert first_payload["target"]["channel_id"] == "111" + assert first_payload["message"] == "Task completed successfully" + assert first_payload["title"] == "Schedule: My Task" + # Second call — user_id target + second_payload = mock_deliver.call_args_list[1][0][1] + assert second_payload["target"]["channel_id"] == "222" + + @patch("turnstone.server._deliver_notification") + def test_empty_targets_skipped(self, mock_deliver): + ws = MagicMock() + ws.notify_targets = "[]" + _fire_notify_targets(ws, "content") + mock_deliver.assert_not_called() + + @patch("turnstone.server._deliver_notification") + def test_empty_content_skipped(self, mock_deliver): + ws = MagicMock() + ws.notify_targets = '[{"channel_type":"discord","channel_id":"1"}]' + _fire_notify_targets(ws, "") + mock_deliver.assert_not_called() + + @patch("turnstone.server._deliver_notification") + def test_invalid_json_targets_skipped(self, mock_deliver): + ws = MagicMock() + ws.notify_targets = "not json" + _fire_notify_targets(ws, "content") + mock_deliver.assert_not_called() + + +# --------------------------------------------------------------------------- +# Scheduler dispatch passthrough +# --------------------------------------------------------------------------- + + +class TestSchedulerDispatch: + def test_notify_targets_passed_to_sdk(self): + collector = MagicMock() + storage = MagicMock() + # Wire up lock acquisition + state: dict[str, dict[str, str] | None] = {"scheduler_lock": None} + + def _get(key: str, **_kw: object) -> dict[str, str] | None: + return state.get(key) + + def _upsert(key: str, value: str, **_kw: object) -> None: + state[key] = {"value": value} + + def _delete(key: str, **_kw: object) -> None: + state.pop(key, None) + + storage.get_system_setting.side_effect = _get + storage.upsert_system_setting.side_effect = _upsert + storage.delete_system_setting.side_effect = _delete + + targets = [{"channel_type": "discord", "channel_id": "123"}] + task = { + "task_id": "t1", + "name": "Test", + "description": "", + "schedule_type": "cron", + "cron_expr": "0 9 * * *", + "at_time": "", + "target_mode": "auto", + "model": "gpt-5", + "initial_message": "Run it", + "auto_approve": 0, + "auto_approve_tools": "", + "skill": "", + "notify_targets": json.dumps(targets), + "enabled": 1, + "created_by": "admin", + "next_run": "2020-01-01T09:00:00", + "last_run": "", + "created": "2020-01-01T00:00:00", + "updated": "2020-01-01T00:00:00", + } + + mock_resp = MagicMock() + mock_resp.ws_id = "ws_abc" + mock_client = MagicMock() + mock_client.create_workstream.return_value = mock_resp + + from turnstone.console.scheduler import TaskScheduler + + scheduler = TaskScheduler(collector, storage) + + collector.nodes.return_value = [ + {"node_id": "node-001", "reachable": True, "ws_total": 1, "max_ws": 10} + ] + + with ( + patch.object(scheduler, "_get_sdk_client", return_value=mock_client), + patch.object(scheduler, "_get_node_url", return_value="http://n:8000"), + ): + scheduler._dispatch_to_node(task, "node-001", "2020-01-01T09:00:00") + + mock_client.create_workstream.assert_called_once() + call_kwargs = mock_client.create_workstream.call_args.kwargs + assert call_kwargs["notify_targets"] == json.dumps(targets) + + +# --------------------------------------------------------------------------- +# Schedule API CRUD with notify_targets +# --------------------------------------------------------------------------- + + +class TestScheduleAPINotifyTargets: + def test_create_with_notify_targets(self, client): + targets = [{"channel_type": "discord", "channel_id": "123456"}] + resp = client.post( + "/v1/api/admin/schedules", + json=_cron_payload(notify_targets=targets), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["notify_targets"] == targets + + def test_create_without_notify_targets(self, client): + resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) + assert resp.status_code == 200 + assert resp.json()["notify_targets"] == [] + + def test_create_invalid_notify_targets(self, client): + resp = client.post( + "/v1/api/admin/schedules", + json=_cron_payload(notify_targets="not json"), + ) + assert resp.status_code == 400 + assert "notify_targets" in resp.json()["error"] + + def test_create_notify_targets_missing_channel_type(self, client): + targets = [{"channel_id": "123"}] + resp = client.post( + "/v1/api/admin/schedules", + json=_cron_payload(notify_targets=targets), + ) + assert resp.status_code == 400 + + def test_create_notify_targets_missing_id(self, client): + targets = [{"channel_type": "discord"}] + resp = client.post( + "/v1/api/admin/schedules", + json=_cron_payload(notify_targets=targets), + ) + assert resp.status_code == 400 + + def test_update_notify_targets(self, client): + create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) + task_id = create_resp.json()["task_id"] + + new_targets = [{"channel_type": "discord", "user_id": "999"}] + resp = client.put( + f"/v1/api/admin/schedules/{task_id}", + json={"notify_targets": new_targets}, + ) + assert resp.status_code == 200 + assert resp.json()["notify_targets"] == new_targets + + def test_update_clear_notify_targets(self, client): + targets = [{"channel_type": "discord", "channel_id": "123"}] + create_resp = client.post( + "/v1/api/admin/schedules", + json=_cron_payload(notify_targets=targets), + ) + task_id = create_resp.json()["task_id"] + + resp = client.put( + f"/v1/api/admin/schedules/{task_id}", + json={"notify_targets": []}, + ) + assert resp.status_code == 200 + assert resp.json()["notify_targets"] == [] + + def test_get_includes_notify_targets(self, client): + targets = [{"channel_type": "discord", "channel_id": "456"}] + create_resp = client.post( + "/v1/api/admin/schedules", + json=_cron_payload(notify_targets=targets), + ) + task_id = create_resp.json()["task_id"] + + get_resp = client.get(f"/v1/api/admin/schedules/{task_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["notify_targets"] == targets + + def test_update_invalid_notify_targets(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={"notify_targets": "not json"}, + ) + assert resp.status_code == 400 diff --git a/tests/test_notify_http.py b/tests/test_notify_http.py index 9428dce8..181169ca 100644 --- a/tests/test_notify_http.py +++ b/tests/test_notify_http.py @@ -210,6 +210,34 @@ class TestNotifyEndpoint: results = resp.json()["results"] assert results[0]["status"] == "failed" + def test_adapter_timeout(self, storage, mock_adapter, monkeypatch): + """Adapter calls that exceed the timeout return timeout status.""" + import asyncio + + async def _hang(*_args: object) -> str: + await asyncio.sleep(300) + return "" + + mock_adapter.send = _hang + + # Use a very short timeout to keep the test fast + from turnstone.channels import _http as _http_mod + + monkeypatch.setattr(_http_mod, "_NOTIFY_ADAPTER_TIMEOUT", 0.1) + app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET) + tc = TestClient(app) + resp = tc.post( + "/v1/api/notify", + json={ + "target": {"channel_type": "discord", "channel_id": "123456"}, + "message": "Hello!", + }, + headers=_auth_headers(), + ) + assert resp.status_code == 200 + results = resp.json()["results"] + assert results[0]["status"] == "timeout" + def test_invalid_json(self, client): resp = client.post( "/v1/api/notify", diff --git a/turnstone/api/schemas.py b/turnstone/api/schemas.py index 0b92bdbc..8ece8b16 100644 --- a/turnstone/api/schemas.py +++ b/turnstone/api/schemas.py @@ -195,6 +195,10 @@ class CreateScheduleRequest(BaseModel): auto_approve: bool = Field(default=False) auto_approve_tools: list[str] = Field(default_factory=list) skill: str = Field(default="", description="Skill name (replaces default skills)") + notify_targets: list[dict[str, str]] = Field( + default_factory=list, + description="Notification targets on completion (channel_type + channel_id/user_id)", + ) enabled: bool = Field(default=True) @@ -212,6 +216,7 @@ class UpdateScheduleRequest(BaseModel): auto_approve: bool | None = None auto_approve_tools: list[str] | None = None skill: str | None = None + notify_targets: list[dict[str, str]] | None = None enabled: bool | None = None @@ -230,6 +235,7 @@ class ScheduleInfo(BaseModel): auto_approve: bool = False auto_approve_tools: list[str] = Field(default_factory=list) skill: str = "" + notify_targets: list[dict[str, str]] = Field(default_factory=list) enabled: bool = True created_by: str = "" last_run: str | None = None diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index c06c393a..3b312351 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -57,6 +57,13 @@ class CreateWorkstreamRequest(BaseModel): description="Workstream ID to resume atomically during creation (empty = fresh start)", ) skill: str = Field(default="", description="Skill name (replaces default skills)") + notify_targets: str | list[dict[str, str]] = Field( + default="[]", + description=( + "Notification targets, accepted as either a JSON string or a structured " + "array of objects containing channel_type + channel_id/user_id" + ), + ) client_type: str = Field( default="", description="Client surface type (web, cli, chat). Defaults to web for server-created sessions.", diff --git a/turnstone/channels/_http.py b/turnstone/channels/_http.py index 9cb7b134..0c46cc1f 100644 --- a/turnstone/channels/_http.py +++ b/turnstone/channels/_http.py @@ -27,6 +27,8 @@ if TYPE_CHECKING: log = get_logger(__name__) +_NOTIFY_ADAPTER_TIMEOUT: float = 30.0 + # ws_id is a hex string (8–32 chars depending on entry point). _WS_ID_RE = re.compile(r"^[0-9a-f]{8,32}$") @@ -131,10 +133,12 @@ async def _handle_notify(request: Request) -> JSONResponse: ) continue try: - if ws_id: - msg_id = await adapter.send_notification(channel_id, content, ws_id) - else: - msg_id = await adapter.send(channel_id, content) + coro = ( + adapter.send_notification(channel_id, content, ws_id) + if ws_id + else adapter.send(channel_id, content) + ) + msg_id = await asyncio.wait_for(coro, timeout=_NOTIFY_ADAPTER_TIMEOUT) results.append( { "channel_type": channel_type, @@ -149,6 +153,19 @@ async def _handle_notify(request: Request) -> JSONResponse: channel_id=channel_id, message_id=msg_id, ) + except TimeoutError: + log.warning( + "notify.timeout", + channel_type=channel_type, + channel_id=channel_id, + ) + results.append( + { + "channel_type": channel_type, + "channel_id": channel_id, + "status": "timeout", + } + ) except Exception: log.exception( "notify.delivery_failed", diff --git a/turnstone/console/scheduler.py b/turnstone/console/scheduler.py index 0fe35b00..12392614 100644 --- a/turnstone/console/scheduler.py +++ b/turnstone/console/scheduler.py @@ -318,6 +318,7 @@ class TaskScheduler: auto_approve_tools=",".join(self._parse_tools(task)), user_id=task.get("created_by", ""), skill=task.get("skill", ""), + notify_targets=task.get("notify_targets", "[]"), ) ws_id = resp.ws_id except Exception: diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 671a4549..8e91d8be 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -1737,6 +1737,14 @@ def _normalize_task_dict(task: dict[str, Any]) -> dict[str, Any]: task["auto_approve_tools"] = [s.strip() for s in tools_str.split(",") if s.strip()] task["auto_approve"] = bool(task.get("auto_approve", 0)) task["enabled"] = bool(task.get("enabled", 1)) + # Normalize notify_targets from JSON string to list + import json as _json + + raw_nt = task.get("notify_targets", "[]") + try: + task["notify_targets"] = _json.loads(raw_nt) if isinstance(raw_nt, str) else raw_nt + except (_json.JSONDecodeError, TypeError): + task["notify_targets"] = [] return task @@ -1833,6 +1841,18 @@ async def admin_create_schedule(request: Request) -> JSONResponse: skill_name = str(body.get("skill", "")).strip()[:256] enabled = bool(body.get("enabled", True)) + # Validate notify_targets + from turnstone.server import _validate_notify_targets + + raw_nt = body.get("notify_targets", "[]") + if isinstance(raw_nt, list): + import json as _json + + raw_nt = _json.dumps(raw_nt) + notify_targets, nt_err = _validate_notify_targets(raw_nt) + if nt_err: + return JSONResponse({"error": nt_err}, status_code=400) + if not name: return JSONResponse({"error": "name is required"}, status_code=400) if not initial_message: @@ -1874,6 +1894,7 @@ async def admin_create_schedule(request: Request) -> JSONResponse: created_by=created_by, next_run=next_run if enabled else "", skill=skill_name, + notify_targets=notify_targets, ) if not enabled: @@ -1955,6 +1976,18 @@ async def admin_update_schedule(request: Request) -> JSONResponse: updates["skill"] = skill_val if "enabled" in body: updates["enabled"] = bool(body["enabled"]) + if "notify_targets" in body: + from turnstone.server import _validate_notify_targets + + raw_nt = body["notify_targets"] + if isinstance(raw_nt, list): + import json as _json + + raw_nt = _json.dumps(raw_nt) + nt_str, nt_err = _validate_notify_targets(raw_nt) + if nt_err: + return JSONResponse({"error": nt_err}, status_code=400) + updates["notify_targets"] = nt_str # Validate schedule fields if changed stype = updates.get("schedule_type", existing["schedule_type"]) diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 95d8070c..9d455050 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -943,10 +943,10 @@ function _renderSchedules(schedules) { var schedule = s.schedule_type === "cron" ? s.cron_expr - : (s.at_time || "").slice(0, 16).replace("T", " "); + : _utcToLocalDatetime(s.at_time).replace("T", " "); var target = s.target_mode; var nextRun = s.next_run - ? escapeHtml(s.next_run).slice(0, 16).replace("T", " ") + ? _utcToLocalDatetime(s.next_run).replace("T", " ") : "\u2014"; var enabled = s.enabled; var statusCls = enabled ? "sched-active" : "sched-disabled"; @@ -1078,6 +1078,140 @@ function confirmDeleteSchedule(taskId, name) { ); } +// --- Schedule helpers: dropdowns, notify rows, timezone --- + +function _populateScheduleSelect(selectId, url, labelKey, valueKey, opts) { + var sel = document.getElementById(selectId); + // Keep the first option (placeholder) and remove the rest + while (sel.options.length > 1) sel.remove(1); + // Add temporary option for pre-selected value so form is correct before fetch completes + if (opts && opts.selected) { + var tmp = document.createElement("option"); + tmp.value = opts.selected; + tmp.textContent = opts.selected; + tmp.dataset.temporary = "1"; + sel.appendChild(tmp); + sel.value = opts.selected; + } + authFetch(url) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + var temp = sel.querySelector("[data-temporary]"); + if (temp) temp.remove(); + var items = opts && opts.listKey ? data[opts.listKey] : data; + if (!Array.isArray(items)) return; + items.forEach(function (item) { + var opt = document.createElement("option"); + opt.value = item[valueKey]; + opt.textContent = + opts && opts.display ? opts.display(item) : item[labelKey]; + sel.appendChild(opt); + }); + if (opts && opts.selected) sel.value = opts.selected; + }) + .catch(function () { + /* dropdown stays with placeholder or temporary option */ + }); +} + +function _addNotifyRow(prefix, targetType, targetId) { + var container = document.getElementById(prefix + "-notify-rows"); + var row = document.createElement("div"); + row.className = "notify-row"; + + var typeSel = document.createElement("select"); + typeSel.setAttribute("aria-label", "Target type"); + var optCh = document.createElement("option"); + optCh.value = "channel_id"; + optCh.textContent = "Channel"; + var optUsr = document.createElement("option"); + optUsr.value = "user_id"; + optUsr.textContent = "User DM"; + typeSel.appendChild(optCh); + typeSel.appendChild(optUsr); + if (targetType) typeSel.value = targetType; + + var idInput = document.createElement("input"); + idInput.type = "text"; + idInput.placeholder = "Discord ID"; + idInput.setAttribute("aria-label", "Discord ID"); + idInput.spellcheck = false; + if (targetId) idInput.value = targetId; + + var removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "notify-row-remove"; + removeBtn.setAttribute("aria-label", "Remove target"); + removeBtn.textContent = "\u00d7"; + removeBtn.onclick = function () { + row.remove(); + }; + + row.appendChild(typeSel); + row.appendChild(idInput); + row.appendChild(removeBtn); + container.appendChild(row); + idInput.focus(); +} + +function _collectNotifyTargets(prefix) { + var rows = document + .getElementById(prefix + "-notify-rows") + .querySelectorAll(".notify-row"); + var targets = []; + for (var i = 0; i < rows.length; i++) { + var type = rows[i].querySelector("select").value; + var id = (rows[i].querySelector("input").value || "").trim(); + if (!id) continue; + var t = { channel_type: "discord" }; + t[type] = id; + targets.push(t); + } + return targets; +} + +function _populateNotifyRows(prefix, targets) { + var container = document.getElementById(prefix + "-notify-rows"); + while (container.firstChild) container.removeChild(container.firstChild); + if (!Array.isArray(targets)) return; + targets.forEach(function (t) { + var targetType = "channel_id" in t ? "channel_id" : "user_id"; + var targetId = t[targetType] || ""; + _addNotifyRow(prefix, targetType, targetId); + }); +} + +function _localToUtcIso(localDatetimeStr) { + // datetime-local gives "YYYY-MM-DDTHH:MM" in browser local time + // Convert to UTC ISO string for the server + var d = new Date(localDatetimeStr); + if (isNaN(d.getTime())) return ""; + return d.toISOString().replace(/\.\d{3}Z$/, "+00:00"); +} + +function _utcToLocalDatetime(utcStr) { + // Convert UTC ISO string to datetime-local format in browser local time + if (!utcStr) return ""; + var d = new Date(utcStr); + if (isNaN(d.getTime())) return utcStr.slice(0, 16); + var pad = function (n) { + return n < 10 ? "0" + n : "" + n; + }; + return ( + d.getFullYear() + + "-" + + pad(d.getMonth() + 1) + + "-" + + pad(d.getDate()) + + "T" + + pad(d.getHours()) + + ":" + + pad(d.getMinutes()) + ); +} + // --- Create Schedule Modal --- function toggleScheduleTypeFields() { @@ -1108,10 +1242,29 @@ function showCreateScheduleModal() { document.getElementById("cs-at").value = ""; document.getElementById("cs-target").value = "auto"; document.getElementById("cs-node").value = ""; - document.getElementById("cs-model").value = ""; - document.getElementById("cs-template").value = ""; document.getElementById("cs-message").value = ""; document.getElementById("cs-autoapprove").checked = false; + _populateNotifyRows("cs", []); + // Populate model dropdown + _populateScheduleSelect("cs-model", "/v1/api/models", "alias", "alias", { + listKey: "models", + display: function (m) { + return m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")"; + }, + }); + // Populate skill dropdown + _populateScheduleSelect( + "cs-template", + "/v1/api/admin/skills", + "name", + "name", + { + listKey: "skills", + display: function (s) { + return s.name; + }, + }, + ); toggleScheduleTypeFields(); toggleScheduleNodeField(); document.getElementById("cs-submit").disabled = false; @@ -1144,6 +1297,7 @@ function submitCreateSchedule() { var message = (document.getElementById("cs-message").value || "").trim(); var skill = (document.getElementById("cs-template").value || "").trim(); var autoApprove = document.getElementById("cs-autoapprove").checked; + var notifyTargets = _collectNotifyTargets("cs"); var errEl = document.getElementById("create-schedule-error"); if (!name) return _showModalError(errEl, "Name is required"); @@ -1153,11 +1307,9 @@ function submitCreateSchedule() { if (schedType === "at" && !atTime) return _showModalError(errEl, "Run time is required"); - // Normalize datetime-local to "YYYY-MM-DDTHH:MM:SS+00:00" (UTC) + // Convert browser local time to UTC for the server if (schedType === "at" && atTime) { - if (atTime.length === 16) atTime += ":00"; - else if (atTime.length > 19) atTime = atTime.slice(0, 19); - atTime += "+00:00"; + atTime = _localToUtcIso(atTime); } if (targetMode === "node") targetMode = nodeId; @@ -1180,6 +1332,7 @@ function submitCreateSchedule() { initial_message: message, auto_approve: autoApprove, skill: skill, + notify_targets: notifyTargets, }), }) .then(function (r) { @@ -1233,7 +1386,7 @@ function showEditScheduleModal(taskId) { document.getElementById("es-desc").value = s.description || ""; document.getElementById("es-type").value = s.schedule_type; document.getElementById("es-cron").value = s.cron_expr || ""; - document.getElementById("es-at").value = (s.at_time || "").slice(0, 16); + document.getElementById("es-at").value = _utcToLocalDatetime(s.at_time); var isSpecificNode = s.target_mode && s.target_mode !== "auto" && @@ -1245,11 +1398,32 @@ function showEditScheduleModal(taskId) { document.getElementById("es-node").value = isSpecificNode ? s.target_mode : ""; - document.getElementById("es-model").value = s.model || ""; - document.getElementById("es-template").value = s.skill || ""; + // Populate model dropdown with current value pre-selected + _populateScheduleSelect("es-model", "/v1/api/models", "alias", "alias", { + listKey: "models", + selected: s.model || "", + display: function (m) { + return m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")"; + }, + }); + // Populate skill dropdown with current value pre-selected + _populateScheduleSelect( + "es-template", + "/v1/api/admin/skills", + "name", + "name", + { + listKey: "skills", + selected: s.skill || "", + display: function (sk) { + return sk.name; + }, + }, + ); document.getElementById("es-message").value = s.initial_message || ""; document.getElementById("es-autoapprove").checked = !!s.auto_approve; document.getElementById("es-enabled").checked = !!s.enabled; + _populateNotifyRows("es", s.notify_targets || []); toggleEditScheduleTypeFields(); toggleEditScheduleNodeField(); document.getElementById("edit-schedule-error").style.display = "none"; @@ -1289,12 +1463,8 @@ function submitEditSchedule() { if (targetMode === "node") targetMode = (document.getElementById("es-node").value || "").trim(); var atTime = document.getElementById("es-at").value || ""; - if (atTime) { - if (atTime.length === 16) atTime += ":00"; - else if (atTime.length > 19) atTime = atTime.slice(0, 19); - atTime += "+00:00"; - } + var editNotifyTargets = _collectNotifyTargets("es"); var errEl = document.getElementById("edit-schedule-error"); if (!name) return _showModalError(errEl, "Name is required"); @@ -1304,6 +1474,11 @@ function submitEditSchedule() { if (schedType === "at" && !atTime) return _showModalError(errEl, "Run time is required"); + // Convert browser local time to UTC for the server + if (schedType === "at" && atTime) { + atTime = _localToUtcIso(atTime); + } + var btn = document.getElementById("es-submit"); btn.disabled = true; btn.textContent = "Saving\u2026"; @@ -1312,19 +1487,18 @@ function submitEditSchedule() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: (document.getElementById("es-name").value || "").trim(), + name: name, description: (document.getElementById("es-desc").value || "").trim(), - schedule_type: document.getElementById("es-type").value, - cron_expr: (document.getElementById("es-cron").value || "").trim(), + schedule_type: schedType, + cron_expr: cronExpr, at_time: atTime, target_mode: targetMode, model: (document.getElementById("es-model").value || "").trim(), skill: (document.getElementById("es-template").value || "").trim(), - initial_message: ( - document.getElementById("es-message").value || "" - ).trim(), + initial_message: message, auto_approve: document.getElementById("es-autoapprove").checked, enabled: document.getElementById("es-enabled").checked, + notify_targets: editNotifyTargets, }), }) .then(function (r) { diff --git a/turnstone/console/static/governance.js b/turnstone/console/static/governance.js index 76a7bee4..920b9126 100644 --- a/turnstone/console/static/governance.js +++ b/turnstone/console/static/governance.js @@ -890,6 +890,7 @@ function showCreateTemplateModal() { document.getElementById("csk-auto-approve").checked = false; document.getElementById("csk-allowed-tools").value = ""; document.getElementById("csk-allowed-tools").disabled = false; + document.getElementById("csk-notify-on-complete").value = ""; document.getElementById("csk-enabled").checked = true; document.getElementById("csk-auto-approve").onchange = function () { document.getElementById("csk-allowed-tools").disabled = this.checked; @@ -949,6 +950,23 @@ function submitCreateTemplate() { }) .filter(Boolean) : []; + var csNotifyRaw = ( + document.getElementById("csk-notify-on-complete").value || "" + ).trim(); + var csNotifyVal = "[]"; + if (csNotifyRaw) { + try { + var csNotifyParsed = JSON.parse(csNotifyRaw); + if (!Array.isArray(csNotifyParsed)) + throw new Error("must be a JSON array"); + csNotifyVal = JSON.stringify(csNotifyParsed); + } catch (ne) { + var ne2 = document.getElementById("create-template-error"); + ne2.textContent = "Notify on completion: " + ne.message; + ne2.style.display = ""; + return; + } + } document.getElementById("ctm-submit").disabled = true; var csVersion = (document.getElementById("skill-version").value || "").trim(); var createBody = { @@ -975,6 +993,7 @@ function submitCreateTemplate() { token_budget: csBudget ? parseInt(csBudget, 10) : 0, agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null, allowed_tools: JSON.stringify(csAllowedArr), + notify_on_complete: csNotifyVal, enabled: document.getElementById("csk-enabled").checked, }; if (csVersion) createBody.version = csVersion; @@ -1097,6 +1116,9 @@ function showEditTemplateModal(tmplId) { document.getElementById("esk-allowed-tools").disabled = tmpl.auto_approve || false; document.getElementById("esk-enabled").checked = tmpl.enabled !== false; + var notifyVal = tmpl.notify_on_complete || "[]"; + document.getElementById("esk-notify-on-complete").value = + notifyVal && notifyVal !== "[]" ? notifyVal : ""; document.getElementById("esk-auto-approve").onchange = function () { document.getElementById("esk-allowed-tools").disabled = this.checked; }; @@ -1553,6 +1575,23 @@ function submitEditTemplate() { }) .filter(Boolean) : []; + var esNotifyRaw = ( + document.getElementById("esk-notify-on-complete").value || "" + ).trim(); + var esNotifyVal = "[]"; + if (esNotifyRaw) { + try { + var esNotifyParsed = JSON.parse(esNotifyRaw); + if (!Array.isArray(esNotifyParsed)) + throw new Error("must be a JSON array"); + esNotifyVal = JSON.stringify(esNotifyParsed); + } catch (ne) { + var ne3 = document.getElementById("edit-template-error"); + ne3.textContent = "Notify on completion: " + ne.message; + ne3.style.display = ""; + return; + } + } document.getElementById("etm-submit").disabled = true; var esVersion = (document.getElementById("etm-version").value || "").trim(); var updateBody = { @@ -1579,6 +1618,7 @@ function submitEditTemplate() { token_budget: esBudget ? parseInt(esBudget, 10) : 0, agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null, allowed_tools: JSON.stringify(esAllowedArr), + notify_on_complete: esNotifyVal, enabled: document.getElementById("esk-enabled").checked, }; if (esVersion) updateBody.version = esVersion; diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index f6bc1576..0c2d6098 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -862,12 +862,15 @@ window.TURNSTONE_KB_SHORTCUTS = [