mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 584437b98e | |||
| 3abe1c0058 | |||
| e24d8b9597 | |||
| e11e6f6b70 | |||
| b8d728fd79 | |||
| 2138c19821 | |||
| 627bf06ced | |||
| b67da0f48a | |||
| e05b6adc67 | |||
| 40355d8303 | |||
| 2cbd926b9d | |||
| 40c0ab5a58 | |||
| a5e8b8b17e | |||
| bed691c62a | |||
| c930078f3d | |||
| f148c4b423 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.5.8"
|
||||
version = "1.5.10"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -317,6 +318,13 @@ class TestPromptTemplateCRUD:
|
||||
def test_get_prompt_template_nonexistent(self, db):
|
||||
assert db.get_prompt_template("missing") is None
|
||||
|
||||
def test_create_prompt_template_duplicate_id_raises_conflict(self, db):
|
||||
from turnstone.core.storage._protocol import StorageConflictError
|
||||
|
||||
db.create_prompt_template("dup", "first", "general", "A")
|
||||
with pytest.raises(StorageConflictError, match="prompt_template conflict"):
|
||||
db.create_prompt_template("dup", "second", "general", "B")
|
||||
|
||||
def test_list_prompt_templates_ordered_by_name(self, db):
|
||||
db.create_prompt_template("t2", "beta", "general", "B")
|
||||
db.create_prompt_template("t1", "alpha", "general", "A")
|
||||
|
||||
@@ -451,6 +451,71 @@ class TestInterruptedWorkstreamRepair:
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
assert msgs[2]["role"] == "user"
|
||||
|
||||
def test_repair_false_preserves_partial_trailing_turn(self, tmp_db):
|
||||
"""``repair=False`` is the display-read contract for ``/history``.
|
||||
|
||||
The default repair pass strips the trailing
|
||||
``assistant(tool_calls)`` when not all tool results are persisted
|
||||
— correct for ``session.resume`` (LLM context), wrong for the
|
||||
REST display read. A user refreshing the coordinator page mid-
|
||||
tool-execution would otherwise lose the entire trailing turn
|
||||
from the UI. ``repair=False`` returns the raw persisted state.
|
||||
"""
|
||||
import json
|
||||
|
||||
tc_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"ls"}'},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"pwd"}'},
|
||||
},
|
||||
]
|
||||
)
|
||||
save_message("s1", "user", "hello")
|
||||
save_message("s1", "assistant", "Checking", tool_calls=tc_json)
|
||||
save_message("s1", "tool", "file.txt", tool_call_id="call_1")
|
||||
# No call_2 result persisted — mid-execution refresh.
|
||||
msgs = get_storage().load_messages("s1", repair=False)
|
||||
# All three rows survive — the trailing partial turn is what the
|
||||
# operator was actually watching live.
|
||||
assert [m["role"] for m in msgs] == ["user", "assistant", "tool"]
|
||||
assert msgs[1].get("tool_calls") and len(msgs[1]["tool_calls"]) == 2
|
||||
assert msgs[2]["tool_call_id"] == "call_1"
|
||||
|
||||
def test_repair_false_does_not_synthesize_orphan_results(self, tmp_db):
|
||||
"""``repair=False`` must NOT splice synthetic ``"Tool execution
|
||||
was cancelled."`` rows for mid-conversation orphans either —
|
||||
the operator never saw those rows, and showing them would
|
||||
invent UI content that doesn't reflect persisted state.
|
||||
"""
|
||||
import json
|
||||
|
||||
tc_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"ls"}'},
|
||||
},
|
||||
]
|
||||
)
|
||||
save_message("s1", "user", "first")
|
||||
save_message("s1", "assistant", "Working", tool_calls=tc_json)
|
||||
# Cancel landed before any tool result — next turn happens.
|
||||
save_message("s1", "user", "second")
|
||||
save_message("s1", "assistant", "ok")
|
||||
msgs = get_storage().load_messages("s1", repair=False)
|
||||
roles = [m["role"] for m in msgs]
|
||||
# No synthetic tool row spliced after the orphaned tool_calls.
|
||||
assert roles == ["user", "assistant", "user", "assistant"]
|
||||
assert all(m["role"] != "tool" for m in msgs)
|
||||
|
||||
|
||||
# ── Workstream config persistence ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -256,19 +256,13 @@ class TestSkillInstall:
|
||||
def test_install_from_skills_sh(self, client: TestClient) -> None:
|
||||
package = _sample_package()
|
||||
|
||||
with (
|
||||
patch("turnstone.core.skill_sources.SkillsShClient") as mock_cls,
|
||||
patch(
|
||||
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
|
||||
) as mock_fetch,
|
||||
):
|
||||
with patch("turnstone.core.skill_sources.SkillsShClient") as mock_cls:
|
||||
instance = mock_cls.return_value
|
||||
instance.resolve_github_url = AsyncMock(return_value="https://github.com/owner/repo")
|
||||
mock_fetch.return_value = package
|
||||
instance.download_skill = AsyncMock(return_value=package)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/api/admin/skills/install",
|
||||
json={"source": "skills.sh", "skill_id": "owner/test-skill"},
|
||||
json={"source": "skills.sh", "skill_id": "owner/repo/test-skill"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
+285
-4
@@ -12,10 +12,41 @@ from turnstone.core.skill_sources import (
|
||||
SkillSourceError,
|
||||
SkillsShClient,
|
||||
_parse_github_url,
|
||||
_split_skills_sh_id,
|
||||
fetch_skill_from_github,
|
||||
)
|
||||
|
||||
|
||||
class TestSplitSkillsShId:
|
||||
"""skills.sh canonical id parsing."""
|
||||
|
||||
def test_three_segments(self) -> None:
|
||||
assert _split_skills_sh_id("owner/repo/leaf") == ("owner", "repo", "leaf")
|
||||
|
||||
def test_strips_surrounding_slashes(self) -> None:
|
||||
assert _split_skills_sh_id("/owner/repo/leaf/") == ("owner", "repo", "leaf")
|
||||
|
||||
def test_rejects_two_segments(self) -> None:
|
||||
with pytest.raises(SkillSourceError, match="expected"):
|
||||
_split_skills_sh_id("owner/repo")
|
||||
|
||||
def test_rejects_four_segments(self) -> None:
|
||||
with pytest.raises(SkillSourceError, match="expected"):
|
||||
_split_skills_sh_id("a/b/c/d")
|
||||
|
||||
def test_rejects_empty_segment(self) -> None:
|
||||
with pytest.raises(SkillSourceError, match="expected"):
|
||||
_split_skills_sh_id("owner//leaf")
|
||||
|
||||
def test_rejects_internal_whitespace(self) -> None:
|
||||
with pytest.raises(SkillSourceError, match="expected"):
|
||||
_split_skills_sh_id("owner/repo/leaf with space")
|
||||
|
||||
def test_rejects_url_hostile_chars(self) -> None:
|
||||
with pytest.raises(SkillSourceError, match="expected"):
|
||||
_split_skills_sh_id("owner/repo/leaf?query=x")
|
||||
|
||||
|
||||
class TestParseGitHubUrl:
|
||||
"""GitHub URL parsing."""
|
||||
|
||||
@@ -166,11 +197,30 @@ class TestSkillsShClient:
|
||||
assert "custom.registry.io" in str(call_args)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_github_url(self) -> None:
|
||||
async def test_source_url_normalizes_dirty_id(self) -> None:
|
||||
# Whitespace-bearing id from /api/search must produce the same
|
||||
# source_url that download_skill persists, so the discover-UI
|
||||
# "already installed" check matches.
|
||||
from turnstone.core.skill_sources import _skills_sh_source_url
|
||||
|
||||
clean = _skills_sh_source_url("https://skills.sh", "owner/repo/leaf")
|
||||
dirty = _skills_sh_source_url("https://skills.sh", " owner/repo/leaf ")
|
||||
leading_slash = _skills_sh_source_url("https://skills.sh", "/owner/repo/leaf/")
|
||||
assert clean == dirty == leading_slash == "https://skills.sh/skills/owner/repo/leaf"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_derives_source_url_when_missing(self) -> None:
|
||||
# Real skills.sh /api/search responses don't carry source_url.
|
||||
# We synthesise one from the canonical id so the discover-UI
|
||||
# "already installed" check matches what download_skill persists.
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {"source_url": "https://github.com/owner/skill-repo"}
|
||||
mock_response.json.return_value = {
|
||||
"skills": [
|
||||
{"id": "owner/repo/leaf", "name": "leaf"},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
@@ -180,9 +230,240 @@ class TestSkillsShClient:
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
url = await client.resolve_github_url("owner/skill")
|
||||
results = await client.search()
|
||||
|
||||
assert url == "https://github.com/owner/skill-repo"
|
||||
assert len(results) == 1
|
||||
assert results[0].source_url == "https://skills.sh/skills/owner/repo/leaf"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_success(self) -> None:
|
||||
skill_md = """---
|
||||
name: leaf
|
||||
description: A leaf skill
|
||||
author: Owner
|
||||
tags: [demo]
|
||||
---
|
||||
body
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [
|
||||
{"path": "SKILL.md", "contents": skill_md},
|
||||
{"path": "scripts/run.sh", "contents": "#!/bin/sh\necho hi\n"},
|
||||
# Filtered out: not in _RESOURCE_DIRS
|
||||
{"path": "README.md", "contents": "ignored"},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
package = await client.download_skill("owner/repo/leaf")
|
||||
|
||||
# Confirm the request hit /api/download/{owner}/{repo}/{skill}
|
||||
url_called = instance.get.call_args.args[0]
|
||||
assert url_called == "https://skills.sh/api/download/owner/repo/leaf"
|
||||
|
||||
assert package.parsed.name == "leaf"
|
||||
assert package.parsed.author == "Owner"
|
||||
assert package.listing.id == "owner/repo/leaf"
|
||||
assert package.listing.source == "skills.sh"
|
||||
assert package.listing.source_url == "https://skills.sh/skills/owner/repo/leaf"
|
||||
assert package.resources == {"scripts/run.sh": "#!/bin/sh\necho hi\n"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_invalid_id(self) -> None:
|
||||
client = SkillsShClient()
|
||||
with pytest.raises(SkillSourceError, match="expected 'owner/repo/skill-name'"):
|
||||
await client.download_skill("not-a-three-segment-id")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_404(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_response.text = "Not Found"
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
with pytest.raises(SkillNotFoundError, match="has no skill"):
|
||||
await client.download_skill("owner/repo/missing")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_no_skill_md(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [{"path": "scripts/run.sh", "contents": "x"}],
|
||||
}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
with pytest.raises(SkillNotFoundError, match="no SKILL.md"):
|
||||
await client.download_skill("owner/repo/leaf")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_empty_files(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"files": []}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
with pytest.raises(SkillSourceError, match="returned no files"):
|
||||
await client.download_skill("owner/repo/leaf")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_files_not_a_list(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"files": "oops"}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
with pytest.raises(SkillSourceError, match="returned no files"):
|
||||
await client.download_skill("owner/repo/leaf")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_oversized_skill_md(self) -> None:
|
||||
from turnstone.core.skill_sources import _MAX_SKILL_MD_SIZE
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [{"path": "SKILL.md", "contents": "x" * (_MAX_SKILL_MD_SIZE + 1)}],
|
||||
}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
with pytest.raises(SkillSourceError, match="exceeds size cap"):
|
||||
await client.download_skill("owner/repo/leaf")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_oversized_multibyte_skill_md(self) -> None:
|
||||
# 4-byte emoji × N: code-point count stays under the cap, but UTF-8
|
||||
# byte length is 4× higher. Catches the regression where the size
|
||||
# cap was measured in code points rather than bytes.
|
||||
from turnstone.core.skill_sources import _MAX_SKILL_MD_SIZE
|
||||
|
||||
# Half the cap in code points → 2× the cap in encoded bytes.
|
||||
emoji_count = (_MAX_SKILL_MD_SIZE // 2) + 1
|
||||
payload = "🎉" * emoji_count
|
||||
assert len(payload) <= _MAX_SKILL_MD_SIZE # would pass code-point check
|
||||
assert len(payload.encode("utf-8")) > _MAX_SKILL_MD_SIZE # but exceeds byte cap
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [{"path": "SKILL.md", "contents": payload}],
|
||||
}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
with pytest.raises(SkillSourceError, match="exceeds size cap"):
|
||||
await client.download_skill("owner/repo/leaf")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_resource_cap(self) -> None:
|
||||
from turnstone.core.skill_sources import _MAX_RESOURCE_FILES
|
||||
|
||||
skill_md = """---
|
||||
name: leaf
|
||||
description: caps test
|
||||
---
|
||||
"""
|
||||
# 12 valid resources — only the first _MAX_RESOURCE_FILES should land.
|
||||
files = [{"path": "SKILL.md", "contents": skill_md}]
|
||||
for i in range(_MAX_RESOURCE_FILES + 2):
|
||||
files.append({"path": f"scripts/r{i}.sh", "contents": f"#{i}\n"})
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"files": files}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
package = await client.download_skill("owner/repo/leaf")
|
||||
|
||||
assert len(package.resources) == _MAX_RESOURCE_FILES
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_download_skill_filters_non_text_extension(self) -> None:
|
||||
skill_md = """---
|
||||
name: leaf
|
||||
description: ext test
|
||||
---
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [
|
||||
{"path": "SKILL.md", "contents": skill_md},
|
||||
# Right dir, wrong extension — must be dropped.
|
||||
{"path": "scripts/binary.exe", "contents": "MZ..."},
|
||||
# Right dir + right extension — kept.
|
||||
{"path": "scripts/keep.sh", "contents": "echo\n"},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
|
||||
instance = AsyncMock()
|
||||
instance.get = AsyncMock(return_value=mock_response)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client_cls.return_value = instance
|
||||
|
||||
client = SkillsShClient()
|
||||
package = await client.download_skill("owner/repo/leaf")
|
||||
|
||||
assert package.resources == {"scripts/keep.sh": "echo\n"}
|
||||
|
||||
|
||||
class TestFetchSkillFromGithub:
|
||||
|
||||
@@ -705,6 +705,7 @@ def api_client(api_storage):
|
||||
admin_create_skill,
|
||||
admin_delete_skill,
|
||||
admin_list_skills,
|
||||
admin_unlock_skill,
|
||||
admin_update_skill,
|
||||
)
|
||||
|
||||
@@ -716,6 +717,11 @@ def api_client(api_storage):
|
||||
Route("/api/admin/skills", admin_create_skill, methods=["POST"]),
|
||||
Route("/api/admin/skills/{skill_id}", admin_update_skill, methods=["PUT"]),
|
||||
Route("/api/admin/skills/{skill_id}", admin_delete_skill, methods=["DELETE"]),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/unlock",
|
||||
admin_unlock_skill,
|
||||
methods=["POST"],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1028,6 +1034,45 @@ class TestSkillAPI:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["token_estimate"] == 200
|
||||
|
||||
def test_update_skill_notify_on_complete_empty_string_normalises_to_array(
|
||||
self, api_client, api_storage
|
||||
):
|
||||
"""Empty / whitespace ``notify_on_complete`` is normalised to ``"[]"``.
|
||||
|
||||
Defends against a regression where ``if nc and nc != "[]":`` would
|
||||
short-circuit on a blank value, skip JSON validation, and persist a
|
||||
non-JSON empty string into storage.
|
||||
"""
|
||||
_create_template(api_storage, "s1", "norm-empty", "x")
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={"notify_on_complete": ""},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert api_storage.get_prompt_template("s1")["notify_on_complete"] == "[]"
|
||||
|
||||
def test_update_skill_notify_on_complete_legacy_object_normalises(
|
||||
self, api_client, api_storage
|
||||
):
|
||||
"""The legacy ``"{}"`` sentinel coerces to ``"[]"`` on write."""
|
||||
_create_template(api_storage, "s1", "norm-obj", "x")
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={"notify_on_complete": "{}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert api_storage.get_prompt_template("s1")["notify_on_complete"] == "[]"
|
||||
|
||||
def test_update_skill_notify_on_complete_rejects_non_array_json(self, api_client, api_storage):
|
||||
"""Valid JSON that isn't an array is a 400 — was previously accepted."""
|
||||
_create_template(api_storage, "s1", "reject-obj", "x")
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={"notify_on_complete": '{"channel": "discord"}'},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "array" in resp.json()["error"].lower()
|
||||
|
||||
def test_delete_skill_endpoint(self, api_client, api_storage):
|
||||
"""DELETE /v1/api/admin/skills/{id} deletes the skill."""
|
||||
_create_template(api_storage, "s1", "deletable", "content")
|
||||
@@ -1058,6 +1103,117 @@ class TestSkillAPI:
|
||||
resp = api_client.delete("/v1/api/admin/skills/s1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unlock_skill_flips_readonly(self, api_client, api_storage):
|
||||
"""POST /unlock on a readonly skill flips readonly to False."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
"installed",
|
||||
"upstream content",
|
||||
origin="source",
|
||||
source_url="https://skills.sh/skills/owner/repo/leaf",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
resp = api_client.post("/v1/api/admin/skills/s1/unlock")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["readonly"] is False
|
||||
# Origin preserved so the UI can still surface "Customized from upstream".
|
||||
assert data["origin"] == "source"
|
||||
assert data["source_url"] == "https://skills.sh/skills/owner/repo/leaf"
|
||||
|
||||
def test_unlock_skill_persists(self, api_client, api_storage):
|
||||
"""Unlock side-effects survive a fresh load from storage."""
|
||||
_create_template(api_storage, "s1", "installed", "x", origin="source", readonly=True)
|
||||
|
||||
api_client.post("/v1/api/admin/skills/s1/unlock")
|
||||
|
||||
row = api_storage.get_prompt_template("s1")
|
||||
assert row is not None
|
||||
assert bool(row["readonly"]) is False
|
||||
|
||||
def test_unlock_skill_snapshots_pre_unlock_state(self, api_client, api_storage):
|
||||
"""Pre-unlock skill state is snapshotted to skill_versions for rollback."""
|
||||
_create_template(
|
||||
api_storage, "s1", "installed", "upstream content", origin="source", readonly=True
|
||||
)
|
||||
assert api_storage.list_skill_versions("s1") == []
|
||||
|
||||
api_client.post("/v1/api/admin/skills/s1/unlock")
|
||||
|
||||
versions = api_storage.list_skill_versions("s1")
|
||||
assert len(versions) == 1
|
||||
assert versions[0]["version"] == 1
|
||||
|
||||
def test_unlock_skill_versions_after_existing_history(self, api_client, api_storage):
|
||||
"""Unlock picks the next version number based on max(version), not list length.
|
||||
|
||||
Defends against regressions where len(list)+1 racing with a concurrent
|
||||
update could produce a (skill_id, version) collision. Seeds an
|
||||
out-of-order version (3) and verifies unlock chooses 4, not 2.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
_create_template(
|
||||
api_storage, "s1", "installed", "v0 content", origin="source", readonly=True
|
||||
)
|
||||
api_storage.create_skill_version(
|
||||
skill_id="s1", version=3, snapshot=_json.dumps({"v": 3}), changed_by="prior"
|
||||
)
|
||||
|
||||
resp = api_client.post("/v1/api/admin/skills/s1/unlock")
|
||||
assert resp.status_code == 200
|
||||
|
||||
versions = sorted(api_storage.list_skill_versions("s1"), key=lambda v: v["version"])
|
||||
assert [v["version"] for v in versions] == [3, 4]
|
||||
|
||||
def test_unlock_skill_already_unlocked(self, api_client, api_storage):
|
||||
"""Unlocking an already-unlocked skill returns 400."""
|
||||
_create_template(api_storage, "s1", "local", "content", origin="manual", readonly=False)
|
||||
|
||||
resp = api_client.post("/v1/api/admin/skills/s1/unlock")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "already unlocked" in resp.json()["error"].lower()
|
||||
|
||||
def test_unlock_skill_not_found(self, api_client):
|
||||
"""Unlocking a nonexistent skill returns 404."""
|
||||
resp = api_client.post("/v1/api/admin/skills/missing/unlock")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_unlock_then_edit_spec_fields_succeeds(self, api_client, api_storage):
|
||||
"""After unlock, PUT can edit spec fields the readonly gate previously rejected."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
"installed",
|
||||
"upstream content",
|
||||
description="upstream description",
|
||||
origin="source",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
api_client.post("/v1/api/admin/skills/s1/unlock")
|
||||
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={
|
||||
"name": "customized",
|
||||
"content": "tuned content",
|
||||
"description": "tuned description",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "customized"
|
||||
assert data["content"] == "tuned content"
|
||||
assert data["description"] == "tuned description"
|
||||
# Origin still records that this came from upstream.
|
||||
assert data["origin"] == "source"
|
||||
|
||||
def test_skill_field_on_workstream_create(self, api_storage):
|
||||
"""Console workstream creation accepts 'skill' field in request body."""
|
||||
# This test verifies the server code that reads body.get("skill", "")
|
||||
|
||||
@@ -898,6 +898,97 @@ class TestHistoryInteractive:
|
||||
# Above-cap → clamps to 500 (response is still 200; we have 4 rows).
|
||||
assert client.get(base, params={"limit": 999}).status_code == 200
|
||||
|
||||
def test_returns_partial_trailing_turn_during_tool_execution(self, _inject_storage):
|
||||
"""The ``/history`` REST endpoint is a *display* read and must
|
||||
surface partial state. When the operator refreshes the page
|
||||
mid-tool-execution — assistant ``tool_calls`` saved, only some
|
||||
results saved — the trailing turn must come back on the wire so
|
||||
the UI can render what the operator was watching live.
|
||||
|
||||
Storage's ``load_messages`` defaults to a repair pass that
|
||||
strips this exact shape (correct for ``session.resume``, wrong
|
||||
for display). ``make_history_handler`` must opt out via
|
||||
``repair=False``; flipping that flag back on breaks this test.
|
||||
"""
|
||||
import json
|
||||
|
||||
ws_id = "ws-mid-exec"
|
||||
_inject_storage.register_workstream(ws_id, kind="interactive", user_id="test-user")
|
||||
_inject_storage.save_message(ws_id, "user", "kick off")
|
||||
tc_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"ls"}'},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"pwd"}'},
|
||||
},
|
||||
]
|
||||
)
|
||||
_inject_storage.save_message(ws_id, "assistant", "Working", tool_calls=tc_json)
|
||||
_inject_storage.save_message(ws_id, "tool", "file.txt", tool_call_id="call_1")
|
||||
# call_2 result not yet persisted — operator refreshes here.
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = ws_id
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get.return_value = mock_ws
|
||||
client = _build_history_app(mock_mgr, _inject_storage)
|
||||
|
||||
r = client.get(f"/v1/api/workstreams/{ws_id}/history")
|
||||
assert r.status_code == 200
|
||||
roles = [m.get("role") for m in r.json()["messages"]]
|
||||
# All three rows survive — the trailing assistant + partial
|
||||
# tool result are what the operator was watching live. The
|
||||
# default-repair shape would have been just ``["user"]``.
|
||||
assert roles == ["user", "assistant", "tool"]
|
||||
|
||||
# Confirm the default-repair path collapses this to just the
|
||||
# user message — locks in the regression contract.
|
||||
with_repair = _inject_storage.load_messages(ws_id, repair=True)
|
||||
assert [m.get("role") for m in with_repair] == ["user"]
|
||||
|
||||
def test_history_does_not_synthesize_orphan_results(self, _inject_storage):
|
||||
"""``repair=False`` via ``/history`` must NOT splice synthetic
|
||||
``"Tool execution was cancelled."`` rows for mid-conversation
|
||||
orphaned tool_calls — the operator never saw those rows, and
|
||||
showing them would invent UI content that doesn't reflect
|
||||
persisted state.
|
||||
"""
|
||||
import json
|
||||
|
||||
ws_id = "ws-orphan-mid"
|
||||
_inject_storage.register_workstream(ws_id, kind="interactive", user_id="test-user")
|
||||
_inject_storage.save_message(ws_id, "user", "first")
|
||||
tc_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"ls"}'},
|
||||
},
|
||||
]
|
||||
)
|
||||
_inject_storage.save_message(ws_id, "assistant", "Working", tool_calls=tc_json)
|
||||
# Cancel landed before any tool result — next turn happens.
|
||||
_inject_storage.save_message(ws_id, "user", "second")
|
||||
_inject_storage.save_message(ws_id, "assistant", "ok")
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = ws_id
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get.return_value = mock_ws
|
||||
client = _build_history_app(mock_mgr, _inject_storage)
|
||||
|
||||
r = client.get(f"/v1/api/workstreams/{ws_id}/history")
|
||||
assert r.status_code == 200
|
||||
roles = [m.get("role") for m in r.json()["messages"]]
|
||||
# No synthetic tool row spliced after the orphaned tool_calls.
|
||||
assert roles == ["user", "assistant", "user", "assistant"]
|
||||
assert all(m.get("role") != "tool" for m in r.json()["messages"])
|
||||
|
||||
|
||||
class TestBuildHistoryReminderPropagation:
|
||||
"""``_build_history`` must surface the ``_reminders`` side-channel on
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.5.8"
|
||||
__version__ = "1.5.10"
|
||||
|
||||
@@ -319,7 +319,7 @@ class SkillInfo(BaseModel):
|
||||
max_tokens: int | None = None
|
||||
token_budget: int = 0
|
||||
agent_max_turns: int | None = None
|
||||
notify_on_complete: str = "{}"
|
||||
notify_on_complete: str = "[]"
|
||||
enabled: bool = True
|
||||
priority: int = 0
|
||||
allowed_tools: str = "[]"
|
||||
@@ -362,7 +362,7 @@ class CreateSkillRequest(BaseModel):
|
||||
max_tokens: int | None = None
|
||||
token_budget: int = 0
|
||||
agent_max_turns: int | None = None
|
||||
notify_on_complete: str = "{}"
|
||||
notify_on_complete: str = "[]"
|
||||
enabled: bool = True
|
||||
priority: int = 0
|
||||
allowed_tools: str = "[]"
|
||||
|
||||
@@ -703,6 +703,13 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
"Re-scan a skill for security signals",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/skills/{skill_id}/unlock",
|
||||
"POST",
|
||||
"Unlock a readonly (installed) skill so spec fields and resources can be edited",
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Skill Resources ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/skills/{skill_id}/resources",
|
||||
|
||||
@@ -1488,12 +1488,17 @@ class CoordinatorClient:
|
||||
is_own_child = full.get("parent_ws_id") == self._coord_ws_id
|
||||
if not (is_self or is_own_child):
|
||||
return miss
|
||||
# load_messages returns the full history in chronological order
|
||||
# (no limit param in the Protocol) — slice the tail here. Defensive
|
||||
# load_messages returns the full history in chronological order.
|
||||
# We slice the tail in Python because the SQL tail-N is
|
||||
# approximate across conversation boundaries. Defensive
|
||||
# try/except: storage errors should not break inspect.
|
||||
messages: list[Any] = []
|
||||
try:
|
||||
all_msgs = self._storage.load_messages(ws_id)
|
||||
# repair=False — inspect is a display read (admin viewing a
|
||||
# child's history in the tree UI). The LLM-context repair
|
||||
# pass would strip trailing partial turns the operator is
|
||||
# watching.
|
||||
all_msgs = self._storage.load_messages(ws_id, repair=False)
|
||||
if message_limit and message_limit > 0:
|
||||
messages = all_msgs[-message_limit:]
|
||||
else:
|
||||
@@ -1725,7 +1730,11 @@ def _last_assistant_text(storage: Any, ws_id: str) -> str | None:
|
||||
in just to surface its final turn.
|
||||
"""
|
||||
try:
|
||||
rows = storage.load_messages(ws_id, limit=_WAIT_MESSAGE_TAIL_LIMIT)
|
||||
# repair=False — this reads the tail for display ("waiting on" bubble).
|
||||
# The repair pass would strip a trailing partial assistant turn,
|
||||
# making us return the penultimate assistant message instead of the
|
||||
# one the operator is watching.
|
||||
rows = storage.load_messages(ws_id, limit=_WAIT_MESSAGE_TAIL_LIMIT, repair=False)
|
||||
except Exception:
|
||||
log.debug("coord_client.wait.load_messages_failed ws=%s", ws_id, exc_info=True)
|
||||
return None
|
||||
|
||||
+226
-23
@@ -1328,7 +1328,9 @@ async def cluster_ws_detail(request: Request) -> JSONResponse:
|
||||
# Tail-N bound pushed into SQL (load_messages supports limit
|
||||
# on both backends). Offloaded to the default executor so
|
||||
# the async SSE loop stays unblocked under rapid fan-out.
|
||||
messages = await asyncio.to_thread(storage.load_messages, ws_id, limit=limit)
|
||||
messages = await asyncio.to_thread(
|
||||
storage.load_messages, ws_id, limit=limit, repair=False
|
||||
)
|
||||
except Exception:
|
||||
log.debug("cluster_ws_detail.load_messages_failed", exc_info=True)
|
||||
|
||||
@@ -6333,14 +6335,25 @@ def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], J
|
||||
fields["activation"] = activation
|
||||
|
||||
if "notify_on_complete" in body:
|
||||
nc = str(body.get("notify_on_complete", "{}")).strip()
|
||||
if nc and nc != "{}":
|
||||
nc = str(body.get("notify_on_complete", "[]")).strip()
|
||||
# Normalise empty/whitespace and the legacy ``"{}"`` sentinel
|
||||
# (inherited from migrations 011/021's server_default — older rows
|
||||
# that haven't been touched by migration 051 may still carry it)
|
||||
# to the canonical empty-array literal so a blank field can never
|
||||
# bypass validation and persist a non-JSON value.
|
||||
if not nc or nc == "{}":
|
||||
nc = "[]"
|
||||
if nc != "[]":
|
||||
try:
|
||||
_json.loads(nc)
|
||||
parsed = _json.loads(nc)
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
return {}, JSONResponse(
|
||||
{"error": "notify_on_complete must be valid JSON"}, status_code=400
|
||||
)
|
||||
if not isinstance(parsed, list):
|
||||
return {}, JSONResponse(
|
||||
{"error": "notify_on_complete must be a JSON array"}, status_code=400
|
||||
)
|
||||
fields["notify_on_complete"] = nc
|
||||
|
||||
if "allowed_tools" in body:
|
||||
@@ -6394,7 +6407,13 @@ def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str,
|
||||
"max_tokens": r.get("max_tokens"),
|
||||
"token_budget": r.get("token_budget", 0),
|
||||
"agent_max_turns": r.get("agent_max_turns"),
|
||||
"notify_on_complete": r.get("notify_on_complete", "{}"),
|
||||
# Coerce the legacy ``"{}"`` sentinel from rows pre-migration 051;
|
||||
# the field is contractually a JSON-array string everywhere else.
|
||||
"notify_on_complete": (
|
||||
"[]"
|
||||
if (r.get("notify_on_complete") or "[]") == "{}"
|
||||
else r.get("notify_on_complete", "[]")
|
||||
),
|
||||
"enabled": r.get("enabled", True),
|
||||
"priority": r.get("priority", 0),
|
||||
"allowed_tools": r.get("allowed_tools", "[]"),
|
||||
@@ -7019,6 +7038,66 @@ async def admin_rescan_skill(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
async def admin_unlock_skill(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/skills/{skill_id}/unlock — detach an installed skill from upstream.
|
||||
|
||||
Flips ``readonly`` False so spec fields and resources become editable. The
|
||||
pre-unlock state is snapshotted into ``skill_versions`` so an operator can
|
||||
diff against — or restore — what we got from the upstream source. ``origin``
|
||||
stays ``"source"`` so the UI can still surface "modified from upstream"
|
||||
against unlocked installs.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.skills")
|
||||
if err:
|
||||
return err
|
||||
|
||||
skill_id = request.path_params["skill_id"]
|
||||
existing = storage.get_prompt_template(skill_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
if not existing.get("readonly"):
|
||||
return JSONResponse({"error": "Skill is already unlocked"}, status_code=400)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
|
||||
snapshot = _json.dumps(existing, default=str)
|
||||
version_int = storage.unlock_skill(skill_id, snapshot, audit_uid)
|
||||
if version_int is None:
|
||||
# Row vanished between the existence check and the atomic unlock —
|
||||
# treat as 404 rather than 500.
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"skill.unlock",
|
||||
"skill",
|
||||
skill_id,
|
||||
{
|
||||
"name": existing.get("name") or "",
|
||||
"source_url": existing.get("source_url") or "",
|
||||
"origin": existing.get("origin") or "",
|
||||
"snapshot_version": version_int,
|
||||
},
|
||||
ip,
|
||||
)
|
||||
|
||||
updated = storage.get_prompt_template(skill_id)
|
||||
if updated is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
rc_map = storage.count_skill_resources_bulk([skill_id])
|
||||
return JSONResponse(_skill_to_response(updated, resource_count=rc_map.get(skill_id, 0)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin: Skill Resources
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -7330,7 +7409,22 @@ async def admin_skill_discover(request: Request) -> JSONResponse:
|
||||
try:
|
||||
listings = await client.search(query=q, limit=limit)
|
||||
except SkillSourceError as exc:
|
||||
log.warning(
|
||||
"skill.discover.source_error q=%s discovery_url=%s err=%s",
|
||||
q,
|
||||
discovery_url,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return JSONResponse({"error": f"Discovery error: {exc}"}, status_code=502)
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
"skill.discover.unexpected q=%s discovery_url=%s err=%s",
|
||||
q,
|
||||
discovery_url,
|
||||
exc,
|
||||
)
|
||||
return JSONResponse({"error": f"Unexpected discovery error: {exc}"}, status_code=500)
|
||||
|
||||
# Mark which skills are already installed (by source_url match)
|
||||
installed_map: dict[str, dict[str, str]] = {}
|
||||
@@ -7376,6 +7470,7 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
fetch_skill_from_github,
|
||||
fetch_skills_from_github_repo,
|
||||
)
|
||||
from turnstone.core.storage._protocol import StorageConflictError
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
@@ -7393,31 +7488,62 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
if source not in ("skills.sh", "github"):
|
||||
return JSONResponse({"error": "source must be 'skills.sh' or 'github'"}, status_code=400)
|
||||
|
||||
skill_id_param = str(body.get("skill_id", "")).strip()
|
||||
url_param = str(body.get("url", "")).strip()
|
||||
log.debug(
|
||||
"skill.install.start source=%s skill_id=%s url=%s",
|
||||
source,
|
||||
skill_id_param or "-",
|
||||
url_param or "-",
|
||||
)
|
||||
|
||||
def _log_install_failure(level: int, event: str, exc: BaseException) -> None:
|
||||
log.log(
|
||||
level,
|
||||
"%s source=%s skill_id=%s url=%s err=%s",
|
||||
event,
|
||||
source,
|
||||
skill_id_param or "-",
|
||||
url_param or "-",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
if source == "skills.sh":
|
||||
skill_id_param = str(body.get("skill_id", "")).strip()
|
||||
if not skill_id_param:
|
||||
return JSONResponse({"error": "skill_id is required"}, status_code=400)
|
||||
|
||||
discovery_url = _get_discovery_url(request)
|
||||
client = SkillsShClient(base_url=discovery_url)
|
||||
github_url = await client.resolve_github_url(skill_id_param)
|
||||
packages = [await fetch_skill_from_github(github_url)]
|
||||
packages = [await client.download_skill(skill_id_param)]
|
||||
log.debug(
|
||||
"skill.install.downloaded skill_id=%s name=%s resources=%d",
|
||||
skill_id_param,
|
||||
packages[0].parsed.name,
|
||||
len(packages[0].resources),
|
||||
)
|
||||
else:
|
||||
url = str(body.get("url", "")).strip()
|
||||
if not url:
|
||||
if not url_param:
|
||||
return JSONResponse({"error": "url is required"}, status_code=400)
|
||||
try:
|
||||
packages = [await fetch_skill_from_github(url)]
|
||||
packages = [await fetch_skill_from_github(url_param)]
|
||||
except SkillNotFoundError:
|
||||
# No root SKILL.md — try scanning for a multi-skill repo
|
||||
packages = await fetch_skills_from_github_repo(url)
|
||||
log.debug("skill.install.repo_scan url=%s", url_param)
|
||||
packages = await fetch_skills_from_github_repo(url_param)
|
||||
except SkillNotFoundError as exc:
|
||||
_log_install_failure(logging.WARNING, "skill.install.not_found", exc)
|
||||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||||
except SkillSourceError as exc:
|
||||
_log_install_failure(logging.WARNING, "skill.install.source_error", exc)
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
except ValueError as exc:
|
||||
_log_install_failure(logging.WARNING, "skill.install.value_error", exc)
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
except Exception as exc:
|
||||
_log_install_failure(logging.ERROR, "skill.install.unexpected", exc)
|
||||
return JSONResponse({"error": f"Unexpected error fetching skill: {exc}"}, status_code=500)
|
||||
|
||||
import json as _json
|
||||
|
||||
@@ -7430,11 +7556,20 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
|
||||
# Check for duplicate by source_url
|
||||
if pkg_source_url and storage.get_skill_by_source_url(pkg_source_url):
|
||||
log.debug(
|
||||
"skill.install.skip name=%s reason=already_installed source_url=%s",
|
||||
package.parsed.name,
|
||||
pkg_source_url,
|
||||
)
|
||||
skipped.append({"name": package.parsed.name, "reason": "already installed"})
|
||||
continue
|
||||
|
||||
# Check for duplicate by name
|
||||
if storage.get_prompt_template_by_name(package.parsed.name):
|
||||
log.debug(
|
||||
"skill.install.skip name=%s reason=name_exists",
|
||||
package.parsed.name,
|
||||
)
|
||||
skipped.append({"name": package.parsed.name, "reason": "name exists"})
|
||||
continue
|
||||
|
||||
@@ -7474,18 +7609,52 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
token_estimate=token_estimate,
|
||||
allowed_tools=allowed_tools_str,
|
||||
)
|
||||
except Exception:
|
||||
except StorageConflictError as exc:
|
||||
# Genuine uniqueness/constraint violation racing past the
|
||||
# pre-checks above — operator can re-try.
|
||||
log.warning(
|
||||
"skill.install.create_conflict name=%s skill_id=%s err=%s",
|
||||
parsed.name,
|
||||
skill_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
skipped.append({"name": parsed.name, "reason": "conflict"})
|
||||
continue
|
||||
|
||||
# Store bundled resources
|
||||
for res_path, res_content in package.resources.items():
|
||||
storage.create_skill_resource(
|
||||
resource_id=uuid.uuid4().hex,
|
||||
skill_id=skill_id,
|
||||
path=res_path,
|
||||
content=res_content,
|
||||
except Exception as exc:
|
||||
# Anything else — DB connection, disk full, permission — is
|
||||
# operational and shouldn't be relabeled "conflict".
|
||||
log.exception(
|
||||
"skill.install.create_failed name=%s skill_id=%s err=%s",
|
||||
parsed.name,
|
||||
skill_id,
|
||||
exc,
|
||||
)
|
||||
skipped.append({"name": parsed.name, "reason": "internal error"})
|
||||
continue
|
||||
|
||||
# Store bundled resources, tallying any failures so the caller can
|
||||
# see that the skill row was committed but is missing some assets
|
||||
# (the SKILL.md is still usable on its own — we don't roll back).
|
||||
failed_resources: list[str] = []
|
||||
for res_path, res_content in package.resources.items():
|
||||
try:
|
||||
storage.create_skill_resource(
|
||||
resource_id=uuid.uuid4().hex,
|
||||
skill_id=skill_id,
|
||||
path=res_path,
|
||||
content=res_content,
|
||||
)
|
||||
except Exception as exc:
|
||||
failed_resources.append(res_path)
|
||||
log.warning(
|
||||
"skill.install.resource_failed name=%s skill_id=%s path=%s err=%s",
|
||||
parsed.name,
|
||||
skill_id,
|
||||
res_path,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
record_audit(
|
||||
storage,
|
||||
@@ -7493,16 +7662,38 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
"skill.install",
|
||||
"skill",
|
||||
skill_id,
|
||||
{"name": parsed.name, "source": source, "source_url": pkg_source_url},
|
||||
{
|
||||
"name": parsed.name,
|
||||
"source": source,
|
||||
"source_url": pkg_source_url,
|
||||
"failed_resources": failed_resources,
|
||||
},
|
||||
ip,
|
||||
)
|
||||
|
||||
skill = storage.get_prompt_template(skill_id)
|
||||
if skill:
|
||||
installed.append(_skill_to_response(skill, resource_count=len(package.resources)))
|
||||
stored_resources = len(package.resources) - len(failed_resources)
|
||||
entry = _skill_to_response(skill, resource_count=stored_resources)
|
||||
if failed_resources:
|
||||
entry["failed_resources"] = failed_resources
|
||||
installed.append(entry)
|
||||
log.info(
|
||||
"skill.install.ok name=%s skill_id=%s resources=%d failed_resources=%d",
|
||||
parsed.name,
|
||||
skill_id,
|
||||
stored_resources,
|
||||
len(failed_resources),
|
||||
)
|
||||
|
||||
if not installed and skipped:
|
||||
# All skills were duplicates
|
||||
log.debug(
|
||||
"skill.install.done source=%s installed=0 skipped=%d total=%d",
|
||||
source,
|
||||
len(skipped),
|
||||
len(packages),
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "All skills already installed",
|
||||
@@ -7513,6 +7704,13 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
log.debug(
|
||||
"skill.install.done source=%s installed=%d skipped=%d total=%d",
|
||||
source,
|
||||
len(installed),
|
||||
len(skipped),
|
||||
len(packages),
|
||||
)
|
||||
# Consistent envelope for both single and batch installs
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -12195,6 +12393,11 @@ def create_app(
|
||||
admin_rescan_skill,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/unlock",
|
||||
admin_unlock_skill,
|
||||
methods=["POST"],
|
||||
),
|
||||
# Node metadata
|
||||
Route("/api/admin/node-metadata", admin_get_all_node_metadata),
|
||||
Route(
|
||||
|
||||
@@ -1314,7 +1314,9 @@ function toggleScheduleNodeField() {
|
||||
function showCreateScheduleModal() {
|
||||
var overlay = document.getElementById("create-schedule-overlay");
|
||||
overlay.style.display = "flex";
|
||||
document.getElementById("create-schedule-error").style.display = "none";
|
||||
document
|
||||
.getElementById("create-schedule-error")
|
||||
.classList.remove("is-visible");
|
||||
document.getElementById("cs-name").value = "";
|
||||
document.getElementById("cs-desc").value = "";
|
||||
document.getElementById("cs-type").value = "cron";
|
||||
@@ -1506,7 +1508,9 @@ function showEditScheduleModal(taskId) {
|
||||
_populateNotifyRows("es", s.notify_targets || []);
|
||||
toggleEditScheduleTypeFields();
|
||||
toggleEditScheduleNodeField();
|
||||
document.getElementById("edit-schedule-error").style.display = "none";
|
||||
document
|
||||
.getElementById("edit-schedule-error")
|
||||
.classList.remove("is-visible");
|
||||
document.getElementById("es-submit").disabled = false;
|
||||
document.getElementById("es-submit").textContent = "Save";
|
||||
var overlay = document.getElementById("edit-schedule-overlay");
|
||||
@@ -1845,7 +1849,9 @@ function showCreateChannelModal() {
|
||||
}
|
||||
var overlay = document.getElementById("create-channel-overlay");
|
||||
overlay.style.display = "flex";
|
||||
document.getElementById("create-channel-error").style.display = "none";
|
||||
document
|
||||
.getElementById("create-channel-error")
|
||||
.classList.remove("is-visible");
|
||||
var ctSel = document.getElementById("cc-type");
|
||||
var uidInput = document.getElementById("cc-uid");
|
||||
ctSel.value = "discord";
|
||||
@@ -1924,7 +1930,7 @@ function submitCreateChannel() {
|
||||
function showCreateUserModal() {
|
||||
var overlay = document.getElementById("create-user-overlay");
|
||||
overlay.style.display = "flex";
|
||||
document.getElementById("create-user-error").style.display = "none";
|
||||
document.getElementById("create-user-error").classList.remove("is-visible");
|
||||
document.getElementById("cu-username").value = "";
|
||||
document.getElementById("cu-displayname").value = "";
|
||||
document.getElementById("cu-password").value = "";
|
||||
@@ -2005,7 +2011,7 @@ function showCreateTokenModal() {
|
||||
}
|
||||
var overlay = document.getElementById("create-token-overlay");
|
||||
overlay.style.display = "flex";
|
||||
document.getElementById("create-token-error").style.display = "none";
|
||||
document.getElementById("create-token-error").classList.remove("is-visible");
|
||||
document.getElementById("ct-name").value = "";
|
||||
document.getElementById("ct-scopes").value = "read,write,approve";
|
||||
document.getElementById("ct-expires").value = "";
|
||||
@@ -3201,9 +3207,15 @@ function _resetSetting(key) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Show an error message in a modal's [role="alert"] element. The .is-visible
|
||||
// class is the canonical toggle — see `.admin-modal [role="alert"]` in
|
||||
// style.css. Do NOT use `el.style.display = "block"` here; the CSS rule
|
||||
// `.admin-modal [role="alert"] { display: none }` is selector-equivalent and
|
||||
// will hide the element again as soon as the inline style is cleared. Hide
|
||||
// side is `el.classList.remove("is-visible")`.
|
||||
function _showModalError(el, msg) {
|
||||
el.textContent = msg;
|
||||
el.style.display = "block";
|
||||
el.classList.add("is-visible");
|
||||
}
|
||||
|
||||
/* ── MCP Servers tab ─────────────────────────────────────────────────────── */
|
||||
@@ -3552,7 +3564,7 @@ function showCreateMcpModal() {
|
||||
document.getElementById("mcp-oauth-client-secret").value = "";
|
||||
document.getElementById("mcp-oauth-scopes").value = "";
|
||||
document.getElementById("mcp-oauth-audience").value = "";
|
||||
document.getElementById("mcp-create-error").style.display = "none";
|
||||
document.getElementById("mcp-create-error").classList.remove("is-visible");
|
||||
toggleMcpTransport();
|
||||
toggleMcpAuthFields();
|
||||
_wireMcpAudienceAutofill();
|
||||
@@ -3726,7 +3738,7 @@ function submitCreateMcp() {
|
||||
if (form.error) {
|
||||
var e = document.getElementById("mcp-create-error");
|
||||
e.textContent = form.error;
|
||||
e.style.display = "";
|
||||
e.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
var editId = document.getElementById("mcp-edit-id").value;
|
||||
@@ -3757,7 +3769,7 @@ function submitCreateMcp() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("mcp-create-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("mcp-create-submit").disabled = false;
|
||||
@@ -3939,7 +3951,7 @@ function showImportMcpModal() {
|
||||
_mcpImportTrigger = document.activeElement;
|
||||
document.getElementById("mcp-import-overlay").style.display = "flex";
|
||||
document.getElementById("mcp-import-json").value = "";
|
||||
document.getElementById("mcp-import-error").style.display = "none";
|
||||
document.getElementById("mcp-import-error").classList.remove("is-visible");
|
||||
document.getElementById("mcp-import-json").focus();
|
||||
_mcpImportTrap = _installTrap("mcp-import-overlay", "mcp-import-box");
|
||||
}
|
||||
@@ -3956,7 +3968,7 @@ function submitImportMcp() {
|
||||
if (!raw) {
|
||||
var e = document.getElementById("mcp-import-error");
|
||||
e.textContent = "Paste a JSON config";
|
||||
e.style.display = "";
|
||||
e.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
var parsed;
|
||||
@@ -3965,13 +3977,13 @@ function submitImportMcp() {
|
||||
} catch (ex) {
|
||||
var e2 = document.getElementById("mcp-import-error");
|
||||
e2.textContent = "Invalid JSON: " + ex.message;
|
||||
e2.style.display = "";
|
||||
e2.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
|
||||
var e3 = document.getElementById("mcp-import-error");
|
||||
e3.textContent = 'No "mcpServers" key found in JSON';
|
||||
e3.style.display = "";
|
||||
e3.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
document.getElementById("mcp-import-submit").disabled = true;
|
||||
@@ -4001,7 +4013,7 @@ function submitImportMcp() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("mcp-import-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("mcp-import-submit").disabled = false;
|
||||
@@ -4278,7 +4290,7 @@ function _showInstallMcpModal(srv, hasRemote, hasPackage) {
|
||||
_mcpInstallTrigger = document.activeElement;
|
||||
var ov = document.getElementById("mcp-install-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("mcp-install-error").style.display = "none";
|
||||
document.getElementById("mcp-install-error").classList.remove("is-visible");
|
||||
|
||||
// Summary
|
||||
document.getElementById("mcp-install-summary").innerHTML =
|
||||
@@ -4552,7 +4564,7 @@ function _doRegistryInstall(
|
||||
var errEl = document.getElementById("mcp-install-error");
|
||||
if (errEl && overlay && overlay.style.display !== "none") {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
} else {
|
||||
showToast("Install failed: " + e.message);
|
||||
// Re-render to reset card button states
|
||||
|
||||
@@ -203,7 +203,7 @@ function showCreateRoleModal() {
|
||||
document.getElementById("cr-displayname").value = "";
|
||||
document.getElementById("cr-perms-container").innerHTML =
|
||||
_buildPermCheckboxes("cr", []);
|
||||
document.getElementById("create-role-error").style.display = "none";
|
||||
document.getElementById("create-role-error").classList.remove("is-visible");
|
||||
document.getElementById("cr-name").focus();
|
||||
_crTrapHandler = _installTrap("create-role-overlay", "create-role-box");
|
||||
}
|
||||
@@ -224,7 +224,7 @@ function submitCreateRole() {
|
||||
if (!name) {
|
||||
var e = document.getElementById("create-role-error");
|
||||
e.textContent = "Name is required";
|
||||
e.style.display = "";
|
||||
e.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
if (!dname) dname = name;
|
||||
@@ -253,7 +253,7 @@ function submitCreateRole() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("create-role-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("cr-submit").disabled = false;
|
||||
@@ -277,7 +277,7 @@ function showEditRoleModal(roleId) {
|
||||
var selected = (role.permissions || "").split(",");
|
||||
document.getElementById("er-perms-container").innerHTML =
|
||||
_buildPermCheckboxes("er", selected);
|
||||
document.getElementById("edit-role-error").style.display = "none";
|
||||
document.getElementById("edit-role-error").classList.remove("is-visible");
|
||||
_erTrapHandler = _installTrap("edit-role-overlay", "edit-role-box");
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ function submitEditRole() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("edit-role-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("er-submit").disabled = false;
|
||||
@@ -533,7 +533,7 @@ function showCreatePolicyModal() {
|
||||
document.getElementById("cp-pattern").value = "";
|
||||
document.getElementById("cp-action").value = "ask";
|
||||
document.getElementById("cp-priority").value = "0";
|
||||
document.getElementById("create-policy-error").style.display = "none";
|
||||
document.getElementById("create-policy-error").classList.remove("is-visible");
|
||||
document.getElementById("cp-name").focus();
|
||||
_cpTrapHandler = _installTrap("create-policy-overlay", "create-policy-box");
|
||||
}
|
||||
@@ -556,7 +556,7 @@ function submitCreatePolicy() {
|
||||
if (!name || !pattern) {
|
||||
var e = document.getElementById("create-policy-error");
|
||||
e.textContent = "Name and pattern are required";
|
||||
e.style.display = "";
|
||||
e.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
document.getElementById("cp-submit").disabled = true;
|
||||
@@ -585,7 +585,7 @@ function submitCreatePolicy() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("create-policy-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("cp-submit").disabled = false;
|
||||
@@ -610,7 +610,7 @@ function showEditPolicyModal(policyId) {
|
||||
document.getElementById("ep-action").value = policy.action;
|
||||
document.getElementById("ep-priority").value = policy.priority;
|
||||
document.getElementById("ep-enabled").checked = policy.enabled;
|
||||
document.getElementById("edit-policy-error").style.display = "none";
|
||||
document.getElementById("edit-policy-error").classList.remove("is-visible");
|
||||
_epTrapHandler = _installTrap("edit-policy-overlay", "edit-policy-box");
|
||||
}
|
||||
|
||||
@@ -652,7 +652,7 @@ function submitEditPolicy() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("edit-policy-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("ep-submit").disabled = false;
|
||||
@@ -664,7 +664,7 @@ function submitEditPolicy() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadGovSkills() {
|
||||
authFetch("/v1/api/admin/skills")
|
||||
return authFetch("/v1/api/admin/skills")
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
@@ -1072,7 +1072,9 @@ function showCreateTemplateModal() {
|
||||
document.getElementById("csk-auto-approve").onchange = function () {
|
||||
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("create-template-error").style.display = "none";
|
||||
document
|
||||
.getElementById("create-template-error")
|
||||
.classList.remove("is-visible");
|
||||
// Clear resource list
|
||||
_pendingResources = [];
|
||||
_renderPendingResources();
|
||||
@@ -1110,12 +1112,21 @@ function hideCreateTemplateModal() {
|
||||
}
|
||||
|
||||
function submitCreateTemplate() {
|
||||
// Clear any prior error before re-validating — a successful submit shouldn't
|
||||
// leave stale red text on-screen, and the in-flight PUT period shouldn't
|
||||
// either. Cheaper than reasoning about every catch path remembering to
|
||||
// clear on success.
|
||||
var prevErr = document.getElementById("create-template-error");
|
||||
if (prevErr) {
|
||||
prevErr.classList.remove("is-visible");
|
||||
prevErr.textContent = "";
|
||||
}
|
||||
var name = document.getElementById("ctm-name").value.trim();
|
||||
var content = document.getElementById("ctm-content").value;
|
||||
if (!name || !content) {
|
||||
var e = document.getElementById("create-template-error");
|
||||
e.textContent = "Name and content are required";
|
||||
e.style.display = "";
|
||||
e.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
var varList = _detectTemplateVars(content);
|
||||
@@ -1157,7 +1168,7 @@ function submitCreateTemplate() {
|
||||
} catch (ne) {
|
||||
var ne2 = document.getElementById("create-template-error");
|
||||
ne2.textContent = "Notify on completion: " + ne.message;
|
||||
ne2.style.display = "";
|
||||
ne2.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1240,7 +1251,7 @@ function submitCreateTemplate() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("create-template-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("ctm-submit").disabled = false;
|
||||
@@ -1248,7 +1259,14 @@ function submitCreateTemplate() {
|
||||
}
|
||||
|
||||
function showEditTemplateModal(tmplId) {
|
||||
_etmTriggerEl = document.activeElement;
|
||||
var ov = document.getElementById("edit-template-overlay");
|
||||
// When called against an already-open modal (e.g. mutate-in-place after
|
||||
// unlock), preserve the original trigger so focus restores to the row
|
||||
// launcher on close, and don't reinstall the focus trap.
|
||||
var alreadyOpen = ov && ov.style.display === "flex";
|
||||
if (!alreadyOpen) {
|
||||
_etmTriggerEl = document.activeElement;
|
||||
}
|
||||
var tmpl = null;
|
||||
for (var i = 0; i < _govSkills.length; i++) {
|
||||
if (_govSkills[i].template_id === tmplId) {
|
||||
@@ -1257,7 +1275,6 @@ function showEditTemplateModal(tmplId) {
|
||||
}
|
||||
}
|
||||
if (!tmpl) return;
|
||||
var ov = document.getElementById("edit-template-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("etm-id").value = tmplId;
|
||||
document.getElementById("etm-name").value = tmpl.name;
|
||||
@@ -1316,7 +1333,9 @@ function showEditTemplateModal(tmplId) {
|
||||
document.getElementById("esk-auto-approve").onchange = function () {
|
||||
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("edit-template-error").style.display = "none";
|
||||
// The CSS contract for .admin-modal [role="alert"] is hide-by-default,
|
||||
// .is-visible to show — so toggling style.display does nothing here.
|
||||
document.getElementById("edit-template-error").classList.remove("is-visible");
|
||||
// Scan report section
|
||||
var scanSection = document.getElementById("etm-scan-section");
|
||||
if (scanSection) {
|
||||
@@ -1414,6 +1433,10 @@ function showEditTemplateModal(tmplId) {
|
||||
|
||||
// --- Readonly mode for imported skills ---
|
||||
var isReadonly = tmpl.readonly || false;
|
||||
// An unlocked install retains origin="source" — combined with !readonly it
|
||||
// means the operator detached the skill from upstream and may have edits.
|
||||
var isUnlockedInstall =
|
||||
!isReadonly && tmpl.origin && tmpl.origin === "source";
|
||||
var editTitle = document.getElementById("edit-template-title");
|
||||
if (editTitle)
|
||||
editTitle.textContent = isReadonly ? "View Skill" : "Edit Skill";
|
||||
@@ -1426,16 +1449,37 @@ function showEditTemplateModal(tmplId) {
|
||||
} else if (isReadonly && tmpl.origin && tmpl.origin !== "manual") {
|
||||
originBadge.textContent = "Installed skill";
|
||||
originBadge.style.display = "inline-flex";
|
||||
} else if (isUnlockedInstall && tmpl.source_url) {
|
||||
originBadge.textContent = "Customized from \u00a0" + tmpl.source_url;
|
||||
originBadge.style.display = "inline-flex";
|
||||
} else if (isUnlockedInstall) {
|
||||
originBadge.textContent = "Customized from upstream";
|
||||
originBadge.style.display = "inline-flex";
|
||||
} else {
|
||||
originBadge.style.display = "none";
|
||||
}
|
||||
}
|
||||
var lockBtn = document.getElementById("etm-lock-btn");
|
||||
if (lockBtn) {
|
||||
// Inline-flex (not "") so display: none doesn't bleed into a
|
||||
// browser-default block reflow on re-show.
|
||||
lockBtn.style.display = isReadonly ? "inline-flex" : "none";
|
||||
lockBtn.dataset.skillId = tmplId;
|
||||
lockBtn.dataset.skillName = tmpl.name || "";
|
||||
}
|
||||
var submitBtn = document.getElementById("etm-submit");
|
||||
if (submitBtn) {
|
||||
submitBtn.style.display = "";
|
||||
submitBtn.textContent = isReadonly ? "Save Config" : "Save";
|
||||
// Always reset to enabled — submitEditTemplate disables this on click
|
||||
// and re-enables in .finally, but a stale disabled=true survives a
|
||||
// mutate-in-place re-render (e.g. after unlock) and would leave the
|
||||
// button non-functional otherwise.
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
// Spec/content fields: locked for installed skills (preserve source fidelity)
|
||||
// Spec/content fields: locked for installed skills (preserve source fidelity).
|
||||
// Point screen readers at the origin badge so the "why is this disabled?"
|
||||
// affordance sighted users see is also announced.
|
||||
[
|
||||
"etm-name",
|
||||
"etm-category",
|
||||
@@ -1450,7 +1494,13 @@ function showEditTemplateModal(tmplId) {
|
||||
"etm-default",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.disabled = isReadonly;
|
||||
if (!el) return;
|
||||
el.disabled = isReadonly;
|
||||
if (isReadonly) {
|
||||
el.setAttribute("aria-describedby", "etm-origin-badge");
|
||||
} else {
|
||||
el.removeAttribute("aria-describedby");
|
||||
}
|
||||
});
|
||||
// Runtime config fields: always editable (local settings, not part of SKILL.md spec)
|
||||
[
|
||||
@@ -1483,12 +1533,25 @@ function showEditTemplateModal(tmplId) {
|
||||
if (resSection) {
|
||||
_loadSkillResources(tmplId, isReadonly);
|
||||
}
|
||||
_etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box");
|
||||
// Focus management
|
||||
if (isReadonly) {
|
||||
if (cancelBtn) cancelBtn.focus();
|
||||
} else {
|
||||
document.getElementById("etm-name").focus();
|
||||
if (!alreadyOpen) {
|
||||
_etmTrapHandler = _installTrap(
|
||||
"edit-template-overlay",
|
||||
"edit-template-box",
|
||||
);
|
||||
// Focus management — only on the initial open. Re-renders preserve
|
||||
// wherever focus was so a screen reader doesn't get a transition.
|
||||
// For readonly skills, prefer the lock button so keyboard users land
|
||||
// on the unlock affordance instead of having to Tab past every
|
||||
// disabled spec field to reach it. Cancel is still one Shift-Tab away.
|
||||
if (isReadonly) {
|
||||
if (lockBtn && lockBtn.style.display !== "none") {
|
||||
lockBtn.focus();
|
||||
} else if (cancelBtn) {
|
||||
cancelBtn.focus();
|
||||
}
|
||||
} else {
|
||||
document.getElementById("etm-name").focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1501,6 +1564,57 @@ function hideEditTemplateModal() {
|
||||
_etmTriggerEl = null;
|
||||
}
|
||||
|
||||
function unlockSkill() {
|
||||
var btn = document.getElementById("etm-lock-btn");
|
||||
if (!btn) return;
|
||||
var skillId = btn.dataset.skillId;
|
||||
var skillName = btn.dataset.skillName || "this skill";
|
||||
if (!skillId) return;
|
||||
showConfirmModal(
|
||||
"Customize " + skillName + "?",
|
||||
"This detaches the skill from its upstream source so you can edit " +
|
||||
"content, description, and resources locally. The current version is " +
|
||||
"saved to History — you can revert from there. Future updates from " +
|
||||
"the upstream source will not be applied.",
|
||||
"Customize",
|
||||
function () {
|
||||
_performUnlockSkill(skillId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function _performUnlockSkill(skillId) {
|
||||
var btn = document.getElementById("etm-lock-btn");
|
||||
if (btn) btn.disabled = true;
|
||||
authFetch("/v1/api/admin/skills/" + skillId + "/unlock", {
|
||||
method: "POST",
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) {
|
||||
return r.json().then(function (data) {
|
||||
throw new Error((data && data.error) || "Unlock failed");
|
||||
});
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Skill unlocked — fields are now editable");
|
||||
// Refresh the cached list, then re-render the open modal in place from
|
||||
// the fresh row. No close/reopen → no flicker, no focus bounce.
|
||||
return loadGovSkills().then(function () {
|
||||
showEditTemplateModal(skillId);
|
||||
});
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast(e.message || "Unlock failed");
|
||||
})
|
||||
.finally(function () {
|
||||
// Re-enable in case the re-render didn't happen (error path); the
|
||||
// success path hides the button anyway via showEditTemplateModal.
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1741,6 +1855,15 @@ function _addPendingResource() {
|
||||
}
|
||||
|
||||
function submitEditTemplate() {
|
||||
// Clear any prior error before re-validating — a successful submit shouldn't
|
||||
// leave stale red text visible during the in-flight PUT, and a successful
|
||||
// PUT shouldn't either. Cheaper than reasoning about every catch path
|
||||
// remembering to clear on success.
|
||||
var prevErr = document.getElementById("edit-template-error");
|
||||
if (prevErr) {
|
||||
prevErr.classList.remove("is-visible");
|
||||
prevErr.textContent = "";
|
||||
}
|
||||
var id = document.getElementById("etm-id").value;
|
||||
var content = document.getElementById("etm-content").value;
|
||||
var varList = _detectTemplateVars(content);
|
||||
@@ -1782,7 +1905,7 @@ function submitEditTemplate() {
|
||||
} catch (ne) {
|
||||
var ne3 = document.getElementById("edit-template-error");
|
||||
ne3.textContent = "Notify on completion: " + ne.message;
|
||||
ne3.style.display = "";
|
||||
ne3.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1836,7 +1959,7 @@ function submitEditTemplate() {
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("edit-template-error");
|
||||
el.textContent = e.message;
|
||||
el.style.display = "";
|
||||
el.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("etm-submit").disabled = false;
|
||||
@@ -2604,7 +2727,7 @@ function showGitHubImportModal() {
|
||||
urlInput.value = "";
|
||||
var errEl = document.getElementById("github-import-error");
|
||||
errEl.textContent = "";
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
_giTrapHandler = _installTrap("github-import-overlay", "github-import-box");
|
||||
urlInput.focus();
|
||||
}
|
||||
@@ -2623,19 +2746,19 @@ function submitGitHubImport() {
|
||||
var errEl = document.getElementById("github-import-error");
|
||||
if (!url) {
|
||||
errEl.textContent = "URL is required";
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
if (!/^https?:\/\/github\.com\//i.test(url)) {
|
||||
errEl.textContent = "Must be a GitHub URL";
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
|
||||
var submitBtn = document.getElementById("gi-submit");
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Installing\u2026";
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
|
||||
authFetch("/v1/api/admin/skills/install", {
|
||||
method: "POST",
|
||||
@@ -2676,7 +2799,7 @@ function submitGitHubImport() {
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
@@ -2823,7 +2946,7 @@ function showCreatePromptPolicyModal() {
|
||||
document.getElementById("cpp-gate").value = "";
|
||||
document.getElementById("cpp-content").value = "";
|
||||
document.getElementById("cpp-priority").value = "0";
|
||||
document.getElementById("cpp-error").style.display = "none";
|
||||
document.getElementById("cpp-error").classList.remove("is-visible");
|
||||
document.getElementById("cpp-name").focus();
|
||||
_cppTrapHandler = _installTrap(
|
||||
"create-ppolicy-overlay",
|
||||
@@ -2846,10 +2969,10 @@ function submitCreatePromptPolicy() {
|
||||
var content = document.getElementById("cpp-content").value.trim();
|
||||
if (!name || !content) {
|
||||
errEl.textContent = "Name and content are required";
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
var submitBtn = document.getElementById("cpp-submit");
|
||||
submitBtn.disabled = true;
|
||||
authFetch("/v1/api/admin/prompt-policies", {
|
||||
@@ -2878,7 +3001,7 @@ function submitCreatePromptPolicy() {
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
@@ -2901,7 +3024,7 @@ function showEditPromptPolicyModal(policyId) {
|
||||
document.getElementById("epp-content").value = p.content || "";
|
||||
document.getElementById("epp-priority").value = p.priority || 0;
|
||||
document.getElementById("epp-enabled").checked = p.enabled;
|
||||
document.getElementById("epp-error").style.display = "none";
|
||||
document.getElementById("epp-error").classList.remove("is-visible");
|
||||
var ov = document.getElementById("edit-ppolicy-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("epp-name").focus();
|
||||
@@ -2924,10 +3047,10 @@ function submitEditPromptPolicy() {
|
||||
var content = document.getElementById("epp-content").value.trim();
|
||||
if (!name || !content) {
|
||||
errEl.textContent = "Name and content are required";
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
return;
|
||||
}
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
var submitBtn = document.getElementById("epp-submit");
|
||||
submitBtn.disabled = true;
|
||||
authFetch("/v1/api/admin/prompt-policies/" + policyId, {
|
||||
@@ -2956,7 +3079,7 @@ function submitEditPromptPolicy() {
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
@@ -3480,7 +3603,7 @@ function showCreateHeuristicRuleModal() {
|
||||
document.getElementById("hr-conf").value = "0.8";
|
||||
document.getElementById("hr-intent").value = "";
|
||||
document.getElementById("hr-reason").value = "";
|
||||
document.getElementById("create-hr-error").style.display = "none";
|
||||
document.getElementById("create-hr-error").classList.remove("is-visible");
|
||||
document.getElementById("hr-submit").disabled = false;
|
||||
document.getElementById("hr-name").focus();
|
||||
_chrTrapHandler = _installTrap("create-hr-overlay", "create-hr-box");
|
||||
@@ -3495,7 +3618,7 @@ function hideCreateHRModal() {
|
||||
|
||||
function submitCreateHeuristicRule() {
|
||||
var errEl = document.getElementById("create-hr-error");
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
var argsText = document.getElementById("hr-args").value.trim();
|
||||
var argPatterns = argsText
|
||||
? argsText.split("\n").filter(function (l) {
|
||||
@@ -3535,7 +3658,7 @@ function submitCreateHeuristicRule() {
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
@@ -3647,7 +3770,7 @@ function _populateEditHRModal(rule, isBuiltin) {
|
||||
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("edit-hr-error").classList.remove("is-visible");
|
||||
document.getElementById("ehr-submit").disabled = false;
|
||||
}
|
||||
|
||||
@@ -3697,7 +3820,7 @@ function hideEditHRModal() {
|
||||
|
||||
function submitEditHeuristicRule() {
|
||||
var errEl = document.getElementById("edit-hr-error");
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
var argsText = document.getElementById("ehr-args").value.trim();
|
||||
var argPatterns = argsText
|
||||
? argsText.split("\n").filter(function (l) {
|
||||
@@ -3751,7 +3874,7 @@ function submitEditHeuristicRule() {
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
@@ -3992,7 +4115,7 @@ function showCreateOutputGuardPatternModal() {
|
||||
document.getElementById("ogp-cred").checked = false;
|
||||
document.getElementById("ogp-redact").value = "";
|
||||
document.getElementById("ogp-regex-result").textContent = "";
|
||||
document.getElementById("create-ogp-error").style.display = "none";
|
||||
document.getElementById("create-ogp-error").classList.remove("is-visible");
|
||||
document.getElementById("ogp-submit").disabled = false;
|
||||
document.getElementById("ogp-name").focus();
|
||||
_cogpTrapHandler = _installTrap("create-ogp-overlay", "create-ogp-box");
|
||||
@@ -4038,7 +4161,7 @@ function validateOGRegex() {
|
||||
|
||||
function submitCreateOGPattern() {
|
||||
var errEl = document.getElementById("create-ogp-error");
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
var payload = {
|
||||
name: document.getElementById("ogp-name").value.trim(),
|
||||
category: document.getElementById("ogp-cat").value,
|
||||
@@ -4072,7 +4195,7 @@ function submitCreateOGPattern() {
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
@@ -4177,7 +4300,7 @@ function _populateEditOGPModal(pat, isBuiltin) {
|
||||
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("edit-ogp-error").classList.remove("is-visible");
|
||||
document.getElementById("eogp-submit").disabled = false;
|
||||
}
|
||||
|
||||
@@ -4255,7 +4378,7 @@ function validateEditOGRegex() {
|
||||
|
||||
function submitEditOGPattern() {
|
||||
var errEl = document.getElementById("edit-ogp-error");
|
||||
errEl.style.display = "none";
|
||||
errEl.classList.remove("is-visible");
|
||||
var patternId = document.getElementById("eogp-id").value;
|
||||
var payload = {
|
||||
name: document.getElementById("eogp-name").value.trim(),
|
||||
@@ -4301,7 +4424,7 @@ function submitEditOGPattern() {
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
errEl.classList.add("is-visible");
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
|
||||
@@ -1010,7 +1010,7 @@
|
||||
<div
|
||||
id="create-hr-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-live="assertive" aria-atomic="true"
|
||||
></div>
|
||||
<label for="hr-name">Name</label>
|
||||
<input
|
||||
@@ -1124,7 +1124,7 @@
|
||||
<div
|
||||
id="create-ogp-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-live="assertive" aria-atomic="true"
|
||||
></div>
|
||||
<label for="ogp-name">Name</label>
|
||||
<input
|
||||
@@ -1245,7 +1245,7 @@
|
||||
<div
|
||||
id="edit-hr-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-live="assertive" aria-atomic="true"
|
||||
></div>
|
||||
<input id="ehr-id" type="hidden" />
|
||||
<input id="ehr-builtin" type="hidden" />
|
||||
@@ -1349,7 +1349,7 @@
|
||||
<div
|
||||
id="edit-ogp-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-live="assertive" aria-atomic="true"
|
||||
></div>
|
||||
<input id="eogp-id" type="hidden" />
|
||||
<input id="eogp-builtin" type="hidden" />
|
||||
@@ -2184,7 +2184,7 @@
|
||||
>
|
||||
<div id="github-import-box" class="admin-modal">
|
||||
<h2 id="github-import-title">Import from GitHub</h2>
|
||||
<div id="github-import-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="github-import-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<label for="gi-url">GitHub URL</label>
|
||||
<input
|
||||
id="gi-url"
|
||||
@@ -2225,7 +2225,7 @@
|
||||
>
|
||||
<div id="create-user-box" class="admin-modal">
|
||||
<h2 id="create-user-title">Create User</h2>
|
||||
<div id="create-user-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="create-user-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<label for="cu-username">Username</label>
|
||||
<input
|
||||
id="cu-username"
|
||||
@@ -2280,7 +2280,7 @@
|
||||
>
|
||||
<div id="create-token-box" class="admin-modal">
|
||||
<h2 id="create-token-title">Create API Token</h2>
|
||||
<div id="create-token-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="create-token-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<label for="ct-name"
|
||||
>Token name <span class="label-hint">optional</span></label
|
||||
>
|
||||
@@ -2388,7 +2388,7 @@
|
||||
>
|
||||
<div id="create-channel-box" class="admin-modal">
|
||||
<h2 id="create-channel-title">Link Channel Account</h2>
|
||||
<div id="create-channel-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="create-channel-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<label for="cc-type">Channel type</label>
|
||||
<select id="cc-type">
|
||||
<option value="discord">Discord</option>
|
||||
@@ -2427,7 +2427,7 @@
|
||||
<div
|
||||
id="create-schedule-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-live="assertive" aria-atomic="true"
|
||||
></div>
|
||||
<div class="modal-columns">
|
||||
<div class="modal-col">
|
||||
@@ -2551,7 +2551,7 @@
|
||||
>
|
||||
<div id="edit-schedule-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-schedule-title">Edit Schedule</h2>
|
||||
<div id="edit-schedule-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="edit-schedule-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<input id="es-id" type="hidden" />
|
||||
<div class="modal-columns">
|
||||
<div class="modal-col">
|
||||
@@ -2675,7 +2675,7 @@
|
||||
>
|
||||
<div id="create-role-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="create-role-title">Create Role</h2>
|
||||
<div id="create-role-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="create-role-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<label for="cr-name">Name</label>
|
||||
<input
|
||||
id="cr-name"
|
||||
@@ -2724,7 +2724,7 @@
|
||||
>
|
||||
<div id="edit-role-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-role-title">Edit Role</h2>
|
||||
<div id="edit-role-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="edit-role-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<input id="er-id" type="hidden" />
|
||||
<label for="er-name">Display name</label>
|
||||
<input id="er-name" type="text" autocomplete="off" />
|
||||
@@ -2761,7 +2761,7 @@
|
||||
>
|
||||
<div id="user-roles-box" class="admin-modal">
|
||||
<h2 id="user-roles-title">Assign Roles</h2>
|
||||
<div id="user-roles-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="user-roles-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<input id="ur-user-id" type="hidden" />
|
||||
<div id="ur-roles-container"></div>
|
||||
<div class="modal-buttons">
|
||||
@@ -2783,7 +2783,7 @@
|
||||
>
|
||||
<div id="create-policy-box" class="admin-modal">
|
||||
<h2 id="create-policy-title">Create Tool Policy</h2>
|
||||
<div id="create-policy-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="create-policy-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<label for="cp-name">Name</label>
|
||||
<input
|
||||
id="cp-name"
|
||||
@@ -2840,7 +2840,7 @@
|
||||
>
|
||||
<div id="edit-policy-box" class="admin-modal">
|
||||
<h2 id="edit-policy-title">Edit Tool Policy</h2>
|
||||
<div id="edit-policy-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="edit-policy-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<input id="ep-id" type="hidden" />
|
||||
<label for="ep-name">Name</label>
|
||||
<input id="ep-name" type="text" autocomplete="off" />
|
||||
@@ -2887,7 +2887,7 @@
|
||||
>
|
||||
<div id="create-ppolicy-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="create-ppolicy-title">Create Prompt</h2>
|
||||
<div id="cpp-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="cpp-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<label for="cpp-name"
|
||||
>Name <span class="label-hint">slug-style identifier</span></label
|
||||
>
|
||||
@@ -2953,7 +2953,7 @@
|
||||
>
|
||||
<div id="edit-ppolicy-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-ppolicy-title">Edit Prompt</h2>
|
||||
<div id="epp-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="epp-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<input id="epp-id" type="hidden" />
|
||||
<label for="epp-name">Name</label>
|
||||
<input id="epp-name" type="text" autocomplete="off" />
|
||||
@@ -2997,7 +2997,7 @@
|
||||
<div
|
||||
id="create-template-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-live="assertive" aria-atomic="true"
|
||||
></div>
|
||||
<div class="skill-spec-body">
|
||||
<div class="skill-spec-col skill-spec-col-meta">
|
||||
@@ -3298,12 +3298,23 @@
|
||||
class="admin-modal admin-modal-wide admin-modal-skill"
|
||||
>
|
||||
<h2 id="edit-template-title">Edit Skill</h2>
|
||||
<button
|
||||
id="etm-lock-btn"
|
||||
class="skill-lock-btn"
|
||||
type="button"
|
||||
style="display: none"
|
||||
onclick="unlockSkill()"
|
||||
aria-label="Customize skill (detach from upstream and unlock editing)"
|
||||
title="Customize — detach from upstream and unlock editing"
|
||||
>
|
||||
<span aria-hidden="true">🔒︎</span>
|
||||
</button>
|
||||
<div
|
||||
id="etm-origin-badge"
|
||||
class="skill-origin-badge"
|
||||
style="display: none"
|
||||
></div>
|
||||
<div id="edit-template-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="edit-template-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<input id="etm-id" type="hidden" />
|
||||
<div class="skill-spec-body">
|
||||
<div class="skill-spec-col skill-spec-col-meta">
|
||||
@@ -3624,7 +3635,7 @@
|
||||
id="mcp-create-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
style="display: none"
|
||||
aria-atomic="true"
|
||||
></div>
|
||||
<input type="hidden" id="mcp-edit-id" value="" />
|
||||
<label for="mcp-name">Server Name</label>
|
||||
@@ -3830,7 +3841,7 @@
|
||||
id="mcp-import-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
style="display: none"
|
||||
aria-atomic="true"
|
||||
></div>
|
||||
<label for="mcp-import-json">Paste JSON</label>
|
||||
<textarea
|
||||
@@ -3893,7 +3904,7 @@
|
||||
id="mcp-install-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
style="display: none"
|
||||
aria-atomic="true"
|
||||
></div>
|
||||
<div id="mcp-install-summary"></div>
|
||||
<div id="mcp-install-source-select"></div>
|
||||
@@ -3923,7 +3934,7 @@
|
||||
>
|
||||
<div id="model-create-box" class="admin-modal">
|
||||
<h2 id="model-create-title">Add Model</h2>
|
||||
<div id="model-create-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="model-create-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<input type="hidden" id="model-edit-id" value="" />
|
||||
<label for="model-alias">Alias</label>
|
||||
<input
|
||||
@@ -4167,7 +4178,7 @@
|
||||
>
|
||||
<div id="coord-delete-box" class="ws-delete-modal-box">
|
||||
<h3 id="coord-delete-title">Delete Coordinators</h3>
|
||||
<div id="coord-delete-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="coord-delete-error" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||
<p id="coord-delete-count"></p>
|
||||
<div id="coord-delete-list" class="ws-delete-modal-list"></div>
|
||||
<div id="coord-delete-buttons" class="ws-delete-modal-buttons">
|
||||
|
||||
@@ -1855,6 +1855,11 @@
|
||||
.admin-modal-skill {
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
/* Reserve space for the absolute-positioned lock button (top-right) so a
|
||||
long title can never collide with it. */
|
||||
.admin-modal-skill > h2 {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.skill-spec-body {
|
||||
display: grid;
|
||||
@@ -2023,6 +2028,16 @@ textarea.skill-content-area {
|
||||
.skill-config-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
/* Touch target: 44×44 minimum on mobile (WCAG 2.5.5 / Apple HIG). */
|
||||
.skill-lock-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
.admin-modal-skill > h2 {
|
||||
padding-right: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
@@ -2074,6 +2089,9 @@ textarea.skill-content-area {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.modal-submit:focus-visible {
|
||||
/* fg-bright (not accent) — modal-submit has an accent background, so
|
||||
accent-on-accent would be invisible. Cancel/secondary use --accent
|
||||
because their backgrounds are transparent / highlight. */
|
||||
outline: 2px solid var(--fg-bright);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -2082,6 +2100,49 @@ textarea.skill-content-area {
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
}
|
||||
/* Lock-icon affordance in the top-right of an installed (readonly) skill's
|
||||
edit modal — clicking detaches the skill from upstream so spec fields
|
||||
become editable. Reserved for the modal it lives in; not a generic
|
||||
button class. */
|
||||
.skill-lock-btn {
|
||||
position: absolute;
|
||||
/* top: 18px (not 14px) so the button drops below the modal's accent-line
|
||||
decoration's visual zone instead of competing with it horizontally. */
|
||||
top: 18px;
|
||||
right: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
/* Render the lock glyph as text where supported (keeps the monochrome
|
||||
instrument-panel aesthetic instead of a coloured emoji). Browsers
|
||||
that don't support font-variant-emoji fall back gracefully. */
|
||||
font-variant-emoji: text;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
z-index: 2;
|
||||
}
|
||||
.skill-lock-btn:hover {
|
||||
background: var(--bg-highlight);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.skill-lock-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.skill-lock-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#create-user-overlay,
|
||||
#create-token-overlay,
|
||||
@@ -2121,6 +2182,14 @@ textarea.skill-content-area {
|
||||
justify-content: center;
|
||||
z-index: 600;
|
||||
}
|
||||
/* Confirm dialogs are launched FROM other overlays (e.g. unlock-skill from
|
||||
the edit-template modal). They share z-index 600, so DOM order picks the
|
||||
winner — and confirm-overlay is earlier in the DOM, so it would render
|
||||
underneath. Bump it above the per-feature overlays but keep it below
|
||||
toasts (z-index 700). */
|
||||
#confirm-overlay {
|
||||
z-index: 650;
|
||||
}
|
||||
|
||||
/* Token display (show-once) */
|
||||
.token-created-warning {
|
||||
@@ -3973,7 +4042,8 @@ textarea.skill-content-area {
|
||||
}
|
||||
.admin-action-btn,
|
||||
.modal-cancel,
|
||||
.modal-submit {
|
||||
.modal-submit,
|
||||
.skill-lock-btn {
|
||||
transition: none;
|
||||
}
|
||||
.admin-modal input,
|
||||
|
||||
@@ -79,10 +79,10 @@ def save_messages_bulk(rows: list[dict[str, Any]]) -> None:
|
||||
log.warning("Failed to bulk-save %d messages", len(rows), exc_info=True)
|
||||
|
||||
|
||||
def load_messages(ws_id: str) -> list[dict[str, Any]]:
|
||||
def load_messages(ws_id: str, *, repair: bool = True) -> list[dict[str, Any]]:
|
||||
"""Load messages for a workstream and reconstruct OpenAI message format."""
|
||||
try:
|
||||
return get_storage().load_messages(ws_id)
|
||||
return get_storage().load_messages(ws_id, repair=repair)
|
||||
except Exception:
|
||||
log.warning("Failed to load messages for ws=%s", ws_id, exc_info=True)
|
||||
return []
|
||||
|
||||
@@ -822,7 +822,7 @@ class ChatSession:
|
||||
self._token_budget: int = 0
|
||||
self._budget_warned: bool = False
|
||||
self._budget_exhausted: bool = False
|
||||
self._notify_on_complete: str = "{}"
|
||||
self._notify_on_complete: str = "[]"
|
||||
self._applied_skill_id: str = ""
|
||||
self._applied_skill_version: int = 0
|
||||
self._applied_skill_content: str = "" # inline prompt from applied skill
|
||||
|
||||
@@ -2269,7 +2269,10 @@ def make_history_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
messages: list[dict[str, Any]] = []
|
||||
if storage is not None:
|
||||
try:
|
||||
messages = await asyncio.to_thread(storage.load_messages, ws_id, limit=limit)
|
||||
# repair=False — display read; see reconstruct_messages docstring.
|
||||
messages = await asyncio.to_thread(
|
||||
storage.load_messages, ws_id, limit=limit, repair=False
|
||||
)
|
||||
except Exception:
|
||||
log.debug("ws.history.load_failed ws=%s", ws_id[:8], exc_info=True)
|
||||
# Audit-trail decoration — attach persisted intent_verdict and
|
||||
|
||||
+139
-21
@@ -11,7 +11,6 @@ import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -34,6 +33,47 @@ _RESOURCE_DIRS = ("scripts", "references", "assets")
|
||||
_TEXT_EXTENSIONS = frozenset(
|
||||
{".md", ".txt", ".sh", ".py", ".js", ".ts", ".json", ".yaml", ".yml", ".toml", ".cfg", ".ini"}
|
||||
)
|
||||
# Per-segment charset for skills.sh ids — matches what GitHub permits in
|
||||
# owner/repo/path-segment names. Rejects whitespace, control chars, and any
|
||||
# URL-hostile content that would break the dedupe-by-source_url contract.
|
||||
_SKILLS_SH_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _accept_resource(rel_path: str, byte_size: int) -> bool:
|
||||
"""Gate predicate shared by GitHub and skills.sh resource ingestion."""
|
||||
first_seg = rel_path.split("/", 1)[0]
|
||||
if first_seg not in _RESOURCE_DIRS:
|
||||
return False
|
||||
ext = os.path.splitext(rel_path)[1].lower()
|
||||
if ext not in _TEXT_EXTENSIONS:
|
||||
return False
|
||||
return byte_size <= _MAX_RESOURCE_SIZE
|
||||
|
||||
|
||||
def _split_skills_sh_id(skill_id: str) -> tuple[str, str, str]:
|
||||
"""Split a skills.sh canonical id into (owner, repo, skill_name).
|
||||
|
||||
skills.sh ids are 3-segment paths like ``tavily-ai/skills/tavily-search``
|
||||
that map directly to its REST routes (``/api/download/[owner]/[repo]/[skill]``).
|
||||
"""
|
||||
parts = skill_id.strip().strip("/").split("/")
|
||||
if len(parts) != 3 or not all(_SKILLS_SH_SEGMENT_RE.match(p) for p in parts):
|
||||
raise SkillSourceError(
|
||||
f"Invalid skills.sh skill id {skill_id!r}: expected 'owner/repo/skill-name'"
|
||||
)
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
|
||||
def _skills_sh_source_url(base_url: str, skill_id: str) -> str:
|
||||
"""Canonical, dedup-stable URL for a skills.sh skill.
|
||||
|
||||
Normalizes the skill_id (strips outer whitespace and slashes) so
|
||||
`search()` and `download_skill()` agree on the persisted URL even
|
||||
when upstream returns sloppy ids — the discover-UI's
|
||||
"already installed" check matches against this exact string.
|
||||
"""
|
||||
canonical = skill_id.strip().strip("/")
|
||||
return f"{base_url.rstrip('/')}/skills/{canonical}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -91,34 +131,119 @@ class SkillsShClient:
|
||||
data = resp.json()
|
||||
results: list[SkillListing] = []
|
||||
for item in data.get("skills", data.get("results", [])):
|
||||
skill_id = str(item.get("id", item.get("name", "")))
|
||||
# The /api/search response does not carry source_url. Derive a
|
||||
# canonical, dedup-stable URL from the skill id so the discover
|
||||
# UI's "already installed" check matches what download_skill
|
||||
# persists.
|
||||
raw_source_url = str(item.get("source_url", item.get("url", ""))).strip()
|
||||
source_url = raw_source_url or (
|
||||
_skills_sh_source_url(self._base_url, skill_id) if skill_id else ""
|
||||
)
|
||||
results.append(
|
||||
SkillListing(
|
||||
id=str(item.get("id", item.get("name", ""))),
|
||||
id=skill_id,
|
||||
name=str(item.get("name", "")),
|
||||
description=str(item.get("description", "")),
|
||||
author=str(item.get("author", "")),
|
||||
source="skills.sh",
|
||||
source_url=str(item.get("source_url", item.get("url", ""))),
|
||||
source_url=source_url,
|
||||
install_count=int(item.get("install_count", item.get("installs", 0))),
|
||||
tags=[str(t) for t in item.get("tags", []) if isinstance(t, str)],
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
async def resolve_github_url(self, skill_id: str) -> str:
|
||||
"""Resolve a skills.sh skill ID to its GitHub URL."""
|
||||
async def download_skill(self, skill_id: str) -> SkillPackage:
|
||||
"""Download a skill bundle from skills.sh and return a ready-to-install package.
|
||||
|
||||
Hits ``/api/download/{owner}/{repo}/{skill}`` (the unauthenticated
|
||||
install endpoint) which returns ``{"files": [{"path", "contents"}, ...]}``
|
||||
with the SKILL.md and any bundled resources inline. No GitHub round-trip.
|
||||
"""
|
||||
owner, repo, name = _split_skills_sh_id(skill_id)
|
||||
|
||||
url = f"{self._base_url}/api/download/{owner}/{repo}/{name}"
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
|
||||
try:
|
||||
resp = await client.get(f"{self._base_url}/api/skills/{quote(skill_id, safe='')}")
|
||||
resp.raise_for_status()
|
||||
resp = await client.get(url)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SkillSourceError(f"Failed to resolve skill {skill_id}: {exc}") from exc
|
||||
raise SkillSourceError(f"Failed to download skill {skill_id}: {exc}") from exc
|
||||
|
||||
data = resp.json()
|
||||
url = str(data.get("source_url", data.get("github_url", data.get("url", ""))))
|
||||
if not url:
|
||||
raise SkillSourceError(f"No source URL for skill {skill_id}")
|
||||
return url
|
||||
if resp.status_code == 404:
|
||||
raise SkillNotFoundError(f"skills.sh has no skill {skill_id}")
|
||||
if resp.status_code >= 400:
|
||||
raise SkillSourceError(
|
||||
f"skills.sh download failed for {skill_id}: HTTP {resp.status_code}"
|
||||
)
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise SkillSourceError(
|
||||
f"skills.sh returned non-JSON for {skill_id}: {resp.text[:200]}"
|
||||
) from exc
|
||||
|
||||
files = payload.get("files")
|
||||
if not isinstance(files, list) or not files:
|
||||
raise SkillSourceError(f"skills.sh returned no files for {skill_id}")
|
||||
|
||||
skill_md_content = ""
|
||||
resource_pairs: list[tuple[str, str]] = []
|
||||
for entry in files:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
path = str(entry.get("path", "")).strip().lstrip("/")
|
||||
contents = entry.get("contents")
|
||||
if not path or not isinstance(contents, str):
|
||||
continue
|
||||
if path == "SKILL.md":
|
||||
# Measure UTF-8 bytes (not code points). `len(str)` is a
|
||||
# *lower* bound on encoded size — multi-byte chars (emoji,
|
||||
# CJK) inflate by up to 4×, so a code-point check would let
|
||||
# an oversized SKILL.md slip past the cap. Strict encoding
|
||||
# surfaces lone surrogates as a clear error.
|
||||
try:
|
||||
skill_md_size = len(contents.encode("utf-8"))
|
||||
except UnicodeEncodeError as exc:
|
||||
raise SkillSourceError(
|
||||
f"SKILL.md for {skill_id} contains invalid Unicode: {exc}"
|
||||
) from exc
|
||||
if skill_md_size > _MAX_SKILL_MD_SIZE:
|
||||
raise SkillSourceError(
|
||||
f"SKILL.md for {skill_id} exceeds size cap ({_MAX_SKILL_MD_SIZE} bytes)"
|
||||
)
|
||||
skill_md_content = contents
|
||||
else:
|
||||
resource_pairs.append((path, contents))
|
||||
|
||||
if not skill_md_content:
|
||||
raise SkillNotFoundError(f"skills.sh bundle for {skill_id} has no SKILL.md")
|
||||
|
||||
parsed = parse_skill_md(skill_md_content)
|
||||
|
||||
resources: dict[str, str] = {}
|
||||
for path, contents in resource_pairs:
|
||||
if len(resources) >= _MAX_RESOURCE_FILES:
|
||||
break
|
||||
if not _accept_resource(path, len(contents.encode("utf-8"))):
|
||||
continue
|
||||
resources[path] = contents
|
||||
|
||||
# Reconstruct from validated parts so the listing carries the
|
||||
# canonical id, never the raw caller input.
|
||||
canonical_id = f"{owner}/{repo}/{name}"
|
||||
listing = SkillListing(
|
||||
id=canonical_id,
|
||||
name=parsed.name or name,
|
||||
description=parsed.description,
|
||||
author=parsed.author,
|
||||
source="skills.sh",
|
||||
source_url=_skills_sh_source_url(self._base_url, canonical_id),
|
||||
install_count=0,
|
||||
tags=list(parsed.tags),
|
||||
)
|
||||
return SkillPackage(listing=listing, parsed=parsed, resources=resources)
|
||||
|
||||
|
||||
def _parse_github_url(url: str) -> tuple[str, str, str, str, bool]:
|
||||
@@ -152,14 +277,7 @@ def _find_resource_files(
|
||||
if not item_path.startswith(f"{skill_md_dir}/"):
|
||||
continue
|
||||
rel_path = item_path[len(skill_md_dir) + 1 :]
|
||||
first_seg = rel_path.split("/")[0] if "/" in rel_path else ""
|
||||
if first_seg not in _RESOURCE_DIRS:
|
||||
continue
|
||||
ext = os.path.splitext(rel_path)[1].lower()
|
||||
if ext not in _TEXT_EXTENSIONS:
|
||||
continue
|
||||
size = item.get("size", 0)
|
||||
if size > _MAX_RESOURCE_SIZE:
|
||||
if not _accept_resource(rel_path, item.get("size", 0)):
|
||||
continue
|
||||
resource_files.append({"path": rel_path, "full_path": item_path})
|
||||
return resource_files[:_MAX_RESOURCE_FILES]
|
||||
|
||||
@@ -242,7 +242,9 @@ class PostgreSQLBackend:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def load_messages(self, ws_id: str, *, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
def load_messages(
|
||||
self, ws_id: str, *, limit: int | None = None, repair: bool = True
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._conn() as conn:
|
||||
if limit is not None and limit > 0:
|
||||
rows = conn.execute(
|
||||
@@ -286,7 +288,7 @@ class PostgreSQLBackend:
|
||||
if limit is not None and limit > 0:
|
||||
message_ids = [r[0] for r in rows]
|
||||
attachments = self.load_attachments_for_messages(ws_id, message_ids=message_ids)
|
||||
return _reconstruct_messages(list(rows), ws_id, attachments or None)
|
||||
return _reconstruct_messages(list(rows), ws_id, attachments or None, repair=repair)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._conn() as conn:
|
||||
@@ -2379,7 +2381,7 @@ class PostgreSQLBackend:
|
||||
max_tokens: int | None = None,
|
||||
token_budget: int = 0,
|
||||
agent_max_turns: int | None = None,
|
||||
notify_on_complete: str = "{}",
|
||||
notify_on_complete: str = "[]",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
@@ -2395,49 +2397,58 @@ class PostgreSQLBackend:
|
||||
# Scan skill content for risk signals
|
||||
risk_level, scan_report, scan_version = _scan_skill_content(content, allowed_tools)
|
||||
|
||||
from turnstone.core.storage._protocol import StorageConflictError
|
||||
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"origin": origin,
|
||||
"mcp_server": mcp_server,
|
||||
"readonly": 1 if readonly else 0,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"source_url": source_url,
|
||||
"version": version,
|
||||
"author": author,
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"kind": kind,
|
||||
"risk_level": risk_level,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
"model": model,
|
||||
"auto_approve": 1 if auto_approve else 0,
|
||||
"temperature": temperature,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"max_tokens": max_tokens,
|
||||
"token_budget": token_budget,
|
||||
"agent_max_turns": agent_max_turns,
|
||||
"notify_on_complete": notify_on_complete,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"priority": priority,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"origin": origin,
|
||||
"mcp_server": mcp_server,
|
||||
"readonly": 1 if readonly else 0,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"source_url": source_url,
|
||||
"version": version,
|
||||
"author": author,
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"kind": kind,
|
||||
"risk_level": risk_level,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
"model": model,
|
||||
"auto_approve": 1 if auto_approve else 0,
|
||||
"temperature": temperature,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"max_tokens": max_tokens,
|
||||
"token_budget": token_budget,
|
||||
"agent_max_turns": agent_max_turns,
|
||||
"notify_on_complete": notify_on_complete,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"priority": priority,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
except sa.exc.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
msg = str(exc.orig) if exc.orig is not None else str(exc)
|
||||
raise StorageConflictError(
|
||||
f"prompt_template conflict ({template_id}/{name}): {msg}"
|
||||
) from exc
|
||||
conn.commit()
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
@@ -2553,6 +2564,42 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def unlock_skill(self, template_id: str, snapshot: str, changed_by: str) -> int | None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates.c.template_id).where(
|
||||
prompt_templates.c.template_id == template_id
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
current_max = conn.execute(
|
||||
sa.select(sa.func.coalesce(sa.func.max(skill_versions.c.version), 0)).where(
|
||||
skill_versions.c.skill_id == template_id
|
||||
)
|
||||
).scalar()
|
||||
next_version = int(current_max or 0) + 1
|
||||
conn.execute(
|
||||
sa.insert(skill_versions),
|
||||
{
|
||||
"skill_id": template_id,
|
||||
"version": next_version,
|
||||
"snapshot": snapshot,
|
||||
"changed_by": changed_by,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.execute(
|
||||
sa.update(prompt_templates)
|
||||
.where(prompt_templates.c.template_id == template_id)
|
||||
# readonly is an Integer column (see _schema.py); match the
|
||||
# 0/1 idiom create_prompt_template uses for the same flag.
|
||||
.values(readonly=0, updated=now)
|
||||
)
|
||||
conn.commit()
|
||||
return next_version
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
with self._conn() as conn:
|
||||
result = conn.execute(
|
||||
|
||||
@@ -162,7 +162,9 @@ class StorageBackend(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def load_messages(self, ws_id: str, *, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
def load_messages(
|
||||
self, ws_id: str, *, limit: int | None = None, repair: bool = True
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Load messages for a workstream and reconstruct OpenAI message format.
|
||||
|
||||
``limit`` caps the number of underlying conversation rows fetched
|
||||
@@ -172,6 +174,15 @@ class StorageBackend(Protocol):
|
||||
entries than ``limit`` when a tool-call group splits across the
|
||||
boundary; callers that need strict tail-N semantics must slice
|
||||
again client-side. Default ``None`` fetches the full history.
|
||||
|
||||
``repair`` (default True) post-processes the result into a
|
||||
wire-shape valid for an LLM round-trip — drops a trailing
|
||||
``assistant(tool_calls)`` whose results aren't all present and
|
||||
fills mid-conversation orphans with synthetic cancellation
|
||||
results. Display-only readers (``/history`` REST) should pass
|
||||
``repair=False`` so the user sees the actual partial state
|
||||
instead of having the trailing turn silently stripped during
|
||||
live tool execution.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -1206,7 +1217,7 @@ class StorageBackend(Protocol):
|
||||
max_tokens: int | None = None,
|
||||
token_budget: int = 0,
|
||||
agent_max_turns: int | None = None,
|
||||
notify_on_complete: str = "{}",
|
||||
notify_on_complete: str = "[]",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
@@ -1243,6 +1254,22 @@ class StorageBackend(Protocol):
|
||||
"""Update specified fields on a prompt template. Returns True if found."""
|
||||
...
|
||||
|
||||
def unlock_skill(self, template_id: str, snapshot: str, changed_by: str) -> int | None:
|
||||
"""Atomically snapshot a readonly skill and flip ``readonly=False``.
|
||||
|
||||
Writes ``snapshot`` into ``skill_versions`` with the next sequential
|
||||
version number, then sets ``readonly=False`` on the template row, all
|
||||
in a single transaction so concurrent updates can't produce
|
||||
``(skill_id, version)`` collisions or a snapshot whose state is out of
|
||||
sync with the row at the moment readonly is flipped.
|
||||
|
||||
Returns the assigned version number, or ``None`` if the template row
|
||||
does not exist. ``readonly`` is intentionally absent from
|
||||
:data:`SKILL_MUTABLE` — this dedicated writer is the only path for
|
||||
flipping it (matching the ``set_mcp_oauth_client_secret_ct`` pattern).
|
||||
"""
|
||||
...
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
"""Delete a prompt template. Returns True if found."""
|
||||
...
|
||||
|
||||
@@ -383,7 +383,7 @@ prompt_templates = sa.Table(
|
||||
sa.Column("max_tokens", sa.Integer, nullable=True),
|
||||
sa.Column("token_budget", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("agent_max_turns", sa.Integer, nullable=True),
|
||||
sa.Column("notify_on_complete", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("notify_on_complete", sa.Text, nullable=False, server_default="[]"),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
|
||||
@@ -306,7 +306,9 @@ class SQLiteBackend:
|
||||
self._fts5_available = False
|
||||
conn.commit()
|
||||
|
||||
def load_messages(self, ws_id: str, *, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
def load_messages(
|
||||
self, ws_id: str, *, limit: int | None = None, repair: bool = True
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._conn() as conn:
|
||||
if limit is not None and limit > 0:
|
||||
# Tail-N: fetch the last `limit` rows via DESC + LIMIT
|
||||
@@ -354,7 +356,7 @@ class SQLiteBackend:
|
||||
if limit is not None and limit > 0:
|
||||
message_ids = [r[0] for r in rows]
|
||||
attachments = self.load_attachments_for_messages(ws_id, message_ids=message_ids)
|
||||
return _reconstruct_messages(list(rows), ws_id, attachments or None)
|
||||
return _reconstruct_messages(list(rows), ws_id, attachments or None, repair=repair)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._conn() as conn:
|
||||
@@ -2532,7 +2534,7 @@ class SQLiteBackend:
|
||||
max_tokens: int | None = None,
|
||||
token_budget: int = 0,
|
||||
agent_max_turns: int | None = None,
|
||||
notify_on_complete: str = "{}",
|
||||
notify_on_complete: str = "[]",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
@@ -2548,49 +2550,58 @@ class SQLiteBackend:
|
||||
# Scan skill content for risk signals
|
||||
risk_level, scan_report, scan_version = _scan_skill_content(content, allowed_tools)
|
||||
|
||||
from turnstone.core.storage._protocol import StorageConflictError
|
||||
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"origin": origin,
|
||||
"mcp_server": mcp_server,
|
||||
"readonly": 1 if readonly else 0,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"source_url": source_url,
|
||||
"version": version,
|
||||
"author": author,
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"kind": kind,
|
||||
"risk_level": risk_level,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
"model": model,
|
||||
"auto_approve": 1 if auto_approve else 0,
|
||||
"temperature": temperature,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"max_tokens": max_tokens,
|
||||
"token_budget": token_budget,
|
||||
"agent_max_turns": agent_max_turns,
|
||||
"notify_on_complete": notify_on_complete,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"priority": priority,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"origin": origin,
|
||||
"mcp_server": mcp_server,
|
||||
"readonly": 1 if readonly else 0,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"source_url": source_url,
|
||||
"version": version,
|
||||
"author": author,
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"kind": kind,
|
||||
"risk_level": risk_level,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
"model": model,
|
||||
"auto_approve": 1 if auto_approve else 0,
|
||||
"temperature": temperature,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"max_tokens": max_tokens,
|
||||
"token_budget": token_budget,
|
||||
"agent_max_turns": agent_max_turns,
|
||||
"notify_on_complete": notify_on_complete,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"priority": priority,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
except sa.exc.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
msg = str(exc.orig) if exc.orig is not None else str(exc)
|
||||
raise StorageConflictError(
|
||||
f"prompt_template conflict ({template_id}/{name}): {msg}"
|
||||
) from exc
|
||||
conn.commit()
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
@@ -2706,6 +2717,40 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def unlock_skill(self, template_id: str, snapshot: str, changed_by: str) -> int | None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates.c.template_id).where(
|
||||
prompt_templates.c.template_id == template_id
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
current_max = conn.execute(
|
||||
sa.select(sa.func.coalesce(sa.func.max(skill_versions.c.version), 0)).where(
|
||||
skill_versions.c.skill_id == template_id
|
||||
)
|
||||
).scalar()
|
||||
next_version = int(current_max or 0) + 1
|
||||
conn.execute(
|
||||
sa.insert(skill_versions),
|
||||
{
|
||||
"skill_id": template_id,
|
||||
"version": next_version,
|
||||
"snapshot": snapshot,
|
||||
"changed_by": changed_by,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.execute(
|
||||
sa.update(prompt_templates)
|
||||
.where(prompt_templates.c.template_id == template_id)
|
||||
.values(readonly=0, updated=now)
|
||||
)
|
||||
conn.commit()
|
||||
return next_version
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
with self._conn() as conn:
|
||||
result = conn.execute(
|
||||
|
||||
@@ -286,6 +286,8 @@ def reconstruct_messages(
|
||||
rows: list[Any],
|
||||
ws_id: str,
|
||||
attachments_by_msg: dict[int, list[dict[str, Any]]] | None = None,
|
||||
*,
|
||||
repair: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Reconstruct OpenAI message format from stored conversation rows.
|
||||
|
||||
@@ -299,6 +301,17 @@ def reconstruct_messages(
|
||||
When ``attachments_by_msg`` is provided, any user row whose id has
|
||||
attachments is rebuilt with multipart list content (text +
|
||||
image_url/document parts).
|
||||
|
||||
When ``repair`` is True (default) the result is post-processed to
|
||||
produce a wire-shape valid for an LLM round-trip: the trailing
|
||||
``assistant(tool_calls)`` turn is dropped if not all tool_call ids
|
||||
have a matching tool result, and any mid-conversation orphaned
|
||||
tool_calls are filled with synthetic cancellation results. Callers
|
||||
that consume the messages as LLM context (e.g. ``session.resume``)
|
||||
must keep this on. Callers reading for *display* (the ``/history``
|
||||
REST endpoint) should pass ``repair=False`` so the user sees the
|
||||
actual partial state — refreshing during tool execution otherwise
|
||||
silently drops the trailing turn from the UI.
|
||||
"""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
@@ -374,6 +387,12 @@ def reconstruct_messages(
|
||||
tmsg["_reminders"] = json.loads(reminders_json)
|
||||
messages.append(tmsg)
|
||||
|
||||
if not repair:
|
||||
# Both passes below are LLM-context corrections — trailing-turn
|
||||
# strip and orphan synthesis. Display callers want neither; see
|
||||
# the reconstruct_messages docstring.
|
||||
return messages
|
||||
|
||||
# Repair: strip trailing incomplete tool call turns
|
||||
while messages:
|
||||
tail_tools = 0
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"""Backfill ``prompt_templates.notify_on_complete`` from ``'{}'`` to ``'[]'``.
|
||||
|
||||
The column was added in migration 011 with ``server_default='{}'`` (an empty
|
||||
JSON object) and inherited that default through 021's lift into
|
||||
``prompt_templates``. Every consumer of the field — the admin form, the
|
||||
JSON-array validator in ``submitEditTemplate``, the
|
||||
``_validate_notify_targets`` helper in ``server.py``, and the documented
|
||||
shape (an array of channel/contact identifiers) — treats it as a JSON
|
||||
array. The mismatch was silent for newly-installed remote skills until
|
||||
the unlock action exposed them to the editor: opening the modal and
|
||||
clicking Save tripped the array validator on the inherited ``'{}'`` and
|
||||
the request never left the browser.
|
||||
|
||||
This migration:
|
||||
|
||||
* Rewrites every row whose ``notify_on_complete`` is the legacy ``'{}'``
|
||||
sentinel (or NULL despite the NOT NULL constraint, defensively) to the
|
||||
correct empty-array literal ``'[]'``.
|
||||
* Does **not** touch rows where an operator has explicitly written a
|
||||
non-default value — even if that value is itself non-array, we leave
|
||||
it for the admin to fix via the UI rather than guess at intent.
|
||||
* Pairs with a server-side default change: the column's
|
||||
``server_default`` and every ``create_prompt_template`` /
|
||||
Pydantic-schema default are flipped to ``'[]'`` in the same PR so new
|
||||
rows land correct.
|
||||
|
||||
Downgrade is a deliberate **no-op**: pre-migration ``'{}'`` rows and
|
||||
operator-written ``'[]'`` rows are indistinguishable after upgrade, and
|
||||
``'[]'`` is the correct shape under every consumer's interpretation, so
|
||||
leaving the data untouched on downgrade is strictly safer than reversing
|
||||
it. (The known-invalid ``'{}'`` sentinel would otherwise re-emerge and
|
||||
re-trigger the original validator failures.)
|
||||
|
||||
Revision ID: 051
|
||||
Revises: 050
|
||||
Create Date: 2026-05-08
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "051"
|
||||
down_revision = "050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE prompt_templates "
|
||||
"SET notify_on_complete = '[]' "
|
||||
"WHERE notify_on_complete = '{}' OR notify_on_complete IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Intentional no-op. The data state cannot be cleanly inverted: a
|
||||
# pre-migration ``'{}'`` row and an operator-written ``'[]'`` row both
|
||||
# look like ``'[]'`` after upgrade, and rewriting every ``'[]'`` row back
|
||||
# to ``'{}'`` would (a) destroy legitimate operator intent and
|
||||
# (b) reintroduce the known-invalid sentinel that every consumer of
|
||||
# ``notify_on_complete`` rejects. ``'[]'`` is the correct shape for the
|
||||
# column under any consumer's interpretation, so leaving the data
|
||||
# untouched on downgrade is strictly safer than reversing it.
|
||||
pass
|
||||
+1
-1
@@ -2134,7 +2134,7 @@ async def _interactive_create_post_install(
|
||||
# surprising) skill-template path from a deliberate
|
||||
# operator "Approve + Always" click.
|
||||
ws.ui._auto_approve_tools_source = {t: AutoApproveReason.SKILL for t in tools_list}
|
||||
sess._notify_on_complete = skill_data.get("notify_on_complete", "{}")
|
||||
sess._notify_on_complete = skill_data.get("notify_on_complete", "[]")
|
||||
sess._applied_skill_id = skill_data["template_id"]
|
||||
sess._applied_skill_version = applied_skill_version
|
||||
if skill_data.get("content"):
|
||||
|
||||
Reference in New Issue
Block a user