mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: deliver scheduled workstream results to Discord on completion (#308)
When a scheduled workstream finishes execution, deliver the final assistant response to configured Discord channels/users via the existing channel gateway notify infrastructure. - Add notify_targets column to scheduled_tasks (migration 034) - Add notify_targets field to Workstream dataclass - Storage: accept/return/update notify_targets in protocol, SQLite, PostgreSQL - Server: validate targets, extract last assistant content, deliver via gateway with retry, post-completion hook in _run_initial finally block - Schedule targets override skill notify_on_complete (dedup rule) - SDK: notify_targets param on async + sync create_workstream - Console scheduler: pass notify_targets through dispatch - Console server: schedule CRUD accepts/validates/returns notify_targets - API schemas: notify_targets on schedule + workstream request/response - Admin UI: notify textarea in schedule create/edit modals with JSON validation, monospace font, aria-describedby hints - Governance UI: notify_on_complete textarea in skill create/edit with client-side JSON validation and field reset on create - Bounds: max 10 targets, 256 char field limit, gateway response body verification matching _exec_notify pattern - Gateway: 30s asyncio.wait_for timeout on adapter.send to prevent hung Discord API calls from blocking the notify endpoint indefinitely - 39 new tests covering validation, extraction, delivery, dispatch, CRUD, and adapter timeout
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -862,12 +862,15 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<div class="modal-col">
|
||||
<div class="modal-col-heading">Execution</div>
|
||||
<label for="cs-model">Model <span class="label-hint">optional</span></label>
|
||||
<input id="cs-model" type="text" placeholder="Default model" autocomplete="off">
|
||||
<select id="cs-model"><option value="">Default model</option></select>
|
||||
<label for="cs-template">Skill <span class="label-hint">optional</span></label>
|
||||
<input id="cs-template" type="text" placeholder="Skill name" autocomplete="off">
|
||||
<select id="cs-template"><option value="">None</option></select>
|
||||
<label for="cs-message">Initial message</label>
|
||||
<textarea id="cs-message" rows="3" placeholder="What should the workstream do?"></textarea>
|
||||
<label class="admin-checkbox"><input id="cs-autoapprove" type="checkbox"> Auto-approve tool calls</label>
|
||||
<label>Notify on completion <span class="label-hint">optional</span></label>
|
||||
<div id="cs-notify-rows"></div>
|
||||
<button type="button" class="admin-inline-add" onclick="_addNotifyRow('cs')" aria-label="Add notification target">+ Add target</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
@@ -918,13 +921,16 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<div class="modal-col">
|
||||
<div class="modal-col-heading">Execution</div>
|
||||
<label for="es-model">Model</label>
|
||||
<input id="es-model" type="text" autocomplete="off">
|
||||
<select id="es-model"><option value="">Default model</option></select>
|
||||
<label for="es-template">Skill <span class="label-hint">optional</span></label>
|
||||
<input id="es-template" type="text" autocomplete="off">
|
||||
<select id="es-template"><option value="">None</option></select>
|
||||
<label for="es-message">Initial message</label>
|
||||
<textarea id="es-message" rows="3"></textarea>
|
||||
<label class="admin-checkbox"><input id="es-autoapprove" type="checkbox"> Auto-approve tool calls</label>
|
||||
<label class="admin-checkbox"><input id="es-enabled" type="checkbox"> Enabled</label>
|
||||
<label>Notify on completion <span class="label-hint">optional</span></label>
|
||||
<div id="es-notify-rows"></div>
|
||||
<button type="button" class="admin-inline-add" onclick="_addNotifyRow('es')" aria-label="Add notification target">+ Add target</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
@@ -1180,6 +1186,9 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<label class="admin-checkbox"><input id="csk-auto-approve" type="checkbox"> Auto-approve all tools</label>
|
||||
<label for="csk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
|
||||
<input id="csk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
|
||||
<label for="csk-notify-on-complete">Notify on completion <span class="label-hint">optional</span></label>
|
||||
<textarea id="csk-notify-on-complete" rows="2" placeholder='[{"channel_type":"discord","channel_id":"123..."}]' spellcheck="false" aria-describedby="csk-notify-hint" style="font-family:var(--font-mono);font-size:12px"></textarea>
|
||||
<span id="csk-notify-hint" class="label-hint" style="display:block;margin-top:3px">JSON array. Each: channel_type + channel_id or user_id</span>
|
||||
<label class="admin-checkbox"><input id="csk-enabled" type="checkbox" checked> Enabled</label>
|
||||
</details>
|
||||
<details class="admin-details">
|
||||
@@ -1296,6 +1305,9 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<label class="admin-checkbox"><input id="esk-auto-approve" type="checkbox"> Auto-approve all tools</label>
|
||||
<label for="esk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
|
||||
<input id="esk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
|
||||
<label for="esk-notify-on-complete">Notify on completion <span class="label-hint">optional</span></label>
|
||||
<textarea id="esk-notify-on-complete" rows="2" placeholder='[{"channel_type":"discord","channel_id":"123..."}]' spellcheck="false" aria-describedby="esk-notify-hint" style="font-family:var(--font-mono);font-size:12px"></textarea>
|
||||
<span id="esk-notify-hint" class="label-hint" style="display:block;margin-top:3px">JSON array. Each: channel_type + channel_id or user_id</span>
|
||||
<label class="admin-checkbox"><input id="esk-enabled" type="checkbox" checked> Enabled</label>
|
||||
</details>
|
||||
<div id="etm-scan-section" style="display:none" class="admin-field">
|
||||
|
||||
@@ -1202,6 +1202,30 @@
|
||||
.admin-modal [role="alert"] { display: none; color: var(--red); font-size: 12px; margin-bottom: 8px; }
|
||||
.admin-modal [role="alert"].is-visible { display: block; }
|
||||
|
||||
.admin-inline-add {
|
||||
background: none; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm);
|
||||
color: var(--fg-dim); font: inherit; font-size: 12px; padding: 5px 10px; cursor: pointer;
|
||||
width: 100%; margin-top: 6px; transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.admin-inline-add:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.admin-inline-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.notify-row {
|
||||
display: flex; gap: 6px; margin-bottom: 4px; align-items: center;
|
||||
}
|
||||
.notify-row select, .notify-row input {
|
||||
padding: 7px 8px;
|
||||
background: var(--bg); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm); color: var(--fg); font: inherit; font-size: 12px;
|
||||
}
|
||||
.notify-row select { width: 90px; flex-shrink: 0; }
|
||||
.notify-row input { flex: 1; min-width: 0; }
|
||||
.notify-row-remove {
|
||||
background: none; border: none; color: var(--fg-dim); cursor: pointer;
|
||||
font-size: 16px; padding: 0 4px; line-height: 1; flex-shrink: 0;
|
||||
}
|
||||
.notify-row-remove:hover { color: var(--red); }
|
||||
.notify-row-remove:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
|
||||
|
||||
.admin-details { margin-top: 12px; border: 1px solid var(--border); border-radius: 6px; padding: 0 12px; }
|
||||
.admin-details[open] { padding-bottom: 12px; }
|
||||
.admin-details summary {
|
||||
|
||||
@@ -1258,6 +1258,10 @@ class ChatSession:
|
||||
except Exception:
|
||||
log.warning("session.skill_catalog_failed", exc_info=True)
|
||||
search_skills = []
|
||||
# Exclude the already-applied skill from the catalog so the model
|
||||
# doesn't suggest activating a skill that is already loaded.
|
||||
applied_name = self._skill_name or ""
|
||||
search_skills = [sk for sk in search_skills if sk.get("name", "") != applied_name]
|
||||
if search_skills:
|
||||
catalog_lines = ["<available-skills>"]
|
||||
for sk in search_skills[:30]:
|
||||
|
||||
@@ -926,6 +926,7 @@ class PostgreSQLBackend:
|
||||
created_by: str,
|
||||
next_run: str,
|
||||
skill: str = "",
|
||||
notify_targets: str = "[]",
|
||||
) -> None:
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
@@ -946,6 +947,7 @@ class PostgreSQLBackend:
|
||||
auto_approve=1 if auto_approve else 0,
|
||||
auto_approve_tools=",".join(auto_approve_tools),
|
||||
skill=skill,
|
||||
notify_targets=notify_targets,
|
||||
enabled=1,
|
||||
created_by=created_by,
|
||||
next_run=next_run,
|
||||
@@ -987,6 +989,7 @@ class PostgreSQLBackend:
|
||||
"auto_approve",
|
||||
"auto_approve_tools",
|
||||
"skill",
|
||||
"notify_targets",
|
||||
"enabled",
|
||||
"last_run",
|
||||
"next_run",
|
||||
|
||||
@@ -352,6 +352,7 @@ class StorageBackend(Protocol):
|
||||
created_by: str,
|
||||
next_run: str,
|
||||
skill: str = "",
|
||||
notify_targets: str = "[]",
|
||||
) -> None:
|
||||
"""Create a scheduled task. No-op if task_id already exists."""
|
||||
...
|
||||
|
||||
@@ -153,6 +153,7 @@ scheduled_tasks = sa.Table(
|
||||
sa.Column("auto_approve", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("auto_approve_tools", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("skill", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("notify_targets", sa.Text, nullable=False, server_default="[]"),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("last_run", sa.Text),
|
||||
|
||||
@@ -997,6 +997,7 @@ class SQLiteBackend:
|
||||
created_by: str,
|
||||
next_run: str,
|
||||
skill: str = "",
|
||||
notify_targets: str = "[]",
|
||||
) -> None:
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
@@ -1016,6 +1017,7 @@ class SQLiteBackend:
|
||||
"auto_approve": 1 if auto_approve else 0,
|
||||
"auto_approve_tools": ",".join(auto_approve_tools),
|
||||
"skill": skill,
|
||||
"notify_targets": notify_targets,
|
||||
"enabled": 1,
|
||||
"created_by": created_by,
|
||||
"next_run": next_run,
|
||||
@@ -1056,6 +1058,7 @@ class SQLiteBackend:
|
||||
"auto_approve",
|
||||
"auto_approve_tools",
|
||||
"skill",
|
||||
"notify_targets",
|
||||
"enabled",
|
||||
"last_run",
|
||||
"next_run",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add notify_targets column to scheduled_tasks.
|
||||
|
||||
Revision ID: 034
|
||||
Revises: 033
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "034"
|
||||
down_revision = "033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("notify_targets", sa.Text, nullable=False, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scheduled_tasks", "notify_targets")
|
||||
@@ -63,6 +63,7 @@ class Workstream:
|
||||
worker_thread: threading.Thread | None = None
|
||||
error_message: str = ""
|
||||
last_active: float = field(default_factory=time.monotonic, repr=False)
|
||||
notify_targets: str = "[]"
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
||||
@@ -107,6 +107,7 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
client_type: str = "",
|
||||
notify_targets: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
body: dict[str, Any] = {}
|
||||
if name:
|
||||
@@ -129,6 +130,8 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
body["ws_id"] = ws_id
|
||||
if client_type:
|
||||
body["client_type"] = client_type
|
||||
if notify_targets and notify_targets != "[]":
|
||||
body["notify_targets"] = notify_targets
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/workstreams/new",
|
||||
@@ -490,6 +493,7 @@ class TurnstoneServer:
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
client_type: str = "",
|
||||
notify_targets: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
return self._runner.run(
|
||||
self._async.create_workstream(
|
||||
@@ -503,6 +507,7 @@ class TurnstoneServer:
|
||||
user_id=user_id,
|
||||
ws_id=ws_id,
|
||||
client_type=client_type,
|
||||
notify_targets=notify_targets,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1629,6 +1629,178 @@ async def command(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification helpers — completion delivery for scheduled workstreams
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_NOTIFY_TARGETS = 10
|
||||
|
||||
|
||||
def _validate_notify_targets(raw: Any) -> tuple[str, str]:
|
||||
"""Validate and normalize notify_targets input.
|
||||
|
||||
Returns (json_string, error_message). Error is empty on success.
|
||||
"""
|
||||
if not raw:
|
||||
return "[]", ""
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return "[]", "notify_targets must be valid JSON"
|
||||
elif isinstance(raw, list):
|
||||
parsed = raw
|
||||
else:
|
||||
return "[]", "notify_targets must be a JSON array or string"
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
return "[]", "notify_targets must be a JSON array"
|
||||
|
||||
if len(parsed) > _MAX_NOTIFY_TARGETS:
|
||||
return "[]", f"notify_targets limited to {_MAX_NOTIFY_TARGETS} entries"
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for i, t in enumerate(parsed):
|
||||
if not isinstance(t, dict):
|
||||
return "[]", f"notify_targets[{i}] must be an object"
|
||||
if "channel_type" not in t:
|
||||
return "[]", f"notify_targets[{i}] missing channel_type"
|
||||
|
||||
has_channel_id = "channel_id" in t and t.get("channel_id") is not None
|
||||
has_user_id = "user_id" in t and t.get("user_id") is not None
|
||||
if has_channel_id and has_user_id:
|
||||
return "[]", f"notify_targets[{i}] must specify only one of channel_id or user_id"
|
||||
if not has_channel_id and not has_user_id:
|
||||
return "[]", f"notify_targets[{i}] requires channel_id or user_id"
|
||||
|
||||
normalized_target: dict[str, str] = {}
|
||||
for key in ("channel_type", "channel_id", "user_id"):
|
||||
val = t.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
if not isinstance(val, str):
|
||||
return "[]", f"notify_targets[{i}].{key} must be a non-empty string <= 256 chars"
|
||||
stripped = val.strip()
|
||||
if not stripped:
|
||||
return "[]", f"notify_targets[{i}].{key} must be a non-empty string <= 256 chars"
|
||||
if len(stripped) > 256:
|
||||
return "[]", f"notify_targets[{i}].{key} must be a non-empty string <= 256 chars"
|
||||
normalized_target[key] = stripped
|
||||
|
||||
normalized.append(normalized_target)
|
||||
|
||||
return json.dumps(normalized), ""
|
||||
|
||||
|
||||
def _extract_last_assistant_content(session: Any) -> str:
|
||||
"""Return the text content of the last assistant message."""
|
||||
for msg in reversed(session.messages):
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _fire_notify_targets(ws: Any, content: str) -> None:
|
||||
"""Send completion notifications to all configured targets."""
|
||||
if not content or not ws.notify_targets:
|
||||
return
|
||||
|
||||
try:
|
||||
targets = json.loads(ws.notify_targets)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not targets or not isinstance(targets, list):
|
||||
return
|
||||
|
||||
from turnstone.core.session import _notify_auth_headers
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
auth_headers = _notify_auth_headers()
|
||||
task_name = ws.name or ws.id[:8]
|
||||
|
||||
for target in targets:
|
||||
if not isinstance(target, dict):
|
||||
continue
|
||||
channel_type = target.get("channel_type", "")
|
||||
resolved: dict[str, str] = {}
|
||||
if "channel_id" in target:
|
||||
resolved = {"channel_type": channel_type, "channel_id": target["channel_id"]}
|
||||
elif "user_id" in target:
|
||||
resolved = {"channel_type": channel_type, "channel_id": target["user_id"]}
|
||||
else:
|
||||
continue
|
||||
|
||||
payload = {
|
||||
"target": resolved,
|
||||
"message": content,
|
||||
"title": f"Schedule: {task_name}",
|
||||
"ws_id": ws.id,
|
||||
}
|
||||
|
||||
_deliver_notification(storage, payload, auth_headers)
|
||||
|
||||
|
||||
def _deliver_notification(
|
||||
storage: Any,
|
||||
payload: dict[str, Any],
|
||||
auth_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""POST to channel gateway /v1/api/notify with retry."""
|
||||
import httpx
|
||||
|
||||
for attempt in range(3):
|
||||
services = storage.list_services("channel", max_age_seconds=120)
|
||||
if not services:
|
||||
if attempt < 2:
|
||||
time.sleep(1.0 if attempt == 0 else 3.0)
|
||||
continue
|
||||
log.warning("notify_completion.no_services")
|
||||
return
|
||||
|
||||
for svc in services:
|
||||
url = svc["url"].rstrip("/") + "/v1/api/notify"
|
||||
if not url.startswith(("http://", "https://")):
|
||||
continue
|
||||
try:
|
||||
resp = httpx.post(url, json=payload, timeout=10, headers=auth_headers)
|
||||
if resp.status_code < 300:
|
||||
# Verify at least one target was delivered (mirrors _exec_notify)
|
||||
try:
|
||||
data = resp.json()
|
||||
results = data.get("results") if isinstance(data, dict) else None
|
||||
if isinstance(results, list) and any(
|
||||
isinstance(r, dict) and r.get("status") == "sent" for r in results
|
||||
):
|
||||
log.info("notify_completion.delivered", ws_id=payload.get("ws_id"))
|
||||
return
|
||||
except Exception:
|
||||
log.debug("notify_completion.response_parse_error", url=url, exc_info=True)
|
||||
log.warning("notify_completion.no_successful_delivery", url=url)
|
||||
continue
|
||||
log.warning(
|
||||
"notify_completion.failed",
|
||||
status=resp.status_code,
|
||||
url=url,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("notify_completion.error", url=url)
|
||||
continue
|
||||
|
||||
if attempt < 2:
|
||||
time.sleep(1.0 if attempt == 0 else 3.0)
|
||||
|
||||
|
||||
async def create_workstream(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/workstreams/new — create a new workstream."""
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
@@ -1780,6 +1952,22 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
sess._applied_skill_content = skill_data["content"]
|
||||
sess._save_config()
|
||||
|
||||
# Resolve notify_targets: schedule targets override skill targets
|
||||
notify_targets_raw = body.get("notify_targets", "[]")
|
||||
if isinstance(notify_targets_raw, list):
|
||||
notify_targets_raw = json.dumps(notify_targets_raw)
|
||||
nt_str, nt_err = _validate_notify_targets(notify_targets_raw)
|
||||
if nt_err:
|
||||
return JSONResponse({"error": nt_err}, status_code=400)
|
||||
# Skill fallback (only if schedule didn't specify targets)
|
||||
if nt_str == "[]" and skill_data:
|
||||
skill_notify = skill_data.get("notify_on_complete", "[]")
|
||||
if skill_notify and skill_notify != "{}" and skill_notify != "[]":
|
||||
fallback_str, fallback_err = _validate_notify_targets(skill_notify)
|
||||
if not fallback_err:
|
||||
nt_str = fallback_str
|
||||
ws.notify_targets = nt_str
|
||||
|
||||
# Pin locally-created workstreams so the console routes to this node.
|
||||
# Console-routed creates pass ws_id in the request body — those are
|
||||
# already bucket-aligned and don't need an override. Direct creates
|
||||
@@ -1809,6 +1997,12 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
if isinstance(ws.ui, WebUI):
|
||||
ws.ui.on_stream_end()
|
||||
ws.ui.on_state_change("idle")
|
||||
finally:
|
||||
try:
|
||||
last_content = _extract_last_assistant_content(session)
|
||||
_fire_notify_targets(ws, last_content)
|
||||
except Exception:
|
||||
log.warning("notify_completion.hook_error", ws_id=ws.id, exc_info=True)
|
||||
|
||||
t = threading.Thread(target=_run_initial, daemon=True, name=f"ws-init-{ws.id[:8]}")
|
||||
ws.worker_thread = t
|
||||
|
||||
Reference in New Issue
Block a user