Compare commits

..

16 Commits

Author SHA1 Message Date
Patrick Buckley 584437b98e chore: bump version to 1.5.10 2026-05-08 15:18:46 -07:00
Patrick Buckley 3abe1c0058 fix(skills): apply Copilot review feedback on PR #495
Two findings, both confirmed against the source:

1. Migration 051's downgrade rewrote every '[]' row back to '{}',
   which would (a) destroy operator-written empty arrays and
   (b) reintroduce the known-invalid sentinel that every consumer
   rejects. Pre-migration '{}' rows and operator-authored '[]' rows
   are indistinguishable after upgrade — there is no clean inverse
   for the data state. Made downgrade an explicit no-op with the
   rationale documented inline; '[]' is the correct shape under any
   consumer's interpretation, so leaving the data untouched on
   downgrade is strictly safer than reversing it. Updated the
   module docstring to call this out.

2. admin_update_skill's notify_on_complete validator short-circuited
   on empty string: `if nc and nc != "[]":` skipped the JSON-parse
   branch when nc=="" and persisted the empty string straight to
   storage, leaving a non-JSON value behind. Folded the empty case
   into the existing "{}" coercion so any blank/whitespace/legacy
   value normalises to "[]" before the array-validation gate.

Tests: three new regressions in TestSkillAPI — empty-string
normalises, "{}" sentinel coerces, non-array JSON 400s. The third
locks in the array-only validator that the previous "valid JSON"
gate would have accepted.
2026-05-08 15:18:10 -07:00
Patrick Buckley e24d8b9597 fix(skills): notify_on_complete default is "[]" not "{}"
Every consumer of prompt_templates.notify_on_complete treats it as a
JSON-array string (the admin form's array editor, the JSON.isArray
validator in submitEditTemplate, _validate_notify_targets in
server.py, the documented "list of channel/contact identifiers"
shape). But the column's server_default — set in migration 011 and
inherited through 021's lift into prompt_templates — has been "{}"
(an empty JSON object) since day one.

Newly-installed remote skills inherit the schema default, so every
unlock-then-edit flow trips the array validator on the inherited
"{}" and the request never leaves the browser. The user-visible
symptom was "click Save, nothing happens"; the latent symptom was
silent shape divergence between every install and every operator-
authored skill.

Migration 051: rewrites every legacy "{}" row to "[]". Operator-
edited values (anything that's neither "{}" nor NULL) are left
intact. Downgrade restores "{}" only on rows still holding the
post-migration "[]" so any later operator edits stick.

Server-side defaults flipped to "[]" in the same PR so new rows
land correct without depending on the column's server_default:

- _schema.py prompt_templates.notify_on_complete server_default
- StorageBackend protocol create_prompt_template kwarg
- sqlite + postgres create_prompt_template kwargs
- console_schemas.py SkillCreateRequest / SkillUpdateRequest /
  SkillInfo Pydantic defaults
- core/session.py ChatSession._notify_on_complete initial value
- server.py initial-message worker fallback when skill_data omits
  the field

admin_update_skill validator now also rejects non-array JSON (was
"valid JSON" only — would have accepted "{}" or "{\"a\": 1}").
_skill_to_response coerces legacy "{}" rows to "[]" on read so the
admin UI sees a consistent shape even before migration 051 runs.
The frontend's `tmpl.notify_on_complete || "[]"` fallback already
handled empty-string but not "{}" — the read-side coercion makes
it moot.
2026-05-08 15:18:10 -07:00
Patrick Buckley e11e6f6b70 fix(ui): aria-atomic on modal error elements + drop stale inline display
Designer-review follow-up to the .is-visible sweep. With role=alert
+ aria-live=assertive, AT engines re-announce when the element's
text content changes — but without aria-atomic some engines only
read the diff between old and new content. With aria-atomic=true
the entire updated message is read each time, which matters when a
validation error is replaced by a server error on retry (or
vice-versa).

Added aria-atomic=true to all 24 modal error elements (every
role=alert with aria-live=assertive). Same accessibility uplift
across the board — no per-modal exceptions.

Also dropped the stale `style="display: none"` attribute from the
three MCP error elements (mcp-create-error, mcp-import-error,
mcp-install-error). The CSS rule

  .admin-modal [role="alert"] { display: none; }

already hides them by default — the inline attribute was redundant
and would have overridden the .is-visible toggle if the class-based
contract is ever changed.
2026-05-08 15:18:10 -07:00
Patrick Buckley b8d728fd79 fix(ui): convert remaining modal-error toggles to .is-visible class
Sweep of the latent bug PR #494 fixed for the skill modals: the
project's CSS contract for modal errors is

  .admin-modal [role="alert"]            { display: none; }
  .admin-modal [role="alert"].is-visible { display: block; }

…but ~30 sites across governance.js and admin.js were toggling
`style.display = ""` instead of the .is-visible class. The "show"
side broke silently — clearing the inline style fell back to the
CSS `display: none` so the error never rendered, and any
validation failure looked like an unresponsive button.

Mechanical conversion of every show/hide site for these modal
error elements:

  governance.js
    create-role-error, edit-role-error
    create-policy-error, edit-policy-error
    github-import-error
    cpp-error, epp-error  (custom + eval prompt policies)
    create-hr-error, edit-hr-error  (heuristic rules)
    create-ogp-error, edit-ogp-error  (output-guard patterns)

  admin.js
    mcp-create-error, mcp-import-error, mcp-install-error

Plus the global `_showModalError` helper in admin.js — its
`style.display = "block"` happened to work today (inline display
beats the CSS rule), but normalising it to .is-visible keeps every
modal on a single canonical path. The five modals that route their
show side through that helper (create-user, create-token,
create-channel, create-schedule, edit-schedule) had their hide
sides converted in lockstep.

Added a comment on `_showModalError` documenting the contract so
the next contributor doesn't reintroduce the bug.

Out of scope: model-create-error (already canonical), home-coord-error
(not in .admin-modal), edit/create-template-error (fixed in #494).
No CSS or HTML changes; behaviour-equivalent for hide sides; show
sides go from broken-silent-no-render to correct-render-with-AT-
announcement.
2026-05-08 15:18:10 -07:00
Patrick Buckley 2138c19821 fix(skills-ui): clear prior error at submit-start so it doesn't go stale
Once edit-template-error is actually visible (the visibility fix in
this same PR), a stale error now persists across resubmit cycles:
the user sees a red message, fixes the input, clicks Save, the
validator passes, the PUT goes out — and the previous error stays
on-screen the whole time, only clearing when the modal closes on
success.

Fix at the start of submitEditTemplate / submitCreateTemplate:
clear .is-visible AND empty textContent. Cheaper than tracking
every validator branch and every .catch path; a fresh submit is a
clean slate.
2026-05-08 15:18:10 -07:00
Patrick Buckley 627bf06ced fix(skills-ui): show validation errors via .is-visible, not style.display
Smoke-testing the unlock flow surfaced a latent bug: clicking Save
on the edit-skill modal silently no-op'd whenever the
notify-on-complete field had non-JSON content. The error div was
DOM-correct (text content set, role=alert, aria-live=assertive),
but invisible — because the project's modal-error CSS contract is:

  .admin-modal [role="alert"]              { display: none; }
  .admin-modal [role="alert"].is-visible   { display: block; }

…and the JS in submitEditTemplate / submitCreateTemplate was
clearing the inline `display: none` via `el.style.display = ""`.
That falls back to the CSS rule, which still says `display: none`,
so the error never rendered. The user saw no error and the click
felt unresponsive (compounded by the early-return before the
disabled-state reset, which also made Save look broken).

Fixed both skill-modal flows (create + edit) by toggling the
canonical `.is-visible` class instead. Six sites in governance.js:
the two early-return show paths, the two .catch show paths, and
the two modal-open hide-resets.

Scope note: this same bug pattern exists in ~20 other modal error
sites across governance.js and admin.js (create-role, edit-role,
create-policy, edit-policy, github-import, cpp, epp, create-hr,
edit-hr, create-ogp, edit-ogp, mcp-create, mcp-import, mcp-install,
plus admin.js sites that don't go through _showModalError). All
pre-existing, broken silently for who knows how long. Out of scope
for this PR — recommend a follow-up sweep that also normalises
_showModalError's `style.display = "block"` to the same convention.
2026-05-08 15:18:10 -07:00
Patrick Buckley b67da0f48a fix(skills): apply designer review on lock-icon UX
Designer review of the cb5fa1b lock-icon iteration flagged five
items; four are addressed here, one was a deliberate trade-off
documented below.

- Glyph hardening (#2): the lock character is now 🔒︎ — U+1F512 with
  the U+FE0E text variation selector — paired with the existing
  font-variant-emoji: text rule. font-variant-emoji shipped late
  and isn't universal yet (Chrome 131+, Safari 16.4+, Firefox 132+);
  the explicit text VS is belt-and-braces so older Chromium / most
  Linux don't fall back to a coloured emoji that would clash with
  the monochrome instrument-panel aesthetic.
- Accent-line de-conflict (#3): top:14px → 18px so the lock button
  sits below the modal's ::before accent-line decoration's visual
  band rather than competing with it horizontally. h2's
  padding-right reservation (44px) still gives the title clearance.
- Mobile touch target (#4): @media (max-width: 700px) bumps the
  button to 44×44 (WCAG 2.5.5 / Apple HIG / Material minimum) and
  shifts it to top:8px right:8px, with h2 padding-right widened to
  56px to match.
- Keyboard discoverability (#6): on readonly open, focus lands on
  the lock button instead of Cancel. Keyboard users hit the unlock
  affordance immediately instead of having to Tab past every
  disabled spec input to reach it. Cancel is one Shift-Tab away.

Deferred:
- (#1) Reviewer flagged top-right placement as risking confusion
  with the universal × close-button convention. Keeping the
  icon-only design per product direction; the bordered chip styling
  + accent-coloured hover make it visually distinct from the
  thin-stroke unbordered × pattern, and the confirm dialog catches
  any misclick safely.
- (#5) Optional empty-corner indicator after unlock — the
  "Customized from upstream" badge text already carries the signal;
  not adding new chrome.
2026-05-08 15:18:10 -07:00
Patrick Buckley e05b6adc67 fix(skills): unlock UX — lock icon top-right, save reset, confirm z-index
Three issues from manual smoke-testing the unlock flow:

1. Confirm dialog rendered behind the edit-skill modal. Both
   overlays sat at z-index 600, and confirm-overlay is earlier in
   the DOM than edit-template-overlay — so DOM order put the parent
   modal on top of its own confirm. Bumped confirm-overlay to 650
   (still below toasts at 700) since confirm dialogs are launched
   FROM other overlays and need to sit above them.

2. Save button stayed disabled (or non-functional) after unlock.
   submitEditTemplate disables etm-submit on click and re-enables in
   .finally, but a stale disabled=true survives the mutate-in-place
   re-render that runs after unlock. Always reset
   submitBtn.disabled = false in showEditTemplateModal so the
   re-render path can never inherit a stuck disabled state.

3. UX redesign — moved the unlock affordance from a "Customize…"
   button at the bottom of the footer to a 🔒 icon button at the
   top-right of the modal. The lock glyph is the universal "this is
   locked, click to unlock" affordance and reads more clearly than
   a footer button next to Cancel/Save. font-variant-emoji: text
   keeps it monochrome on browsers that support it (instrument-panel
   aesthetic) with graceful fallback to coloured emoji elsewhere.
   admin-modal-skill h2 reserves padding-right so a long title can
   never collide with the absolute-positioned button.

Cleanup: removed the now-unused .modal-secondary and
.modal-buttons-spacer rules; the bottom etm-unlock button + flex
spacer are gone from the modal footer.
2026-05-08 15:18:10 -07:00
Patrick Buckley 40355d8303 fix(skills): match readonly column int idiom in postgres unlock_skill
Copilot caught that prompt_templates.readonly is an Integer column
(_schema.py: sa.Column("readonly", sa.Integer, nullable=False,
server_default="0")) and create_prompt_template stores it as 1/0,
but unlock_skill in the postgres backend was passing a Python bool
(readonly=False). The sqlite impl already uses 0; this aligns the
two backends and matches the 0/1 idiom used for the sibling flag
columns (is_default, auto_approve, enabled).

The other Copilot findings on this PR (loadGovSkills race, NBSP
double-space, list_skill_versions O(history_size), ignored
set_skill_readonly return value + None re-read) were all closed by
the prior review-feedback commit (eea795d): the snapshot+flip is
now an atomic unlock_skill() that uses SELECT MAX(version)+1
internally, the handler guards both the unlock_skill return and the
post-flip get_prompt_template re-read, the JS chains
showEditTemplateModal off loadGovSkills's promise, and the badge
NBSP matches the sibling pattern.
2026-05-08 15:18:10 -07:00
Patrick Buckley 2cbd926b9d fix(skills): apply review feedback on unlock action
Code review caught a race + a missing None guard; designer review
caught a window.confirm regression and a button-hierarchy issue.

Backend:
- Race fix (bug-2): replace set_skill_readonly+create_skill_version
  with a single atomic unlock_skill(template_id, snapshot, changed_by)
  -> int|None on the storage protocol (sqlite + postgres). Snapshot
  insert + readonly flip happen in one transaction; the next version
  number is computed via SELECT MAX(version)+1 inside the txn rather
  than len(list)+1 outside, closing the (skill_id, version)
  collision window where two concurrent admin actions could both pick
  the same version.
- None guard (bug-3): check the post-flip get_prompt_template re-read;
  return 404 instead of letting _skill_to_response(None) raise.
- Audit body: also record snapshot_version, and harden None-vs-empty
  with `or ""` on the existing.get(...) calls.

Frontend:
- D-1: replace window.confirm with the existing showConfirmModal
  (admin.js:2350) — themed dialog, focus-trap, can render the source
  URL with consistent typography. The native dialog could collapse
  the multi-paragraph copy depending on browser.
- D-2: mutate-in-place on success rather than hide → reload → reopen.
  loadGovSkills now returns its fetch promise so unlockSkill can
  chain showEditTemplateModal after the cache refresh — no flicker,
  no focus bounce, and it kills bug-1 (the reopen was reading stale
  _govSkills before loadGovSkills resolved). showEditTemplateModal
  is idempotent when already open: it skips the trigger-element
  capture and the focus-trap reinstall.
- D-3: button hierarchy. Drop flex:1 from .modal-secondary so the
  Save button keeps a stable width whether or not Customize is
  rendered; insert a flex-spacer between Customize and Save so the
  destructive-ish detach groups left next to Cancel and the primary
  action floats right.
- D-4: NBSP normalized to match the existing   escape pattern
  on the sibling badge line (was an actual NBSP byte).
- D-5: success toast now reads "Skill unlocked — fields are now
  editable" so the operator gets a positive affirmation that the
  edit affordance is live.
- D-10: aria-describedby="etm-origin-badge" on disabled spec inputs
  so screen-reader users get the same "this came from upstream"
  context that sighted users see in the cyan badge.

Tests: + test_unlock_skill_versions_after_existing_history seeds an
out-of-order version (3) and asserts unlock picks 4, defending
against the len()-based version computation regressing.
2026-05-08 15:18:10 -07:00
Patrick Buckley 40c0ab5a58 feat(skills): unlock action lets operators customize installed skills
skills.sh / GitHub installs land with readonly=True so admins can only
tune runtime config (model, temperature, etc.); the SKILL.md spec is
locked. In practice, upstream skills aren't always tuned for turnstone,
so locking the spec adds friction without a real safety win — every
edit is audited and version-snapshotted regardless.

This adds an explicit unlock so the boundary stays visible (multi-user
audit trail benefits from a discrete event, vs. silently dropping the
gate). Behaviour:

- POST /v1/api/admin/skills/{id}/unlock — flips readonly=False on a
  readonly row. Snapshots the pre-unlock state into skill_versions so
  the upstream-pristine version is recoverable from the History tab.
  Records skill.unlock audit with {name, source_url, origin}. 400 on
  already-unlocked, 404 on missing.
- origin stays "source" after unlock so the UI keeps a "Customized
  from upstream" provenance badge — the readonly flag is the gate, the
  origin field is the lineage.
- Storage: dedicated set_skill_readonly writer on the protocol +
  sqlite + postgres backends. readonly is intentionally absent from
  SKILL_MUTABLE so the generic update path can't piggyback on a
  provenance flip — the dedicated writer pattern matches what's
  already used for set_mcp_oauth_client_secret_ct.
- Frontend: "Customize…" button in the edit modal (visible only when
  readonly), with a confirm dialog explaining the upstream-detach.
  Once unlocked the existing edit-skill flow handles spec edits with
  no other changes. Origin badge updates to show "Customized from"
  the upstream URL when a source-origin row is unlocked.

Tests cover: unlock flips readonly + persists, pre-unlock snapshot
written to skill_versions, 400 on already-unlocked, 404 on missing,
post-unlock PUT can edit name/content/description (the readonly gate
no longer fires).
2026-05-08 15:18:10 -07:00
Patrick Buckley a5e8b8b17e fix(skills): apply PR #491 review feedback (size cap + dedup + conflict mapping)
Three issues caught by Copilot on the initial PR:

1. SKILL.md size cap was measured in code points, not UTF-8 bytes.
   `len(str)` is a *lower* bound on encoded byte length — multi-byte
   chars (emoji, CJK) inflate up to 4×, so a 100k-emoji SKILL.md
   (400KB encoded) would slip past the 256KB cap. Switch to
   `len(contents.encode("utf-8"))` and surface lone-surrogate failures
   as SkillSourceError instead of dropping them silently. New
   regression test feeds emoji content.

2. _skills_sh_source_url did not normalize the skill_id, so a sloppy
   id from `/api/search` (whitespace, surrounding slashes) would pass
   `_split_skills_sh_id`'s charset check (which strips first) and
   produce a malformed persisted source_url that broke the
   discover-UI dedup contract. Strip the id inside the helper, and
   reconstruct the canonical id from validated parts in
   download_skill's listing so downstream callers never see the raw
   input.

3. The catch-all `except Exception:` around create_prompt_template
   relabeled every storage failure (DB connection, disk full,
   permission errors) as "conflict", masking operational issues.
   Translate IntegrityError → StorageConflictError at the storage
   shim (matching the pattern already used for OIDC user
   provisioning) in both sqlite and postgres backends, then catch
   StorageConflictError specifically in the install handler. Real
   conflicts → "conflict" + warning; other exceptions → new
   "internal error" reason + log.exception.

Tests: +3 (oversized multibyte SKILL.md, source_url normalization,
storage-layer conflict translation). 226 passing.
2026-05-08 15:18:10 -07:00
Patrick Buckley bed691c62a fix(skills): switch skills.sh install to /api/download endpoint
The skills.sh install path was failing with 404s because their public
API surface changed: /api/skills/{id} is gone, replaced by
/api/skill/[owner]/[repo]/[skill] (auth-walled) and
/api/download/[owner]/[repo]/[skill] (unauthenticated, returns the
SKILL.md + bundled resources inline as JSON). The error was not
surfacing in logs because admin_skill_install had a silent
`except Exception:` around create_prompt_template that relabeled every
storage failure as "conflict" with no log entry.

- Replace SkillsShClient.resolve_github_url with download_skill that
  hits /api/download/{owner}/{repo}/{skill} and returns a SkillPackage
  directly. No GitHub round-trip; no rate-limit surface.
- Add _split_skills_sh_id with strict per-segment charset validation
  ([A-Za-z0-9._-]+) so URL-hostile content can't produce a malformed
  request or divergent persisted source_url.
- Use len(contents) instead of len(contents.encode("utf-8",
  errors="ignore")) for the SKILL.md size cap — errors='ignore' was
  silently dropping invalid units, making the cap bypassable.
- Extract _accept_resource(rel_path, byte_size) gate predicate; share
  it between download_skill and the GitHub _find_resource_files helper.
- Have search() derive a deterministic source_url from the skill id
  when /api/search omits one (which it currently always does), so the
  discover-UI "already installed" check matches what download_skill
  persists.
- Add structured logging across admin_skill_install and
  admin_skill_discover: a shared _log_install_failure helper for the
  four except branches (was four near-duplicate log calls with one
  drift), plus per-resource failure tallying — partial-resource
  installs now surface failed_resources in the response and audit
  record instead of silently committing the skill row with missing
  assets.

Tests: 7 new — empty/non-list files, oversized SKILL.md, resource
cap, non-text extension filtering, plus _split_skills_sh_id charset
rejection (whitespace, query chars). Verified end-to-end against
live skills.sh with tavily-search.
2026-05-08 15:18:10 -07:00
Patrick Buckley c930078f3d chore: bump version to 1.5.9 2026-05-07 22:56:06 -07:00
Patrick Buckley f148c4b423 fix: apply repair=False to all display-read load_messages call sites 2026-05-07 22:48:13 -07:00
28 changed files with 1615 additions and 258 deletions
+1 -1
View File
@@ -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
View File
@@ -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")
+65
View File
@@ -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 ─────────────────────────────────────
+3 -9
View File
@@ -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
View File
@@ -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:
+156
View File
@@ -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", "")
+91
View File
@@ -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 -1
View File
@@ -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"
+2 -2
View File
@@ -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 = "[]"
+7
View File
@@ -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",
+13 -4
View File
@@ -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
View File
@@ -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(
+28 -16
View File
@@ -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
+176 -53
View File
@@ -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;
+35 -24
View File
@@ -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">
+71 -1
View File
@@ -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,
+2 -2
View File
@@ -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 []
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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]
+92 -45
View File
@@ -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(
+29 -2
View File
@@ -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."""
...
+1 -1
View File
@@ -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),
+90 -45
View File
@@ -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(
+19
View File
@@ -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
@@ -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
View File
@@ -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"):
Generated
+1 -1
View File
@@ -2533,7 +2533,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.5.8"
version = "1.5.10"
source = { editable = "." }
dependencies = [
{ name = "alembic" },