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:
Patrick Buckley
2026-04-05 17:18:26 -07:00
committed by GitHub
parent 7968f1b361
commit adb42c66da
20 changed files with 1139 additions and 30 deletions
+530
View File
@@ -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
+28
View File
@@ -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",
+6
View File
@@ -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
+7
View File
@@ -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.",
+21 -4
View File
@@ -27,6 +27,8 @@ if TYPE_CHECKING:
log = get_logger(__name__)
_NOTIFY_ADAPTER_TIMEOUT: float = 30.0
# ws_id is a hex string (832 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",
+1
View File
@@ -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:
+33
View File
@@ -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"])
+196 -22
View File
@@ -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) {
+40
View File
@@ -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;
+16 -4
View File
@@ -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">
+24
View File
@@ -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 {
+4
View File
@@ -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]:
+3
View File
@@ -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",
+1
View File
@@ -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."""
...
+1
View File
@@ -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),
+3
View File
@@ -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")
+1
View File
@@ -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:
+5
View File
@@ -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,
)
)
+194
View File
@@ -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