mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99b0e8db12 | |||
| d22f5a4baf | |||
| da5eae5352 | |||
| adb42c66da | |||
| 7968f1b361 | |||
| 8de53f5cc1 | |||
| 8808a56801 |
@@ -85,7 +85,7 @@ turnstone/
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.44/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.45/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.2.0a2"
|
||||
version = "1.2.0a3"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -77,7 +77,7 @@ include = [
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.44/**/*",
|
||||
"turnstone/shared_static/katex-0.16.45/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.14.0/**/*",
|
||||
"turnstone/shared_static/hls-1.6.15/**/*",
|
||||
|
||||
@@ -180,6 +180,10 @@ case "$LIB" in
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "NOTE: If you added a NEW library (not just updating a version), also update"
|
||||
echo " the _ASSET_RE regex in turnstone/core/web_helpers.py — its negative lookahead"
|
||||
echo " skips vendored directories to avoid double-versioning static asset URLs."
|
||||
echo ""
|
||||
echo "Verify the update:"
|
||||
echo " git diff --stat"
|
||||
|
||||
@@ -1145,6 +1145,132 @@ class TestJWTAudienceIssuer:
|
||||
create_jwt("user1", frozenset({"read"}), "test", self.SECRET, expiry_seconds=-1)
|
||||
|
||||
|
||||
class TestJWTVersionClaim:
|
||||
SECRET = "test-secret-that-is-at-least-32-chars"
|
||||
|
||||
def test_create_jwt_with_version(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
from turnstone.core.auth import create_jwt
|
||||
|
||||
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET, version="1.2")
|
||||
payload = pyjwt.decode(
|
||||
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
|
||||
)
|
||||
assert payload["ver"] == "1.2"
|
||||
|
||||
def test_create_jwt_without_version(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
from turnstone.core.auth import create_jwt
|
||||
|
||||
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET)
|
||||
payload = pyjwt.decode(
|
||||
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
|
||||
)
|
||||
assert "ver" not in payload
|
||||
|
||||
def test_validate_jwt_carries_token_version(self):
|
||||
from turnstone.core.auth import create_jwt, validate_jwt
|
||||
|
||||
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET, version="1.2")
|
||||
result = validate_jwt(token, self.SECRET)
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.token_version == "1.2"
|
||||
|
||||
def test_validate_jwt_no_ver_returns_empty_token_version(self):
|
||||
from turnstone.core.auth import create_jwt, validate_jwt
|
||||
|
||||
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET)
|
||||
result = validate_jwt(token, self.SECRET)
|
||||
assert result is not None
|
||||
assert result.token_version == ""
|
||||
|
||||
def test_check_request_accepts_matching_version(self):
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, check_request, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
"user1",
|
||||
frozenset({"read"}),
|
||||
"test",
|
||||
self.SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
version="1.2",
|
||||
)
|
||||
allowed, _status, _msg, result = check_request(
|
||||
"GET",
|
||||
"/v1/api/workstreams",
|
||||
f"Bearer {token}",
|
||||
jwt_secret=self.SECRET,
|
||||
jwt_audience=JWT_AUD_SERVER,
|
||||
jwt_version="1.2",
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
|
||||
def test_check_request_accepts_no_ver_backward_compat(self):
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, check_request, create_jwt
|
||||
|
||||
# Token without ver claim should be accepted (backward compat)
|
||||
token = create_jwt(
|
||||
"user1",
|
||||
frozenset({"read"}),
|
||||
"test",
|
||||
self.SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
allowed, _status, _msg, _result = check_request(
|
||||
"GET",
|
||||
"/v1/api/workstreams",
|
||||
f"Bearer {token}",
|
||||
jwt_secret=self.SECRET,
|
||||
jwt_audience=JWT_AUD_SERVER,
|
||||
jwt_version="1.2",
|
||||
)
|
||||
assert allowed
|
||||
|
||||
def test_check_request_rejects_old_version_jwt(self):
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, check_request, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
"user1",
|
||||
frozenset({"read"}),
|
||||
"test",
|
||||
self.SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
version="1.1",
|
||||
)
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET",
|
||||
"/v1/api/workstreams",
|
||||
f"Bearer {token}",
|
||||
jwt_secret=self.SECRET,
|
||||
jwt_audience=JWT_AUD_SERVER,
|
||||
jwt_version="1.2",
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 401
|
||||
assert msg == "version_mismatch"
|
||||
|
||||
|
||||
class TestVersionSlot:
|
||||
def test_returns_major_minor(self):
|
||||
from turnstone.core.auth import jwt_version_slot
|
||||
|
||||
slot = jwt_version_slot()
|
||||
parts = slot.split(".")
|
||||
assert len(parts) == 2
|
||||
|
||||
def test_strips_patch_and_prerelease(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("turnstone.__version__", "2.3.1a5"):
|
||||
from turnstone.core.auth import jwt_version_slot
|
||||
|
||||
assert jwt_version_slot() == "2.3"
|
||||
|
||||
|
||||
class TestServiceTokenManager:
|
||||
SECRET = "test-secret-that-is-at-least-32-chars"
|
||||
|
||||
@@ -1224,6 +1350,22 @@ class TestServiceTokenManager:
|
||||
)
|
||||
assert payload["aud"] == JWT_AUD_SERVER
|
||||
|
||||
def test_service_token_no_version_claim(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
from turnstone.core.auth import ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="svc",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=self.SECRET,
|
||||
)
|
||||
payload = pyjwt.decode(
|
||||
mgr.token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
|
||||
)
|
||||
assert "ver" not in payload
|
||||
|
||||
|
||||
class TestIsSecureRequest:
|
||||
def test_https_scheme(self):
|
||||
|
||||
@@ -260,6 +260,90 @@ class TestMessageCog:
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /ask command — model selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAskModelSelection:
|
||||
"""Tests for the /ask command's model parameter and channel default."""
|
||||
|
||||
def _make_cog_and_interaction(self):
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
bot.user = MagicMock()
|
||||
bot.user.id = 99999
|
||||
|
||||
ts = MagicMock()
|
||||
ts.router = MagicMock()
|
||||
ts.router.resolve_user = AsyncMock(return_value="u_abc")
|
||||
ts.router.get_or_create_workstream = AsyncMock(return_value=("ws-1", True))
|
||||
ts.router.send_message = AsyncMock()
|
||||
ts.router.get_channel_default_alias = AsyncMock(return_value="")
|
||||
ts.subscribe_ws = AsyncMock()
|
||||
ts.config = MagicMock()
|
||||
ts.config.model = "cli-model"
|
||||
ts.config.thread_auto_archive = 1440
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
|
||||
interaction = MagicMock(spec=discord.Interaction)
|
||||
interaction.user = MagicMock()
|
||||
interaction.user.id = 67890
|
||||
interaction.response = MagicMock()
|
||||
interaction.response.defer = AsyncMock()
|
||||
interaction.followup = MagicMock()
|
||||
interaction.followup.send = AsyncMock()
|
||||
thread = AsyncMock(spec=discord.Thread)
|
||||
thread.id = 111
|
||||
thread.mention = "<#111>"
|
||||
channel = MagicMock(spec=discord.TextChannel)
|
||||
channel.create_thread = AsyncMock(return_value=thread)
|
||||
interaction.channel = channel
|
||||
|
||||
return cog, ts, interaction
|
||||
|
||||
def test_explicit_model_overrides_all(self):
|
||||
cog, ts, interaction = self._make_cog_and_interaction()
|
||||
ts.router.get_channel_default_alias = AsyncMock(return_value="channel-default")
|
||||
|
||||
_run(cog._cmd_ask(interaction, "hello", model="explicit-model"))
|
||||
|
||||
_, kwargs = ts.router.get_or_create_workstream.call_args
|
||||
assert kwargs["model"] == "explicit-model"
|
||||
|
||||
def test_channel_default_used_when_no_explicit_model(self):
|
||||
cog, ts, interaction = self._make_cog_and_interaction()
|
||||
ts.router.get_channel_default_alias = AsyncMock(return_value="channel-default")
|
||||
|
||||
_run(cog._cmd_ask(interaction, "hello"))
|
||||
|
||||
_, kwargs = ts.router.get_or_create_workstream.call_args
|
||||
assert kwargs["model"] == "channel-default"
|
||||
|
||||
def test_cli_model_fallback(self):
|
||||
cog, ts, interaction = self._make_cog_and_interaction()
|
||||
# Channel default is empty → fall back to CLI --model.
|
||||
ts.router.get_channel_default_alias = AsyncMock(return_value="")
|
||||
|
||||
_run(cog._cmd_ask(interaction, "hello"))
|
||||
|
||||
_, kwargs = ts.router.get_or_create_workstream.call_args
|
||||
assert kwargs["model"] == "cli-model"
|
||||
|
||||
def test_empty_model_when_no_defaults(self):
|
||||
cog, ts, interaction = self._make_cog_and_interaction()
|
||||
ts.router.get_channel_default_alias = AsyncMock(return_value="")
|
||||
ts.config.model = ""
|
||||
|
||||
_run(cog._cmd_ask(interaction, "hello"))
|
||||
|
||||
_, kwargs = ts.router.get_or_create_workstream.call_args
|
||||
assert kwargs["model"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_footer (views.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+15
-1
@@ -1427,7 +1427,7 @@ class TestSharedStatic:
|
||||
def test_index_imports_shared_base_css(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert '/shared/base.css"' in resp.text
|
||||
assert "/shared/base.css?v=" in resp.text
|
||||
|
||||
def test_index_imports_shared_scripts(self, client):
|
||||
resp = client.get("/")
|
||||
@@ -1445,6 +1445,20 @@ class TestSharedStatic:
|
||||
app_pos = body.find("/static/app.js")
|
||||
assert shared_pos < app_pos
|
||||
|
||||
def test_index_cache_control_no_cache(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.headers.get("cache-control") == "no-cache"
|
||||
|
||||
def test_index_etag_present(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.headers.get("etag")
|
||||
|
||||
def test_index_etag_304(self, client):
|
||||
resp = client.get("/")
|
||||
etag = resp.headers.get("etag")
|
||||
resp2 = client.get("/", headers={"If-None-Match": etag})
|
||||
assert resp2.status_code == 304
|
||||
|
||||
|
||||
class TestProxySharedStatic:
|
||||
"""Tests for proxy rewriting of /shared/ paths."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -230,7 +230,7 @@ class TestSettingsSchema:
|
||||
def test_secret_flag(self, client):
|
||||
r = client.get("/v1/api/admin/settings/schema")
|
||||
by_key = {s["key"]: s for s in r.json()["schema"]}
|
||||
assert by_key["judge.api_key"]["is_secret"] is True
|
||||
assert by_key["tools.tavily_api_key"]["is_secret"] is True
|
||||
assert by_key["tools.timeout"]["is_secret"] is False
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ class TestSecretMasking:
|
||||
from turnstone.core.settings_registry import serialize_value
|
||||
|
||||
storage.upsert_system_setting(
|
||||
key="judge.api_key",
|
||||
key="tools.tavily_api_key",
|
||||
value=serialize_value("sk-real-secret"),
|
||||
node_id="",
|
||||
is_secret=True,
|
||||
@@ -252,12 +252,12 @@ class TestSecretMasking:
|
||||
)
|
||||
r = client.get("/v1/api/admin/settings")
|
||||
by_key = {s["key"]: s for s in r.json()["settings"]}
|
||||
assert by_key["judge.api_key"]["value"] == "***"
|
||||
assert by_key["tools.tavily_api_key"]["value"] == "***"
|
||||
|
||||
def test_secret_writable_via_api(self, client):
|
||||
"""Secret settings can be written via API (write-only pattern)."""
|
||||
r = client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
"/v1/api/admin/settings/tools.tavily_api_key",
|
||||
json={"value": "sk-secret-123"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -268,19 +268,19 @@ class TestSecretMasking:
|
||||
"""Submitting '***' for a secret setting is a no-op (preserve existing)."""
|
||||
# First write a real value
|
||||
r1 = client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
"/v1/api/admin/settings/tools.tavily_api_key",
|
||||
json={"value": "sk-real-key"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
# Now submit the sentinel — should return unchanged with full response shape
|
||||
r2 = client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
"/v1/api/admin/settings/tools.tavily_api_key",
|
||||
json={"value": "***"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
data = r2.json()
|
||||
assert data.get("unchanged") is True
|
||||
assert data["key"] == "judge.api_key"
|
||||
assert data["key"] == "tools.tavily_api_key"
|
||||
assert data["value"] == "***"
|
||||
assert data["type"] == "str"
|
||||
assert data["is_secret"] is True
|
||||
@@ -288,12 +288,12 @@ class TestSecretMasking:
|
||||
def test_secret_still_masked_in_list(self, client):
|
||||
"""After writing a secret, list still shows '***'."""
|
||||
client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
"/v1/api/admin/settings/tools.tavily_api_key",
|
||||
json={"value": "sk-written-via-api"},
|
||||
)
|
||||
r = client.get("/v1/api/admin/settings")
|
||||
by_key = {s["key"]: s for s in r.json()["settings"]}
|
||||
assert by_key["judge.api_key"]["value"] == "***"
|
||||
assert by_key["tools.tavily_api_key"]["value"] == "***"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for turnstone.core.web_helpers — version_html() cache-busting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TestVersionHtml:
|
||||
def test_app_css_gets_version(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<link rel="stylesheet" href="/shared/base.css">'
|
||||
result = version_html(html)
|
||||
assert "?v=" in result
|
||||
assert "/shared/base.css?v=" in result
|
||||
|
||||
def test_app_js_gets_version(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/static/app.js"></script>'
|
||||
result = version_html(html)
|
||||
assert "/static/app.js?v=" in result
|
||||
|
||||
def test_shared_js_gets_version(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/shared/utils.js"></script>'
|
||||
result = version_html(html)
|
||||
assert "/shared/utils.js?v=" in result
|
||||
|
||||
def test_vendored_katex_skipped(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<link rel="stylesheet" href="/shared/katex-0.16.44/katex.min.css">'
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
def test_vendored_hljs_skipped(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/shared/hljs-11.11.1/highlight.min.js"></script>'
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
def test_vendored_mermaid_skipped(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/shared/mermaid-11.14.0/mermaid.min.js"></script>'
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
def test_vendored_hls_skipped(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/shared/hls-1.6.15/hls.min.js"></script>'
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
def test_external_urls_not_modified(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = (
|
||||
'<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono" rel="stylesheet">'
|
||||
)
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
def test_docs_link_not_modified(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<a href="/docs#/System:%20Settings" target="_blank">docs</a>'
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
def test_multiple_tags(self):
|
||||
from turnstone import __version__
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = (
|
||||
'<link rel="stylesheet" href="/shared/base.css">\n'
|
||||
'<link rel="stylesheet" href="/shared/katex-0.16.44/katex.min.css">\n'
|
||||
'<link rel="stylesheet" href="/static/style.css">\n'
|
||||
'<script src="/shared/utils.js"></script>\n'
|
||||
'<script src="/shared/hljs-11.11.1/highlight.min.js"></script>\n'
|
||||
'<script src="/static/app.js"></script>'
|
||||
)
|
||||
result = version_html(html)
|
||||
assert f'/shared/base.css?v={__version__}"' in result
|
||||
assert f'/static/style.css?v={__version__}"' in result
|
||||
assert f'/shared/utils.js?v={__version__}"' in result
|
||||
assert f'/static/app.js?v={__version__}"' in result
|
||||
# Vendored libs unchanged
|
||||
assert '/shared/katex-0.16.44/katex.min.css"' in result
|
||||
assert '/shared/hljs-11.11.1/highlight.min.js"' in result
|
||||
|
||||
def test_version_matches_package(self):
|
||||
from turnstone import __version__
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/static/app.js"></script>'
|
||||
result = version_html(html)
|
||||
assert f"?v={__version__}" in result
|
||||
|
||||
def test_double_apply_is_idempotent(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/static/app.js"></script>'
|
||||
once = version_html(html)
|
||||
twice = version_html(once)
|
||||
assert once == twice
|
||||
assert twice.count("?v=") == 1
|
||||
|
||||
def test_existing_query_string_preserved(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/static/app.js?foo=bar"></script>'
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged — already has query string
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.2.0a2"
|
||||
__version__ = "1.2.0a3"
|
||||
|
||||
@@ -876,6 +876,8 @@ class AvailableModelInfo(BaseModel):
|
||||
|
||||
class ListAvailableModelsResponse(BaseModel):
|
||||
models: list[AvailableModelInfo] = Field(default_factory=list)
|
||||
default_alias: str = ""
|
||||
channel_default_alias: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.",
|
||||
@@ -270,3 +277,5 @@ class AvailableModelInfo(BaseModel):
|
||||
|
||||
class ListAvailableModelsResponse(BaseModel):
|
||||
models: list[AvailableModelInfo] = Field(default_factory=list)
|
||||
default_alias: str = ""
|
||||
channel_default_alias: str = ""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,8 @@ backend for persistent channel-to-workstream mappings.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
@@ -23,6 +24,8 @@ if TYPE_CHECKING:
|
||||
log = get_logger(__name__)
|
||||
|
||||
_WS_CREATE_TIMEOUT = 30.0 # seconds
|
||||
_CHANNEL_DEFAULT_TTL = 300.0 # cache channel default alias for 5 minutes
|
||||
_MODELS_CACHE_TTL = 30.0 # cache model list for autocomplete
|
||||
|
||||
|
||||
class ChannelRouter:
|
||||
@@ -83,6 +86,13 @@ class ChannelRouter:
|
||||
timeout=_WS_CREATE_TIMEOUT,
|
||||
)
|
||||
|
||||
# Cached channel default alias (TTL-based).
|
||||
self._channel_default_alias: str = ""
|
||||
self._channel_default_ts: float = 0.0
|
||||
# Cached model list for autocomplete (shorter TTL).
|
||||
self._models_cache: dict[str, Any] = {}
|
||||
self._models_cache_ts: float = 0.0
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def aclose(self) -> None:
|
||||
@@ -93,6 +103,48 @@ class ChannelRouter:
|
||||
await self._console.aclose()
|
||||
log.info("channel_router.closed")
|
||||
|
||||
# -- model listing -------------------------------------------------------
|
||||
|
||||
async def list_models(self, *, cached: bool = False) -> dict[str, Any]:
|
||||
"""Fetch available model aliases and defaults from the server/console.
|
||||
|
||||
When *cached* is True, returns a TTL-cached result to avoid
|
||||
per-keystroke HTTP traffic during autocomplete.
|
||||
"""
|
||||
if cached:
|
||||
now = time.monotonic()
|
||||
if self._models_cache and (now - self._models_cache_ts) < _MODELS_CACHE_TTL:
|
||||
return self._models_cache
|
||||
|
||||
if self._console:
|
||||
resp: Any = await self._console.list_models()
|
||||
else:
|
||||
assert self._server is not None
|
||||
resp = await self._server.list_models()
|
||||
# SDK returns a Pydantic model; convert to dict for callers.
|
||||
data: dict[str, Any] = resp.model_dump() if hasattr(resp, "model_dump") else resp
|
||||
|
||||
# Update cache regardless of `cached` flag — a fresh fetch is
|
||||
# always worth caching for subsequent callers.
|
||||
self._models_cache = data
|
||||
self._models_cache_ts = time.monotonic()
|
||||
return data
|
||||
|
||||
async def get_channel_default_alias(self) -> str:
|
||||
"""Return the channel default model alias (cached with TTL)."""
|
||||
now = time.monotonic()
|
||||
if (now - self._channel_default_ts) < _CHANNEL_DEFAULT_TTL:
|
||||
return self._channel_default_alias
|
||||
# Mark refresh window before awaiting so concurrent callers
|
||||
# reuse the cached value instead of triggering duplicate fetches.
|
||||
self._channel_default_ts = now
|
||||
try:
|
||||
data = await self.list_models()
|
||||
self._channel_default_alias = data.get("channel_default_alias", "")
|
||||
except Exception:
|
||||
log.debug("channel_router.channel_default_fetch_failed", exc_info=True)
|
||||
return self._channel_default_alias
|
||||
|
||||
# -- internal helpers ----------------------------------------------------
|
||||
|
||||
async def _is_ws_alive(self, ws_id: str) -> bool:
|
||||
|
||||
@@ -13,6 +13,7 @@ from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
@@ -60,9 +61,25 @@ class MessageCog:
|
||||
await cog_self._cmd_unlink(interaction)
|
||||
|
||||
@app_commands.command(name="ask", description="Start a new Turnstone workstream")
|
||||
@app_commands.describe(message="Your message to the assistant")
|
||||
async def ask(self_cog: _Cog, interaction: discord.Interaction, message: str) -> None: # noqa: N805
|
||||
await cog_self._cmd_ask(interaction, message)
|
||||
@app_commands.describe(
|
||||
message="Your message to the assistant",
|
||||
model="Model alias (leave blank for default)",
|
||||
)
|
||||
async def ask(
|
||||
self_cog: _Cog, # noqa: N805
|
||||
interaction: discord.Interaction,
|
||||
message: str,
|
||||
model: str = "",
|
||||
) -> None:
|
||||
await cog_self._cmd_ask(interaction, message, model=model)
|
||||
|
||||
@ask.autocomplete("model")
|
||||
async def _model_autocomplete(
|
||||
self_cog: _Cog, # noqa: N805
|
||||
interaction: discord.Interaction,
|
||||
current: str,
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
return await cog_self._autocomplete_model(interaction, current)
|
||||
|
||||
@app_commands.command(name="status", description="Show workstream status")
|
||||
async def status(self_cog: _Cog, interaction: discord.Interaction) -> None: # noqa: N805
|
||||
@@ -187,11 +204,14 @@ class MessageCog:
|
||||
# first, then send the message. With SSE the event stream is
|
||||
# reliable once connected, but we still subscribe first for
|
||||
# consistency.
|
||||
mention_model = await self.ts.router.get_channel_default_alias()
|
||||
if not mention_model:
|
||||
mention_model = self.ts.config.model
|
||||
ws_id, _is_new = await self.ts.router.get_or_create_workstream(
|
||||
channel_type="discord",
|
||||
channel_id=str(thread.id),
|
||||
name=thread_name,
|
||||
model=self.ts.config.model,
|
||||
model=mention_model,
|
||||
initial_message="",
|
||||
client_type="chat",
|
||||
)
|
||||
@@ -331,7 +351,9 @@ class MessageCog:
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
async def _cmd_ask(self, interaction: discord.Interaction, message: str) -> None:
|
||||
async def _cmd_ask(
|
||||
self, interaction: discord.Interaction, message: str, *, model: str = ""
|
||||
) -> None:
|
||||
"""Create a new thread and workstream with an initial message."""
|
||||
import discord
|
||||
|
||||
@@ -366,11 +388,18 @@ class MessageCog:
|
||||
)
|
||||
return
|
||||
|
||||
# Resolve model: explicit > channel default > CLI --model > server default.
|
||||
effective_model = model
|
||||
if not effective_model:
|
||||
effective_model = await self.ts.router.get_channel_default_alias()
|
||||
if not effective_model:
|
||||
effective_model = self.ts.config.model
|
||||
|
||||
ws_id, _is_new = await self.ts.router.get_or_create_workstream(
|
||||
channel_type="discord",
|
||||
channel_id=str(thread.id),
|
||||
name=thread_name,
|
||||
model=self.ts.config.model,
|
||||
model=effective_model,
|
||||
initial_message="",
|
||||
client_type="chat",
|
||||
)
|
||||
@@ -389,6 +418,28 @@ class MessageCog:
|
||||
author=str(interaction.user),
|
||||
)
|
||||
|
||||
async def _autocomplete_model(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
"""Return model alias suggestions for the /ask autocomplete."""
|
||||
from discord import app_commands
|
||||
|
||||
try:
|
||||
data = await self.ts.router.list_models(cached=True)
|
||||
except Exception:
|
||||
return []
|
||||
choices: list[app_commands.Choice[str]] = []
|
||||
for m in data.get("models", []):
|
||||
alias = m.get("alias", "")
|
||||
if not alias:
|
||||
continue
|
||||
if current and current.lower() not in alias.lower():
|
||||
continue
|
||||
choices.append(app_commands.Choice(name=alias, value=alias))
|
||||
if len(choices) >= 25:
|
||||
break
|
||||
return choices
|
||||
|
||||
async def _cmd_status(self, interaction: discord.Interaction) -> None:
|
||||
"""Show workstream status for the current thread."""
|
||||
import discord
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -41,7 +41,13 @@ from turnstone.api.docs import make_docs_handler, make_openapi_handler
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.metrics import ConsoleMetrics
|
||||
from turnstone.console.router import ConsoleRouter
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, JWT_AUD_SERVER, AuthMiddleware, create_jwt
|
||||
from turnstone.core.auth import (
|
||||
JWT_AUD_CONSOLE,
|
||||
JWT_AUD_SERVER,
|
||||
AuthMiddleware,
|
||||
create_jwt,
|
||||
jwt_version_slot,
|
||||
)
|
||||
from turnstone.core.hash_ring import NoAvailableNodeError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -58,11 +64,17 @@ log = logging.getLogger("turnstone.console.server")
|
||||
_STATIC_DIR = Path(__file__).parent / "static"
|
||||
_SHARED_DIR = Path(__file__).parent.parent / "shared_static"
|
||||
_HTML = ""
|
||||
_HTML_ETAG = ""
|
||||
|
||||
|
||||
def _load_static() -> None:
|
||||
global _HTML
|
||||
_HTML = (_STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
import hashlib
|
||||
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
global _HTML, _HTML_ETAG
|
||||
_HTML = version_html((_STATIC_DIR / "index.html").read_text(encoding="utf-8"))
|
||||
_HTML_ETAG = '"' + hashlib.md5(_HTML.encode()).hexdigest()[:16] + '"' # noqa: S324
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -202,8 +214,13 @@ def _pick_best_node(collector: ClusterCollector) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(_HTML)
|
||||
async def index(request: Request) -> Response:
|
||||
if request.headers.get("If-None-Match") == _HTML_ETAG:
|
||||
return Response(status_code=304, headers={"ETag": _HTML_ETAG, "Cache-Control": "no-cache"})
|
||||
resp = HTMLResponse(_HTML)
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
resp.headers["ETag"] = _HTML_ETAG
|
||||
return resp
|
||||
|
||||
|
||||
async def cluster_overview(request: Request) -> JSONResponse:
|
||||
@@ -383,7 +400,26 @@ async def list_available_models(request: Request) -> JSONResponse:
|
||||
rows = storage.list_model_definitions(enabled_only=True)
|
||||
# Only expose alias/model/provider — rows also contain api_key, base_url, etc.
|
||||
models = [{"alias": r["alias"], "model": r["model"], "provider": r["provider"]} for r in rows]
|
||||
return JSONResponse({"models": models})
|
||||
|
||||
# Include effective defaults for clients (web UI, channel gateway).
|
||||
default_alias = ""
|
||||
channel_default_alias = ""
|
||||
cs = getattr(request.app.state, "config_store", None)
|
||||
if cs is not None:
|
||||
default_alias = cs.get("model.default_alias") or ""
|
||||
channel_default_alias = cs.get("channels.default_model_alias") or ""
|
||||
enabled_aliases = {r["alias"] for r in rows}
|
||||
if default_alias and default_alias not in enabled_aliases:
|
||||
default_alias = ""
|
||||
if channel_default_alias and channel_default_alias not in enabled_aliases:
|
||||
channel_default_alias = ""
|
||||
return JSONResponse(
|
||||
{
|
||||
"models": models,
|
||||
"default_alias": default_alias,
|
||||
"channel_default_alias": channel_default_alias,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -889,7 +925,9 @@ async def proxy_index(request: Request) -> Response:
|
||||
+ "</script>"
|
||||
)
|
||||
page = page.replace("<body>", "<body>" + banner + _CONSOLE_PROXY_STYLE + shim, 1)
|
||||
return HTMLResponse(page)
|
||||
html_resp = HTMLResponse(page)
|
||||
html_resp.headers["Cache-Control"] = "no-cache"
|
||||
return html_resp
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("Proxy index error for %s: %s", node_id, exc)
|
||||
return JSONResponse({"error": "Node unreachable"}, status_code=502)
|
||||
@@ -1699,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
|
||||
|
||||
|
||||
@@ -1795,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:
|
||||
@@ -1836,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:
|
||||
@@ -1917,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"])
|
||||
@@ -6402,8 +6473,17 @@ async def admin_list_output_guard_patterns(request: Request) -> JSONResponse:
|
||||
result.append(entry)
|
||||
|
||||
# Add built-ins not overridden in DB
|
||||
import re as _re
|
||||
|
||||
_flags_reverse = {
|
||||
_re.IGNORECASE: "IGNORECASE",
|
||||
_re.MULTILINE: "MULTILINE",
|
||||
_re.DOTALL: "DOTALL",
|
||||
}
|
||||
for pat in _BUILTIN_OG_PATTERNS:
|
||||
if pat.name not in seen_names:
|
||||
# Derive pattern_flags from compiled regex so overrides preserve them
|
||||
pf = ",".join(n for f, n in _flags_reverse.items() if pat.compiled.flags & f)
|
||||
result.append(
|
||||
{
|
||||
"pattern_id": "",
|
||||
@@ -6411,7 +6491,7 @@ async def admin_list_output_guard_patterns(request: Request) -> JSONResponse:
|
||||
"category": pat.category,
|
||||
"risk_level": pat.risk_level,
|
||||
"pattern": pat.compiled.pattern,
|
||||
"pattern_flags": "",
|
||||
"pattern_flags": pf,
|
||||
"flag_name": pat.flag_name,
|
||||
"annotation": pat.annotation,
|
||||
"is_credential": pat.is_credential,
|
||||
@@ -7421,7 +7501,9 @@ def _build_console_middleware(cors_origins: list[str] | None = None) -> list[Mid
|
||||
from turnstone.core.web_helpers import cors_middleware
|
||||
|
||||
stack.append(cors_middleware(cors_origins))
|
||||
stack.append(Middleware(AuthMiddleware, jwt_audience=JWT_AUD_CONSOLE))
|
||||
stack.append(
|
||||
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_CONSOLE, jwt_version=jwt_version_slot())
|
||||
)
|
||||
return stack
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -1910,7 +2084,9 @@ function _installTrap(overlayId, boxId, trapRef) {
|
||||
else if (overlayId === "edit-ppolicy-overlay")
|
||||
hideEditPromptPolicyModal();
|
||||
else if (overlayId === "create-hr-overlay") hideCreateHRModal();
|
||||
else if (overlayId === "edit-hr-overlay") hideEditHRModal();
|
||||
else if (overlayId === "create-ogp-overlay") hideCreateOGPModal();
|
||||
else if (overlayId === "edit-ogp-overlay") hideEditOGPModal();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2003,7 +2179,9 @@ document.addEventListener("keydown", function (e) {
|
||||
["create-ppolicy-overlay", hideCreatePromptPolicyModal],
|
||||
["edit-ppolicy-overlay", hideEditPromptPolicyModal],
|
||||
["create-hr-overlay", hideCreateHRModal],
|
||||
["edit-hr-overlay", hideEditHRModal],
|
||||
["create-ogp-overlay", hideCreateOGPModal],
|
||||
["edit-ogp-overlay", hideEditOGPModal],
|
||||
];
|
||||
for (var gi = 0; gi < govOverlays.length; gi++) {
|
||||
var govEl = document.getElementById(govOverlays[gi][0]);
|
||||
@@ -2135,6 +2313,7 @@ var _settingsSectionOrder = [
|
||||
"tools",
|
||||
"server",
|
||||
"cluster",
|
||||
"channels",
|
||||
"mcp",
|
||||
"ratelimit",
|
||||
"health",
|
||||
@@ -2150,6 +2329,7 @@ function _settingsSectionLabel(section) {
|
||||
tools: "Tools",
|
||||
server: "Server",
|
||||
cluster: "Cluster",
|
||||
channels: "Channels",
|
||||
mcp: "MCP",
|
||||
ratelimit: "Rate Limiting",
|
||||
health: "Health",
|
||||
@@ -2347,10 +2527,15 @@ function loadSettings() {
|
||||
if (!r.ok) throw new Error("Failed to load schema");
|
||||
return r.json();
|
||||
}),
|
||||
authFetch("/v1/api/admin/model-definitions").then(function (r) {
|
||||
if (!r.ok) return { models: [] };
|
||||
return r.json();
|
||||
}),
|
||||
])
|
||||
.then(function (results) {
|
||||
var valuesArr = results[0].settings || [];
|
||||
var schemaArr = results[1].schema || [];
|
||||
var modelDefs = results[2].models || [];
|
||||
|
||||
// Build schema lookup
|
||||
var schemaMap = {};
|
||||
@@ -2384,6 +2569,20 @@ function loadSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
// Inject dynamic choices for model alias settings from model definitions.
|
||||
var enabledAliases = [""];
|
||||
for (var m = 0; m < modelDefs.length; m++) {
|
||||
if (modelDefs[m].enabled) enabledAliases.push(modelDefs[m].alias);
|
||||
}
|
||||
if (enabledAliases.length > 1) {
|
||||
if (merged["model.default_alias"]) {
|
||||
merged["model.default_alias"].choices = enabledAliases;
|
||||
}
|
||||
if (merged["channels.default_model_alias"]) {
|
||||
merged["channels.default_model_alias"].choices = enabledAliases;
|
||||
}
|
||||
}
|
||||
|
||||
_settingsOriginal = {};
|
||||
|
||||
// Group by section
|
||||
@@ -2399,6 +2598,7 @@ function loadSettings() {
|
||||
_renderSettings(el, grouped);
|
||||
})
|
||||
.catch(function (err) {
|
||||
// NOTE: escapeHtml sanitises err.message before insertion.
|
||||
el.innerHTML =
|
||||
'<div class="dashboard-empty">Failed to load settings: ' +
|
||||
escapeHtml(err.message || String(err)) +
|
||||
@@ -2519,9 +2719,15 @@ function _renderSettingRow(item) {
|
||||
html += '<div class="settings-input">';
|
||||
if (item.is_secret) {
|
||||
html +=
|
||||
'<span class="settings-secret" role="note" aria-label="' +
|
||||
'<input type="password" data-setting-key="' +
|
||||
escapedKey +
|
||||
'" aria-label="Secret value for ' +
|
||||
escapedShort +
|
||||
': managed via config file or environment variable">(managed via config file / env)</span>';
|
||||
'" autocomplete="off" value="" placeholder="' +
|
||||
(item.source === "storage" ? "***" : "not set") +
|
||||
'" oninput="_onSettingChange(\'' +
|
||||
escapedKey +
|
||||
"')\">";
|
||||
} else if (item.type === "bool") {
|
||||
var checked =
|
||||
item.value === true || item.value === "true" ? " checked" : "";
|
||||
@@ -2546,8 +2752,17 @@ function _renderSettingRow(item) {
|
||||
"')\">";
|
||||
for (var c = 0; c < item.choices.length; c++) {
|
||||
var sel = item.choices[c] === String(item.value) ? " selected" : "";
|
||||
var label =
|
||||
item.choices[c] === "" ? "(none)" : escapeHtml(item.choices[c]);
|
||||
var label;
|
||||
if (item.choices[c] !== "") {
|
||||
label = escapeHtml(item.choices[c]);
|
||||
} else if (
|
||||
item.key === "model.default_alias" ||
|
||||
item.key === "channels.default_model_alias"
|
||||
) {
|
||||
label = "(server default)";
|
||||
} else {
|
||||
label = "(none)";
|
||||
}
|
||||
html +=
|
||||
'<option value="' +
|
||||
escapeHtml(item.choices[c]) +
|
||||
@@ -2617,14 +2832,12 @@ function _renderSettingRow(item) {
|
||||
}
|
||||
|
||||
// Save button (hidden until value changes)
|
||||
if (!item.is_secret) {
|
||||
html +=
|
||||
'<button class="settings-save-btn" data-save-key="' +
|
||||
escapedKey +
|
||||
'" onclick="_saveSettingValue(\'' +
|
||||
escapedKey +
|
||||
"')\">save</button>";
|
||||
}
|
||||
html +=
|
||||
'<button class="settings-save-btn" data-save-key="' +
|
||||
escapedKey +
|
||||
'" onclick="_saveSettingValue(\'' +
|
||||
escapedKey +
|
||||
"')\">save</button>";
|
||||
|
||||
// Reset link (when stored — including secrets, to clear legacy overrides)
|
||||
if (item.source === "storage") {
|
||||
@@ -2738,6 +2951,13 @@ function _saveSettingValue(key) {
|
||||
return;
|
||||
}
|
||||
value = Number(inp.value);
|
||||
} else if (inp.type === "password") {
|
||||
if (inp.value === "") {
|
||||
// Nothing to save — user didn't enter a value.
|
||||
if (saveBtn) saveBtn.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
value = inp.value;
|
||||
} else {
|
||||
value = inp.value;
|
||||
}
|
||||
@@ -2763,6 +2983,11 @@ function _saveSettingValue(key) {
|
||||
// Update original so dirty detection resets
|
||||
if (inp.type === "checkbox") {
|
||||
_settingsOriginal[key] = inp.checked;
|
||||
} else if (inp.type === "password") {
|
||||
// Clear the field after save; show "***" placeholder.
|
||||
inp.value = "";
|
||||
inp.placeholder = "***";
|
||||
_settingsOriginal[key] = "";
|
||||
} else {
|
||||
_settingsOriginal[key] = inp.value;
|
||||
}
|
||||
@@ -2806,7 +3031,10 @@ function _saveSettingValue(key) {
|
||||
}
|
||||
|
||||
// Brief row flash for visual feedback
|
||||
if (row) {
|
||||
if (
|
||||
row &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
row.style.background = "var(--accent-glow)";
|
||||
setTimeout(function () {
|
||||
row.style.background = "";
|
||||
|
||||
@@ -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;
|
||||
@@ -2741,6 +2781,10 @@ var _chrTrapHandler = null; // create heuristic rule
|
||||
var _cogpTrapHandler = null; // create output guard pattern
|
||||
var _chrTriggerEl = null;
|
||||
var _cogpTriggerEl = null;
|
||||
var _ehrTrapHandler = null; // edit heuristic rule
|
||||
var _eogpTrapHandler = null; // edit output guard pattern
|
||||
var _ehrTriggerEl = null;
|
||||
var _eogpTriggerEl = null;
|
||||
|
||||
// -- Sub-section switcher ---------------------------------------------------
|
||||
|
||||
@@ -3033,34 +3077,80 @@ function renderHeuristicRules() {
|
||||
r.source === "builtin"
|
||||
? '<span class="scope-badge">built-in</span>'
|
||||
: r.source === "builtin-overridden"
|
||||
? '<span class="scope-badge scope-scan-safe">overridden</span>'
|
||||
? '<span class="scope-badge scope-channel">modified</span>'
|
||||
: r.source === "builtin-disabled"
|
||||
? '<span class="scope-badge scope-deny">disabled</span>'
|
||||
? '<span class="scope-badge">built-in</span>'
|
||||
: '<span class="scope-badge scope-write">custom</span>';
|
||||
var statusBadge = r.enabled
|
||||
? '<span class="scope-badge scope-scan-safe">active</span>'
|
||||
: '<span class="scope-badge scope-deny">disabled</span>';
|
||||
// Note: all dynamic values are escaped via escapeHtml() — safe for innerHTML
|
||||
var actions = "";
|
||||
if (r.rule_id) {
|
||||
var eName = escapeHtml(r.name);
|
||||
if (!r.rule_id) {
|
||||
// Pure built-in: Disable + Edit
|
||||
actions =
|
||||
'<button class="admin-btn-action" onclick="toggleHeuristicRule(\'' +
|
||||
'<button class="admin-btn-action" data-disable-builtin-hr="' +
|
||||
eName +
|
||||
'" aria-label="Disable ' +
|
||||
eName +
|
||||
'">Disable</button> ' +
|
||||
'<button class="admin-btn-action" data-edit-hr-builtin="' +
|
||||
eName +
|
||||
'" aria-label="Edit ' +
|
||||
eName +
|
||||
'">Edit</button>';
|
||||
} else if (r.builtin) {
|
||||
// Overridden or disabled built-in: Enable/Disable + Edit + Reset
|
||||
actions =
|
||||
'<button class="admin-btn-action" data-toggle-hr="' +
|
||||
r.rule_id +
|
||||
"\'," +
|
||||
'" data-enabled="' +
|
||||
!r.enabled +
|
||||
')">' +
|
||||
'" aria-label="' +
|
||||
(r.enabled ? "Disable" : "Enable") +
|
||||
" " +
|
||||
eName +
|
||||
'">' +
|
||||
(r.enabled ? "Disable" : "Enable") +
|
||||
"</button> " +
|
||||
'<button class="admin-btn-danger" onclick="deleteHeuristicRule(\'' +
|
||||
'<button class="admin-btn-action" data-edit-hr="' +
|
||||
r.rule_id +
|
||||
"')\">Delete</button>";
|
||||
'" aria-label="Edit ' +
|
||||
eName +
|
||||
'">Edit</button> ' +
|
||||
'<button class="admin-btn-caution" data-reset-hr="' +
|
||||
r.rule_id +
|
||||
'" aria-label="Reset ' +
|
||||
eName +
|
||||
'">Reset</button>';
|
||||
} else {
|
||||
// Custom rule: Enable/Disable + Edit + Delete
|
||||
actions =
|
||||
'<button class="admin-btn-action" onclick="overrideBuiltinHeuristicRule(\'' +
|
||||
escapeHtml(r.name) +
|
||||
"')\">Customize</button>";
|
||||
'<button class="admin-btn-action" data-toggle-hr="' +
|
||||
r.rule_id +
|
||||
'" data-enabled="' +
|
||||
!r.enabled +
|
||||
'" aria-label="' +
|
||||
(r.enabled ? "Disable" : "Enable") +
|
||||
" " +
|
||||
eName +
|
||||
'">' +
|
||||
(r.enabled ? "Disable" : "Enable") +
|
||||
"</button> " +
|
||||
'<button class="admin-btn-action" data-edit-hr="' +
|
||||
r.rule_id +
|
||||
'" aria-label="Edit ' +
|
||||
eName +
|
||||
'">Edit</button> ' +
|
||||
'<button class="admin-btn-danger" data-delete-hr="' +
|
||||
r.rule_id +
|
||||
'" aria-label="Delete ' +
|
||||
eName +
|
||||
'">Delete</button>';
|
||||
}
|
||||
html +=
|
||||
'<div class="admin-row">' +
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
'<span class="admin-col"><code>' +
|
||||
escapeHtml(r.name) +
|
||||
"</code></span>" +
|
||||
@@ -3087,6 +3177,42 @@ function renderHeuristicRules() {
|
||||
"</span></div>";
|
||||
}
|
||||
c.innerHTML = html;
|
||||
// Bind data-attribute event handlers
|
||||
c.querySelectorAll("[data-disable-builtin-hr]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
disableBuiltinHeuristicRule(this.getAttribute("data-disable-builtin-hr"));
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-toggle-hr]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
toggleHeuristicRule(
|
||||
this.getAttribute("data-toggle-hr"),
|
||||
this.getAttribute("data-enabled") === "true",
|
||||
);
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-edit-hr-builtin]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
showEditBuiltinHeuristicRuleModal(
|
||||
this.getAttribute("data-edit-hr-builtin"),
|
||||
);
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-edit-hr]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
showEditHeuristicRuleModal(this.getAttribute("data-edit-hr"));
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-reset-hr]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
resetHeuristicRule(this.getAttribute("data-reset-hr"));
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-delete-hr]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
deleteHeuristicRule(this.getAttribute("data-delete-hr"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleHeuristicRule(ruleId, enabled) {
|
||||
@@ -3112,9 +3238,16 @@ function toggleHeuristicRule(ruleId, enabled) {
|
||||
}
|
||||
|
||||
function deleteHeuristicRule(ruleId) {
|
||||
var ruleName = "";
|
||||
for (var j = 0; j < _judgeHeuristicRules.length; j++) {
|
||||
if (_judgeHeuristicRules[j].rule_id === ruleId) {
|
||||
ruleName = _judgeHeuristicRules[j].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
showConfirmModal(
|
||||
"Delete Rule",
|
||||
"Delete this heuristic rule? This action cannot be undone.",
|
||||
'Delete custom rule "' + ruleName + '"? This action cannot be undone.',
|
||||
"Delete",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/judge/heuristic-rules/" + ruleId, {
|
||||
@@ -3138,52 +3271,6 @@ function deleteHeuristicRule(ruleId) {
|
||||
);
|
||||
}
|
||||
|
||||
function overrideBuiltinHeuristicRule(name) {
|
||||
// Find the built-in rule data
|
||||
var rule = null;
|
||||
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
||||
if (_judgeHeuristicRules[i].name === name) {
|
||||
rule = _judgeHeuristicRules[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rule) return;
|
||||
// Create a DB copy marked as builtin override, initially disabled
|
||||
var payload = {
|
||||
name: rule.name,
|
||||
risk_level: rule.risk_level,
|
||||
confidence: rule.confidence,
|
||||
recommendation: rule.recommendation,
|
||||
tool_pattern: rule.tool_pattern,
|
||||
arg_patterns: rule.arg_patterns,
|
||||
intent_template: rule.intent_template || "",
|
||||
reasoning_template: rule.reasoning_template || "",
|
||||
tier: rule.tier || rule.risk_level,
|
||||
priority: rule.priority || 0,
|
||||
builtin: true,
|
||||
enabled: false,
|
||||
};
|
||||
authFetch("/v1/api/admin/judge/heuristic-rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Built-in rule overridden (disabled)");
|
||||
loadJudgeHeuristicRules();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast("Error: " + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateHeuristicRuleModal() {
|
||||
_chrTriggerEl = document.activeElement;
|
||||
var ov = document.getElementById("create-hr-overlay");
|
||||
@@ -3259,6 +3346,222 @@ function submitCreateHeuristicRule() {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Heuristic Rule: disable / edit / reset ---------------------------------
|
||||
|
||||
function disableBuiltinHeuristicRule(name) {
|
||||
var rule = null;
|
||||
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
||||
if (_judgeHeuristicRules[i].name === name) {
|
||||
rule = _judgeHeuristicRules[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rule) return;
|
||||
var payload = {
|
||||
name: rule.name,
|
||||
risk_level: rule.risk_level,
|
||||
confidence: rule.confidence,
|
||||
recommendation: rule.recommendation,
|
||||
tool_pattern: rule.tool_pattern,
|
||||
arg_patterns: rule.arg_patterns,
|
||||
intent_template: rule.intent_template || "",
|
||||
reasoning_template: rule.reasoning_template || "",
|
||||
tier: rule.tier || rule.risk_level,
|
||||
priority: rule.priority || 0,
|
||||
builtin: true,
|
||||
enabled: false,
|
||||
};
|
||||
authFetch("/v1/api/admin/judge/heuristic-rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Built-in rule disabled \u2014 Reset to restore defaults");
|
||||
loadJudgeHeuristicRules();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast("Error: " + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function resetHeuristicRule(ruleId) {
|
||||
var ruleName = "";
|
||||
for (var j = 0; j < _judgeHeuristicRules.length; j++) {
|
||||
if (_judgeHeuristicRules[j].rule_id === ruleId) {
|
||||
ruleName = _judgeHeuristicRules[j].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
showConfirmModal(
|
||||
"Reset to Built-in",
|
||||
'Reset "' +
|
||||
ruleName +
|
||||
'" to its built-in defaults? Your customizations will be removed.',
|
||||
"Reset",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/judge/heuristic-rules/" + ruleId, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Rule reset to built-in defaults");
|
||||
loadJudgeHeuristicRules();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast("Error: " + e.message);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function _populateEditHRModal(rule, isBuiltin) {
|
||||
document.getElementById("ehr-id").value = rule.rule_id || "";
|
||||
document.getElementById("ehr-builtin").value = isBuiltin ? "true" : "false";
|
||||
document.getElementById("ehr-priority").value = rule.priority || 0;
|
||||
document.getElementById("ehr-name").value = rule.name;
|
||||
document.getElementById("ehr-name").disabled = isBuiltin;
|
||||
document.getElementById("ehr-tier").value = rule.tier || rule.risk_level;
|
||||
document.getElementById("ehr-risk").value = rule.risk_level;
|
||||
document.getElementById("ehr-rec").value = rule.recommendation;
|
||||
document.getElementById("ehr-tool").value = rule.tool_pattern;
|
||||
// arg_patterns comes as JSON string from API
|
||||
var args = rule.arg_patterns || "[]";
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
args = [];
|
||||
}
|
||||
}
|
||||
document.getElementById("ehr-args").value = args.join("\n");
|
||||
document.getElementById("ehr-conf").value = rule.confidence;
|
||||
document.getElementById("ehr-intent").value = rule.intent_template || "";
|
||||
document.getElementById("ehr-reason").value = rule.reasoning_template || "";
|
||||
document.getElementById("edit-hr-error").style.display = "none";
|
||||
document.getElementById("ehr-submit").disabled = false;
|
||||
}
|
||||
|
||||
function showEditHeuristicRuleModal(ruleId) {
|
||||
_ehrTriggerEl = document.activeElement;
|
||||
var rule = null;
|
||||
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
||||
if (_judgeHeuristicRules[i].rule_id === ruleId) {
|
||||
rule = _judgeHeuristicRules[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rule) return;
|
||||
_populateEditHRModal(rule, !!rule.builtin);
|
||||
var ov = document.getElementById("edit-hr-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("ehr-tier").focus();
|
||||
_ehrTrapHandler = _installTrap("edit-hr-overlay", "edit-hr-box");
|
||||
}
|
||||
|
||||
function showEditBuiltinHeuristicRuleModal(name) {
|
||||
_ehrTriggerEl = document.activeElement;
|
||||
var rule = null;
|
||||
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
||||
if (
|
||||
_judgeHeuristicRules[i].name === name &&
|
||||
!_judgeHeuristicRules[i].rule_id
|
||||
) {
|
||||
rule = _judgeHeuristicRules[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rule) return;
|
||||
_populateEditHRModal(rule, true);
|
||||
var ov = document.getElementById("edit-hr-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("ehr-tier").focus();
|
||||
_ehrTrapHandler = _installTrap("edit-hr-overlay", "edit-hr-box");
|
||||
}
|
||||
|
||||
function hideEditHRModal() {
|
||||
document.getElementById("edit-hr-overlay").style.display = "none";
|
||||
_ehrTrapHandler = _removeTrap(_ehrTrapHandler);
|
||||
if (_ehrTriggerEl && _ehrTriggerEl.focus) _ehrTriggerEl.focus();
|
||||
_ehrTriggerEl = null;
|
||||
}
|
||||
|
||||
function submitEditHeuristicRule() {
|
||||
var errEl = document.getElementById("edit-hr-error");
|
||||
errEl.style.display = "none";
|
||||
var argsText = document.getElementById("ehr-args").value.trim();
|
||||
var argPatterns = argsText
|
||||
? argsText.split("\n").filter(function (l) {
|
||||
return l.trim();
|
||||
})
|
||||
: [];
|
||||
var ruleId = document.getElementById("ehr-id").value;
|
||||
var payload = {
|
||||
name: document.getElementById("ehr-name").value.trim(),
|
||||
tier: document.getElementById("ehr-tier").value,
|
||||
risk_level: document.getElementById("ehr-risk").value,
|
||||
recommendation: document.getElementById("ehr-rec").value,
|
||||
tool_pattern: document.getElementById("ehr-tool").value.trim(),
|
||||
arg_patterns: argPatterns,
|
||||
confidence: parseFloat(document.getElementById("ehr-conf").value) || 0.8,
|
||||
intent_template: document.getElementById("ehr-intent").value.trim(),
|
||||
reasoning_template: document.getElementById("ehr-reason").value.trim(),
|
||||
priority: parseInt(document.getElementById("ehr-priority").value, 10) || 0,
|
||||
};
|
||||
var btn = document.getElementById("ehr-submit");
|
||||
btn.disabled = true;
|
||||
|
||||
var url, method;
|
||||
if (ruleId) {
|
||||
// Existing DB row — update in place
|
||||
url = "/v1/api/admin/judge/heuristic-rules/" + ruleId;
|
||||
method = "PUT";
|
||||
} else {
|
||||
// Pure built-in first edit — create override
|
||||
url = "/v1/api/admin/judge/heuristic-rules";
|
||||
method = "POST";
|
||||
payload.builtin = true;
|
||||
payload.enabled = true;
|
||||
}
|
||||
authFetch(url, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
hideEditHRModal();
|
||||
showToast(ruleId ? "Rule updated" : "Rule overridden");
|
||||
loadJudgeHeuristicRules();
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// -- Output Guard Patterns section ------------------------------------------
|
||||
|
||||
function loadJudgeOGPatterns() {
|
||||
@@ -3290,34 +3593,80 @@ function renderOGPatterns() {
|
||||
p.source === "builtin"
|
||||
? '<span class="scope-badge">built-in</span>'
|
||||
: p.source === "builtin-overridden"
|
||||
? '<span class="scope-badge scope-scan-safe">overridden</span>'
|
||||
? '<span class="scope-badge scope-channel">modified</span>'
|
||||
: p.source === "builtin-disabled"
|
||||
? '<span class="scope-badge scope-deny">disabled</span>'
|
||||
? '<span class="scope-badge">built-in</span>'
|
||||
: '<span class="scope-badge scope-write">custom</span>';
|
||||
var statusBadge = p.enabled
|
||||
? '<span class="scope-badge scope-scan-safe">active</span>'
|
||||
: '<span class="scope-badge scope-deny">disabled</span>';
|
||||
// Note: all dynamic values are escaped via escapeHtml() — safe for innerHTML
|
||||
var actions = "";
|
||||
if (p.pattern_id) {
|
||||
var eName = escapeHtml(p.name);
|
||||
if (!p.pattern_id) {
|
||||
// Pure built-in: Disable + Edit
|
||||
actions =
|
||||
'<button class="admin-btn-action" onclick="toggleOGPattern(\'' +
|
||||
'<button class="admin-btn-action" data-disable-builtin-ogp="' +
|
||||
eName +
|
||||
'" aria-label="Disable ' +
|
||||
eName +
|
||||
'">Disable</button> ' +
|
||||
'<button class="admin-btn-action" data-edit-ogp-builtin="' +
|
||||
eName +
|
||||
'" aria-label="Edit ' +
|
||||
eName +
|
||||
'">Edit</button>';
|
||||
} else if (p.builtin) {
|
||||
// Overridden or disabled built-in: Enable/Disable + Edit + Reset
|
||||
actions =
|
||||
'<button class="admin-btn-action" data-toggle-ogp="' +
|
||||
p.pattern_id +
|
||||
"\'," +
|
||||
'" data-enabled="' +
|
||||
!p.enabled +
|
||||
')">' +
|
||||
'" aria-label="' +
|
||||
(p.enabled ? "Disable" : "Enable") +
|
||||
" " +
|
||||
eName +
|
||||
'">' +
|
||||
(p.enabled ? "Disable" : "Enable") +
|
||||
"</button> " +
|
||||
'<button class="admin-btn-danger" onclick="deleteOGPattern(\'' +
|
||||
'<button class="admin-btn-action" data-edit-ogp="' +
|
||||
p.pattern_id +
|
||||
"')\">Delete</button>";
|
||||
'" aria-label="Edit ' +
|
||||
eName +
|
||||
'">Edit</button> ' +
|
||||
'<button class="admin-btn-caution" data-reset-ogp="' +
|
||||
p.pattern_id +
|
||||
'" aria-label="Reset ' +
|
||||
eName +
|
||||
'">Reset</button>';
|
||||
} else {
|
||||
// Custom rule: Enable/Disable + Edit + Delete
|
||||
actions =
|
||||
'<button class="admin-btn-action" onclick="overrideBuiltinOGPattern(\'' +
|
||||
escapeHtml(p.name) +
|
||||
"')\">Customize</button>";
|
||||
'<button class="admin-btn-action" data-toggle-ogp="' +
|
||||
p.pattern_id +
|
||||
'" data-enabled="' +
|
||||
!p.enabled +
|
||||
'" aria-label="' +
|
||||
(p.enabled ? "Disable" : "Enable") +
|
||||
" " +
|
||||
eName +
|
||||
'">' +
|
||||
(p.enabled ? "Disable" : "Enable") +
|
||||
"</button> " +
|
||||
'<button class="admin-btn-action" data-edit-ogp="' +
|
||||
p.pattern_id +
|
||||
'" aria-label="Edit ' +
|
||||
eName +
|
||||
'">Edit</button> ' +
|
||||
'<button class="admin-btn-danger" data-delete-ogp="' +
|
||||
p.pattern_id +
|
||||
'" aria-label="Delete ' +
|
||||
eName +
|
||||
'">Delete</button>';
|
||||
}
|
||||
html +=
|
||||
'<div class="admin-row">' +
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
'<span class="admin-col"><code>' +
|
||||
escapeHtml(p.name) +
|
||||
"</code></span>" +
|
||||
@@ -3341,6 +3690,40 @@ function renderOGPatterns() {
|
||||
"</span></div>";
|
||||
}
|
||||
c.innerHTML = html;
|
||||
// Bind data-attribute event handlers
|
||||
c.querySelectorAll("[data-disable-builtin-ogp]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
disableBuiltinOGPattern(this.getAttribute("data-disable-builtin-ogp"));
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-toggle-ogp]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
toggleOGPattern(
|
||||
this.getAttribute("data-toggle-ogp"),
|
||||
this.getAttribute("data-enabled") === "true",
|
||||
);
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-edit-ogp-builtin]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
showEditBuiltinOGPatternModal(this.getAttribute("data-edit-ogp-builtin"));
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-edit-ogp]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
showEditOGPatternModal(this.getAttribute("data-edit-ogp"));
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-reset-ogp]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
resetOGPattern(this.getAttribute("data-reset-ogp"));
|
||||
});
|
||||
});
|
||||
c.querySelectorAll("[data-delete-ogp]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
deleteOGPattern(this.getAttribute("data-delete-ogp"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOGPattern(patternId, enabled) {
|
||||
@@ -3366,9 +3749,16 @@ function toggleOGPattern(patternId, enabled) {
|
||||
}
|
||||
|
||||
function deleteOGPattern(patternId) {
|
||||
var patName = "";
|
||||
for (var j = 0; j < _judgeOGPatterns.length; j++) {
|
||||
if (_judgeOGPatterns[j].pattern_id === patternId) {
|
||||
patName = _judgeOGPatterns[j].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
showConfirmModal(
|
||||
"Delete Pattern",
|
||||
"Delete this output guard pattern? This action cannot be undone.",
|
||||
'Delete custom pattern "' + patName + '"? This action cannot be undone.',
|
||||
"Delete",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/judge/output-guard-patterns/" + patternId, {
|
||||
@@ -3392,50 +3782,6 @@ function deleteOGPattern(patternId) {
|
||||
);
|
||||
}
|
||||
|
||||
function overrideBuiltinOGPattern(name) {
|
||||
var pat = null;
|
||||
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
||||
if (_judgeOGPatterns[i].name === name) {
|
||||
pat = _judgeOGPatterns[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pat) return;
|
||||
var payload = {
|
||||
name: pat.name,
|
||||
category: pat.category,
|
||||
risk_level: pat.risk_level,
|
||||
pattern: pat.pattern || "",
|
||||
flag_name: pat.flag_name,
|
||||
annotation: pat.annotation || "",
|
||||
pattern_flags: pat.pattern_flags || "",
|
||||
is_credential: pat.is_credential || false,
|
||||
redact_label: pat.redact_label || "",
|
||||
priority: pat.priority || 0,
|
||||
builtin: true,
|
||||
enabled: false,
|
||||
};
|
||||
authFetch("/v1/api/admin/judge/output-guard-patterns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Built-in pattern overridden (disabled)");
|
||||
loadJudgeOGPatterns();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast("Error: " + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateOutputGuardPatternModal() {
|
||||
_cogpTriggerEl = document.activeElement;
|
||||
var ov = document.getElementById("create-ogp-overlay");
|
||||
@@ -3536,3 +3882,232 @@ function submitCreateOGPattern() {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// -- Output Guard Pattern: disable / edit / reset ---------------------------
|
||||
|
||||
function disableBuiltinOGPattern(name) {
|
||||
var pat = null;
|
||||
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
||||
if (_judgeOGPatterns[i].name === name) {
|
||||
pat = _judgeOGPatterns[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pat) return;
|
||||
var payload = {
|
||||
name: pat.name,
|
||||
category: pat.category,
|
||||
risk_level: pat.risk_level,
|
||||
pattern: pat.pattern || "",
|
||||
flag_name: pat.flag_name,
|
||||
annotation: pat.annotation || "",
|
||||
pattern_flags: pat.pattern_flags || "",
|
||||
is_credential: pat.is_credential || false,
|
||||
redact_label: pat.redact_label || "",
|
||||
priority: pat.priority || 0,
|
||||
builtin: true,
|
||||
enabled: false,
|
||||
};
|
||||
authFetch("/v1/api/admin/judge/output-guard-patterns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Built-in pattern disabled \u2014 Reset to restore defaults");
|
||||
loadJudgeOGPatterns();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast("Error: " + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function resetOGPattern(patternId) {
|
||||
var patName = "";
|
||||
for (var j = 0; j < _judgeOGPatterns.length; j++) {
|
||||
if (_judgeOGPatterns[j].pattern_id === patternId) {
|
||||
patName = _judgeOGPatterns[j].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
showConfirmModal(
|
||||
"Reset to Built-in",
|
||||
'Reset "' +
|
||||
patName +
|
||||
'" to its built-in defaults? Your customizations will be removed.',
|
||||
"Reset",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/judge/output-guard-patterns/" + patternId, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Pattern reset to built-in defaults");
|
||||
loadJudgeOGPatterns();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast("Error: " + e.message);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function _populateEditOGPModal(pat, isBuiltin) {
|
||||
document.getElementById("eogp-id").value = pat.pattern_id || "";
|
||||
document.getElementById("eogp-builtin").value = isBuiltin ? "true" : "false";
|
||||
document.getElementById("eogp-priority").value = pat.priority || 0;
|
||||
document.getElementById("eogp-name").value = pat.name;
|
||||
document.getElementById("eogp-name").disabled = isBuiltin;
|
||||
document.getElementById("eogp-cat").value = pat.category;
|
||||
document.getElementById("eogp-risk").value = pat.risk_level;
|
||||
document.getElementById("eogp-pattern").value = pat.pattern || "";
|
||||
document.getElementById("eogp-flag").value = pat.flag_name || "";
|
||||
document.getElementById("eogp-flag").disabled = isBuiltin;
|
||||
document.getElementById("eogp-ann").value = pat.annotation || "";
|
||||
document.getElementById("eogp-flags").value = pat.pattern_flags || "";
|
||||
document.getElementById("eogp-cred").checked = !!pat.is_credential;
|
||||
document.getElementById("eogp-redact").value = pat.redact_label || "";
|
||||
document.getElementById("eogp-regex-result").textContent = "";
|
||||
document.getElementById("edit-ogp-error").style.display = "none";
|
||||
document.getElementById("eogp-submit").disabled = false;
|
||||
}
|
||||
|
||||
function showEditOGPatternModal(patternId) {
|
||||
_eogpTriggerEl = document.activeElement;
|
||||
var pat = null;
|
||||
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
||||
if (_judgeOGPatterns[i].pattern_id === patternId) {
|
||||
pat = _judgeOGPatterns[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pat) return;
|
||||
_populateEditOGPModal(pat, !!pat.builtin);
|
||||
var ov = document.getElementById("edit-ogp-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("eogp-cat").focus();
|
||||
_eogpTrapHandler = _installTrap("edit-ogp-overlay", "edit-ogp-box");
|
||||
}
|
||||
|
||||
function showEditBuiltinOGPatternModal(name) {
|
||||
_eogpTriggerEl = document.activeElement;
|
||||
var pat = null;
|
||||
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
||||
if (_judgeOGPatterns[i].name === name && !_judgeOGPatterns[i].pattern_id) {
|
||||
pat = _judgeOGPatterns[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pat) return;
|
||||
_populateEditOGPModal(pat, true);
|
||||
var ov = document.getElementById("edit-ogp-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("eogp-cat").focus();
|
||||
_eogpTrapHandler = _installTrap("edit-ogp-overlay", "edit-ogp-box");
|
||||
}
|
||||
|
||||
function hideEditOGPModal() {
|
||||
document.getElementById("edit-ogp-overlay").style.display = "none";
|
||||
_eogpTrapHandler = _removeTrap(_eogpTrapHandler);
|
||||
if (_eogpTriggerEl && _eogpTriggerEl.focus) _eogpTriggerEl.focus();
|
||||
_eogpTriggerEl = null;
|
||||
}
|
||||
|
||||
function validateEditOGRegex() {
|
||||
var pattern = document.getElementById("eogp-pattern").value;
|
||||
var resultEl = document.getElementById("eogp-regex-result");
|
||||
if (!pattern) {
|
||||
resultEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
authFetch("/v1/api/admin/judge/validate-regex", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pattern: pattern }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Validation failed");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (d.valid) {
|
||||
resultEl.textContent = "Valid";
|
||||
resultEl.style.color = "var(--green)";
|
||||
} else {
|
||||
resultEl.textContent = d.error || "Invalid";
|
||||
resultEl.style.color = "var(--red)";
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
resultEl.textContent = "Validation failed";
|
||||
resultEl.style.color = "var(--red)";
|
||||
});
|
||||
}
|
||||
|
||||
function submitEditOGPattern() {
|
||||
var errEl = document.getElementById("edit-ogp-error");
|
||||
errEl.style.display = "none";
|
||||
var patternId = document.getElementById("eogp-id").value;
|
||||
var payload = {
|
||||
name: document.getElementById("eogp-name").value.trim(),
|
||||
category: document.getElementById("eogp-cat").value,
|
||||
risk_level: document.getElementById("eogp-risk").value,
|
||||
pattern: document.getElementById("eogp-pattern").value,
|
||||
flag_name: document.getElementById("eogp-flag").value.trim(),
|
||||
annotation: document.getElementById("eogp-ann").value.trim(),
|
||||
pattern_flags: document.getElementById("eogp-flags").value.trim(),
|
||||
is_credential: document.getElementById("eogp-cred").checked,
|
||||
redact_label: document.getElementById("eogp-redact").value.trim(),
|
||||
priority: parseInt(document.getElementById("eogp-priority").value, 10) || 0,
|
||||
};
|
||||
var btn = document.getElementById("eogp-submit");
|
||||
btn.disabled = true;
|
||||
|
||||
var url, method;
|
||||
if (patternId) {
|
||||
url = "/v1/api/admin/judge/output-guard-patterns/" + patternId;
|
||||
method = "PUT";
|
||||
} else {
|
||||
url = "/v1/api/admin/judge/output-guard-patterns";
|
||||
method = "POST";
|
||||
payload.builtin = true;
|
||||
payload.enabled = true;
|
||||
}
|
||||
authFetch(url, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
hideEditOGPModal();
|
||||
showToast(patternId ? "Pattern updated" : "Pattern overridden");
|
||||
loadJudgeOGPatterns();
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -417,6 +417,88 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Judge: Edit Heuristic Rule Modal -->
|
||||
<div id="edit-hr-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-hr-title">
|
||||
<div id="edit-hr-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-hr-title">Edit Heuristic Rule</h2>
|
||||
<div id="edit-hr-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="ehr-id" type="hidden">
|
||||
<input id="ehr-builtin" type="hidden">
|
||||
<input id="ehr-priority" type="hidden" value="0">
|
||||
<label for="ehr-name">Name</label>
|
||||
<input id="ehr-name" type="text" autocomplete="off" spellcheck="false">
|
||||
<div style="display:flex;gap:12px">
|
||||
<div style="flex:1">
|
||||
<label for="ehr-tier">Tier</label>
|
||||
<select id="ehr-tier"><option>critical</option><option>high</option><option>medium</option><option>low</option></select>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="ehr-risk">Risk Level</label>
|
||||
<select id="ehr-risk"><option>critical</option><option>high</option><option>medium</option><option>low</option></select>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="ehr-rec">Recommendation</label>
|
||||
<select id="ehr-rec"><option>approve</option><option>review</option><option>deny</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<label for="ehr-tool">Tool Pattern <span class="label-hint">fnmatch syntax: bash, write_file, mcp__*</span></label>
|
||||
<input id="ehr-tool" type="text" autocomplete="off" spellcheck="false">
|
||||
<label for="ehr-args">Arg Patterns <span class="label-hint">one regex per line</span></label>
|
||||
<textarea id="ehr-args" rows="3" style="font-family:var(--font-mono);font-size:12px"></textarea>
|
||||
<label for="ehr-conf">Confidence <span class="label-hint">0.0 – 1.0</span></label>
|
||||
<input id="ehr-conf" type="number" step="0.05" min="0" max="1" style="width:100px">
|
||||
<label for="ehr-intent">Intent Description</label>
|
||||
<input id="ehr-intent" type="text" autocomplete="off">
|
||||
<label for="ehr-reason">Reasoning</label>
|
||||
<input id="ehr-reason" type="text" autocomplete="off">
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditHRModal()">Cancel</button>
|
||||
<button id="ehr-submit" class="modal-submit" onclick="submitEditHeuristicRule()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Judge: Edit Output Guard Pattern Modal -->
|
||||
<div id="edit-ogp-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-ogp-title">
|
||||
<div id="edit-ogp-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-ogp-title">Edit Output Guard Pattern</h2>
|
||||
<div id="edit-ogp-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="eogp-id" type="hidden">
|
||||
<input id="eogp-builtin" type="hidden">
|
||||
<input id="eogp-priority" type="hidden" value="0">
|
||||
<label for="eogp-name">Name</label>
|
||||
<input id="eogp-name" type="text" autocomplete="off" spellcheck="false">
|
||||
<div style="display:flex;gap:12px">
|
||||
<div style="flex:1">
|
||||
<label for="eogp-cat">Category</label>
|
||||
<select id="eogp-cat"><option>prompt_injection</option><option>credentials</option><option>encoded_payloads</option><option>adversarial_urls</option><option>info_disclosure</option></select>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="eogp-risk">Risk Level</label>
|
||||
<select id="eogp-risk"><option>high</option><option>medium</option><option>low</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<label for="eogp-pattern">Regex Pattern</label>
|
||||
<input id="eogp-pattern" type="text" autocomplete="off" spellcheck="false" style="font-family:var(--font-mono);font-size:12px">
|
||||
<button class="admin-btn-action" style="margin:4px 0 8px" onclick="validateEditOGRegex()">Validate regex</button>
|
||||
<span id="eogp-regex-result" role="status" aria-live="polite" style="font-size:11px;margin-left:8px"></span>
|
||||
<label for="eogp-flag">Flag Name</label>
|
||||
<input id="eogp-flag" type="text" autocomplete="off" spellcheck="false">
|
||||
<label for="eogp-ann">Annotation</label>
|
||||
<input id="eogp-ann" type="text" autocomplete="off">
|
||||
<label for="eogp-flags">Pattern Flags <span class="label-hint">comma-separated: IGNORECASE, MULTILINE, DOTALL</span></label>
|
||||
<input id="eogp-flags" type="text" autocomplete="off">
|
||||
<div style="display:flex;gap:16px;margin:8px 0">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px"><input id="eogp-cred" type="checkbox"> Is Credential</label>
|
||||
<label style="font-size:12px">Redact Label <input id="eogp-redact" type="text" placeholder="api_key" style="width:100px;margin-left:4px"></label>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditOGPModal()">Cancel</button>
|
||||
<button id="eogp-submit" class="modal-submit" onclick="submitEditOGPattern()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Tab -->
|
||||
<div id="admin-skills" class="admin-panel" role="tabpanel" aria-labelledby="tab-skills" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
@@ -862,12 +944,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 +1003,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 +1268,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 +1387,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">
|
||||
|
||||
@@ -1033,6 +1033,22 @@
|
||||
.admin-btn-danger:hover { opacity: 1; background: rgba(248, 113, 113, 0.1); }
|
||||
.admin-btn-danger:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
|
||||
|
||||
.admin-btn-caution {
|
||||
background: none;
|
||||
border: 1px solid var(--yellow);
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.admin-btn-caution:hover { opacity: 1; background: rgba(251, 191, 36, 0.1); }
|
||||
.admin-btn-caution:focus-visible { outline: 2px solid var(--yellow); outline-offset: 2px; }
|
||||
|
||||
.admin-btn-action {
|
||||
background: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -1202,6 +1218,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 {
|
||||
@@ -1409,7 +1449,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay,
|
||||
#github-import-overlay,
|
||||
#model-create-overlay,
|
||||
#create-hr-overlay, #create-ogp-overlay {
|
||||
#create-hr-overlay, #edit-hr-overlay, #create-ogp-overlay, #edit-ogp-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
@@ -1465,14 +1505,14 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
/* Judge: Heuristic Rules - hide Tier, Risk, Rec on mobile */
|
||||
#judge-heuristic-section .admin-colheaders,
|
||||
#judge-heuristic-section .admin-row {
|
||||
grid-template-columns: 1fr 100px 90px 60px 120px;
|
||||
grid-template-columns: 1fr 100px 90px 60px 160px;
|
||||
}
|
||||
.admin-col-htier, .admin-col-hrisk, .admin-col-hrec { display: none; }
|
||||
|
||||
/* Judge: Output Guard - hide Risk, Flag on mobile */
|
||||
#judge-output-guard-section .admin-colheaders,
|
||||
#judge-output-guard-section .admin-row {
|
||||
grid-template-columns: 1fr 120px 90px 60px 120px;
|
||||
grid-template-columns: 1fr 120px 90px 60px 160px;
|
||||
}
|
||||
.admin-col-ogrisk, .admin-col-ogflag { display: none; }
|
||||
}
|
||||
@@ -1644,12 +1684,12 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
========================================================================== */
|
||||
#judge-heuristic-section .admin-colheaders,
|
||||
#judge-heuristic-section .admin-row {
|
||||
grid-template-columns: 1.2fr 70px 70px 100px 70px 90px 60px 120px;
|
||||
grid-template-columns: 1.2fr 70px 70px 100px 70px 90px 60px 170px;
|
||||
}
|
||||
/* Judge: Output Guard Patterns grid */
|
||||
#judge-output-guard-section .admin-colheaders,
|
||||
#judge-output-guard-section .admin-row {
|
||||
grid-template-columns: 1.2fr 120px 60px 100px 90px 60px 120px;
|
||||
grid-template-columns: 1.2fr 120px 60px 100px 90px 60px 170px;
|
||||
}
|
||||
|
||||
/* Audit action badges */
|
||||
@@ -1991,6 +2031,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
/* Input column */
|
||||
.settings-input input[type="text"],
|
||||
.settings-input input[type="number"],
|
||||
.settings-input input[type="password"],
|
||||
.settings-input select {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
@@ -2168,17 +2209,6 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
}
|
||||
.settings-help-ref:hover { text-decoration: underline; }
|
||||
|
||||
/* Secret field — match input box height for grid alignment */
|
||||
.settings-secret {
|
||||
color: var(--fg-dim);
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
cursor: not-allowed;
|
||||
display: inline-block;
|
||||
padding: 4px 0;
|
||||
border: 1px solid transparent; /* invisible border matches input's 1px border */
|
||||
}
|
||||
|
||||
/* Docs link in toolbar */
|
||||
.settings-docs-link {
|
||||
font-family: var(--font-display);
|
||||
@@ -2201,6 +2231,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
.settings-desc { display: none; }
|
||||
.settings-input input[type="text"],
|
||||
.settings-input input[type="number"],
|
||||
.settings-input input[type="password"],
|
||||
.settings-input select { max-width: 100%; }
|
||||
}
|
||||
|
||||
@@ -2451,7 +2482,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
.node-link, .dash-cell-node, .pagination button { transition: none; }
|
||||
.dash-row.has-link::after, .node-group-header::before { transition: none; }
|
||||
#new-ws-box select, #new-ws-box input, #new-ws-buttons button { transition: none; }
|
||||
.admin-nav, .admin-row, .admin-btn-danger, .admin-btn-action, .judge-section-btn { transition: none; }
|
||||
.admin-nav, .admin-row, .admin-btn-danger, .admin-btn-caution, .admin-btn-action, .judge-section-btn { transition: none; }
|
||||
.settings-toggle-slider, .settings-toggle-slider::before { transition: none; }
|
||||
.settings-save-btn, .settings-reset-btn, .settings-docs-link, .settings-help-btn { transition: none; }
|
||||
.admin-sidebar, .admin-sidebar-backdrop { transition: none; }
|
||||
|
||||
+45
-4
@@ -54,6 +54,19 @@ _MIN_SECRET_LENGTH = 32 # 256 bits minimum for HMAC-SHA256
|
||||
|
||||
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve", "service"})
|
||||
|
||||
|
||||
def jwt_version_slot() -> str:
|
||||
"""Return ``major.minor`` from ``__version__`` for JWT version claims.
|
||||
|
||||
Only major.minor is used so that patch/pre-release bumps do not
|
||||
force every user to re-authenticate.
|
||||
"""
|
||||
from turnstone import __version__
|
||||
|
||||
parts = __version__.split(".")
|
||||
return f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else __version__
|
||||
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
USERNAME_MAX_LEN = 64
|
||||
|
||||
@@ -195,6 +208,7 @@ class AuthResult:
|
||||
scopes: frozenset[str]
|
||||
token_source: str # "jwt", "database", "password", or service origin (e.g. "console", "cli")
|
||||
permissions: frozenset[str] = frozenset()
|
||||
token_version: str = "" # JWT ``ver`` claim (major.minor), empty for pre-upgrade tokens
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
"""Return True if this result includes *scope*."""
|
||||
@@ -310,6 +324,7 @@ def create_jwt(
|
||||
audience: str = "",
|
||||
permissions: frozenset[str] = frozenset(),
|
||||
expiry_seconds: int | None = None,
|
||||
version: str | None = None,
|
||||
) -> str:
|
||||
"""Create a signed JWT with user identity, scopes, and permissions."""
|
||||
import jwt
|
||||
@@ -330,6 +345,8 @@ def create_jwt(
|
||||
payload["aud"] = audience
|
||||
if permissions:
|
||||
payload["permissions"] = ",".join(sorted(permissions))
|
||||
if version:
|
||||
payload["ver"] = version
|
||||
return jwt.encode(payload, secret, algorithm="HS256")
|
||||
|
||||
|
||||
@@ -339,6 +356,10 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
|
||||
When *audience* is non-empty the ``aud`` claim is verified. Tokens
|
||||
without an ``aud`` claim are accepted when *audience* is empty (backward
|
||||
compatibility during the rollout window).
|
||||
|
||||
The ``ver`` claim (if present) is carried through on
|
||||
:attr:`AuthResult.token_version` so callers can enforce version gating
|
||||
without a second decode.
|
||||
"""
|
||||
import jwt
|
||||
|
||||
@@ -360,6 +381,7 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
|
||||
scopes_str = payload.get("scopes", "")
|
||||
source = payload.get("src", "jwt")
|
||||
perms_str = payload.get("permissions", "")
|
||||
token_ver = payload.get("ver", "")
|
||||
|
||||
perms = frozenset(p for p in perms_str.split(",") if p) if perms_str else frozenset()
|
||||
|
||||
@@ -368,6 +390,7 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
|
||||
scopes=parse_scopes(scopes_str),
|
||||
token_source=source,
|
||||
permissions=perms,
|
||||
token_version=token_ver,
|
||||
)
|
||||
|
||||
|
||||
@@ -454,6 +477,7 @@ def check_request(
|
||||
*,
|
||||
jwt_secret: str = "",
|
||||
jwt_audience: str = "",
|
||||
jwt_version: str = "",
|
||||
storage: Any = None,
|
||||
) -> tuple[bool, int, str, AuthResult | None]:
|
||||
"""Validate a request.
|
||||
@@ -477,13 +501,21 @@ def check_request(
|
||||
if not raw_token:
|
||||
return False, 401, "Unauthorized: missing or invalid token", None
|
||||
|
||||
# Authenticate
|
||||
# Authenticate (single decode — version checked afterward)
|
||||
result = _authenticate_token(
|
||||
raw_token, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
|
||||
raw_token,
|
||||
jwt_secret=jwt_secret,
|
||||
jwt_audience=jwt_audience,
|
||||
storage=storage,
|
||||
)
|
||||
if result is None:
|
||||
return False, 401, "Unauthorized: missing or invalid token", None
|
||||
|
||||
# Version gate — reject tokens minted by a different major.minor.
|
||||
# Tokens without a ``ver`` claim are accepted (backward compat).
|
||||
if jwt_version and result.token_version and result.token_version != jwt_version:
|
||||
return False, 401, "version_mismatch", None
|
||||
|
||||
# Check scope
|
||||
needed = required_scope(method, path)
|
||||
if not result.has_scope(needed):
|
||||
@@ -740,9 +772,10 @@ class AuthMiddleware:
|
||||
server (``JWT_AUD_SERVER``) and the console (``JWT_AUD_CONSOLE``).
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, jwt_audience: str = "") -> None:
|
||||
def __init__(self, app: ASGIApp, jwt_audience: str = "", jwt_version: str = "") -> None:
|
||||
self.app = app
|
||||
self._jwt_audience = jwt_audience
|
||||
self._jwt_version = jwt_version
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
@@ -771,10 +804,15 @@ class AuthMiddleware:
|
||||
cookie_header,
|
||||
jwt_secret=jwt_secret,
|
||||
jwt_audience=self._jwt_audience,
|
||||
jwt_version=self._jwt_version,
|
||||
storage=storage,
|
||||
)
|
||||
if not allowed:
|
||||
response = JSONResponse({"error": msg}, status_code=status)
|
||||
body: dict[str, Any] = {"error": msg}
|
||||
if msg == "version_mismatch":
|
||||
body["error"] = "Unauthorized: session expired after server upgrade"
|
||||
body["code"] = "version_mismatch"
|
||||
response = JSONResponse(body, status_code=status)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
@@ -880,6 +918,7 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
secret=jwt_secret,
|
||||
audience=audience,
|
||||
permissions=result.permissions,
|
||||
version=jwt_version_slot(),
|
||||
)
|
||||
|
||||
role = "full" if result.has_scope("write") else "read"
|
||||
@@ -1023,6 +1062,7 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
secret=jwt_secret,
|
||||
audience=audience,
|
||||
permissions=frozenset(perms),
|
||||
version=jwt_version_slot(),
|
||||
)
|
||||
|
||||
resp_body: dict[str, str] = {
|
||||
@@ -1249,6 +1289,7 @@ async def handle_oidc_callback(request: Request, audience: str) -> Response:
|
||||
secret=jwt_secret,
|
||||
audience=jwt_audience,
|
||||
permissions=frozenset(perms),
|
||||
version=jwt_version_slot(),
|
||||
)
|
||||
|
||||
# Set cookie and redirect to app
|
||||
|
||||
@@ -501,9 +501,19 @@ class ChatSession:
|
||||
"""Return a web search client for the configured backend, or None."""
|
||||
from turnstone.core.web_search import resolve_web_search_client
|
||||
|
||||
# ConfigStore (DB) takes precedence over config.toml / env var
|
||||
tavily_key: str | None = None
|
||||
cs = getattr(self, "_config_store", None)
|
||||
if cs is not None:
|
||||
db_key = cs.get("tools.tavily_api_key")
|
||||
if db_key:
|
||||
tavily_key = str(db_key)
|
||||
if not tavily_key:
|
||||
tavily_key = get_tavily_key()
|
||||
|
||||
return resolve_web_search_client(
|
||||
backend=self._get_web_search_backend(),
|
||||
tavily_key=get_tavily_key(),
|
||||
tavily_key=tavily_key,
|
||||
mcp_client=self._mcp_client,
|
||||
timeout=self.tool_timeout,
|
||||
)
|
||||
@@ -1248,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]:
|
||||
|
||||
@@ -207,6 +207,18 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
min_value=1,
|
||||
max_value=50,
|
||||
),
|
||||
SettingDef(
|
||||
"tools.tavily_api_key",
|
||||
"str",
|
||||
"",
|
||||
"Tavily API key for web search (write-only)",
|
||||
"tools",
|
||||
is_secret=True,
|
||||
help="API key for the Tavily web search service. When set, enables the Tavily "
|
||||
"backend for web_search tool calls (higher quality than DuckDuckGo). "
|
||||
"Overrides $TAVILY_API_KEY and config.toml [api] tavily_key.",
|
||||
reference_url="https://tavily.com",
|
||||
),
|
||||
SettingDef(
|
||||
"tools.web_search_backend",
|
||||
"str",
|
||||
@@ -271,6 +283,17 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
"database. Each node only connects to the servers it needs, so this "
|
||||
"limit is on definitions, not active connections.",
|
||||
),
|
||||
# -- channels -------------------------------------------------------
|
||||
SettingDef(
|
||||
"channels.default_model_alias",
|
||||
"str",
|
||||
"",
|
||||
"Default model alias for channel workstreams (empty = use server default)",
|
||||
"channels",
|
||||
help="Which model alias to use when a channel adapter (Discord, etc.) "
|
||||
"creates a new workstream without an explicit model. When empty, falls "
|
||||
"back to the server-wide model.default_alias.",
|
||||
),
|
||||
# -- mcp ------------------------------------------------------------
|
||||
SettingDef(
|
||||
"mcp.config_path",
|
||||
|
||||
@@ -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")
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -73,3 +74,34 @@ def cors_middleware(origins: list[str]) -> Middleware:
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static asset cache-busting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches src="/static/..." and href="/shared/..." (and vice-versa) but skips
|
||||
# vendored libraries whose directory names already contain a version number
|
||||
# (e.g. katex-0.16.44/, hljs-11.11.1/) and URLs that already have a query
|
||||
# string (prevents double-append if called twice).
|
||||
_ASSET_RE = re.compile(
|
||||
r'(?P<attr>(?:src|href)=")'
|
||||
r"(?P<path>/(?:static|shared)/)"
|
||||
r"(?!(?:katex|hljs|hls|mermaid)-\d)"
|
||||
r'(?P<file>[^"?]+)"'
|
||||
)
|
||||
|
||||
|
||||
def version_html(html: str) -> str:
|
||||
"""Inject ``?v=VERSION`` into ``/static/`` and ``/shared/`` asset URLs.
|
||||
|
||||
Vendored libraries with version-bearing directory names are skipped.
|
||||
URLs that already contain a query string are left unchanged.
|
||||
Called once at startup when loading HTML into memory.
|
||||
"""
|
||||
from turnstone import __version__
|
||||
|
||||
def _repl(m: re.Match[str]) -> str:
|
||||
return f'{m.group("attr")}{m.group("path")}{m.group("file")}?v={__version__}"'
|
||||
|
||||
return _ASSET_RE.sub(_repl, html)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,6 +24,7 @@ from turnstone.api.console_schemas import (
|
||||
ImportMcpConfigResponse,
|
||||
ListAdminMemoriesResponse,
|
||||
ListAuditEventsResponse,
|
||||
ListAvailableModelsResponse,
|
||||
ListMcpServersResponse,
|
||||
ListOrgsResponse,
|
||||
ListRolesResponse,
|
||||
@@ -185,6 +186,14 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
response_model=ConsoleCreateWsResponse,
|
||||
)
|
||||
|
||||
# -- models --------------------------------------------------------------
|
||||
|
||||
async def list_models(self) -> ListAvailableModelsResponse:
|
||||
"""GET /v1/api/models — available model aliases and defaults."""
|
||||
return await self._request(
|
||||
"GET", "/v1/api/models", response_model=ListAvailableModelsResponse
|
||||
)
|
||||
|
||||
# -- routing proxy -------------------------------------------------------
|
||||
|
||||
async def route_create_workstream(
|
||||
@@ -1050,6 +1059,11 @@ class TurnstoneConsole:
|
||||
)
|
||||
)
|
||||
|
||||
# -- models --------------------------------------------------------------
|
||||
|
||||
def list_models(self) -> ListAvailableModelsResponse:
|
||||
return self._runner.run(self._async.list_models())
|
||||
|
||||
# -- routing proxy -------------------------------------------------------
|
||||
|
||||
def route_create_workstream(
|
||||
|
||||
@@ -26,6 +26,7 @@ from turnstone.api.server_schemas import (
|
||||
CreateWorkstreamResponse,
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListAvailableModelsResponse,
|
||||
ListMemoriesResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListSkillSummaryResponse,
|
||||
@@ -87,6 +88,12 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
async def dashboard(self) -> DashboardResponse:
|
||||
return await self._request("GET", "/v1/api/dashboard", response_model=DashboardResponse)
|
||||
|
||||
async def list_models(self) -> ListAvailableModelsResponse:
|
||||
"""GET /v1/api/models — available model aliases and defaults."""
|
||||
return await self._request(
|
||||
"GET", "/v1/api/models", response_model=ListAvailableModelsResponse
|
||||
)
|
||||
|
||||
async def create_workstream(
|
||||
self,
|
||||
*,
|
||||
@@ -100,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:
|
||||
@@ -122,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",
|
||||
@@ -467,6 +477,9 @@ class TurnstoneServer:
|
||||
def dashboard(self) -> DashboardResponse:
|
||||
return self._runner.run(self._async.dashboard())
|
||||
|
||||
def list_models(self) -> ListAvailableModelsResponse:
|
||||
return self._runner.run(self._async.list_models())
|
||||
|
||||
def create_workstream(
|
||||
self,
|
||||
*,
|
||||
@@ -480,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(
|
||||
@@ -493,6 +507,7 @@ class TurnstoneServer:
|
||||
user_id=user_id,
|
||||
ws_id=ws_id,
|
||||
client_type=client_type,
|
||||
notify_targets=notify_targets,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+229
-6
@@ -15,6 +15,7 @@ import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -40,12 +41,13 @@ from starlette.staticfiles import StaticFiles
|
||||
from turnstone import __version__
|
||||
from turnstone.api.docs import make_docs_handler, make_openapi_handler
|
||||
from turnstone.api.server_spec import build_server_spec
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, AuthMiddleware
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, AuthMiddleware, jwt_version_slot
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.metrics import metrics as _metrics
|
||||
from turnstone.core.ratelimit import resolve_client_ip
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled, SessionUI # noqa: F401
|
||||
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
|
||||
from turnstone.core.web_helpers import version_html as _version_html
|
||||
from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState
|
||||
from turnstone.prompts import ClientType
|
||||
|
||||
@@ -62,7 +64,8 @@ log = get_logger(__name__)
|
||||
|
||||
_STATIC_DIR = Path(__file__).parent / "ui" / "static"
|
||||
_SHARED_DIR = Path(__file__).parent / "shared_static"
|
||||
_HTML = (_STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
_HTML = _version_html((_STATIC_DIR / "index.html").read_text(encoding="utf-8"))
|
||||
_HTML_ETAG = '"' + hashlib.md5(_HTML.encode()).hexdigest()[:16] + '"' # noqa: S324
|
||||
_VALID_WS_ID = re.compile(r"^[0-9a-f]{32}$")
|
||||
|
||||
|
||||
@@ -844,9 +847,14 @@ def _audit_context(request: Request) -> tuple[str, str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
async def index(request: Request) -> Response:
|
||||
"""GET / — serve the embedded HTML client."""
|
||||
return HTMLResponse(_HTML)
|
||||
if request.headers.get("If-None-Match") == _HTML_ETAG:
|
||||
return Response(status_code=304, headers={"ETag": _HTML_ETAG, "Cache-Control": "no-cache"})
|
||||
resp = HTMLResponse(_HTML)
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
resp.headers["ETag"] = _HTML_ETAG
|
||||
return resp
|
||||
|
||||
|
||||
async def events_sse(request: Request) -> Response:
|
||||
@@ -1195,7 +1203,28 @@ async def list_available_models(request: Request) -> JSONResponse:
|
||||
"provider": cfg.provider,
|
||||
}
|
||||
)
|
||||
return JSONResponse({"models": models})
|
||||
# Include effective defaults for clients (web UI, channel gateway).
|
||||
cs = getattr(request.app.state, "config_store", None)
|
||||
default_alias = ""
|
||||
channel_default_alias = ""
|
||||
if cs is not None:
|
||||
default_alias = cs.get("model.default_alias") or ""
|
||||
channel_default_alias = cs.get("channels.default_model_alias") or ""
|
||||
if not default_alias:
|
||||
default_alias = registry.default
|
||||
# Clear defaults that point to unknown/disabled aliases.
|
||||
enabled_aliases = set(registry.list_aliases())
|
||||
if default_alias and default_alias not in enabled_aliases:
|
||||
default_alias = ""
|
||||
if channel_default_alias and channel_default_alias not in enabled_aliases:
|
||||
channel_default_alias = ""
|
||||
return JSONResponse(
|
||||
{
|
||||
"models": models,
|
||||
"default_alias": default_alias,
|
||||
"channel_default_alias": channel_default_alias,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _count_ws_states(wss: list[Workstream]) -> dict[str, int]:
|
||||
@@ -1600,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
|
||||
@@ -1751,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
|
||||
@@ -1780,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
|
||||
@@ -2510,7 +2733,7 @@ def _build_middleware(cors_origins: list[str] | None = None) -> list[Middleware]
|
||||
stack.append(cors_middleware(cors_origins))
|
||||
stack.extend(
|
||||
[
|
||||
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_SERVER),
|
||||
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_SERVER, jwt_version=jwt_version_slot()),
|
||||
Middleware(RateLimitMiddleware),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -12,12 +12,39 @@ var _AUTH_TITLE = window.TURNSTONE_AUTH_TITLE || "turnstone";
|
||||
var _loginTrapHandler = null;
|
||||
var _loginBusy = false;
|
||||
var _authMode = "login"; // "login", "setup", "token"
|
||||
var _authUpgradeReload = false;
|
||||
|
||||
// Cross-tab auth sync — when one tab logs in/out, others follow.
|
||||
var _authChannel =
|
||||
typeof BroadcastChannel !== "undefined"
|
||||
? new BroadcastChannel("turnstone_auth")
|
||||
: null;
|
||||
if (_authChannel) {
|
||||
_authChannel.onmessage = function (e) {
|
||||
if (e.data === "login") {
|
||||
hideLogin();
|
||||
if (typeof window.onLoginSuccess === "function") window.onLoginSuccess();
|
||||
} else if (e.data === "logout") {
|
||||
showLogin();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function authFetch(url, opts) {
|
||||
var maxRetries = 2;
|
||||
for (var attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
var r = await fetch(url, opts);
|
||||
if (r.status === 401) {
|
||||
try {
|
||||
var body = await r.clone().json();
|
||||
if (body && body.code === "version_mismatch") {
|
||||
_authUpgradeReload = true;
|
||||
showLogin("upgrade");
|
||||
throw new Error("auth");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === "auth") throw e;
|
||||
}
|
||||
showLogin();
|
||||
throw new Error("auth");
|
||||
}
|
||||
@@ -79,7 +106,7 @@ function initLogin() {
|
||||
|
||||
function _buildLoginHTML() {
|
||||
return (
|
||||
'<form id="login-box">' +
|
||||
'<form id="login-box" aria-describedby="login-subtitle">' +
|
||||
'<h2 id="login-title">' +
|
||||
escapeHtml(_AUTH_TITLE) +
|
||||
"</h2>" +
|
||||
@@ -232,7 +259,7 @@ function _showError(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
function showLogin(reason) {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (!overlay) return;
|
||||
overlay.style.display = "flex";
|
||||
@@ -242,6 +269,7 @@ function showLogin() {
|
||||
_clearError();
|
||||
|
||||
// Check auth status to determine mode
|
||||
var _loginReason = reason;
|
||||
fetch("/v1/api/auth/status")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
@@ -251,6 +279,12 @@ function showLogin() {
|
||||
_switchMode("setup");
|
||||
} else {
|
||||
_switchMode("login");
|
||||
if (_loginReason === "upgrade") {
|
||||
var subtitle = document.getElementById("login-subtitle");
|
||||
if (subtitle)
|
||||
subtitle.textContent =
|
||||
"The server was updated \u2014 please sign in again";
|
||||
}
|
||||
}
|
||||
_updateOIDCUI(data);
|
||||
})
|
||||
@@ -465,15 +499,24 @@ function _setBusy(busy, label) {
|
||||
}
|
||||
|
||||
function _onSuccess() {
|
||||
// After a version-triggered re-auth, reload the page to pick up fresh
|
||||
// JS/CSS via the updated ?v= query strings in the new HTML.
|
||||
if (_authUpgradeReload) {
|
||||
_authUpgradeReload = false;
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
hideLogin();
|
||||
var logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.style.display = "";
|
||||
if (_authChannel) _authChannel.postMessage("login");
|
||||
if (typeof window.onLoginSuccess === "function") window.onLoginSuccess();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
fetch("/v1/api/auth/logout", { method: "POST" }).then(function () {
|
||||
sessionStorage.removeItem("turnstone_permissions");
|
||||
if (_authChannel) _authChannel.postMessage("logout");
|
||||
if (typeof window.onLogout === "function") window.onLogout();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user