From 75eda9a0963fb0da8e25e8e6764e3bd13c895d0e Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 16 Mar 2026 14:47:06 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20unified=20skills=20system=20=E2=80=94?= =?UTF-8?q?=20merge=20prompt=20templates=20+=20workstream=20tem=E2=80=A6?= =?UTF-8?q?=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: unified skills system — merge prompt templates + workstream templates Evolves prompt_templates into a first-class skills entity and merges workstream templates into the same model, collapsing two concepts into one. Migration 021: 21 new columns on prompt_templates (skills metadata, security scan fields, session config from WS templates), skill_resources table for bundled files, skill_versions table for auto-snapshot version history. Data migration converts existing WS templates into skills with name collision handling, migrates version history, renames workstreams and scheduled_tasks columns, cleans orphaned permissions, drops old tables. Key changes: - All public interfaces renamed: templates → skills (API, CLI, SDK, UI) - Session config (model, temperature, token_budget, auto_approve, etc.) now lives on the skill and is applied at workstream creation - /skill slash command, set_skill() API, --skill CLI flag - BM25 skill search via SkillSearchManager for activation="search" skills - Admin UI: Skills tab with collapsible Session Config section, description subtitles, activation/origin/MCP badges, pagination - Shared validation helper (_parse_skill_session_config) for DRY CRUD - Version history with auto-snapshot on every edit + API endpoint - Cascade delete (resources + versions) on skill removal - Security: range validation, activation allowlist, fail-closed enabled check, duplicate name 409, readonly guard, JSON validation - 77 new tests across storage, runtime, search, API integration, and migration behavior verification (2521 total) * fix: address Copilot review + rename admin.templates → admin.skills - Skip skill lookup when resume_ws is set (avoids spurious 400) - Fix _applied_skill_version mismatch (1 in both workstreams table and session) - Remove stale template field from MQ protocol diagram - Rename admin.templates permission to admin.skills everywhere (runtime, frontend, tests, docs) with migration step for persisted role data - Fix stale /api/templates references in docs and diagrams - Update docstrings/comments for skills terminology * fix: address Copilot round 2 — skill version lineage + stale doc refs - Compute actual skill version from skill_versions count (not hardcoded 1) - Use same version in both workstreams table and session metadata - Fix response payload example: "templates" → "skills" key - Fix "Each template summary" → "Each skill summary" --- docs/api-reference.md | 60 +- docs/architecture.md | 35 +- docs/console.md | 18 +- docs/diagrams/06-mq-protocol.puml | 3 +- docs/diagrams/14-storage-architecture.puml | 6 +- docs/diagrams/19-governance-architecture.puml | 24 +- .../diagrams/21-ws-template-architecture.puml | 169 -- docs/diagrams/png/06-mq-protocol.png | 4 +- docs/diagrams/png/14-storage-architecture.png | 4 +- .../png/19-governance-architecture.png | 4 +- .../png/21-ws-template-architecture.png | 3 - docs/governance.md | 68 +- docs/sdk.md | 10 +- docs/tools.md | 16 +- sdk/typescript/openapi-console.json | 1200 ++++++++------ sdk/typescript/openapi-server.json | 138 +- sdk/typescript/src/console.ts | 88 +- sdk/typescript/src/index.ts | 12 +- sdk/typescript/src/server.ts | 17 +- sdk/typescript/src/types.ts | 224 ++- tests/test_governance_endpoints.py | 112 +- tests/test_prompt_templates_runtime.py | 86 +- tests/test_protocol.py | 12 +- tests/test_session.py | 20 +- tests/test_skills.py | 1455 +++++++++++++++++ tests/test_ws_template_runtime.py | 749 --------- tests/test_ws_template_storage.py | 329 ---- turnstone/api/console_schemas.py | 151 +- turnstone/api/console_spec.py | 140 +- turnstone/api/schemas.py | 9 +- turnstone/api/server_schemas.py | 30 +- turnstone/api/server_spec.py | 29 +- turnstone/bootstrap.py | 8 +- turnstone/channels/_config.py | 2 +- turnstone/channels/_routing.py | 9 +- turnstone/channels/discord/bot.py | 2 +- turnstone/cli.py | 8 +- turnstone/console/scheduler.py | 6 +- turnstone/console/server.py | 761 +++++---- turnstone/console/static/admin.js | 54 +- turnstone/console/static/app.js | 34 +- turnstone/console/static/governance.js | 669 +++----- turnstone/console/static/index.html | 270 ++- turnstone/console/static/style.css | 56 +- turnstone/core/mcp_client.py | 3 + turnstone/core/memory.py | 48 +- turnstone/core/session.py | 146 +- turnstone/core/settings_registry.py | 4 +- turnstone/core/skill_search.py | 59 + turnstone/core/storage/_postgresql.py | 337 ++-- turnstone/core/storage/_protocol.py | 113 +- turnstone/core/storage/_schema.py | 91 +- turnstone/core/storage/_sqlite.py | 337 ++-- turnstone/core/storage/_utils.py | 18 +- .../versions/021_skills_evolution.py | 364 +++++ turnstone/core/workstream.py | 23 +- turnstone/mq/bridge.py | 15 +- turnstone/mq/client.py | 6 +- turnstone/mq/protocol.py | 3 +- turnstone/sdk/console.py | 192 +-- turnstone/sdk/server.py | 40 +- turnstone/server.py | 194 +-- turnstone/ui/static/app.js | 35 +- turnstone/ui/static/index.html | 6 +- 64 files changed, 4477 insertions(+), 4661 deletions(-) delete mode 100644 docs/diagrams/21-ws-template-architecture.puml delete mode 100644 docs/diagrams/png/21-ws-template-architecture.png create mode 100644 tests/test_skills.py delete mode 100644 tests/test_ws_template_runtime.py delete mode 100644 tests/test_ws_template_storage.py create mode 100644 turnstone/core/skill_search.py create mode 100644 turnstone/core/storage/migrations/versions/021_skills_evolution.py diff --git a/docs/api-reference.md b/docs/api-reference.md index 59c63ce9..8120915e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -622,64 +622,35 @@ Each saved workstream object: --- -### `GET /v1/api/templates` +### `GET /v1/api/skills` -Returns a summary list of all available prompt templates. This is a read-only -endpoint (requires `read` scope) that exposes template names and categories -without revealing template content. Useful for populating template selectors -in UIs or discovering available templates before creating a workstream. +Returns a summary list of all available skills. This is a read-only +endpoint (requires `read` scope) that exposes skill names and categories +without revealing skill content. Useful for populating skill selectors +in UIs or discovering available skills before creating a workstream. **Response:** ```json { - "templates": [ + "skills": [ {"name": "safety-guidelines", "category": "safety", "is_default": true, "origin": "manual"}, {"name": "mcp__server__code", "category": "", "is_default": false, "origin": "mcp"} ] } ``` -Each template summary: +Each skill summary: | Field | Type | Description | |--------------|--------|------------------------------------------------------| -| `name` | string | Template name (used in `template` field on creation) | -| `category` | string | Template category | -| `is_default` | bool | Whether template is auto-applied to all sessions | -| `origin` | string | Template origin: `manual` or `mcp` | +| `name` | string | Skill name (used in `skill` field on workstream creation) | +| `category` | string | Skill category | +| `is_default` | bool | Whether skill is auto-applied to all sessions | +| `origin` | string | Skill origin: `manual` or `mcp` | -> **Note:** For full template management (create, update, delete, view content), -> use the admin endpoints at `GET /v1/api/admin/templates` (requires `admin.templates` permission). - ---- - -### `GET /v1/api/ws-templates` - -Returns a summary list of enabled workstream templates. This is a read-only -endpoint (requires `read` scope) for populating template selectors in UIs. - -**Response:** - -```json -{ - "ws_templates": [ - {"name": "code-review", "description": "Code review profile", "model": "gpt-5"}, - {"name": "ops-triage", "description": "On-call triage", "model": ""} - ] -} -``` - -Each workstream template summary: - -| Field | Type | Description | -|---------------|--------|-------------------------------------------------| -| `name` | string | Template name (used in `ws_template` on creation)| -| `description` | string | Human-readable description | -| `model` | string | Model alias override (empty = use default) | - -> **Note:** For full workstream template management, use the admin endpoints at -> `GET /v1/api/admin/ws-templates` (requires `admin.templates` permission). +> **Note:** For full skill management (create, update, delete, view content), +> use the admin endpoints at `GET /v1/api/admin/skills` (requires `admin.skills` permission). --- @@ -873,10 +844,9 @@ All fields are optional. The body can be empty or an empty JSON object. | `model` | string | default | Model alias from the registry (`[models.*]`) | | `auto_approve` | bool | false | Auto-approve all tool calls for this workstream | | `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)| -| `template` | string | "" | Prompt template name (replaces default templates; 400 if not found)| -| `ws_template` | string | "" | Workstream template name. Applies model, temperature, reasoning effort, max tokens, auto-approve policy, and token budget. Returns 400 if not found or disabled. | +| `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). | -> **Template precedence:** When `ws_template` is specified, its model override takes effect before workstream creation. Both `template` (prompt template) and `ws_template` (workstream template) can be used together — `ws_template` controls the behavioral profile while `template` sets the system message text. If `ws_template` defines its own system prompt or prompt template reference, that takes precedence over the `template` parameter. +> **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream. **Response (success):** diff --git a/docs/architecture.md b/docs/architecture.md index 23fc6c2b..a9b8b1ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -698,7 +698,7 @@ supports_vision = true **Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional `"model"` field. The bridge `CreateWorkstreamMessage` carries the same field -through the MQ protocol, along with `ws_template` (workstream template name) +through the MQ protocol, along with `skill` (skill name) which can override the model before workstream creation. ### Tool Output Truncation @@ -1278,8 +1278,8 @@ The console has two write-path capabilities: 1. **Workstream creation** — pushes `CreateWorkstreamMessage` to Redis inbound queues targeting specific nodes. The bridge on each node picks up the message and creates the workstream on the local server. Auto-selects the node with - the most available capacity if no target is specified. When a `ws_template` - field is present, the server resolves the template BEFORE `mgr.create()` + the most available capacity if no target is specified. When a `skill` + field is present, the server resolves the skill BEFORE `mgr.create()` (applying the model override to the creation request) and snapshot-applies remaining settings (auto-approve, token budget, temperature, etc.) to the workstream config AFTER creation. @@ -1417,7 +1417,7 @@ at 100 (FIFO eviction) and cleaned up on workstream close. > See also: [Governance documentation](governance.md) | [Governance Architecture diagram](diagrams/19-governance-architecture.puml) Turnstone governance extends the Phase 1 auth system with role-based access -control (RBAC), tool execution policies, prompt templates, usage tracking, +control (RBAC), tool execution policies, skills, usage tracking, and audit logging. The permission model has two layers: legacy scopes (`read`, `write`, `approve`) checked by `AuthMiddleware`, and 15 granular permissions checked per-endpoint by `require_permission()`. Three built-in @@ -1427,25 +1427,22 @@ can be created with any permission subset. JWTs carry both `scopes` and Tool policies use glob pattern matching (`fnmatch`) with priority-ordered first-match-wins evaluation to control tool execution (allow/deny/ask). -Prompt templates provide reusable system messages with `{{variable}}` -substitution. Usage events are recorded per-LLM-request for token -accounting. An append-only audit log captures all admin mutations. +Skills provide reusable system messages with `{{variable}}` substitution +plus session configuration (model, temperature, auto-approve, token budget, +etc.). Usage events are recorded per-LLM-request for token accounting. +An append-only audit log captures all admin mutations. -Workstream templates build on top of prompt templates as complete behavioral -profiles applied at workstream creation. While prompt templates inject system -message text, workstream templates define model, temperature, reasoning effort, -max tokens, auto-approve policy, token budget, and agent max turns. Templates -are snapshot-applied once at creation — not a live binding. The -`workstream_templates` table (migration 011) supports auto-versioning, and -workstreams record which template and version spawned them. Token budget +Skills are snapshot-applied once at workstream creation — not a live binding. +The `prompt_templates` table (which stores skills) supports auto-versioning, +and workstreams record which skill and version spawned them. Token budget enforcement tracks consumption in `session.send()` with 80% warning and 100% approval gate via the `__budget_override__` synthetic tool name. -The console admin panel adds 6 governance tabs (Roles, Policies, Templates, -WS Templates, Usage, Audit), a Memories tab, a Settings tab (form-based -editor for all ConfigStore settings), and an MCP Servers tab (database-backed -server definitions with live connection status and cluster-wide reload) for a -total of 14 tabs, all permission-gated. +The console admin panel adds 5 governance tabs (Roles, Policies, Skills, +Usage, Audit), a Memories tab, a Settings tab (form-based editor for all +ConfigStore settings), and an MCP Servers tab (database-backed server +definitions with live connection status and cluster-wide reload) for a +total of 13 tabs, all permission-gated. Both Python and TypeScript SDKs expose governance methods on the console client. diff --git a/docs/console.md b/docs/console.md index cc8367b0..6b8d368b 100644 --- a/docs/console.md +++ b/docs/console.md @@ -306,18 +306,6 @@ Revoke a specific API token. These endpoints manage the `channel_users` table mappings that connect external platform identities (e.g. Discord user IDs) to turnstone users. See [Channel Integrations](channels.md) for details on the linking flow. -### Workstream Templates - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/v1/api/admin/ws-templates` | List all workstream templates | -| POST | `/v1/api/admin/ws-templates` | Create a workstream template | -| GET | `/v1/api/admin/ws-templates/{id}` | Get a single workstream template | -| PUT | `/v1/api/admin/ws-templates/{id}` | Update (auto-versions, audit logged) | -| DELETE | `/v1/api/admin/ws-templates/{id}` | Delete + cascade versions (audit logged) | -| GET | `/v1/api/admin/ws-templates/{id}/versions` | Version history | -| GET | `/v1/api/ws-templates` | Enabled templates summary (name, description, model) — requires write scope, not admin | - #### `GET /v1/api/auth/status` Public endpoint for login UI state detection. Returns auth configuration, not @@ -407,7 +395,7 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated Triggered by the "+ new" header button. A modal dialog with: - **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" pushes to the shared queue for any bridge to pick up, or a specific node from the list (showing capacity). -- **Profile** — optional dropdown listing enabled workstream templates. Applies the template's model, auto-approve policy, token budget, and other behavioral settings at creation time. +- **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time. - **Name** — optional text input. Auto-generated if left empty. - **Model** — optional text input for a model alias from the target node's registry. @@ -421,9 +409,9 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna Accessed via the "admin" button in the header (visible when authenticated with `approve` scope). Provides user, API token, channel link, MCP server, -and workstream template management with 14 tabs (see also +and skill management with 13 tabs (see also [Governance](governance.md) for -the Roles, Policies, Templates, WS Templates, Usage, and Audit tabs, and +the Roles, Policies, Skills, Usage, and Audit tabs, and [Settings](settings.md) for the database-backed configuration editor): **Users tab:** diff --git a/docs/diagrams/06-mq-protocol.puml b/docs/diagrams/06-mq-protocol.puml index cb0eddc3..a1ecd1cd 100644 --- a/docs/diagrams/06-mq-protocol.puml +++ b/docs/diagrams/06-mq-protocol.puml @@ -60,8 +60,7 @@ package "Inbound Messages (Client → Bridge)" as InPkg #FFF3E0 { + auto_approve_tools: list[str] = [] + target_node: str = "" + initial_message: str = "" - + template: str = "" - + ws_template: str = "" + + skill: str = "" } class CloseWorkstreamMessage { diff --git a/docs/diagrams/14-storage-architecture.puml b/docs/diagrams/14-storage-architecture.puml index 57250693..5cf8b849 100644 --- a/docs/diagrams/14-storage-architecture.puml +++ b/docs/diagrams/14-storage-architecture.puml @@ -64,14 +64,12 @@ class "_schema.py" as Schema <> { +metadata: MetaData +memories: Table +conversations: Table - +workstreams: Table (node_id, alias, title,\n state, ws_template_id, ws_template_version) + +workstreams: Table (node_id, alias, title,\n state, skill_id) +workstream_config: Table +users: Table (username, password_hash) +api_tokens: Table (token_hash, scopes) +channel_users: Table (channel_type) - +workstream_templates: Table (name, model,\n system_prompt, token_budget, version) - +workstream_template_versions: Table\n (template_id, version, snapshot) - +scheduled_tasks: Table (..., ws_template) + +scheduled_tasks: Table (..., skill) -- SQLAlchemy Core Single source of truth diff --git a/docs/diagrams/19-governance-architecture.puml b/docs/diagrams/19-governance-architecture.puml index ea280075..ccf56bc0 100644 --- a/docs/diagrams/19-governance-architecture.puml +++ b/docs/diagrams/19-governance-architecture.puml @@ -23,11 +23,10 @@ package "Governance Storage" { database "user_roles" as ur_db database "orgs" as orgs_db database "tool_policies" as tp_db - database "prompt_templates" as pt_db + database "prompt_templates\n(skills)" as pt_db database "usage_events" as ue_db database "audit_events" as ae_db - database "workstream_templates" as wt_db - database "workstream_template_versions" as wtv_db + database "skills" as wt_db } package "Runtime Enforcement" { @@ -44,10 +43,9 @@ package "Template Runtime" { [set_template() / /template] as tset } -package "WS Template Runtime" { - [resolve_ws_template()] as wtr +package "Skill Runtime" { + [resolve_skill()] as wtr [apply settings\n(model, budget, prompt)] as wta - [drift detection\n(prompt_template_hash)] as wtd [budget gate\n(session.send)] as wtb } @@ -76,7 +74,7 @@ audit --> ae_db : admin handlers govjs --> roles_db : /v1/api/admin/roles govjs --> tp_db : /v1/api/admin/policies -govjs --> pt_db : /v1/api/admin/templates +govjs --> pt_db : /v1/api/admin/skills govjs --> ue_db : /v1/api/admin/usage govjs --> ae_db : /v1/api/admin/audit @@ -87,17 +85,15 @@ tset --> tload : name or None note right of pt_db Read-only listing: - GET /v1/api/templates + GET /v1/api/skills (read scope, summary only) end note -govjs --> wt_db : /v1/api/admin/ws-templates -wtr --> wt_db : get_ws_template_by_name() -wtr --> wta : template settings -wta --> pt_db : prompt_template lookup -wtd --> wt_db : compare hash +govjs --> wt_db : /v1/api/admin/skills +wtr --> wt_db : get_skill_by_name() +wtr --> wta : skill settings +wta --> pt_db : skill lookup wtb --> approve : __budget_override__ -wtv_db <.. wt_db : version snapshots auth -[hidden]-> mw mw -[hidden]-> approve diff --git a/docs/diagrams/21-ws-template-architecture.puml b/docs/diagrams/21-ws-template-architecture.puml deleted file mode 100644 index f2e3e29f..00000000 --- a/docs/diagrams/21-ws-template-architecture.puml +++ /dev/null @@ -1,169 +0,0 @@ -@startuml -!theme plain -title Turnstone — Workstream Template Architecture - -skinparam participant { - BackgroundColor<> #E8EAF6 - BackgroundColor<> #FFE0B2 - BackgroundColor<> #C8E6C9 - BackgroundColor<> #B3E5FC - BackgroundColor<> #F3E5F5 -} - -participant "Admin / Console UI\n(governance.js)" as Admin <> -participant "Server\n(server.py)" as Server <> -participant "ChatSession\n(session.py)" as Session <> -participant "StorageBackend\n(SQLite)" as Storage <> -participant "Integration Points\n(scheduler, channel,\nbridge, MQ)" as Integrations <> - -== Admin CRUD == - -Admin -> Server : POST /v1/api/admin/ws-templates -note right - **Payload:** - name, model, system_prompt, - temperature, reasoning_effort, - max_tokens, agent_max_turns, - auto_approve, auto_approve_tools, - token_budget, prompt_template, - prompt_template_hash, notify_on_complete -end note - -Server -> Storage : create_ws_template() -Storage --> Server : ws_template_id - -Admin -> Server : PUT /v1/api/admin/ws-templates/{id} -Server -> Storage : get_ws_template(id)\n(snapshot pre-update state) -Storage --> Server : existing template -Server -> Storage : create_ws_template_version()\n(version snapshot) -Server -> Storage : update_ws_template(id, ...) -note right - **Versioning:** - Each update snapshots - pre-update state into - workstream_template_versions. - version counter increments. -end note - -Admin -> Server : GET /v1/api/admin/ws-templates -Server -> Storage : list_ws_templates() - -Server <-- Server : GET /v1/api/ws-templates\n(read scope, summary only) -note right - **Read-only listing:** - name, description, model. - Used by creation UI dropdowns. - Available on both server + console. -end note - -Admin -> Server : DELETE /v1/api/admin/ws-templates/{id} -Server -> Storage : delete_ws_template(id) - -== Workstream Creation Flow == - -Integrations -> Server : CreateWorkstreamMessage\n(ws_template="production-agent") -note right - **Sources:** - - Console UI (Profile dropdown) - - Scheduler (ws_template field) - - Channel Router (ws_template) - - Bridge (ws_template forwarding) - - MQ Client (ws_template) -end note - -Server -> Storage : get_ws_template_by_name("production-agent") -Storage --> Server : template dict - -Server -> Server : resolve_ws_template()\napply model override -note right - **Settings applied:** - - model (overrides default) - - system_prompt - - temperature - - reasoning_effort - - max_tokens - - agent_max_turns - - auto_approve / auto_approve_tools - - token_budget - - tool_search config -end note - -Server -> Session : mgr.create(model=template.model, ...) -Session -> Session : _init_system_messages() - -alt template has prompt_template - Session -> Storage : get_prompt_template_by_name() - Session -> Session : _render_template()\n{{model}}, {{ws_id}}, {{node_id}} -end - -Session -> Storage : _save_config()\n+ ws_template_id, ws_template_version - -== Drift Detection == - -Server -> Server : compute prompt_template_hash\n(at creation time) -note right - **Hash stored:** - SHA-256 of prompt_template - content at ws creation time. - Compared at next creation - to detect upstream changes. -end note - -Server -> Storage : update_workstream()\n(store prompt_template_hash) - -... later, new workstream created ... - -Server -> Storage : get_ws_template() -Server -> Server : compare hash vs\ncurrent prompt_template content -alt hash mismatch - Server -> Server : log.warning(\n"prompt template drift detected") -end - -== Token Budget Enforcement == - -Session -> Session : send(message) -Session -> Session : _check_budget_gate() -note right - **Budget gate:** - if token_budget set: - total = prompt_tokens + completion_tokens - if total >= token_budget: - block further sends -end note - -alt budget exceeded - Session -> Session : approve_tools(\n__budget_override__) - note right - Model can request - budget override via - special approval label. - User must approve. - end note -else within budget - Session -> Session : continue normal flow -end - -== Storage Schema == - -note over Storage - **workstream_templates** - id, name (unique), model, system_prompt, - temperature, reasoning_effort, max_tokens, - agent_max_turns, auto_approve, auto_approve_tools, - token_budget, prompt_template, prompt_template_hash, - tool_search, tool_search_threshold, tool_search_max_results, - version, created_at, updated_at - - **workstream_template_versions** - id, template_id (FK), version, snapshot (JSON), - created_at - - **workstreams** (updated columns) - + ws_template_id: str | None - + ws_template_version: int | None - - **scheduled_tasks** (updated column) - + ws_template: str | None -end note - -@enduml diff --git a/docs/diagrams/png/06-mq-protocol.png b/docs/diagrams/png/06-mq-protocol.png index 37430ca1..5536cf0e 100644 --- a/docs/diagrams/png/06-mq-protocol.png +++ b/docs/diagrams/png/06-mq-protocol.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06df9a7fa962755dd270c8f5a8b784041094a3ce44a473b653bb83112324ae07 -size 319204 +oid sha256:2636e2d4d2f84f26f93de6e984b780f6ad5bf8d3618a0202ea0a2ee80859c5b5 +size 312409 diff --git a/docs/diagrams/png/14-storage-architecture.png b/docs/diagrams/png/14-storage-architecture.png index 2681398b..1f8767b1 100644 --- a/docs/diagrams/png/14-storage-architecture.png +++ b/docs/diagrams/png/14-storage-architecture.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb5e7c221f6b1ee1082b37da32e65c45b5e468014cf6881a210e5b4d8a8dca8b -size 255736 +oid sha256:c94556889abb382cd5b818639fc0a4706beef3d9c7a0b4cbedc763943d657dd0 +size 244998 diff --git a/docs/diagrams/png/19-governance-architecture.png b/docs/diagrams/png/19-governance-architecture.png index d806e114..a76fc3c5 100644 --- a/docs/diagrams/png/19-governance-architecture.png +++ b/docs/diagrams/png/19-governance-architecture.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2ff35e2b8a42e82ebae35273587268ac09a0d993cd3d0e8efd79845b955a1cd -size 221837 +oid sha256:98ba80fa1dab4d37299e61be079a6fbc8740fc3ab92196f828a765f74caf4556 +size 200720 diff --git a/docs/diagrams/png/21-ws-template-architecture.png b/docs/diagrams/png/21-ws-template-architecture.png deleted file mode 100644 index 5d738076..00000000 --- a/docs/diagrams/png/21-ws-template-architecture.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fadf5b07f8230ecf97805a86b308eaa9eb30516dd26900e5c9f70e6fb7562bab -size 296339 diff --git a/docs/governance.md b/docs/governance.md index 57079bbd..66619e8f 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -1,8 +1,7 @@ # Governance Turnstone governance provides role-based access control (RBAC), tool execution -policies, prompt templates, usage tracking, and audit logging for the admin -console. +policies, skills, usage tracking, and audit logging for the admin console. ## Architecture @@ -21,7 +20,7 @@ The permission model has two layers: | Role | Permissions | |------|-------------| -| admin | read, write, approve, admin.users, admin.roles, admin.orgs, admin.policies, admin.templates, admin.audit, admin.usage, admin.schedules, admin.watches, tools.approve, workstreams.create, workstreams.close | +| admin | read, write, approve, admin.users, admin.roles, admin.orgs, admin.policies, admin.skills, admin.audit, admin.usage, admin.schedules, admin.watches, tools.approve, workstreams.create, workstreams.close | | operator | read, write, workstreams.create, workstreams.close | | viewer | read | @@ -51,59 +50,35 @@ Admin-defined rules that control tool execution: `mcp__*` to require approval for all) - Built-in tools continue to use `func_name` for backward compatibility -### Prompt Templates +### Skills -Admin-curated system message templates injected at workstream startup: +Admin-curated system message skills injected at workstream startup. Skills also +include session configuration (model, temperature, auto-approve, token budget, +etc.) since workstream templates were merged into the skills system in v0.8.0. -- **Runtime behavior**: Templates are loaded once at session creation and injected - into the system message *before* user `instructions`. Templates set the baseline; +- **Runtime behavior**: Skills are loaded once at session creation and injected + into the system message *before* user `instructions`. Skills set the baseline; instructions customize per-workstream behavior. -- **Default templates**: All `is_default=true` templates auto-apply to new +- **Default skills**: All `is_default=true` skills auto-apply to new workstreams, concatenated in alphabetical order by name. Use name prefixes (e.g. `01-safety`, `02-style`) to control ordering. - **Explicit selection**: `--template ` CLI flag, `template` field on `POST /v1/api/workstreams/new`, console creation modal dropdown, scheduled task - config, and channel adapter config. An explicit template *replaces* defaults. + config, and channel adapter config. An explicit skill *replaces* defaults. - **Variables**: Three built-in placeholders resolved at load time: `{{model}}` (active model name), `{{ws_id}}` (workstream ID), `{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is. - **Runtime switching**: `/template ` to switch, `/template clear` to revert to defaults, `/template` to show current. Persisted across resume. - **Categories**: general, engineering, support, custom, mcp -- **Content limit**: 32 KB per template (enforced on create/update) -- **Storage**: `prompt_templates` table with JSON `variables` array. Migration 010 - adds `template` column to `scheduled_tasks`. -- **MCP sync**: MCP server prompts auto-sync into prompt_templates with - `origin="mcp"`, `mcp_server` set, and `readonly=True`. Manual templates take +- **Content limit**: 32 KB per skill (enforced on create/update) +- **Storage**: `prompt_templates` table (stores skills) with JSON `variables` + array. Migration 010 adds `template` column to `scheduled_tasks`. +- **MCP sync**: MCP server prompts auto-sync into the `prompt_templates` table + with `origin="mcp"`, `mcp_server` set, and `readonly=True`. Manual skills take precedence on name collision. MCP-synced content updates reset `is_default` to prevent compromised servers from injecting defaults. Admin UI shows origin badge - and disables edit/delete for MCP-sourced templates. - -### Workstream Templates - -Workstream templates are behavioral profiles applied at workstream creation — the next level beyond prompt templates. While prompt templates inject system message text, workstream templates define the complete workstream configuration. - -**What they define:** -- System prompt (inline text OR reference to a prompt template by name) -- Model override (empty = server default) -- Temperature, reasoning effort, max tokens, agent max turns -- Auto-approve policy (blanket and/or per-tool list) -- Token budget (0 = unlimited; warns at 80%, requires approval at 100%) -- Completion notification config (stored for v2 dispatch) - -**Storage:** `workstream_templates` table (migration 011) with auto-versioning. Edits snapshot the pre-update state into `workstream_template_versions`. Workstreams record which template and version spawned them via `ws_template_id` + `ws_template_version` columns. - -**Applied once at creation:** Template settings are snapshot-applied to the workstream's config. Not a live binding — template updates don't affect running workstreams. - -**Prompt template drift detection:** When a workstream template references a prompt template, a SHA-256 hash of the prompt content is stored at ws_template create/update time. At workstream creation, the server compares the stored hash against current content and logs a warning on mismatch. - -**Admin API:** 7 endpoints under `/v1/api/admin/ws-templates` (list, create, get, update, delete, version history) plus a read-only summary at `/v1/api/ws-templates`. Permission: `admin.ws_templates`. - -**Console UI:** "WS Templates" tab with CRUD table, create/edit modals (name, description, system prompt source toggle, model, auto-approve, per-tool auto-approve, temperature, reasoning effort, max tokens, agent max turns, token budget, enabled), and version history modal. "Profile" dropdown on workstream creation modal. "WS Template" dropdown on scheduler create/edit modals. - -**Token budget enforcement:** Tracked in `session.send()`. At 80% consumption, emits an info message. At 100%, the next turn requires explicit approval via the `__budget_override__` synthetic tool name (reuses existing approval UI — inline in browser, Discord buttons, bridge auto-approve). The synthetic name can be targeted by tool policies (e.g. `__budget_override__` → `allow` for admins). - -**SDK:** Python (`list_ws_templates`, `create_ws_template`, `get_ws_template`, `update_ws_template`, `delete_ws_template`, `list_ws_template_versions`) and TypeScript (`listWsTemplates`, `createWsTemplate`, etc.) on both sync and async console clients. `ws_template` parameter on `create_workstream()` for both server and console SDKs. + and disables edit/delete for MCP-sourced skills. ### Usage Tracking @@ -133,7 +108,7 @@ Append-only trail of admin actions: channel.link, channel.unlink, role.create, role.update, role.delete, role.assign, role.unassign, policy.create, policy.update, policy.delete, template.create, template.update, template.delete, - ws_template.create, ws_template.update, ws_template.delete, org.update + skill.create, skill.update, skill.delete, org.update - **Querying**: `GET /v1/api/admin/audit` with action/user/time filters + pagination ## Database Schema @@ -146,7 +121,7 @@ Migration 008 adds 7 tables: | `roles` | Named permission bundles (3 builtin + custom) | | `user_roles` | User-to-role assignments (composite PK) | | `tool_policies` | Per-tool approve/deny/ask rules | -| `prompt_templates` | Reusable system message templates | +| `prompt_templates` | Reusable system message skills | | `usage_events` | Per-request token/tool/cache metrics | | `audit_events` | Admin action log | @@ -162,9 +137,8 @@ All under `/v1/api/admin/` (requires `approve` scope + granular permission). | Roles | 7 (CRUD + assignment) | `admin.roles` / `admin.users` | | Orgs | 3 (list, get, update) | `admin.orgs` | | Tool Policies | 4 (CRUD) | `admin.policies` | -| Prompt Templates | 4 (CRUD) | `admin.templates` | +| Skills | 4 (CRUD) | `admin.skills` | | Schedules | 6 (CRUD + runs) | `admin.schedules` | -| WS Templates | 7 (CRUD + versions + summary) | `admin.ws_templates` | | Watches | 3 (list, create, cancel) | `admin.watches` | | Usage | 1 (aggregated query) | `admin.usage` | | Audit | 1 (paginated, filtered) | `admin.audit` | @@ -177,8 +151,7 @@ Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`. - **Roles** — CRUD roles, permission checkbox grid, user role assignment modal - **Policies** — CRUD tool policies with colored action badges (green/red/amber) -- **Templates** — CRUD prompt templates with wide modal, textarea editor -- **WS Templates** — CRUD workstream templates with create/edit modals, version history +- **Skills** — CRUD skills with wide modal, textarea editor - **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors - **Audit** — Filterable log with relative timestamps, load-more pagination @@ -194,7 +167,6 @@ Both Python and TypeScript console SDKs expose governance methods: - `list_orgs()`, `get_org()`, `update_org()` - `list_policies()`, `create_policy()`, `update_policy()`, `delete_policy()` - `list_templates()`, `create_template()`, `update_template()`, `delete_template()` -- `list_ws_templates()`, `create_ws_template()`, `get_ws_template()`, `update_ws_template()`, `delete_ws_template()`, `list_ws_template_versions()` - `get_usage(since, group_by=...)`, `get_audit(action=..., limit=...)` **TypeScript** (`TurnstoneConsole`): diff --git a/docs/sdk.md b/docs/sdk.md index ec2214f8..ca5fbfc0 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -69,7 +69,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose: |----------|--------|---------| | **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` | | | `dashboard()` | `DashboardResponse` | -| | `create_workstream(*, name, model, auto_approve, ws_template)` | `CreateWorkstreamResponse` | +| | `create_workstream(*, name, model, auto_approve, skill)` | `CreateWorkstreamResponse` | | | `close_workstream(ws_id)` | `StatusResponse` | | **Chat** | `send(message, ws_id)` | `SendResponse` | | | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` | @@ -97,19 +97,13 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose: | | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` | | | `node_detail(node_id)` | `NodeDetailResponse` | | | `snapshot()` | `ClusterSnapshotResponse` | -| | `create_workstream(*, node_id, name, model, initial_message, ws_template)` | `ConsoleCreateWsResponse` | +| | `create_workstream(*, node_id, name, model, initial_message, skill)` | `ConsoleCreateWsResponse` | | **Schedules** | `list_schedules()` | `ListSchedulesResponse` | | | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` | | | `get_schedule(task_id)` | `ScheduleInfo` | | | `update_schedule(task_id, *, name=..., enabled=..., ...)` | `ScheduleInfo` | | | `delete_schedule(task_id)` | `StatusResponse` | | | `list_schedule_runs(task_id, *, limit=50)` | `ListScheduleRunsResponse` | -| **WS Templates** | `list_ws_templates()` | `ListWsTemplatesResponse` | -| | `create_ws_template(*, name, description, ...)` | `WsTemplateInfo` | -| | `get_ws_template(template_id)` | `WsTemplateInfo` | -| | `update_ws_template(template_id, *, name=..., enabled=..., ...)` | `WsTemplateInfo` | -| | `delete_ws_template(template_id)` | `StatusResponse` | -| | `list_ws_template_versions(template_id)` | `ListWsTemplateVersionsResponse` | | **MCP Registry** | `search_mcp_registry(q="", *, limit=20, cursor=None)` | `RegistrySearchResponse` | | | `install_from_registry(registry_name, source, *, index=0, name="", variables=None, env=None, headers=None)` | `McpServerDetail` | | **Streaming** | `stream_cluster_events()` | `Iterator[ClusterEvent]` | diff --git a/docs/tools.md b/docs/tools.md index 85fb95b8..8f7ec9e7 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -812,7 +812,7 @@ the `initialize` handshake. Each prompt is stored with its prefixed name | `name` | string | yes | The prompt name (e.g. `mcp__server__prompt_name`). | | `arguments` | object | no | Key-value argument pairs for the prompt. Values must be strings. | -- **What it does**: Invokes an MCP prompt template by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter. +- **What it does**: Invokes an MCP prompt by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter. - **Auto-approve**: No -- requires user confirmation (invokes external prompt servers). - **Agent availability**: `agent` and `task_agent`. @@ -825,18 +825,18 @@ built-in tool exposes this to the model as a function call. ### Governance Sync Discovered MCP prompts are automatically synced into the `prompt_templates` -governance table as first-class governed templates: +table (which stores skills) as first-class governed skills: -- **Origin tracking**: MCP-sourced templates have `origin="mcp"` and - `mcp_server` set to the server name. Manual templates have +- **Origin tracking**: MCP-sourced skills have `origin="mcp"` and + `mcp_server` set to the server name. Manual skills have `origin="manual"`. -- **Read-only**: MCP-sourced templates are `readonly=True`. The admin API +- **Read-only**: MCP-sourced skills are `readonly=True`. The admin API returns 403 on update/delete attempts. The admin UI disables edit/delete buttons and shows an origin badge. -- **Precedence**: If a manual template and MCP prompt share the same name, - the manual template wins and the MCP prompt is skipped (with a log +- **Precedence**: If a manual skill and MCP prompt share the same name, + the manual skill wins and the MCP prompt is skipped (with a log warning). -- **Lifecycle**: Templates are created on connect, updated on prompt list +- **Lifecycle**: Skills are created on connect, updated on prompt list refresh, and removed when the MCP server no longer exposes the prompt. The sync runs automatically on connect, on `PromptListChangedNotification`, and on manual `/mcp refresh`. diff --git a/sdk/typescript/openapi-console.json b/sdk/typescript/openapi-console.json index 6320a7dd..1d80f84d 100644 --- a/sdk/typescript/openapi-console.json +++ b/sdk/typescript/openapi-console.json @@ -1768,10 +1768,10 @@ } } }, - "/v1/api/admin/templates": { + "/v1/api/admin/skills": { "get": { - "summary": "List prompt templates", - "operationId": "v1_api_admin_templates_get", + "summary": "List skills", + "operationId": "v1_api_admin_skills_get", "tags": [ "Admin" ], @@ -1781,7 +1781,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListPromptTemplatesResponse" + "$ref": "#/components/schemas/ListSkillsResponse" } } } @@ -1789,8 +1789,8 @@ } }, "post": { - "summary": "Create a prompt template", - "operationId": "v1_api_admin_templates_post", + "summary": "Create a skill", + "operationId": "v1_api_admin_skills_post", "tags": [ "Admin" ], @@ -1799,7 +1799,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatePromptTemplateRequest" + "$ref": "#/components/schemas/CreateSkillRequest" } } } @@ -1810,7 +1810,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PromptTemplateInfo" + "$ref": "#/components/schemas/SkillInfo" } } } @@ -1828,176 +1828,16 @@ } } }, - "/v1/api/admin/templates/{template_id}": { - "put": { - "summary": "Update a prompt template", - "operationId": "v1_api_admin_templates_{template_id}_put", - "tags": [ - "Admin" - ], - "parameters": [ - { - "name": "template_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePromptTemplateRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PromptTemplateInfo" - } - } - } - }, - "404": { - "description": "Error 404", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "delete": { - "summary": "Delete a prompt template", - "operationId": "v1_api_admin_templates_{template_id}_delete", - "tags": [ - "Admin" - ], - "parameters": [ - { - "name": "template_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusResponse" - } - } - } - }, - "404": { - "description": "Error 404", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/v1/api/admin/ws-templates": { + "/v1/api/admin/skills/{skill_id}": { "get": { - "summary": "List workstream templates", - "operationId": "v1_api_admin_ws-templates_get", - "tags": [ - "Admin" - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListWsTemplatesResponse" - } - } - } - } - } - }, - "post": { - "summary": "Create a workstream template", - "operationId": "v1_api_admin_ws-templates_post", - "tags": [ - "Admin" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateWsTemplateRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WsTemplateInfo" - } - } - } - }, - "400": { - "description": "Error 400", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Error 409", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/v1/api/admin/ws-templates/{ws_template_id}": { - "get": { - "summary": "Get a workstream template", - "operationId": "v1_api_admin_ws-templates_{ws_template_id}_get", + "summary": "Get a skill by ID", + "operationId": "v1_api_admin_skills_{skill_id}_get", "tags": [ "Admin" ], "parameters": [ { - "name": "ws_template_id", + "name": "skill_id", "in": "path", "required": true, "schema": { @@ -2011,7 +1851,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WsTemplateInfo" + "$ref": "#/components/schemas/SkillInfo" } } } @@ -2029,14 +1869,14 @@ } }, "put": { - "summary": "Update a workstream template", - "operationId": "v1_api_admin_ws-templates_{ws_template_id}_put", + "summary": "Update a skill", + "operationId": "v1_api_admin_skills_{skill_id}_put", "tags": [ "Admin" ], "parameters": [ { - "name": "ws_template_id", + "name": "skill_id", "in": "path", "required": true, "schema": { @@ -2049,7 +1889,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWsTemplateRequest" + "$ref": "#/components/schemas/UpdateSkillRequest" } } } @@ -2060,7 +1900,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WsTemplateInfo" + "$ref": "#/components/schemas/SkillInfo" } } } @@ -2074,28 +1914,18 @@ } } } - }, - "409": { - "description": "Error 409", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } }, "delete": { - "summary": "Delete a workstream template", - "operationId": "v1_api_admin_ws-templates_{ws_template_id}_delete", + "summary": "Delete a skill", + "operationId": "v1_api_admin_skills_{skill_id}_delete", "tags": [ "Admin" ], "parameters": [ { - "name": "ws_template_id", + "name": "skill_id", "in": "path", "required": true, "schema": { @@ -2105,14 +1935,7 @@ ], "responses": { "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusResponse" - } - } - } + "description": "Success" }, "404": { "description": "Error 404", @@ -2127,16 +1950,16 @@ } } }, - "/v1/api/admin/ws-templates/{ws_template_id}/versions": { + "/v1/api/admin/skills/{skill_id}/versions": { "get": { - "summary": "List workstream template version history", - "operationId": "v1_api_admin_ws-templates_{ws_template_id}_versions_get", + "summary": "List version history for a skill", + "operationId": "v1_api_admin_skills_{skill_id}_versions_get", "tags": [ "Admin" ], "parameters": [ { - "name": "ws_template_id", + "name": "skill_id", "in": "path", "required": true, "schema": { @@ -2150,17 +1973,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListWsTemplateVersionsResponse" - } - } - } - }, - "404": { - "description": "Error 404", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListSkillVersionsResponse" } } } @@ -2168,12 +1981,12 @@ } } }, - "/v1/api/ws-templates": { + "/v1/api/skills": { "get": { - "summary": "List enabled workstream templates (summary)", - "operationId": "v1_api_ws-templates_get", + "summary": "List available skills (summary)", + "operationId": "v1_api_skills_get", "tags": [ - "Workstreams" + "Skills" ], "responses": { "200": { @@ -2181,28 +1994,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListWsTemplateSummaryResponse" - } - } - } - } - } - } - }, - "/v1/api/templates": { - "get": { - "summary": "List available prompt templates (summary)", - "operationId": "v1_api_templates_get", - "tags": [ - "Templates" - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListPromptTemplateSummaryResponse" + "$ref": "#/components/schemas/ListSkillSummaryResponse" } } } @@ -4162,16 +3954,10 @@ "title": "Initial Message", "type": "string" }, - "template": { + "skill": { "default": "", - "description": "Prompt template name (replaces default templates)", - "title": "Template", - "type": "string" - }, - "ws_template": { - "default": "", - "description": "Workstream template name (behavioral profile)", - "title": "Ws Template", + "description": "Skill name (replaces default skills)", + "title": "Skill", "type": "string" } }, @@ -4301,16 +4087,10 @@ "title": "Auto Approve Tools", "type": "array" }, - "template": { + "skill": { "default": "", - "description": "Prompt template name", - "title": "Template", - "type": "string" - }, - "ws_template": { - "default": "", - "description": "Workstream template name", - "title": "Ws Template", + "description": "Skill name (replaces default skills)", + "title": "Skill", "type": "string" }, "enabled": { @@ -4453,7 +4233,7 @@ "default": null, "title": "Auto Approve Tools" }, - "template": { + "skill": { "anyOf": [ { "type": "string" @@ -4463,19 +4243,7 @@ } ], "default": null, - "title": "Template" - }, - "ws_template": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Ws Template" + "title": "Skill" }, "enabled": { "anyOf": [ @@ -4549,14 +4317,9 @@ "title": "Auto Approve Tools", "type": "array" }, - "template": { + "skill": { "default": "", - "title": "Template", - "type": "string" - }, - "ws_template": { - "default": "", - "title": "Ws Template", + "title": "Skill", "type": "string" }, "enabled": { @@ -5162,199 +4925,6 @@ "title": "ListToolPoliciesResponse", "type": "object" }, - "PromptTemplateInfo": { - "properties": { - "template_id": { - "title": "Template Id", - "type": "string" - }, - "name": { - "title": "Name", - "type": "string" - }, - "category": { - "title": "Category", - "type": "string" - }, - "content": { - "title": "Content", - "type": "string" - }, - "variables": { - "title": "Variables", - "type": "string" - }, - "is_default": { - "title": "Is Default", - "type": "boolean" - }, - "org_id": { - "title": "Org Id", - "type": "string" - }, - "created_by": { - "title": "Created By", - "type": "string" - }, - "origin": { - "default": "manual", - "title": "Origin", - "type": "string" - }, - "mcp_server": { - "default": "", - "title": "Mcp Server", - "type": "string" - }, - "readonly": { - "default": false, - "title": "Readonly", - "type": "boolean" - }, - "created": { - "title": "Created", - "type": "string" - }, - "updated": { - "title": "Updated", - "type": "string" - } - }, - "required": [ - "template_id", - "name", - "category", - "content", - "variables", - "is_default", - "org_id", - "created_by", - "created", - "updated" - ], - "title": "PromptTemplateInfo", - "type": "object" - }, - "CreatePromptTemplateRequest": { - "properties": { - "name": { - "title": "Name", - "type": "string" - }, - "content": { - "title": "Content", - "type": "string" - }, - "category": { - "default": "general", - "title": "Category", - "type": "string" - }, - "variables": { - "default": "[]", - "title": "Variables", - "type": "string" - }, - "is_default": { - "default": false, - "title": "Is Default", - "type": "boolean" - }, - "org_id": { - "default": "", - "title": "Org Id", - "type": "string" - } - }, - "required": [ - "name", - "content" - ], - "title": "CreatePromptTemplateRequest", - "type": "object" - }, - "UpdatePromptTemplateRequest": { - "properties": { - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Name" - }, - "content": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Content" - }, - "category": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Category" - }, - "variables": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Variables" - }, - "is_default": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Is Default" - } - }, - "title": "UpdatePromptTemplateRequest", - "type": "object" - }, - "ListPromptTemplatesResponse": { - "properties": { - "templates": { - "items": { - "$ref": "#/components/schemas/PromptTemplateInfo" - }, - "title": "Templates", - "type": "array" - } - }, - "required": [ - "templates" - ], - "title": "ListPromptTemplatesResponse", - "type": "object" - }, "UsageBreakdownItem": { "properties": { "key": { @@ -6521,52 +6091,714 @@ "title": "RegistryInstallRequest", "type": "object" }, - "PromptTemplateSummary": { + "SkillInfo": { + "properties": { + "template_id": { + "description": "Skill ID", + "title": "Template Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "tags": { + "default": "[]", + "title": "Tags", + "type": "string" + }, + "variables": { + "default": "[]", + "title": "Variables", + "type": "string" + }, + "is_default": { + "title": "Is Default", + "type": "boolean" + }, + "activation": { + "default": "named", + "title": "Activation", + "type": "string" + }, + "org_id": { + "title": "Org Id", + "type": "string" + }, + "created_by": { + "title": "Created By", + "type": "string" + }, + "origin": { + "default": "manual", + "title": "Origin", + "type": "string" + }, + "mcp_server": { + "default": "", + "title": "Mcp Server", + "type": "string" + }, + "readonly": { + "default": false, + "title": "Readonly", + "type": "boolean" + }, + "source_url": { + "default": "", + "title": "Source Url", + "type": "string" + }, + "version": { + "default": "1.0.0", + "title": "Version", + "type": "string" + }, + "author": { + "default": "", + "title": "Author", + "type": "string" + }, + "token_estimate": { + "default": 0, + "title": "Token Estimate", + "type": "integer" + }, + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "auto_approve": { + "default": false, + "title": "Auto Approve", + "type": "boolean" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "reasoning_effort": { + "default": "", + "title": "Reasoning Effort", + "type": "string" + }, + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "token_budget": { + "default": 0, + "title": "Token Budget", + "type": "integer" + }, + "agent_max_turns": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Agent Max Turns" + }, + "notify_on_complete": { + "default": "{}", + "title": "Notify On Complete", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "allowed_tools": { + "default": "[]", + "title": "Allowed Tools", + "type": "string" + }, + "created": { + "title": "Created", + "type": "string" + }, + "updated": { + "title": "Updated", + "type": "string" + } + }, + "required": [ + "template_id", + "name", + "category", + "content", + "is_default", + "org_id", + "created_by", + "created", + "updated" + ], + "title": "SkillInfo", + "type": "object" + }, + "SkillVersionInfo": { + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "skill_id": { + "title": "Skill Id", + "type": "string" + }, + "version": { + "title": "Version", + "type": "integer" + }, + "snapshot": { + "title": "Snapshot", + "type": "string" + }, + "changed_by": { + "title": "Changed By", + "type": "string" + }, + "created": { + "title": "Created", + "type": "string" + } + }, + "required": [ + "id", + "skill_id", + "version", + "snapshot", + "changed_by", + "created" + ], + "title": "SkillVersionInfo", + "type": "object" + }, + "CreateSkillRequest": { "properties": { "name": { - "description": "Template name", + "title": "Name", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "category": { + "default": "general", + "title": "Category", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "tags": { + "default": "[]", + "title": "Tags", + "type": "string" + }, + "variables": { + "default": "[]", + "title": "Variables", + "type": "string" + }, + "is_default": { + "default": false, + "title": "Is Default", + "type": "boolean" + }, + "activation": { + "default": "named", + "title": "Activation", + "type": "string" + }, + "org_id": { + "default": "", + "title": "Org Id", + "type": "string" + }, + "author": { + "default": "", + "title": "Author", + "type": "string" + }, + "version": { + "default": "1.0.0", + "title": "Version", + "type": "string" + }, + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "auto_approve": { + "default": false, + "title": "Auto Approve", + "type": "boolean" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "reasoning_effort": { + "default": "", + "title": "Reasoning Effort", + "type": "string" + }, + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "token_budget": { + "default": 0, + "title": "Token Budget", + "type": "integer" + }, + "agent_max_turns": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Agent Max Turns" + }, + "notify_on_complete": { + "default": "{}", + "title": "Notify On Complete", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "allowed_tools": { + "default": "[]", + "title": "Allowed Tools", + "type": "string" + } + }, + "required": [ + "name", + "content" + ], + "title": "CreateSkillRequest", + "type": "object" + }, + "UpdateSkillRequest": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + }, + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Category" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "tags": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tags" + }, + "variables": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Variables" + }, + "is_default": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Default" + }, + "activation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Activation" + }, + "author": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Author" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "auto_approve": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auto Approve" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "reasoning_effort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning Effort" + }, + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "token_budget": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Budget" + }, + "agent_max_turns": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Agent Max Turns" + }, + "notify_on_complete": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notify On Complete" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + }, + "allowed_tools": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allowed Tools" + } + }, + "title": "UpdateSkillRequest", + "type": "object" + }, + "ListSkillsResponse": { + "properties": { + "skills": { + "items": { + "$ref": "#/components/schemas/SkillInfo" + }, + "title": "Skills", + "type": "array" + } + }, + "required": [ + "skills" + ], + "title": "ListSkillsResponse", + "type": "object" + }, + "ListSkillVersionsResponse": { + "properties": { + "versions": { + "items": { + "$ref": "#/components/schemas/SkillVersionInfo" + }, + "title": "Versions", + "type": "array" + } + }, + "required": [ + "versions" + ], + "title": "ListSkillVersionsResponse", + "type": "object" + }, + "SkillSummary": { + "properties": { + "name": { + "description": "Skill name", "title": "Name", "type": "string" }, "category": { "default": "", - "description": "Template category", + "description": "Skill category", "title": "Category", "type": "string" }, + "description": { + "default": "", + "description": "Skill description for discovery", + "title": "Description", + "type": "string" + }, + "tags": { + "description": "Semantic tags", + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, "is_default": { "default": false, - "description": "Whether this template is applied by default", + "description": "Whether auto-applied to all sessions", "title": "Is Default", "type": "boolean" }, + "activation": { + "default": "named", + "description": "Activation mode: default, named, search", + "title": "Activation", + "type": "string" + }, "origin": { "default": "manual", - "description": "Template origin: manual or mcp", + "description": "Source: manual, mcp, skills.sh, github", "title": "Origin", "type": "string" + }, + "author": { + "default": "", + "description": "Skill author", + "title": "Author", + "type": "string" + }, + "version": { + "default": "1.0.0", + "description": "Skill version", + "title": "Version", + "type": "string" } }, "required": [ "name" ], - "title": "PromptTemplateSummary", + "title": "SkillSummary", "type": "object" }, - "ListPromptTemplateSummaryResponse": { + "ListSkillSummaryResponse": { "properties": { - "templates": { + "skills": { "items": { - "$ref": "#/components/schemas/PromptTemplateSummary" + "$ref": "#/components/schemas/SkillSummary" }, - "title": "Templates", + "title": "Skills", "type": "array" } }, "required": [ - "templates" + "skills" ], - "title": "ListPromptTemplateSummaryResponse", + "title": "ListSkillSummaryResponse", "type": "object" } } diff --git a/sdk/typescript/openapi-server.json b/sdk/typescript/openapi-server.json index a6ea96da..3ad87547 100644 --- a/sdk/typescript/openapi-server.json +++ b/sdk/typescript/openapi-server.json @@ -437,12 +437,12 @@ } } }, - "/v1/api/templates": { + "/v1/api/skills": { "get": { - "summary": "List available prompt templates (summary)", - "operationId": "v1_api_templates_get", + "summary": "List available skills (summary)", + "operationId": "v1_api_skills_get", "tags": [ - "Templates" + "Skills" ], "responses": { "200": { @@ -450,28 +450,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListPromptTemplateSummaryResponse" - } - } - } - } - } - } - }, - "/v1/api/ws-templates": { - "get": { - "summary": "List enabled workstream templates (summary)", - "operationId": "v1_api_ws-templates_get", - "tags": [ - "Templates" - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListWsTemplateSummaryResponse" + "$ref": "#/components/schemas/ListSkillSummaryResponse" } } } @@ -1278,16 +1257,10 @@ "title": "Resume Ws", "type": "string" }, - "template": { + "skill": { "default": "", - "description": "Prompt template name (replaces default templates)", - "title": "Template", - "type": "string" - }, - "ws_template": { - "default": "", - "description": "Workstream template name to apply defaults from", - "title": "Ws Template", + "description": "Skill name (replaces default skills)", + "title": "Skill", "type": "string" } }, @@ -1914,91 +1887,84 @@ "title": "SearchMemoriesRequest", "type": "object" }, - "PromptTemplateSummary": { + "SkillSummary": { "properties": { "name": { - "description": "Template name", + "description": "Skill name", "title": "Name", "type": "string" }, "category": { "default": "", - "description": "Template category", + "description": "Skill category", "title": "Category", "type": "string" }, + "description": { + "default": "", + "description": "Skill description for discovery", + "title": "Description", + "type": "string" + }, + "tags": { + "description": "Semantic tags", + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, "is_default": { "default": false, - "description": "Whether this template is applied by default", + "description": "Whether auto-applied to all sessions", "title": "Is Default", "type": "boolean" }, + "activation": { + "default": "named", + "description": "Activation mode: default, named, search", + "title": "Activation", + "type": "string" + }, "origin": { "default": "manual", - "description": "Template origin: manual or mcp", + "description": "Source: manual, mcp, skills.sh, github", "title": "Origin", "type": "string" + }, + "author": { + "default": "", + "description": "Skill author", + "title": "Author", + "type": "string" + }, + "version": { + "default": "1.0.0", + "description": "Skill version", + "title": "Version", + "type": "string" } }, "required": [ "name" ], - "title": "PromptTemplateSummary", + "title": "SkillSummary", "type": "object" }, - "ListPromptTemplateSummaryResponse": { + "ListSkillSummaryResponse": { "properties": { - "templates": { + "skills": { "items": { - "$ref": "#/components/schemas/PromptTemplateSummary" + "$ref": "#/components/schemas/SkillSummary" }, - "title": "Templates", + "title": "Skills", "type": "array" } }, "required": [ - "templates" + "skills" ], - "title": "ListPromptTemplateSummaryResponse", - "type": "object" - }, - "WsTemplateSummary": { - "properties": { - "name": { - "title": "Name", - "type": "string" - }, - "description": { - "title": "Description", - "type": "string" - }, - "model": { - "title": "Model", - "type": "string" - } - }, - "required": [ - "name", - "description", - "model" - ], - "title": "WsTemplateSummary", - "type": "object" - }, - "ListWsTemplateSummaryResponse": { - "properties": { - "ws_templates": { - "items": { - "$ref": "#/components/schemas/WsTemplateSummary" - }, - "title": "Ws Templates", - "type": "array" - } - }, - "required": [ - "ws_templates" - ], - "title": "ListWsTemplateSummaryResponse", + "title": "ListSkillSummaryResponse", "type": "object" } } diff --git a/sdk/typescript/src/console.ts b/sdk/typescript/src/console.ts index 05be5356..44b2160f 100644 --- a/sdk/typescript/src/console.ts +++ b/sdk/typescript/src/console.ts @@ -20,8 +20,7 @@ import type { CreatePolicyOptions, CreateRoleOptions, CreateScheduleRequest, - CreateTemplateOptions, - CreateWsTemplateOptions, + CreateSkillRequest, ImportMcpConfigResponse, ListAdminMemoriesResponse, ListMcpServersResponse, @@ -29,16 +28,17 @@ import type { ListSchedulesResponse, ListSettingSchemaResponse, ListSettingsResponse, + ListSkillsResponse, McpServerDetail, RegistryInstallRequest, RegistrySearchResponse, NodeDetailResponse, NodesOptions, OrgInfo, - PromptTemplateInfo, RoleInfo, ScheduleInfo, SettingInfo, + SkillInfo, StatusResponse, ToolPolicyInfo, UpdateMcpServerRequest, @@ -47,14 +47,11 @@ import type { UpdateRoleOptions, UpdateScheduleRequest, UpdateSettingOptions, - UpdateTemplateOptions, - UpdateWsTemplateOptions, + UpdateSkillRequest, UsageQueryOptions, UsageResponse, UserRoleInfo, WorkstreamsOptions, - WsTemplateInfo, - WsTemplateVersionInfo, } from "./types.js"; /** Async client for the turnstone console API. */ @@ -267,74 +264,31 @@ export class TurnstoneConsole extends BaseClient { return this.request("DELETE", `/v1/api/admin/policies/${policyId}`); } - // -- Governance: Prompt Templates ------------------------------------------- + // -- Governance: Skills ------------------------------------------------------- - async listTemplates(): Promise<{ templates: PromptTemplateInfo[] }> { - return this.request("GET", "/v1/api/admin/templates"); - } - - async createTemplate( - opts: CreateTemplateOptions, - ): Promise { - return this.request("POST", "/v1/api/admin/templates", { json: opts }); - } - - async updateTemplate( - templateId: string, - opts: UpdateTemplateOptions, - ): Promise { - return this.request("PUT", `/v1/api/admin/templates/${templateId}`, { - json: opts, - }); - } - - async deleteTemplate(templateId: string): Promise { - return this.request("DELETE", `/v1/api/admin/templates/${templateId}`); - } - - // -- Governance: Workstream Templates ---------------------------------------- - - async listWsTemplates(): Promise { - const data = await this.request<{ ws_templates: WsTemplateInfo[] }>( + async listSkills(): Promise { + const resp = await this.request( "GET", - "/v1/api/admin/ws-templates", + "/v1/api/admin/skills", ); - return data.ws_templates || []; + return resp.skills; } - async createWsTemplate( - opts: CreateWsTemplateOptions, - ): Promise { - return this.request("POST", "/v1/api/admin/ws-templates", { - json: opts, + async createSkill(body: CreateSkillRequest): Promise { + return this.request("POST", "/v1/api/admin/skills", { json: body }); + } + + async updateSkill( + skillId: string, + body: UpdateSkillRequest, + ): Promise { + return this.request("PUT", `/v1/api/admin/skills/${skillId}`, { + json: body, }); } - async getWsTemplate(wsTemplateId: string): Promise { - return this.request("GET", `/v1/api/admin/ws-templates/${wsTemplateId}`); - } - - async updateWsTemplate( - wsTemplateId: string, - opts: UpdateWsTemplateOptions, - ): Promise { - return this.request("PUT", `/v1/api/admin/ws-templates/${wsTemplateId}`, { - json: opts, - }); - } - - async deleteWsTemplate(wsTemplateId: string): Promise { - await this.request("DELETE", `/v1/api/admin/ws-templates/${wsTemplateId}`); - } - - async listWsTemplateVersions( - wsTemplateId: string, - ): Promise { - const data = await this.request<{ versions: WsTemplateVersionInfo[] }>( - "GET", - `/v1/api/admin/ws-templates/${wsTemplateId}/versions`, - ); - return data.versions || []; + async deleteSkill(skillId: string): Promise { + await this.request("DELETE", `/v1/api/admin/skills/${skillId}`); } // -- Governance: Usage & Audit ---------------------------------------------- diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index e3a718dd..af480262 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -126,13 +126,11 @@ export type { ToolPolicyInfo, CreatePolicyOptions, UpdatePolicyOptions, - PromptTemplateInfo, - CreateTemplateOptions, - UpdateTemplateOptions, - WsTemplateInfo, - CreateWsTemplateOptions, - UpdateWsTemplateOptions, - WsTemplateVersionInfo, + SkillSummary, + SkillInfo, + CreateSkillRequest, + UpdateSkillRequest, + ListSkillsResponse, UsageBreakdownItem, UsageResponse, UsageQueryOptions, diff --git a/sdk/typescript/src/server.ts b/sdk/typescript/src/server.ts index 2fa2b4a3..1a9e6ae3 100644 --- a/sdk/typescript/src/server.ts +++ b/sdk/typescript/src/server.ts @@ -11,9 +11,8 @@ import type { HealthResponse, ListMemoriesOptions, ListMemoriesResponse, - ListPromptTemplateSummaryResponse, ListSavedWorkstreamsResponse, - ListWsTemplateSummaryResponse, + SkillSummary, ListWorkstreamsResponse, MemoryInfo, SaveMemoryRequest, @@ -198,14 +197,14 @@ export class TurnstoneServer extends BaseClient { return this.request("GET", "/v1/api/workstreams/saved"); } - // -- Templates -------------------------------------------------------------- + // -- Skills ----------------------------------------------------------------- - async listTemplates(): Promise { - return this.request("GET", "/v1/api/templates"); - } - - async listWsTemplates(): Promise { - return this.request("GET", "/v1/api/ws-templates"); + async listSkills(): Promise { + const resp = await this.request<{ skills: SkillSummary[] }>( + "GET", + "/v1/api/skills", + ); + return resp.skills; } // -- Memories ------------------------------------------------------------- diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 0cff5de3..12675c6e 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -72,8 +72,7 @@ export interface CreateWorkstreamRequest { model?: string; auto_approve?: boolean; resume_ws?: string; - template?: string; - ws_template?: string; + skill?: string; } export interface CreateWorkstreamResponse { @@ -144,32 +143,103 @@ export interface ListSavedWorkstreamsResponse { } // --------------------------------------------------------------------------- -// Server API — Prompt templates +// Server API — Skills // --------------------------------------------------------------------------- -export interface PromptTemplateSummary { +export interface SkillSummary { name: string; category: string; - is_default: boolean; - origin: string; -} - -export interface ListPromptTemplateSummaryResponse { - templates: PromptTemplateSummary[]; -} - -// --------------------------------------------------------------------------- -// Server API — Workstream templates -// --------------------------------------------------------------------------- - -export interface WsTemplateSummary { - name: string; description: string; - model: string; + tags: string[]; + is_default: boolean; + activation: string; + origin: string; + author: string; + version: string; } -export interface ListWsTemplateSummaryResponse { - ws_templates: WsTemplateSummary[]; +export interface SkillInfo { + template_id: string; + name: string; + category: string; + content: string; + description: string; + tags: string[]; + variables: string; + is_default: boolean; + activation: string; + org_id: string; + created_by: string; + origin: string; + mcp_server: string; + readonly: boolean; + source_url: string; + version: string; + author: string; + token_estimate: number; + model: string; + auto_approve: boolean; + temperature: number | null; + reasoning_effort: string; + max_tokens: number | null; + token_budget: number; + agent_max_turns: number | null; + notify_on_complete: string; + enabled: boolean; + allowed_tools: string; + created: string; + updated: string; +} + +export interface CreateSkillRequest { + name: string; + content: string; + category?: string; + description?: string; + tags?: string; + variables?: string; + is_default?: boolean; + activation?: string; + org_id?: string; + author?: string; + version?: string; + model?: string; + auto_approve?: boolean; + temperature?: number | null; + reasoning_effort?: string; + max_tokens?: number | null; + token_budget?: number; + agent_max_turns?: number | null; + notify_on_complete?: string; + enabled?: boolean; + allowed_tools?: string; +} + +export interface UpdateSkillRequest { + name?: string; + content?: string; + category?: string; + description?: string; + tags?: string; + variables?: string; + is_default?: boolean; + activation?: string; + author?: string; + version?: string; + model?: string; + auto_approve?: boolean; + temperature?: number | null; + reasoning_effort?: string; + max_tokens?: number | null; + token_budget?: number; + agent_max_turns?: number | null; + notify_on_complete?: string; + enabled?: boolean; + allowed_tools?: string; +} + +export interface ListSkillsResponse { + skills: SkillInfo[]; } // --------------------------------------------------------------------------- @@ -304,8 +374,7 @@ export interface ConsoleCreateWsRequest { name?: string; model?: string; initial_message?: string; - template?: string; - ws_template?: string; + skill?: string; } export interface ConsoleCreateWsResponse { @@ -477,115 +546,6 @@ export interface UpdatePolicyOptions { enabled?: boolean; } -// --------------------------------------------------------------------------- -// Console API — Governance: Prompt Templates -// --------------------------------------------------------------------------- - -export interface PromptTemplateInfo { - template_id: string; - name: string; - category: string; - content: string; - variables: string; - is_default: boolean; - org_id: string; - created_by: string; - created: string; - updated: string; - origin: string; - mcp_server: string; - readonly: boolean; -} - -export interface CreateTemplateOptions { - name: string; - content: string; - category?: string; - variables?: string; - is_default?: boolean; - org_id?: string; -} - -export interface UpdateTemplateOptions { - name?: string; - content?: string; - category?: string; - variables?: string; - is_default?: boolean; -} - -// --------------------------------------------------------------------------- -// Console API — Governance: Workstream Templates -// --------------------------------------------------------------------------- - -export interface WsTemplateInfo { - ws_template_id: string; - name: string; - description: string; - system_prompt: string; - prompt_template: string; - prompt_template_hash: string; - model: string; - auto_approve: boolean; - auto_approve_tools: string; - temperature: number | null; - reasoning_effort: string; - max_tokens: number | null; - token_budget: number; - agent_max_turns: number | null; - notify_on_complete: string; - org_id: string; - created_by: string; - enabled: boolean; - version: number; - created: string; - updated: string; -} - -export interface CreateWsTemplateOptions { - name: string; - description?: string; - system_prompt?: string; - prompt_template?: string; - model?: string; - auto_approve?: boolean; - auto_approve_tools?: string; - temperature?: number | null; - reasoning_effort?: string; - max_tokens?: number | null; - token_budget?: number; - agent_max_turns?: number | null; - notify_on_complete?: string; - org_id?: string; - enabled?: boolean; -} - -export interface UpdateWsTemplateOptions { - name?: string; - description?: string; - system_prompt?: string; - prompt_template?: string; - model?: string; - auto_approve?: boolean; - auto_approve_tools?: string; - temperature?: number | null; - reasoning_effort?: string; - max_tokens?: number | null; - token_budget?: number; - agent_max_turns?: number | null; - notify_on_complete?: string; - enabled?: boolean; -} - -export interface WsTemplateVersionInfo { - id: number; - ws_template_id: string; - version: number; - snapshot: string; - changed_by: string; - created: string; -} - // --------------------------------------------------------------------------- // Console API — Governance: Usage & Audit // --------------------------------------------------------------------------- diff --git a/tests/test_governance_endpoints.py b/tests/test_governance_endpoints.py index 5f808a7b..c79eb54d 100644 --- a/tests/test_governance_endpoints.py +++ b/tests/test_governance_endpoints.py @@ -20,22 +20,18 @@ from turnstone.console.server import ( admin_audit, admin_create_policy, admin_create_role, - admin_create_template, admin_delete_policy, admin_delete_role, - admin_delete_template, admin_delete_user, admin_get_org, admin_list_orgs, admin_list_policies, admin_list_roles, - admin_list_templates, admin_list_user_roles, admin_unassign_role, admin_update_org, admin_update_policy, admin_update_role, - admin_update_template, admin_usage, ) from turnstone.core.auth import AuthResult @@ -61,7 +57,7 @@ class _InjectAuthMiddleware(BaseHTTPMiddleware): "admin.users", "admin.orgs", "admin.policies", - "admin.templates", + "admin.skills", "admin.usage", "admin.audit", "admin.schedules", @@ -138,19 +134,6 @@ def client(storage): admin_delete_policy, methods=["DELETE"], ), - # Templates - Route("/api/admin/templates", admin_list_templates), - Route("/api/admin/templates", admin_create_template, methods=["POST"]), - Route( - "/api/admin/templates/{template_id}", - admin_update_template, - methods=["PUT"], - ), - Route( - "/api/admin/templates/{template_id}", - admin_delete_template, - methods=["DELETE"], - ), # Usage & Audit Route("/api/admin/usage", admin_usage), Route("/api/admin/audit", admin_audit), @@ -189,16 +172,6 @@ def _policy_payload(**overrides: Any) -> dict[str, Any]: return defaults -def _template_payload(**overrides: Any) -> dict[str, Any]: - defaults: dict[str, Any] = { - "name": "Greeting", - "content": "Hello {{user}}, how can I help?", - "category": "system", - } - defaults.update(overrides) - return defaults - - # --------------------------------------------------------------------------- # Tests — Roles # --------------------------------------------------------------------------- @@ -522,89 +495,6 @@ class TestPolicies: assert resp.status_code == 404 -# --------------------------------------------------------------------------- -# Tests — Prompt templates -# --------------------------------------------------------------------------- - - -class TestTemplates: - def test_list_empty(self, client): - resp = client.get("/v1/api/admin/templates") - assert resp.status_code == 200 - assert resp.json()["templates"] == [] - - def test_create_template(self, client): - resp = client.post("/v1/api/admin/templates", json=_template_payload()) - assert resp.status_code == 200 - tmpl = resp.json() - assert tmpl["name"] == "Greeting" - assert "{{user}}" in tmpl["content"] - assert tmpl["category"] == "system" - assert "template_id" in tmpl - assert "created" in tmpl - - def test_create_template_missing_name(self, client): - resp = client.post( - "/v1/api/admin/templates", - json=_template_payload(name=""), - ) - assert resp.status_code == 400 - assert "name" in resp.json()["error"].lower() - - def test_create_template_missing_content(self, client): - resp = client.post( - "/v1/api/admin/templates", - json=_template_payload(content=""), - ) - assert resp.status_code == 400 - assert "content" in resp.json()["error"].lower() - - def test_list_after_create(self, client): - client.post("/v1/api/admin/templates", json=_template_payload()) - resp = client.get("/v1/api/admin/templates") - assert resp.status_code == 200 - templates = resp.json()["templates"] - assert len(templates) == 1 - assert templates[0]["name"] == "Greeting" - - def test_update_template(self, client): - create_resp = client.post("/v1/api/admin/templates", json=_template_payload()) - template_id = create_resp.json()["template_id"] - - resp = client.put( - f"/v1/api/admin/templates/{template_id}", - json={"name": "Welcome", "content": "Welcome, {{user}}!", "is_default": True}, - ) - assert resp.status_code == 200 - tmpl = resp.json() - assert tmpl["name"] == "Welcome" - assert tmpl["content"] == "Welcome, {{user}}!" - assert tmpl["is_default"] is True - - def test_update_template_not_found(self, client): - resp = client.put( - "/v1/api/admin/templates/nonexistent", - json={"name": "Nope"}, - ) - assert resp.status_code == 404 - - def test_delete_template(self, client): - create_resp = client.post("/v1/api/admin/templates", json=_template_payload()) - template_id = create_resp.json()["template_id"] - - resp = client.delete(f"/v1/api/admin/templates/{template_id}") - assert resp.status_code == 200 - assert resp.json()["status"] == "ok" - - # Verify gone - list_resp = client.get("/v1/api/admin/templates") - assert list_resp.json()["templates"] == [] - - def test_delete_template_not_found(self, client): - resp = client.delete("/v1/api/admin/templates/nonexistent") - assert resp.status_code == 404 - - # --------------------------------------------------------------------------- # Tests — Usage # --------------------------------------------------------------------------- diff --git a/tests/test_prompt_templates_runtime.py b/tests/test_prompt_templates_runtime.py index c020f473..9ec6b94a 100644 --- a/tests/test_prompt_templates_runtime.py +++ b/tests/test_prompt_templates_runtime.py @@ -194,13 +194,13 @@ class TestExplicitTemplate: _create_template(db, "t1", "default-tpl", "DEFAULT_CONTENT", is_default=True) _create_template(db, "t2", "specific-tpl", "SPECIFIC_CONTENT", is_default=False) - session = _make_session(template="specific-tpl") + session = _make_session(skill="specific-tpl") content = _sys_content(session) assert "SPECIFIC_CONTENT" in content assert "DEFAULT_CONTENT" not in content def test_explicit_template_not_found(self, tmp_db): - session = _make_session(template="nonexistent") + session = _make_session(skill="nonexistent") content = _sys_content(session) # Graceful degradation — no template content injected assert "nonexistent" not in content @@ -257,9 +257,9 @@ class TestTemplatePersistence: db = get_storage() _create_template(db, "t1", "my-tpl", "TPL_CONTENT", is_default=False) - session = _make_session(template="my-tpl") + session = _make_session(skill="my-tpl") config = load_workstream_config(session.ws_id) - assert config["template"] == "my-tpl" + assert config["skill"] == "my-tpl" def test_template_restored_on_resume(self, tmp_db): from turnstone.core.memory import save_message @@ -268,17 +268,17 @@ class TestTemplatePersistence: db = get_storage() _create_template(db, "t1", "my-tpl", "PERSISTED_TEMPLATE", is_default=False) - # Create session with template, save a message so resume has history - session1 = _make_session(template="my-tpl") + # Create session with skill, save a message so resume has history + session1 = _make_session(skill="my-tpl") ws_id = session1.ws_id save_message(ws_id, "user", "hello") # New session without template, then resume session2 = _make_session() - assert session2._template_name is None + assert session2._skill_name is None resumed = session2.resume(ws_id) assert resumed - assert session2._template_name == "my-tpl" + assert session2._skill_name == "my-tpl" content = _sys_content(session2) assert "PERSISTED_TEMPLATE" in content @@ -287,7 +287,7 @@ class TestTemplatePersistence: session = _make_session() config = load_workstream_config(session.ws_id) - assert config["template"] == "" + assert config["skill"] == "" # --------------------------------------------------------------------------- @@ -306,8 +306,8 @@ class TestTemplateSlashCommand: content_before = _sys_content(session) assert "SLASH_TEMPLATE" not in content_before - session.handle_command("/template my-tpl") - assert session._template_name == "my-tpl" + session.handle_command("/skill my-tpl") + assert session._skill_name == "my-tpl" content_after = _sys_content(session) assert "SLASH_TEMPLATE" in content_after @@ -318,12 +318,12 @@ class TestTemplateSlashCommand: _create_template(db, "t1", "my-tpl", "EXPLICIT_TEMPLATE", is_default=False) _create_template(db, "t2", "default-tpl", "DEFAULT_TEMPLATE", is_default=True) - session = _make_session(template="my-tpl") + session = _make_session(skill="my-tpl") assert "EXPLICIT_TEMPLATE" in _sys_content(session) assert "DEFAULT_TEMPLATE" not in _sys_content(session) - session.handle_command("/template clear") - assert session._template_name is None + session.handle_command("/skill clear") + assert session._skill_name is None assert "DEFAULT_TEMPLATE" in _sys_content(session) assert "EXPLICIT_TEMPLATE" not in _sys_content(session) @@ -331,7 +331,7 @@ class TestTemplateSlashCommand: ui = NullUI() ui.on_error = MagicMock() session = _make_session(ui=ui) - session.handle_command("/template nonexistent") + session.handle_command("/skill nonexistent") ui.on_error.assert_called_once() assert "not found" in ui.on_error.call_args[0][0].lower() @@ -343,8 +343,8 @@ class TestTemplateSlashCommand: ui = NullUI() ui.on_info = MagicMock() - session = _make_session(ui=ui, template="my-tpl") - session.handle_command("/template") + session = _make_session(ui=ui, skill="my-tpl") + session.handle_command("/skill") ui.on_info.assert_called_once() assert "my-tpl" in ui.on_info.call_args[0][0] @@ -389,7 +389,7 @@ class TestMCPTemplates: readonly=True, ) - session = _make_session(template="mcp__server__code") + session = _make_session(skill="mcp__server__code") content = _sys_content(session) assert "MCP_EXPLICIT" in content @@ -408,7 +408,7 @@ class TestResumeDeletedTemplate: _create_template(db, "t1", "ephemeral-tpl", "EPHEMERAL_CONTENT", is_default=False) # Create session with template, save a message so resume has history - session1 = _make_session(template="ephemeral-tpl") + session1 = _make_session(skill="ephemeral-tpl") ws_id = session1.ws_id save_message(ws_id, "user", "hello") assert "EPHEMERAL_CONTENT" in _sys_content(session1) @@ -421,8 +421,8 @@ class TestResumeDeletedTemplate: resumed = session2.resume(ws_id) assert resumed - assert session2._template_name == "ephemeral-tpl" - assert session2._template_content is None + assert session2._skill_name == "ephemeral-tpl" + assert session2._skill_content is None # System message should not contain the deleted template content content = _sys_content(session2) assert "EPHEMERAL_CONTENT" not in content @@ -436,43 +436,43 @@ class TestResumeDeletedTemplate: # --------------------------------------------------------------------------- -class TestTemplateFactoryPassthrough: - def test_template_passed_through_workstream_create(self, tmp_db): - """WorkstreamManager.create(template=...) propagates to session factory.""" +class TestSkillFactoryPassthrough: + def test_skill_passed_through_workstream_create(self, tmp_db): + """WorkstreamManager.create(skill=...) propagates to session factory.""" from turnstone.core.storage import get_storage from turnstone.core.workstream import WorkstreamManager db = get_storage() _create_template(db, "t1", "factory-tpl", "FACTORY_CONTENT", is_default=False) - captured_template = None + captured_skill = None - def factory(ui, model_alias=None, ws_id=None, *, template=None): - nonlocal captured_template - captured_template = template - return _make_session(template=template) + def factory(ui, model_alias=None, ws_id=None, *, skill=None): + nonlocal captured_skill + captured_skill = skill + return _make_session(skill=captured_skill) mgr = WorkstreamManager(factory) - ws = mgr.create(name="test", template="factory-tpl") - assert captured_template == "factory-tpl" + ws = mgr.create(name="test", skill="factory-tpl") + assert captured_skill == "factory-tpl" assert ws.session is not None - assert ws.session._template_name == "factory-tpl" + assert ws.session._skill_name == "factory-tpl" assert "FACTORY_CONTENT" in _sys_content(ws.session) - def test_template_none_uses_defaults(self, tmp_db): - """WorkstreamManager.create() without template passes None.""" - captured_template = "sentinel" + def test_skill_none_uses_defaults(self, tmp_db): + """WorkstreamManager.create() without skill passes None.""" + captured_skill = "sentinel" - def factory(ui, model_alias=None, ws_id=None, *, template=None): - nonlocal captured_template - captured_template = template - return _make_session(template=template) + def factory(ui, model_alias=None, ws_id=None, *, skill=None): + nonlocal captured_skill + captured_skill = skill + return _make_session(skill=skill) from turnstone.core.workstream import WorkstreamManager mgr = WorkstreamManager(factory) mgr.create(name="test") - assert captured_template is None + assert captured_skill is None class TestTemplateThreadSafety: @@ -482,7 +482,7 @@ class TestTemplateThreadSafety: db = get_storage() _create_template(db, "t1", "thread-tpl", "THREAD_TEMPLATE", is_default=False) - session = _make_session(template="thread-tpl") + session = _make_session(skill="thread-tpl") errors: list[Exception] = [] stop = threading.Event() iterations = 200 @@ -508,9 +508,9 @@ class TestTemplateThreadSafety: try: for i in range(iterations): if i % 2 == 0: - session.set_template("thread-tpl") + session.set_skill("thread-tpl") else: - session.set_template(None) + session.set_skill(None) finally: stop.set() t.join(timeout=5) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 2b9049c4..929cbbe1 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -209,18 +209,18 @@ def test_create_workstream_target_node(): assert restored.name == "debug-ws" -def test_create_workstream_template_field(): - msg = CreateWorkstreamMessage(name="ws", template="code-review") - assert msg.template == "code-review" +def test_create_workstream_skill_field(): + msg = CreateWorkstreamMessage(name="ws", skill="code-review") + assert msg.skill == "code-review" raw = msg.to_json() restored = InboundMessage.from_json(raw) assert isinstance(restored, CreateWorkstreamMessage) - assert restored.template == "code-review" + assert restored.skill == "code-review" -def test_create_workstream_template_default_empty(): +def test_create_workstream_skill_default_empty(): msg = CreateWorkstreamMessage(name="ws") - assert msg.template == "" + assert msg.skill == "" def test_list_nodes_round_trip(): diff --git a/tests/test_session.py b/tests/test_session.py index 55bf375c..4b5e35a3 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -338,25 +338,25 @@ class TestPlanExec: # Last user message in second call is the coaching message assert "did not follow" in captured_messages[1][-1]["content"] - def test_plan_includes_template_content(self, tmp_db, tmp_path, monkeypatch): - """Plan agent system message includes template guardrails.""" + def test_plan_includes_skill_content(self, tmp_db, tmp_path, monkeypatch): + """Plan agent system message includes skill guardrails.""" monkeypatch.chdir(tmp_path) session = _make_session() - session._template_content = "SAFETY: Do not produce harmful plans." + session._skill_content = "SAFETY: Do not produce harmful plans." _, _, messages = self._run_plan(session, "build something") sys_content = messages[0]["content"] assert "SAFETY: Do not produce harmful plans." in sys_content assert ChatSession._PLAN_IDENTITY in sys_content - # Template appears before plan identity + # Skill content appears before plan identity tpl_pos = sys_content.index("SAFETY:") identity_pos = sys_content.index(ChatSession._PLAN_IDENTITY) assert tpl_pos < identity_pos - def test_plan_no_template_is_identity_only(self, tmp_db, tmp_path, monkeypatch): - """Without templates, plan system message is exactly _PLAN_IDENTITY.""" + def test_plan_no_skill_is_identity_only(self, tmp_db, tmp_path, monkeypatch): + """Without skills, plan system message is exactly _PLAN_IDENTITY.""" monkeypatch.chdir(tmp_path) session = _make_session() - assert session._template_content is None + assert session._skill_content is None _, _, messages = self._run_plan(session, "build something") assert messages[0]["content"] == ChatSession._PLAN_IDENTITY @@ -579,11 +579,11 @@ class TestPlanRefinement: assert msgs[3]["role"] == "user" assert "add tests too" in msgs[3]["content"] - def test_refine_plan_includes_template_content(self, tmp_db, tmp_path, monkeypatch): - """_refine_plan system message includes template guardrails.""" + def test_refine_plan_includes_skill_content(self, tmp_db, tmp_path, monkeypatch): + """_refine_plan system message includes skill guardrails.""" monkeypatch.chdir(tmp_path) session = _make_session() - session._template_content = "SAFETY: guardrails here" + session._skill_content = "SAFETY: guardrails here" captured = {} def fake_run_agent(messages, **kwargs): diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 00000000..c9b5b145 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,1455 @@ +"""Tests for the skills foundation feature. + +Covers storage operations, session runtime wiring, skill search, +admin API endpoints, and MCP sync integration. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +import pytest +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.routing import Mount, Route +from starlette.testclient import TestClient + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + +from turnstone.core.auth import AuthResult +from turnstone.core.session import ChatSession +from turnstone.core.skill_search import SkillSearchManager +from turnstone.core.storage._sqlite import SQLiteBackend + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class NullUI: + """UI adapter that discards all output.""" + + def on_thinking_start(self): + pass + + def on_thinking_stop(self): + pass + + def on_reasoning_token(self, text): + pass + + def on_content_token(self, text): + pass + + def on_stream_end(self): + pass + + def approve_tools(self, items): + return True, None + + def on_tool_result(self, call_id, name, output): + pass + + def on_tool_output_chunk(self, call_id, chunk): + pass + + def on_status(self, usage, context_window, effort): + pass + + def on_plan_review(self, content): + return "" + + def on_info(self, message): + pass + + def on_error(self, message): + pass + + def on_state_change(self, state): + pass + + def on_rename(self, name): + pass + + +def _make_session(**kwargs): + defaults = dict( + client=MagicMock(), + model="test-model", + ui=NullUI(), + instructions=None, + temperature=0.5, + max_tokens=4096, + tool_timeout=30, + ) + defaults.update(kwargs) + return ChatSession(**defaults) + + +def _sys_content(session: ChatSession) -> str: + """Extract the system message content.""" + msgs = [m for m in session.system_messages if m["role"] == "system"] + assert msgs + return msgs[0]["content"] + + +def _create_template(db, template_id, name, content, **kwargs): + """Helper to create a prompt template in storage.""" + db.create_prompt_template( + template_id=template_id, + name=name, + category=kwargs.get("category", "general"), + content=content, + variables=kwargs.get("variables", "[]"), + is_default=kwargs.get("is_default", False), + org_id=kwargs.get("org_id", ""), + created_by=kwargs.get("created_by", "test"), + origin=kwargs.get("origin", "manual"), + mcp_server=kwargs.get("mcp_server", ""), + readonly=kwargs.get("readonly", False), + description=kwargs.get("description", ""), + tags=kwargs.get("tags", "[]"), + source_url=kwargs.get("source_url", ""), + version=kwargs.get("version", "1.0.0"), + author=kwargs.get("author", ""), + activation=kwargs.get("activation", "named"), + token_estimate=kwargs.get("token_estimate", 0), + model=kwargs.get("model", ""), + auto_approve=kwargs.get("auto_approve", False), + temperature=kwargs.get("temperature"), + reasoning_effort=kwargs.get("reasoning_effort", ""), + max_tokens=kwargs.get("max_tokens"), + token_budget=kwargs.get("token_budget", 0), + agent_max_turns=kwargs.get("agent_max_turns"), + notify_on_complete=kwargs.get("notify_on_complete", "{}"), + enabled=kwargs.get("enabled", True), + allowed_tools=kwargs.get("allowed_tools", "[]"), + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def db(tmp_path): + """Create a fresh SQLite backend for each test.""" + return SQLiteBackend(str(tmp_path / "test.db")) + + +# --------------------------------------------------------------------------- +# 1. Storage tests +# --------------------------------------------------------------------------- + + +class TestSkillStorage: + def test_create_skill_with_new_fields(self, db): + """Create a prompt template with all skill fields and verify storage.""" + db.create_prompt_template( + template_id="s1", + name="code-review", + category="engineering", + content="You are a code reviewer.", + variables="[]", + is_default=False, + org_id="", + created_by="admin", + description="Reviews code for quality and correctness", + tags='["code", "review"]', + activation="search", + author="alice", + version="2.0.0", + token_estimate=500, + ) + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["template_id"] == "s1" + assert tpl["name"] == "code-review" + assert tpl["category"] == "engineering" + assert tpl["content"] == "You are a code reviewer." + assert tpl["description"] == "Reviews code for quality and correctness" + assert tpl["tags"] == '["code", "review"]' + assert tpl["activation"] == "search" + assert tpl["author"] == "alice" + assert tpl["version"] == "2.0.0" + assert tpl["token_estimate"] == 500 + + def test_list_skills_by_activation(self, db): + """Filter templates by activation mode.""" + _create_template(db, "s1", "default-skill", "D", activation="default", is_default=True) + _create_template(db, "s2", "search-skill", "S", activation="search") + _create_template(db, "s3", "named-skill", "N", activation="named") + _create_template(db, "s4", "another-search", "S2", activation="search") + + defaults = db.list_skills_by_activation("default") + assert len(defaults) == 1 + assert defaults[0]["name"] == "default-skill" + + search = db.list_skills_by_activation("search") + assert len(search) == 2 + names = {s["name"] for s in search} + assert names == {"another-search", "search-skill"} + + def test_activation_is_default_sync_on_create(self, db): + """Creating with activation='default' sets is_default=True.""" + db.create_prompt_template( + template_id="s1", + name="auto-default", + category="general", + content="content", + activation="default", + ) + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["activation"] == "default" + assert tpl["is_default"] is True + + def test_activation_is_default_sync_on_update(self, db): + """Updating activation syncs is_default and vice versa.""" + _create_template(db, "s1", "skill", "content", activation="named") + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["is_default"] is False + + # Setting activation to default should set is_default + db.update_prompt_template("s1", activation="default") + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["is_default"] is True + assert tpl["activation"] == "default" + + # Setting is_default to False should set activation to named + db.update_prompt_template("s1", is_default=False) + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["is_default"] is False + assert tpl["activation"] == "named" + + # Setting is_default to True should set activation to default + db.update_prompt_template("s1", is_default=True) + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["is_default"] is True + assert tpl["activation"] == "default" + + def test_tags_json_roundtrip(self, db): + """Tags stored as JSON string parse back correctly.""" + tags = ["code", "review", "python"] + _create_template(db, "s1", "tagged", "content", tags=json.dumps(tags)) + tpl = db.get_prompt_template("s1") + assert tpl is not None + parsed = json.loads(tpl["tags"]) + assert parsed == tags + + def test_token_estimate_stored(self, db): + """token_estimate is persisted and returned.""" + _create_template(db, "s1", "estimated", "content", token_estimate=500) + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["token_estimate"] == 500 + + def test_get_skill_by_name(self, db): + """get_skill_by_name works as alias for get_prompt_template_by_name.""" + _create_template(db, "s1", "my-skill", "skill content") + tpl = db.get_skill_by_name("my-skill") + assert tpl is not None + assert tpl["template_id"] == "s1" + assert tpl["name"] == "my-skill" + + def test_get_skill_by_name_nonexistent(self, db): + """get_skill_by_name returns None for missing names.""" + assert db.get_skill_by_name("nonexistent") is None + + def test_new_fields_have_defaults(self, db): + """Creating a template without new params uses sensible defaults.""" + db.create_prompt_template( + template_id="s1", + name="old-style", + category="general", + content="Hello", + ) + tpl = db.get_prompt_template("s1") + assert tpl is not None + assert tpl["description"] == "" + assert tpl["tags"] == "[]" + assert tpl["activation"] == "named" + assert tpl["author"] == "" + assert tpl["version"] == "1.0.0" + assert tpl["token_estimate"] == 0 + assert tpl["source_url"] == "" + + def test_list_skills_by_activation_empty(self, db): + """Querying a nonexistent activation returns empty list.""" + result = db.list_skills_by_activation("nonexistent") + assert result == [] + + def test_list_skills_by_activation_ordered_by_name(self, db): + """Results are ordered by name ascending.""" + _create_template(db, "s2", "beta-search", "B", activation="search") + _create_template(db, "s1", "alpha-search", "A", activation="search") + results = db.list_skills_by_activation("search") + assert len(results) == 2 + assert results[0]["name"] == "alpha-search" + assert results[1]["name"] == "beta-search" + + +# --------------------------------------------------------------------------- +# 1b. Skill resource storage tests +# --------------------------------------------------------------------------- + + +class TestSkillResources: + def test_create_and_list_resources(self, db): + _create_template(db, "s1", "my-skill", "content") + db.create_skill_resource("r1", "s1", "scripts/search.py", "import requests") + db.create_skill_resource("r2", "s1", "references/api.md", "# API Docs") + resources = db.list_skill_resources("s1") + assert len(resources) == 2 + assert resources[0]["path"] == "references/api.md" # ordered by path + assert resources[1]["path"] == "scripts/search.py" + + def test_get_resource_by_path(self, db): + _create_template(db, "s1", "my-skill", "content") + db.create_skill_resource("r1", "s1", "scripts/helper.py", "def main(): pass") + r = db.get_skill_resource("s1", "scripts/helper.py") + assert r is not None + assert r["content"] == "def main(): pass" + assert r["content_type"] == "text/plain" + + def test_get_resource_not_found(self, db): + _create_template(db, "s1", "my-skill", "content") + assert db.get_skill_resource("s1", "nonexistent.py") is None + + def test_delete_skill_resources(self, db): + _create_template(db, "s1", "my-skill", "content") + db.create_skill_resource("r1", "s1", "a.py", "code1") + db.create_skill_resource("r2", "s1", "b.py", "code2") + count = db.delete_skill_resources("s1") + assert count == 2 + assert db.list_skill_resources("s1") == [] + + def test_resources_scoped_to_skill(self, db): + _create_template(db, "s1", "skill-a", "content a") + _create_template(db, "s2", "skill-b", "content b") + db.create_skill_resource("r1", "s1", "script.py", "code-a") + db.create_skill_resource("r2", "s2", "script.py", "code-b") + assert len(db.list_skill_resources("s1")) == 1 + assert len(db.list_skill_resources("s2")) == 1 + assert db.get_skill_resource("s1", "script.py")["content"] == "code-a" + + def test_content_type_stored(self, db): + _create_template(db, "s1", "my-skill", "content") + db.create_skill_resource("r1", "s1", "template.json", "{}", content_type="application/json") + r = db.get_skill_resource("s1", "template.json") + assert r["content_type"] == "application/json" + + def test_list_empty(self, db): + assert db.list_skill_resources("nonexistent") == [] + + def test_delete_empty(self, db): + assert db.delete_skill_resources("nonexistent") == 0 + + +# --------------------------------------------------------------------------- +# 2. Session runtime tests +# --------------------------------------------------------------------------- + + +class TestSkillSessionRuntime: + def test_skill_param_alone(self, tmp_db): + """skill= works on its own.""" + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "s1", "my-skill", "SKILL_ONLY_CONTENT") + + session = _make_session(skill="my-skill") + content = _sys_content(session) + assert "SKILL_ONLY_CONTENT" in content + assert session._skill_name == "my-skill" + + def test_set_skill_method(self, tmp_db): + """session.set_skill() activates the skill.""" + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "s1", "dynamic-skill", "DYNAMIC_SKILL_CONTENT") + + session = _make_session() + content_before = _sys_content(session) + assert "DYNAMIC_SKILL_CONTENT" not in content_before + + session.set_skill("dynamic-skill") + assert session._skill_name == "dynamic-skill" + content_after = _sys_content(session) + assert "DYNAMIC_SKILL_CONTENT" in content_after + + def test_skill_slash_command(self, tmp_db): + """The /skill command sets the active skill.""" + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "s1", "slash-skill", "SLASH_SKILL_CONTENT") + + ui = NullUI() + ui.on_info = MagicMock() + session = _make_session(ui=ui) + session.handle_command("/skill slash-skill") + assert session._skill_name == "slash-skill" + content = _sys_content(session) + assert "SLASH_SKILL_CONTENT" in content + ui.on_info.assert_called_once() + assert "slash-skill" in ui.on_info.call_args[0][0] + + def test_skill_slash_command_clear(self, tmp_db): + """The /skill clear command clears the active skill.""" + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "s1", "clearable", "CLEARABLE_CONTENT") + _create_template(db, "s2", "default-one", "DEFAULT_CONTENT", is_default=True) + + ui = NullUI() + ui.on_info = MagicMock() + session = _make_session(ui=ui, skill="clearable") + assert "CLEARABLE_CONTENT" in _sys_content(session) + + session.handle_command("/skill clear") + assert session._skill_name is None + assert "DEFAULT_CONTENT" in _sys_content(session) + assert "CLEARABLE_CONTENT" not in _sys_content(session) + + def test_skill_slash_command_show(self, tmp_db): + """The /skill command with no arg shows current skill.""" + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "s1", "current-skill", "content") + + ui = NullUI() + ui.on_info = MagicMock() + session = _make_session(ui=ui, skill="current-skill") + session.handle_command("/skill") + ui.on_info.assert_called_once() + assert "current-skill" in ui.on_info.call_args[0][0] + + def test_skill_slash_command_not_found(self, tmp_db): + """The /skill command with unknown name shows error.""" + ui = NullUI() + ui.on_error = MagicMock() + session = _make_session(ui=ui) + session.handle_command("/skill nonexistent") + ui.on_error.assert_called_once() + assert "not found" in ui.on_error.call_args[0][0].lower() + + def test_skill_saved_in_config(self, tmp_db): + """_save_config() includes both 'skill' and 'template' keys.""" + from turnstone.core.memory import load_workstream_config + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "s1", "persist-skill", "PERSIST_CONTENT") + + session = _make_session(skill="persist-skill") + config = load_workstream_config(session.ws_id) + assert config["skill"] == "persist-skill" + + def test_skill_resumed_from_config(self, tmp_db): + """Resume reads 'skill' key with precedence over 'template'.""" + from turnstone.core.memory import save_message + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "s1", "resume-skill", "RESUMED_SKILL_CONTENT") + + session1 = _make_session(skill="resume-skill") + ws_id = session1.ws_id + save_message(ws_id, "user", "hello") + + session2 = _make_session() + assert session2._skill_name is None + resumed = session2.resume(ws_id) + assert resumed + assert session2._skill_name == "resume-skill" + content = _sys_content(session2) + assert "RESUMED_SKILL_CONTENT" in content + + +# --------------------------------------------------------------------------- +# 3. Skill search tests +# --------------------------------------------------------------------------- + + +class TestSkillSearch: + def test_search_returns_relevant_results(self): + """SkillSearchManager finds skills matching the query.""" + skills = [ + { + "name": "code-review", + "description": "Review code quality", + "tags": '["code"]', + "content": "You review code.", + "category": "eng", + }, + { + "name": "summarize", + "description": "Summarize documents", + "tags": '["docs"]', + "content": "You summarize.", + "category": "writing", + }, + { + "name": "debug-helper", + "description": "Help debug issues", + "tags": '["code", "debug"]', + "content": "You help debug.", + "category": "eng", + }, + ] + mgr = SkillSearchManager(skills) + results = mgr.search("code review") + assert len(results) > 0 + names = [r["name"] for r in results] + assert "code-review" in names + + def test_search_empty_query_returns_empty(self): + """Searching with empty string returns no results.""" + skills = [ + { + "name": "skill-one", + "description": "A skill", + "tags": "[]", + "content": "content", + "category": "general", + }, + ] + mgr = SkillSearchManager(skills) + results = mgr.search("") + assert results == [] + + def test_search_no_skills_returns_empty(self): + """SkillSearchManager with no skills returns empty on any query.""" + mgr = SkillSearchManager([]) + results = mgr.search("anything") + assert results == [] + + def test_tags_boost_relevance(self): + """Skills with matching tags should rank higher.""" + skills = [ + { + "name": "generic-tool", + "description": "A generic tool for various tasks", + "tags": "[]", + "content": "Does many things.", + "category": "general", + }, + { + "name": "python-linter", + "description": "Lint code", + "tags": '["python", "lint"]', + "content": "You lint code.", + "category": "eng", + }, + ] + mgr = SkillSearchManager(skills) + results = mgr.search("python lint") + assert len(results) > 0 + # The python-linter should appear first because tags match + assert results[0]["name"] == "python-linter" + + def test_count_property(self): + """The count property returns the number of indexed skills.""" + skills = [ + {"name": "a", "description": "", "tags": "[]", "content": "x", "category": ""}, + {"name": "b", "description": "", "tags": "[]", "content": "y", "category": ""}, + {"name": "c", "description": "", "tags": "[]", "content": "z", "category": ""}, + ] + mgr = SkillSearchManager(skills) + assert mgr.count == 3 + + def test_count_empty(self): + """Empty manager has count 0.""" + mgr = SkillSearchManager([]) + assert mgr.count == 0 + + def test_search_uses_content_prefix(self): + """Search indexes first 500 chars of content for matching.""" + skills = [ + { + "name": "obscure-name", + "description": "generic", + "tags": "[]", + "content": "kubernetes cluster management and orchestration", + "category": "devops", + }, + ] + mgr = SkillSearchManager(skills) + results = mgr.search("kubernetes") + assert len(results) == 1 + assert results[0]["name"] == "obscure-name" + + def test_search_limit(self): + """Search respects the limit parameter.""" + skills = [ + { + "name": f"skill-{i}", + "description": "common common common", + "tags": "[]", + "content": "common content", + "category": "", + } + for i in range(10) + ] + mgr = SkillSearchManager(skills) + results = mgr.search("common", limit=3) + assert len(results) <= 3 + + def test_tags_as_list(self): + """Tags can be passed as actual list (not just JSON string).""" + skills = [ + { + "name": "list-tags", + "description": "has list tags", + "tags": ["python", "code"], + "content": "content", + "category": "", + }, + ] + mgr = SkillSearchManager(skills) + results = mgr.search("python") + assert len(results) == 1 + assert results[0]["name"] == "list-tags" + + +# --------------------------------------------------------------------------- +# 4. API tests +# --------------------------------------------------------------------------- + + +class _InjectAuthMiddleware(BaseHTTPMiddleware): + """Inject an admin auth result with admin.skills permission.""" + + async def dispatch(self, request: Request, call_next: Any) -> Response: + request.state.auth_result = AuthResult( + user_id="test-user", + scopes=frozenset({"approve"}), + token_source="config", + permissions=frozenset({"read", "write", "approve", "admin.skills"}), + ) + return await call_next(request) + + +@pytest.fixture() +def api_storage(tmp_path): + return SQLiteBackend(str(tmp_path / "api_test.db")) + + +@pytest.fixture() +def api_client(api_storage): + from turnstone.console.server import ( + admin_create_skill, + admin_delete_skill, + admin_list_skills, + admin_update_skill, + ) + + routes = [ + Mount( + "/v1", + routes=[ + Route("/api/admin/skills", admin_list_skills), + 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"]), + ], + ), + ] + app = Starlette( + routes=routes, + middleware=[Middleware(_InjectAuthMiddleware)], + ) + app.state.auth_storage = api_storage + return TestClient(app) + + +class TestSkillAPI: + def test_list_skills_endpoint(self, api_client, api_storage): + """GET /v1/api/admin/skills returns correct schema.""" + _create_template( + api_storage, + "s1", + "skill-a", + "content A", + description="Desc A", + tags='["tag1"]', + activation="search", + ) + _create_template( + api_storage, "s2", "skill-b", "content B", description="Desc B", activation="named" + ) + + resp = api_client.get("/v1/api/admin/skills") + assert resp.status_code == 200 + data = resp.json() + assert "skills" in data + skills = data["skills"] + assert len(skills) == 2 + # Verify schema fields are present + for skill in skills: + assert "template_id" in skill + assert "name" in skill + assert "description" in skill + assert "tags" in skill + assert "activation" in skill + assert "token_estimate" in skill + assert "author" in skill + assert "version" in skill + assert "origin" in skill + assert "readonly" in skill + # Check tags are parsed as list + s_a = next(s for s in skills if s["name"] == "skill-a") + assert s_a["tags"] == ["tag1"] + assert s_a["activation"] == "search" + + def test_create_skill_endpoint(self, api_client, api_storage): + """POST /v1/api/admin/skills creates with new fields.""" + resp = api_client.post( + "/v1/api/admin/skills", + json={ + "name": "new-skill", + "content": "You are a helpful skill.", + "category": "custom", + "description": "A new custom skill", + "tags": ["test", "custom"], + "activation": "search", + "author": "bob", + "version": "1.2.0", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "new-skill" + assert data["description"] == "A new custom skill" + assert data["activation"] == "search" + assert data["author"] == "bob" + assert data["version"] == "1.2.0" + + def test_create_skill_computes_token_estimate(self, api_client, api_storage): + """Token estimate is computed from content length on creation.""" + content = "x" * 400 # 400 chars -> 100 tokens (400 // 4) + resp = api_client.post( + "/v1/api/admin/skills", + json={"name": "estimated-skill", "content": content}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["token_estimate"] == 100 + + def test_create_skill_requires_name(self, api_client): + """Creating without name returns 400.""" + resp = api_client.post( + "/v1/api/admin/skills", + json={"content": "some content"}, + ) + assert resp.status_code == 400 + assert "name" in resp.json()["error"].lower() + + def test_create_skill_requires_content(self, api_client): + """Creating without content returns 400.""" + resp = api_client.post( + "/v1/api/admin/skills", + json={"name": "no-content"}, + ) + assert resp.status_code == 400 + assert "content" in resp.json()["error"].lower() + + def test_update_skill_endpoint(self, api_client, api_storage): + """PUT /v1/api/admin/skills/{id} updates new fields.""" + _create_template(api_storage, "s1", "update-me", "old content", description="old desc") + + resp = api_client.put( + "/v1/api/admin/skills/s1", + json={ + "description": "updated desc", + "tags": ["updated"], + "activation": "default", + "author": "charlie", + "version": "2.0.0", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["description"] == "updated desc" + assert data["activation"] == "default" + assert data["is_default"] is True + assert data["author"] == "charlie" + assert data["version"] == "2.0.0" + + def test_update_skill_not_found(self, api_client): + """Updating nonexistent skill returns 404.""" + resp = api_client.put( + "/v1/api/admin/skills/nonexistent", + json={"description": "new"}, + ) + assert resp.status_code == 404 + + def test_update_skill_readonly_rejected(self, api_client, api_storage): + """Updating a readonly (MCP-sourced) skill returns 403.""" + _create_template( + api_storage, + "s1", + "mcp-skill", + "mcp content", + origin="mcp", + mcp_server="srv", + readonly=True, + ) + resp = api_client.put( + "/v1/api/admin/skills/s1", + json={"description": "hacked"}, + ) + assert resp.status_code == 403 + + def test_update_skill_recomputes_token_estimate(self, api_client, api_storage): + """Updating content recomputes token_estimate.""" + _create_template(api_storage, "s1", "recompute", "short", token_estimate=1) + new_content = "y" * 800 # 800 // 4 = 200 + resp = api_client.put( + "/v1/api/admin/skills/s1", + json={"content": new_content}, + ) + assert resp.status_code == 200 + assert resp.json()["token_estimate"] == 200 + + 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") + + resp = api_client.delete("/v1/api/admin/skills/s1") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + # Verify deletion + assert api_storage.get_prompt_template("s1") is None + + def test_delete_skill_not_found(self, api_client): + """Deleting nonexistent skill returns 404.""" + resp = api_client.delete("/v1/api/admin/skills/missing") + assert resp.status_code == 404 + + def test_delete_skill_readonly_rejected(self, api_client, api_storage): + """Deleting a readonly skill returns 403.""" + _create_template( + api_storage, + "s1", + "mcp-readonly", + "content", + origin="mcp", + mcp_server="srv", + readonly=True, + ) + resp = api_client.delete("/v1/api/admin/skills/s1") + assert resp.status_code == 403 + + 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", "") + # by importing and checking the function exists with proper handling + from turnstone.console.server import create_workstream + + # Verify the handler function is importable and callable + assert callable(create_workstream) + + def test_create_skill_activation_default_sync(self, api_client, api_storage): + """Creating with activation=default auto-sets is_default.""" + resp = api_client.post( + "/v1/api/admin/skills", + json={ + "name": "auto-default", + "content": "auto default content", + "activation": "default", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["activation"] == "default" + assert data["is_default"] is True + + def test_create_skill_is_default_derives_activation(self, api_client, api_storage): + """Creating with is_default=true and no activation sets activation=default.""" + resp = api_client.post( + "/v1/api/admin/skills", + json={ + "name": "default-derived", + "content": "derived content", + "is_default": True, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["is_default"] is True + assert data["activation"] == "default" + + +# --------------------------------------------------------------------------- +# 5. MCP sync tests +# --------------------------------------------------------------------------- + + +class TestMCPSyncSkillFields: + def test_mcp_sync_sets_activation_named(self): + """Synced MCP prompts get activation='named'.""" + from turnstone.core.mcp_client import MCPClientManager + + mgr = MCPClientManager({}) + storage = MagicMock() + storage.get_prompt_template_by_name.return_value = None + storage.list_prompt_templates_by_origin.return_value = [] + storage.create_prompt_template.return_value = None + mgr.set_storage(storage) + + mgr._prompts = [ + { + "name": "mcp__test__greeting", + "original_name": "greeting", + "server": "test", + "description": "Say hello", + "arguments": [], + }, + ] + + mgr.sync_prompts_to_storage() + + storage.create_prompt_template.assert_called_once() + call_kwargs = storage.create_prompt_template.call_args[1] + assert call_kwargs["activation"] == "named" + + def test_mcp_sync_sets_token_estimate(self): + """Token estimate is computed from content on sync.""" + from turnstone.core.mcp_client import MCPClientManager + + mgr = MCPClientManager({}) + storage = MagicMock() + storage.get_prompt_template_by_name.return_value = None + storage.list_prompt_templates_by_origin.return_value = [] + storage.create_prompt_template.return_value = None + mgr.set_storage(storage) + + mgr._prompts = [ + { + "name": "mcp__srv__prompt", + "original_name": "prompt", + "server": "srv", + "description": "A prompt with known description", + "arguments": [ + {"name": "arg1", "description": "First argument", "required": True}, + ], + }, + ] + + mgr.sync_prompts_to_storage() + + call_kwargs = storage.create_prompt_template.call_args[1] + # token_estimate should be len(content) // 4 where content is the generated description + assert "token_estimate" in call_kwargs + assert isinstance(call_kwargs["token_estimate"], int) + assert call_kwargs["token_estimate"] > 0 + + def test_mcp_sync_update_sets_token_estimate(self): + """Token estimate is recomputed on MCP sync update.""" + from turnstone.core.mcp_client import MCPClientManager + + mgr = MCPClientManager({}) + storage = MagicMock() + storage.get_prompt_template_by_name.return_value = { + "template_id": "existing-id", + "name": "mcp__srv__prompt", + "origin": "mcp", + "mcp_server": "srv", + "readonly": True, + } + storage.list_prompt_templates_by_origin.return_value = [] + storage.update_prompt_template.return_value = True + mgr.set_storage(storage) + + mgr._prompts = [ + { + "name": "mcp__srv__prompt", + "original_name": "prompt", + "server": "srv", + "description": "Updated description", + "arguments": [], + }, + ] + + mgr.sync_prompts_to_storage() + + call_kwargs = storage.update_prompt_template.call_args[1] + assert "token_estimate" in call_kwargs + assert isinstance(call_kwargs["token_estimate"], int) + + def test_mcp_sync_does_not_set_default_activation(self): + """MCP sync never creates with activation='default' (security).""" + from turnstone.core.mcp_client import MCPClientManager + + mgr = MCPClientManager({}) + storage = MagicMock() + storage.get_prompt_template_by_name.return_value = None + storage.list_prompt_templates_by_origin.return_value = [] + storage.create_prompt_template.return_value = None + mgr.set_storage(storage) + + mgr._prompts = [ + { + "name": "mcp__srv__prompt", + "original_name": "prompt", + "server": "srv", + "description": "Any prompt", + "arguments": [], + }, + ] + + mgr.sync_prompts_to_storage() + + call_kwargs = storage.create_prompt_template.call_args[1] + # MCP prompts should never be auto-applied as defaults + assert call_kwargs["activation"] != "default" + assert call_kwargs["is_default"] is False + + +# --------------------------------------------------------------------------- +# 6. Session config application tests +# --------------------------------------------------------------------------- + + +class TestSkillSessionConfigApplication: + """Test that session config fields stored on skills round-trip correctly.""" + + def test_skill_with_model_override(self, db): + """Skill with model= stores and returns the value.""" + _create_template(db, "s1", "model-skill", "content") + db.update_prompt_template("s1", model="test-model") + tpl = db.get_skill_by_name("model-skill") + assert tpl is not None + assert tpl["model"] == "test-model" + + def test_skill_with_temperature(self, db): + """Skill with temperature= stores and returns the value.""" + db.create_prompt_template( + template_id="s1", + name="temp-skill", + category="general", + content="content", + temperature=0.3, + ) + tpl = db.get_skill_by_name("temp-skill") + assert tpl is not None + assert tpl["temperature"] == 0.3 + + def test_skill_with_token_budget(self, db): + """Skill with token_budget= stores and returns the value.""" + db.create_prompt_template( + template_id="s1", + name="budget-skill", + category="general", + content="content", + token_budget=5000, + ) + tpl = db.get_skill_by_name("budget-skill") + assert tpl is not None + assert tpl["token_budget"] == 5000 + + def test_skill_with_auto_approve(self, db): + """Skill with auto_approve=True stores as True.""" + db.create_prompt_template( + template_id="s1", + name="approve-skill", + category="general", + content="content", + auto_approve=True, + ) + tpl = db.get_skill_by_name("approve-skill") + assert tpl is not None + assert tpl["auto_approve"] is True + + def test_skill_with_allowed_tools(self, db): + """Skill with allowed_tools stores and parses correctly.""" + allowed = '["bash","read_file"]' + db.create_prompt_template( + template_id="s1", + name="tools-skill", + category="general", + content="content", + allowed_tools=allowed, + ) + tpl = db.get_skill_by_name("tools-skill") + assert tpl is not None + assert tpl["allowed_tools"] == allowed + parsed = json.loads(tpl["allowed_tools"]) + assert parsed == ["bash", "read_file"] + + def test_skill_with_reasoning_effort(self, db): + """Skill with reasoning_effort= stores and returns the value.""" + db.create_prompt_template( + template_id="s1", + name="effort-skill", + category="general", + content="content", + reasoning_effort="high", + ) + tpl = db.get_skill_by_name("effort-skill") + assert tpl is not None + assert tpl["reasoning_effort"] == "high" + + def test_disabled_skill_not_in_summary(self, db): + """A disabled skill is listed but with enabled=False.""" + db.create_prompt_template( + template_id="s1", + name="disabled-skill", + category="general", + content="content", + enabled=False, + ) + templates = db.list_prompt_templates() + match = [t for t in templates if t["name"] == "disabled-skill"] + assert len(match) == 1 + assert match[0]["enabled"] is False + + def test_session_config_fields_roundtrip(self, db): + """All session config fields round-trip through storage.""" + db.create_prompt_template( + template_id="s1", + name="full-config-skill", + category="general", + content="You are a full config skill.", + model="gpt-5", + auto_approve=True, + temperature=0.7, + reasoning_effort="high", + max_tokens=2048, + token_budget=10000, + agent_max_turns=5, + notify_on_complete='{"channel":"discord"}', + enabled=True, + allowed_tools='["bash","read_file","write_file"]', + ) + tpl = db.get_skill_by_name("full-config-skill") + assert tpl is not None + assert tpl["model"] == "gpt-5" + assert tpl["auto_approve"] is True + assert tpl["temperature"] == 0.7 + assert tpl["reasoning_effort"] == "high" + assert tpl["max_tokens"] == 2048 + assert tpl["token_budget"] == 10000 + assert tpl["agent_max_turns"] == 5 + assert tpl["notify_on_complete"] == '{"channel":"discord"}' + assert tpl["enabled"] is True + parsed_tools = json.loads(tpl["allowed_tools"]) + assert parsed_tools == ["bash", "read_file", "write_file"] + + +# --------------------------------------------------------------------------- +# 7. Migration behavior tests +# --------------------------------------------------------------------------- + + +class TestSkillMigrationBehaviors: + """Test storage behaviors that validate migration 021 data migration correctness.""" + + def test_profile_category_skill_with_session_config(self, db): + """Migrated ws_templates get category='profile' with session config.""" + _create_template( + db, + "s1", + "my-profile", + "Profile content", + category="profile", + model="gpt-5", + temperature=0.3, + token_budget=5000, + auto_approve=True, + ) + skill = db.get_prompt_template_by_name("my-profile") + assert skill is not None + assert skill["category"] == "profile" + assert skill["model"] == "gpt-5" + assert skill["temperature"] == 0.3 + assert skill["token_budget"] == 5000 + assert skill["auto_approve"] is True + + def test_skill_versions_roundtrip(self, db): + """Migrated version history is readable.""" + _create_template(db, "s1", "versioned", "V1 content") + db.create_skill_version("s1", 1, '{"content": "V1 content"}', "admin") + db.create_skill_version("s1", 2, '{"content": "V2 content"}', "admin") + versions = db.list_skill_versions("s1") + assert len(versions) == 2 + assert versions[0]["version"] == 2 # DESC order + assert versions[1]["version"] == 1 + + def test_name_collision_would_use_prefix(self, db): + """If a skill name already exists, migrated data would get skills- prefix.""" + _create_template(db, "s1", "deploy-bot", "Original skill") + # Simulate what migration would do for a ws_template also named "deploy-bot" + _create_template( + db, + "s2", + "skills-deploy-bot", + "Migrated from ws_template", + category="profile", + model="gpt-5", + ) + assert db.get_prompt_template_by_name("deploy-bot") is not None + assert db.get_prompt_template_by_name("skills-deploy-bot") is not None + + +# --------------------------------------------------------------------------- +# 8. Admin endpoint integration tests +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def full_api_storage(tmp_path): + return SQLiteBackend(str(tmp_path / "full_api_test.db")) + + +@pytest.fixture() +def full_api_client(full_api_storage): + from turnstone.console.server import ( + admin_create_skill, + admin_delete_skill, + admin_get_skill, + admin_list_skill_versions, + admin_list_skills, + admin_update_skill, + list_skills_summary, + ) + + routes = [ + Mount( + "/v1", + routes=[ + Route("/api/skills", list_skills_summary), + Route("/api/admin/skills", admin_list_skills), + Route("/api/admin/skills", admin_create_skill, methods=["POST"]), + Route("/api/admin/skills/{skill_id}", admin_get_skill), + 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}/versions", admin_list_skill_versions), + ], + ), + ] + app = Starlette( + routes=routes, + middleware=[Middleware(_InjectAuthMiddleware)], + ) + app.state.auth_storage = full_api_storage + return TestClient(app) + + +class TestSkillAdminEndpoints: + """Integration tests for skill admin API endpoints.""" + + def test_create_skill_via_api(self, full_api_client): + """POST /v1/api/admin/skills creates a skill and returns correct shape.""" + resp = full_api_client.post( + "/v1/api/admin/skills", + json={ + "name": "test-skill", + "content": "You are a test skill.", + "category": "testing", + "description": "A test skill", + "tags": ["test"], + "activation": "named", + "model": "gpt-5", + "temperature": 0.5, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "test-skill" + assert data["content"] == "You are a test skill." + assert data["category"] == "testing" + assert data["description"] == "A test skill" + assert data["tags"] == ["test"] + assert data["activation"] == "named" + assert data["model"] == "gpt-5" + assert data["temperature"] == 0.5 + assert "template_id" in data + + def test_create_skill_duplicate_name_409(self, full_api_client): + """POST with existing name returns 409.""" + full_api_client.post( + "/v1/api/admin/skills", + json={"name": "dup-skill", "content": "content"}, + ) + resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "dup-skill", "content": "other content"}, + ) + assert resp.status_code == 409 + + def test_get_skill_via_api(self, full_api_client): + """GET /v1/api/admin/skills/{id} returns full skill data.""" + create_resp = full_api_client.post( + "/v1/api/admin/skills", + json={ + "name": "get-me", + "content": "content here", + "description": "desc", + }, + ) + skill_id = create_resp.json()["template_id"] + + resp = full_api_client.get(f"/v1/api/admin/skills/{skill_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["template_id"] == skill_id + assert data["name"] == "get-me" + assert data["content"] == "content here" + assert data["description"] == "desc" + + def test_update_skill_via_api(self, full_api_client): + """PUT with new fields updates the skill.""" + create_resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "update-me", "content": "old content"}, + ) + skill_id = create_resp.json()["template_id"] + + resp = full_api_client.put( + f"/v1/api/admin/skills/{skill_id}", + json={ + "description": "new desc", + "temperature": 0.8, + "model": "gpt-5", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["description"] == "new desc" + assert data["temperature"] == 0.8 + assert data["model"] == "gpt-5" + + def test_delete_skill_via_api(self, full_api_client): + """DELETE removes the skill and subsequent GET returns 404.""" + create_resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "delete-me", "content": "content"}, + ) + skill_id = create_resp.json()["template_id"] + + resp = full_api_client.delete(f"/v1/api/admin/skills/{skill_id}") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + get_resp = full_api_client.get(f"/v1/api/admin/skills/{skill_id}") + assert get_resp.status_code == 404 + + def test_list_skills_summary_excludes_disabled(self, full_api_client, full_api_storage): + """GET /v1/api/skills excludes disabled skills.""" + full_api_client.post( + "/v1/api/admin/skills", + json={"name": "enabled-skill", "content": "content"}, + ) + create_resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "disabled-skill", "content": "content"}, + ) + skill_id = create_resp.json()["template_id"] + full_api_client.put( + f"/v1/api/admin/skills/{skill_id}", + json={"enabled": False}, + ) + + resp = full_api_client.get("/v1/api/skills") + assert resp.status_code == 200 + names = [s["name"] for s in resp.json()["skills"]] + assert "enabled-skill" in names + assert "disabled-skill" not in names + + def test_skill_version_history_via_api(self, full_api_client): + """GET /v1/api/admin/skills/{id}/versions returns version history.""" + create_resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "versioned-skill", "content": "v1 content"}, + ) + skill_id = create_resp.json()["template_id"] + + # Update to create a version snapshot + full_api_client.put( + f"/v1/api/admin/skills/{skill_id}", + json={"content": "v2 content"}, + ) + + resp = full_api_client.get(f"/v1/api/admin/skills/{skill_id}/versions") + assert resp.status_code == 200 + versions = resp.json()["versions"] + assert len(versions) == 1 + + def test_create_skill_invalid_temperature_400(self, full_api_client): + """POST with temperature=5 returns 400.""" + resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "bad-temp", "content": "content", "temperature": 5}, + ) + assert resp.status_code == 400 + assert "temperature" in resp.json()["error"].lower() + + def test_create_skill_invalid_activation_400(self, full_api_client): + """POST with activation='bogus' returns 400.""" + resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "bad-act", "content": "content", "activation": "bogus"}, + ) + assert resp.status_code == 400 + assert "activation" in resp.json()["error"].lower() + + def test_list_skills_pagination(self, full_api_client): + """Pagination with limit and offset works, total is independent of limit.""" + for i in range(5): + full_api_client.post( + "/v1/api/admin/skills", + json={"name": f"page-skill-{i}", "content": f"content {i}"}, + ) + # Limit + resp = full_api_client.get("/v1/api/admin/skills?limit=2") + assert resp.status_code == 200 + data = resp.json() + assert len(data["skills"]) == 2 + assert data["total"] >= 5 + + # Offset + resp2 = full_api_client.get("/v1/api/admin/skills?limit=2&offset=2") + assert resp2.status_code == 200 + data2 = resp2.json() + assert len(data2["skills"]) == 2 + assert data2["total"] == data["total"] + # Different skills returned + names1 = {s["name"] for s in data["skills"]} + names2 = {s["name"] for s in data2["skills"]} + assert names1.isdisjoint(names2) + + # No limit returns all + resp3 = full_api_client.get("/v1/api/admin/skills") + assert resp3.status_code == 200 + assert len(resp3.json()["skills"]) >= 5 + + def test_create_skill_invalid_token_budget_type_400(self, full_api_client): + """Non-numeric token_budget returns 400.""" + resp = full_api_client.post( + "/v1/api/admin/skills", + json={"name": "bad-budget", "content": "c", "token_budget": "abc"}, + ) + assert resp.status_code == 400 + assert "integer" in resp.json()["error"].lower() diff --git a/tests/test_ws_template_runtime.py b/tests/test_ws_template_runtime.py deleted file mode 100644 index 1ab6ff17..00000000 --- a/tests/test_ws_template_runtime.py +++ /dev/null @@ -1,749 +0,0 @@ -"""Tests for workstream template runtime — template application, token budget, config persistence.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from turnstone.core.session import ChatSession -from turnstone.mq.protocol import CreateWorkstreamMessage -from turnstone.server import WebUI - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class NullUI: - """UI adapter that discards all output.""" - - def on_thinking_start(self): - pass - - def on_thinking_stop(self): - pass - - def on_reasoning_token(self, text): - pass - - def on_content_token(self, text): - pass - - def on_stream_end(self): - pass - - def approve_tools(self, items): - return True, None - - def on_tool_result(self, call_id, name, output): - pass - - def on_tool_output_chunk(self, call_id, chunk): - pass - - def on_status(self, usage, context_window, effort): - pass - - def on_plan_review(self, content): - return "" - - def on_info(self, message): - pass - - def on_error(self, message): - pass - - def on_state_change(self, state): - pass - - def on_rename(self, name): - pass - - -def _make_session(ui=None, **kwargs): - defaults = dict( - client=MagicMock(), - model="test-model", - ui=ui or NullUI(), - instructions=None, - temperature=0.5, - max_tokens=4096, - tool_timeout=30, - ) - defaults.update(kwargs) - return ChatSession(**defaults) - - -# --------------------------------------------------------------------------- -# Template application — defaults and constructor -# --------------------------------------------------------------------------- - - -def test_session_token_budget_default_zero(tmp_db): - session = _make_session() - assert session._token_budget == 0 - - -def test_session_save_config_includes_ws_template_fields(tmp_db): - session = _make_session() - session._token_budget = 50000 - session._ws_template_id = "tpl-abc" - session._ws_template_version = 3 - session._notify_on_complete = '{"url": "http://example.com"}' - session._save_config() - - from turnstone.core.memory import load_workstream_config - - config = load_workstream_config(session._ws_id) - assert config["token_budget"] == "50000" - assert config["ws_template_id"] == "tpl-abc" - assert config["ws_template_version"] == "3" - assert config["notify_on_complete"] == '{"url": "http://example.com"}' - - -def test_session_resume_restores_token_budget(tmp_db): - s1 = _make_session() - s1._token_budget = 100000 - s1._save_config() - # Seed at least one message so resume can load the workstream - s1.messages.append({"role": "user", "content": "hello"}) - from turnstone.core.memory import save_message - - save_message(s1._ws_id, "user", "hello") - - s2 = _make_session() - assert s2.resume(s1._ws_id) - assert s2._token_budget == 100000 - - -def test_session_resume_restores_ws_template_id(tmp_db): - s1 = _make_session() - s1._ws_template_id = "tpl-xyz" - s1._save_config() - from turnstone.core.memory import save_message - - save_message(s1._ws_id, "user", "ping") - - s2 = _make_session() - assert s2.resume(s1._ws_id) - assert s2._ws_template_id == "tpl-xyz" - - -def test_session_resume_restores_ws_template_version(tmp_db): - s1 = _make_session() - s1._ws_template_version = 7 - s1._save_config() - from turnstone.core.memory import save_message - - save_message(s1._ws_id, "user", "ping") - - s2 = _make_session() - assert s2.resume(s1._ws_id) - assert s2._ws_template_version == 7 - - -def test_session_resume_restores_notify_on_complete(tmp_db): - s1 = _make_session() - s1._notify_on_complete = '{"channel": "#ops"}' - s1._save_config() - from turnstone.core.memory import save_message - - save_message(s1._ws_id, "user", "ping") - - s2 = _make_session() - assert s2.resume(s1._ws_id) - assert s2._notify_on_complete == '{"channel": "#ops"}' - - -# --------------------------------------------------------------------------- -# Token budget tracking -# --------------------------------------------------------------------------- - - -def test_budget_warning_at_80_percent(tmp_db): - ui = MagicMock(spec_set=NullUI) - ui.approve_tools.return_value = (True, None) - session = _make_session(ui=ui) - session._token_budget = 10000 - # Simulate usage at 80% of budget - session._last_usage = {"prompt_tokens": 7500, "completion_tokens": 500} - session._update_token_table({"role": "assistant", "content": "hi"}) - assert session._budget_warned is True - ui.on_info.assert_called_once() - assert "80%" in ui.on_info.call_args[0][0] - - -def test_budget_exhausted_at_100_percent(tmp_db): - ui = MagicMock(spec_set=NullUI) - ui.approve_tools.return_value = (True, None) - session = _make_session(ui=ui) - session._token_budget = 10000 - session._last_usage = {"prompt_tokens": 9000, "completion_tokens": 1500} - session._update_token_table({"role": "assistant", "content": "hi"}) - assert session._budget_exhausted is True - - -def test_budget_zero_no_tracking(tmp_db): - ui = MagicMock(spec_set=NullUI) - ui.approve_tools.return_value = (True, None) - session = _make_session(ui=ui) - assert session._token_budget == 0 - session._last_usage = {"prompt_tokens": 999999, "completion_tokens": 999999} - session._update_token_table({"role": "assistant", "content": "hi"}) - assert session._budget_warned is False - assert session._budget_exhausted is False - ui.on_info.assert_not_called() - - -def test_budget_warning_only_once(tmp_db): - ui = MagicMock(spec_set=NullUI) - ui.approve_tools.return_value = (True, None) - session = _make_session(ui=ui) - session._token_budget = 10000 - # First call at 80% - session._last_usage = {"prompt_tokens": 7500, "completion_tokens": 500} - session._update_token_table({"role": "assistant", "content": "a"}) - assert session._budget_warned is True - assert ui.on_info.call_count == 1 - # Second call still above 80% — should not warn again - session._last_usage = {"prompt_tokens": 8500, "completion_tokens": 500} - session._update_token_table({"role": "assistant", "content": "b"}) - assert session._budget_warned is True - assert ui.on_info.call_count == 1 - - -# --------------------------------------------------------------------------- -# Token budget approval gate in send() -# --------------------------------------------------------------------------- - - -def test_send_blocked_when_budget_exhausted(tmp_db): - ui = MagicMock(spec_set=NullUI) - ui.approve_tools.return_value = (False, None) - session = _make_session(ui=ui) - session._budget_exhausted = True - session._token_budget = 5000 - session.send("hello") - # approve_tools should have been called with __budget_override__ - ui.approve_tools.assert_called_once() - items = ui.approve_tools.call_args[0][0] - assert len(items) == 1 - assert items[0]["func_name"] == "__budget_override__" - assert "5,000" in items[0]["preview"] - # on_error should have been called since approval was denied - ui.on_error.assert_called_once() - assert "budget" in ui.on_error.call_args[0][0].lower() - - -def test_send_continues_after_budget_approval(tmp_db): - ui = MagicMock(spec_set=NullUI) - ui.approve_tools.return_value = (True, None) - session = _make_session(ui=ui) - session._budget_exhausted = True - session._budget_warned = True - session._token_budget = 5000 - - # Patch _create_stream_with_retry to avoid actual LLM call - with ( - patch.object(session, "_create_stream_with_retry"), - patch.object(session, "_stream_response") as mock_resp, - patch.object(session, "_update_token_table"), - patch.object(session, "_print_status_line"), - ): - mock_resp.return_value = {"role": "assistant", "content": "ok", "tool_calls": []} - session.send("hello") - - # Budget flags should be reset - assert session._budget_exhausted is False - assert session._budget_warned is False - # approve_tools was called for budget gate - ui.approve_tools.assert_called_once() - - -def test_send_returns_when_budget_denied(tmp_db): - ui = MagicMock(spec_set=NullUI) - ui.approve_tools.return_value = (False, None) - session = _make_session(ui=ui) - session._budget_exhausted = True - session._token_budget = 5000 - - # Patch to detect if _create_stream_with_retry is called (it shouldn't be) - with patch.object(session, "_create_stream_with_retry") as mock_stream: - session.send("hello") - mock_stream.assert_not_called() - - # Message should NOT have been appended - assert len(session.messages) == 0 - - -# --------------------------------------------------------------------------- -# WebUI auto_approve_tools -# --------------------------------------------------------------------------- - - -def test_webui_auto_approve_tools_default_empty(): - webui = WebUI(ws_id="ws-1") - assert webui.auto_approve_tools == set() - - -def test_webui_auto_approve_tools_subset_approves(): - webui = WebUI(ws_id="ws-1") - webui.auto_approve_tools = {"bash", "read_file", "write_file"} - items = [ - {"func_name": "bash", "preview": "ls", "needs_approval": True}, - {"func_name": "read_file", "preview": "/tmp/x", "needs_approval": True}, - ] - # Patch out policy evaluation and global queue to isolate auto_approve_tools - with patch("turnstone.server.WebUI._global_queue", None): - approved, _ = webui.approve_tools(items) - assert approved is True - - -def test_webui_auto_approve_tools_partial_no_approve(): - webui = WebUI(ws_id="ws-1") - webui.auto_approve_tools = {"bash"} - items = [ - {"func_name": "bash", "preview": "ls", "needs_approval": True}, - {"func_name": "write_file", "preview": "/tmp/x", "needs_approval": True}, - ] - # write_file is NOT in auto_approve_tools, so it won't auto-approve. - # The method will block on _approval_event, so we set it immediately. - webui._approval_event = MagicMock() - webui._approval_event.wait.return_value = None - webui._approval_result = (False, None) - with patch("turnstone.server.WebUI._global_queue", None): - approved, _ = webui.approve_tools(items) - assert approved is False - - -def test_webui_auto_approve_tools_empty_no_effect(): - webui = WebUI(ws_id="ws-1") - webui.auto_approve_tools = set() - items = [ - {"func_name": "bash", "preview": "ls", "needs_approval": True}, - ] - # Empty set should not auto-approve; must wait for manual approval. - webui._approval_event = MagicMock() - webui._approval_event.wait.return_value = None - webui._approval_result = (True, None) - with patch("turnstone.server.WebUI._global_queue", None): - approved, _ = webui.approve_tools(items) - # Approval comes from the manual path (we set _approval_result to True) - assert approved is True - # The approval event wait should have been called (manual approval path) - webui._approval_event.wait.assert_called_once() - - -# --------------------------------------------------------------------------- -# Per-tool "always approve" — interactive "Always" adds to auto_approve_tools -# --------------------------------------------------------------------------- - - -def test_server_always_approve_adds_tool_names(): - """POST /approve with always=True adds pending tool names to auto_approve_tools.""" - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - {"func_name": "bash", "needs_approval": True, "preview": "ls"}, - {"func_name": "read_file", "needs_approval": False, "preview": "/tmp"}, - ], - } - items = webui._pending_approval.get("items", []) - tool_names = { - it.get("approval_label", "") or it.get("func_name", "") - for it in items - if it.get("needs_approval") and it.get("func_name") - } - tool_names.discard("") - tool_names.discard("__budget_override__") - webui.auto_approve_tools.update(tool_names) - - assert webui.auto_approve_tools == {"bash"} - assert webui.auto_approve is False # blanket flag NOT set - - -def test_server_always_approve_uses_approval_label(): - """When approval_label differs from func_name, approval_label is stored.""" - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - { - "func_name": "use_prompt", - "approval_label": "mcp__git__commit_msg", - "needs_approval": True, - "preview": "", - }, - ], - } - items = webui._pending_approval.get("items", []) - tool_names = { - it.get("approval_label", "") or it.get("func_name", "") - for it in items - if it.get("needs_approval") and it.get("func_name") - } - tool_names.discard("") - tool_names.discard("__budget_override__") - webui.auto_approve_tools.update(tool_names) - - assert "mcp__git__commit_msg" in webui.auto_approve_tools - assert "use_prompt" not in webui.auto_approve_tools - - -def test_server_always_approve_excludes_budget_override(): - """__budget_override__ should never be added to auto_approve_tools.""" - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - {"func_name": "__budget_override__", "needs_approval": True, "preview": ""}, - {"func_name": "bash", "needs_approval": True, "preview": "ls"}, - ], - } - items = webui._pending_approval.get("items", []) - tool_names = { - it.get("approval_label", "") or it.get("func_name", "") - for it in items - if it.get("needs_approval") and it.get("func_name") - } - tool_names.discard("") - tool_names.discard("__budget_override__") - webui.auto_approve_tools.update(tool_names) - - assert "__budget_override__" not in webui.auto_approve_tools - assert webui.auto_approve_tools == {"bash"} - - -def test_server_always_approve_accumulates(): - """Successive 'always' approvals accumulate tool names.""" - webui = WebUI(ws_id="ws-1") - - # First always-approve: bash - webui._pending_approval = { - "type": "approve_request", - "items": [{"func_name": "bash", "needs_approval": True, "preview": "ls"}], - } - items = webui._pending_approval["items"] - names = { - it.get("approval_label", "") or it["func_name"] for it in items if it.get("needs_approval") - } - names.discard("__budget_override__") - webui.auto_approve_tools.update(names) - - # Second always-approve: write_file - webui._pending_approval = { - "type": "approve_request", - "items": [{"func_name": "write_file", "needs_approval": True, "preview": ""}], - } - items = webui._pending_approval["items"] - names = { - it.get("approval_label", "") or it["func_name"] for it in items if it.get("needs_approval") - } - names.discard("__budget_override__") - webui.auto_approve_tools.update(names) - - assert webui.auto_approve_tools == {"bash", "write_file"} - - -def test_server_always_approve_no_pending_is_noop(): - """If _pending_approval is None, always=True does nothing.""" - webui = WebUI(ws_id="ws-1") - webui._pending_approval = None - # The guard `if always and approved and ui._pending_approval:` prevents action - assert webui.auto_approve_tools == set() - assert webui.auto_approve is False - - -# --------------------------------------------------------------------------- -# CLI per-tool "always approve" -# --------------------------------------------------------------------------- - - -def test_cli_always_adds_tool_names(): - """CLI 'a' adds pending tool names to auto_approve_tools, not blanket flag.""" - from turnstone.cli import TerminalUI - - ui = TerminalUI() - items = [ - {"func_name": "bash", "header": "bash: ls", "needs_approval": True, "preview": "ls"}, - ] - with patch("builtins.input", return_value="a"): - approved, _ = ui.approve_tools(items) - assert approved is True - assert ui.auto_approve is False - assert "bash" in ui.auto_approve_tools - - -def test_cli_per_tool_auto_approves_subsequent(): - """After 'always' for bash, subsequent bash calls auto-approve silently.""" - from turnstone.cli import TerminalUI - - ui = TerminalUI() - ui.auto_approve_tools = {"bash"} - items = [ - {"func_name": "bash", "header": "bash: ls", "needs_approval": True, "preview": "ls"}, - ] - # Should auto-approve without prompting - approved, _ = ui.approve_tools(items) - assert approved is True - - -def test_cli_per_tool_does_not_approve_unknown(): - """Per-tool set for bash does NOT auto-approve write_file.""" - from turnstone.cli import TerminalUI - - ui = TerminalUI() - ui.auto_approve_tools = {"bash"} - items = [ - { - "func_name": "write_file", - "header": "write_file: /tmp/x", - "needs_approval": True, - "preview": "", - }, - ] - with patch("builtins.input", return_value="n"): - approved, _ = ui.approve_tools(items) - assert approved is False - - -def test_cli_always_excludes_budget_override(): - """CLI 'always' should not add __budget_override__ to auto_approve_tools.""" - from turnstone.cli import TerminalUI - - ui = TerminalUI() - items = [ - { - "func_name": "__budget_override__", - "header": "budget", - "needs_approval": True, - "preview": "", - }, - ] - with patch("builtins.input", return_value="a"): - approved, _ = ui.approve_tools(items) - assert approved is True - assert "__budget_override__" not in ui.auto_approve_tools - - -# --------------------------------------------------------------------------- -# Bridge per-tool "always approve" -# --------------------------------------------------------------------------- - - -def test_bridge_always_adds_to_approve_tools(): - """Bridge 'always' adds tool names to _ws_approve_tools, not _ws_auto_approve.""" - import threading - - from turnstone.mq.bridge import DEFAULT_SAFE_TOOLS, Bridge - - bridge = Bridge.__new__(Bridge) - bridge._lock = threading.Lock() - bridge._ws_auto_approve = {} - bridge._ws_approve_tools = {} - - ws_id = "ws-1" - items = [ - {"func_name": "bash", "needs_approval": True}, - {"func_name": "read_file", "needs_approval": False}, - ] - - # Simulate the always-approve extraction logic from _wait_approval - tool_names = { - it.get("func_name", "") for it in items if it.get("needs_approval") and it.get("func_name") - } - tool_names.discard("") - tool_names.discard("__budget_override__") - if tool_names: - with bridge._lock: - existing = bridge._ws_approve_tools.get(ws_id, set(DEFAULT_SAFE_TOOLS)) - bridge._ws_approve_tools[ws_id] = existing | tool_names - - # bash added, and DEFAULT_SAFE_TOOLS preserved - assert "bash" in bridge._ws_approve_tools[ws_id] - for name in DEFAULT_SAFE_TOOLS: - assert name in bridge._ws_approve_tools[ws_id] - assert ws_id not in bridge._ws_auto_approve - - -# --------------------------------------------------------------------------- -# Integration tests — POST /v1/api/approve with always=True -# --------------------------------------------------------------------------- - - -class TestApproveEndpointAlways: - """Integration tests for the approve handler's per-tool 'always' logic.""" - - @staticmethod - def _make_client(webui): - import queue - import threading - - from starlette.testclient import TestClient - - from turnstone.core.auth import AuthConfig - from turnstone.server import create_app - - mock_ws = MagicMock() - mock_ws.ui = webui - - mock_mgr = MagicMock() - mock_mgr.get.return_value = mock_ws - mock_mgr.list_all.return_value = [] - - app = create_app( - workstreams=mock_mgr, - global_queue=queue.Queue(), - global_listeners=[], - global_listeners_lock=threading.Lock(), - skip_permissions=False, - auth_config=AuthConfig(), - ) - return TestClient(app, raise_server_exceptions=False) - - def test_always_adds_tool_to_auto_approve_tools(self): - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - {"func_name": "bash", "needs_approval": True, "preview": "ls"}, - ], - } - client = self._make_client(webui) - resp = client.post( - "/v1/api/approve", - json={"approved": True, "always": True, "ws_id": "ws-1"}, - ) - assert resp.status_code == 200 - assert "bash" in webui.auto_approve_tools - assert webui.auto_approve is False - - def test_always_uses_approval_label_over_func_name(self): - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - { - "func_name": "use_prompt", - "approval_label": "mcp__git__commit_msg", - "needs_approval": True, - "preview": "", - }, - ], - } - client = self._make_client(webui) - resp = client.post( - "/v1/api/approve", - json={"approved": True, "always": True, "ws_id": "ws-1"}, - ) - assert resp.status_code == 200 - assert "mcp__git__commit_msg" in webui.auto_approve_tools - assert "use_prompt" not in webui.auto_approve_tools - - def test_always_excludes_budget_override(self): - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - {"func_name": "__budget_override__", "needs_approval": True, "preview": ""}, - {"func_name": "bash", "needs_approval": True, "preview": "ls"}, - ], - } - client = self._make_client(webui) - resp = client.post( - "/v1/api/approve", - json={"approved": True, "always": True, "ws_id": "ws-1"}, - ) - assert resp.status_code == 200 - assert "__budget_override__" not in webui.auto_approve_tools - assert "bash" in webui.auto_approve_tools - - def test_always_skips_non_pending_items(self): - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - {"func_name": "bash", "needs_approval": True, "preview": "ls"}, - {"func_name": "read_file", "needs_approval": False, "preview": "/tmp"}, - ], - } - client = self._make_client(webui) - resp = client.post( - "/v1/api/approve", - json={"approved": True, "always": True, "ws_id": "ws-1"}, - ) - assert resp.status_code == 200 - assert webui.auto_approve_tools == {"bash"} - - def test_always_false_does_not_add_tools(self): - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - {"func_name": "bash", "needs_approval": True, "preview": "ls"}, - ], - } - client = self._make_client(webui) - resp = client.post( - "/v1/api/approve", - json={"approved": True, "always": False, "ws_id": "ws-1"}, - ) - assert resp.status_code == 200 - assert webui.auto_approve_tools == set() - - def test_deny_with_always_does_not_add_tools(self): - webui = WebUI(ws_id="ws-1") - webui._pending_approval = { - "type": "approve_request", - "items": [ - {"func_name": "bash", "needs_approval": True, "preview": "ls"}, - ], - } - client = self._make_client(webui) - resp = client.post( - "/v1/api/approve", - json={"approved": False, "always": True, "ws_id": "ws-1"}, - ) - assert resp.status_code == 200 - assert webui.auto_approve_tools == set() - - -# --------------------------------------------------------------------------- -# Protocol round-trip — CreateWorkstreamMessage -# --------------------------------------------------------------------------- - - -def test_create_workstream_message_ws_template(): - msg = CreateWorkstreamMessage(ws_template="deploy-v2") - assert msg.ws_template == "deploy-v2" - assert msg.type == "create_workstream" - - -def test_create_workstream_message_ws_template_default(): - msg = CreateWorkstreamMessage() - assert msg.ws_template == "" - - -# --------------------------------------------------------------------------- -# Config persistence round-trip -# --------------------------------------------------------------------------- - - -def test_save_config_round_trip(tmp_db): - s1 = _make_session() - s1._token_budget = 75000 - s1._ws_template_id = "tpl-roundtrip" - s1._ws_template_version = 12 - s1._notify_on_complete = '{"webhook": "https://hooks.example.com/done"}' - s1._save_config() - - from turnstone.core.memory import save_message - - save_message(s1._ws_id, "user", "test") - - s2 = _make_session() - assert s2.resume(s1._ws_id) - assert s2._token_budget == 75000 - assert s2._ws_template_id == "tpl-roundtrip" - assert s2._ws_template_version == 12 - assert s2._notify_on_complete == '{"webhook": "https://hooks.example.com/done"}' diff --git a/tests/test_ws_template_storage.py b/tests/test_ws_template_storage.py deleted file mode 100644 index 456939bb..00000000 --- a/tests/test_ws_template_storage.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Tests for workstream template storage CRUD operations.""" - -from __future__ import annotations - -import json - -import pytest -import sqlalchemy as sa -from sqlalchemy.exc import IntegrityError - -from turnstone.core.storage._schema import workstreams -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture() -def db(tmp_path): - return SQLiteBackend(str(tmp_path / "test.db")) - - -def _make_template_kwargs(**overrides): - defaults = { - "ws_template_id": "tpl_001", - "name": "research-agent", - "description": "Deep research profile", - "system_prompt": "You are a research assistant.", - "prompt_template": "tpl-greeting", - "model": "gpt-5", - "auto_approve": False, - "auto_approve_tools": "read_file,write_file", - "temperature": 0.7, - "reasoning_effort": "medium", - "max_tokens": 4096, - "token_budget": 100000, - "agent_max_turns": 10, - "notify_on_complete": '{"webhook":"https://example.com"}', - "org_id": "org1", - "created_by": "admin", - "enabled": True, - } - defaults.update(overrides) - return defaults - - -# --------------------------------------------------------------------------- -# CRUD Operations -# --------------------------------------------------------------------------- - - -class TestWsTemplateCRUD: - def test_create_ws_template(self, db): - db.create_ws_template(**_make_template_kwargs()) - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["ws_template_id"] == "tpl_001" - assert tpl["name"] == "research-agent" - - def test_create_ws_template_fields(self, db): - db.create_ws_template(**_make_template_kwargs()) - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["description"] == "Deep research profile" - assert tpl["system_prompt"] == "You are a research assistant." - assert tpl["prompt_template"] == "tpl-greeting" - assert tpl["model"] == "gpt-5" - assert tpl["auto_approve"] is False - assert isinstance(tpl["auto_approve"], bool) - assert tpl["auto_approve_tools"] == "read_file,write_file" - assert tpl["temperature"] == 0.7 - assert tpl["reasoning_effort"] == "medium" - assert tpl["max_tokens"] == 4096 - assert tpl["token_budget"] == 100000 - assert tpl["agent_max_turns"] == 10 - assert tpl["notify_on_complete"] == '{"webhook":"https://example.com"}' - assert tpl["org_id"] == "org1" - assert tpl["created_by"] == "admin" - assert tpl["enabled"] is True - assert isinstance(tpl["enabled"], bool) - assert tpl["version"] == 1 - assert "created" in tpl - assert "updated" in tpl - - def test_get_ws_template_not_found(self, db): - assert db.get_ws_template("nonexistent") is None - - def test_get_ws_template_by_name(self, db): - db.create_ws_template(**_make_template_kwargs()) - tpl = db.get_ws_template_by_name("research-agent") - assert tpl is not None - assert tpl["ws_template_id"] == "tpl_001" - assert tpl["auto_approve"] is False - assert isinstance(tpl["auto_approve"], bool) - assert tpl["enabled"] is True - assert isinstance(tpl["enabled"], bool) - - def test_get_ws_template_by_name_not_found(self, db): - assert db.get_ws_template_by_name("nope") is None - - def test_list_ws_templates(self, db): - db.create_ws_template(**_make_template_kwargs(ws_template_id="t2", name="beta")) - db.create_ws_template(**_make_template_kwargs(ws_template_id="t1", name="alpha")) - templates = db.list_ws_templates() - assert len(templates) == 2 - assert templates[0]["name"] == "alpha" - assert templates[1]["name"] == "beta" - - def test_list_ws_templates_empty(self, db): - assert db.list_ws_templates() == [] - - def test_list_ws_templates_enabled_only(self, db): - db.create_ws_template( - **_make_template_kwargs(ws_template_id="t1", name="active", enabled=True) - ) - db.create_ws_template( - **_make_template_kwargs(ws_template_id="t2", name="disabled", enabled=False) - ) - result = db.list_ws_templates(enabled_only=True) - assert len(result) == 1 - assert result[0]["name"] == "active" - - def test_list_ws_templates_org_filter(self, db): - db.create_ws_template(**_make_template_kwargs(ws_template_id="t1", name="a", org_id="org1")) - db.create_ws_template(**_make_template_kwargs(ws_template_id="t2", name="b", org_id="org2")) - db.create_ws_template(**_make_template_kwargs(ws_template_id="t3", name="c", org_id="org1")) - result = db.list_ws_templates(org_id="org1") - assert len(result) == 2 - assert {r["ws_template_id"] for r in result} == {"t1", "t3"} - - def test_update_ws_template(self, db): - db.create_ws_template(**_make_template_kwargs()) - ok = db.update_ws_template("tpl_001", name="updated-agent", description="New desc") - assert ok is True - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["name"] == "updated-agent" - assert tpl["description"] == "New desc" - - def test_update_ws_template_not_found(self, db): - assert db.update_ws_template("missing", name="x") is False - - def test_update_ws_template_ignores_unknown_fields(self, db): - db.create_ws_template(**_make_template_kwargs()) - ok = db.update_ws_template("tpl_001", name="new-name", org_id="hack", created_by="hack") - assert ok is True - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["name"] == "new-name" - # Non-mutable fields unchanged. - assert tpl["org_id"] == "org1" - assert tpl["created_by"] == "admin" - - def test_update_ws_template_boolean_normalization(self, db): - db.create_ws_template(**_make_template_kwargs()) - db.update_ws_template("tpl_001", auto_approve=True, enabled=False) - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["auto_approve"] is True - assert isinstance(tpl["auto_approve"], bool) - assert tpl["enabled"] is False - assert isinstance(tpl["enabled"], bool) - - def test_delete_ws_template(self, db): - db.create_ws_template(**_make_template_kwargs()) - ok = db.delete_ws_template("tpl_001") - assert ok is True - assert db.get_ws_template("tpl_001") is None - - def test_delete_ws_template_not_found(self, db): - assert db.delete_ws_template("missing") is False - - def test_delete_ws_template_cascades_versions(self, db): - db.create_ws_template(**_make_template_kwargs()) - # Create a version snapshot via update. - db.update_ws_template("tpl_001", name="v2-name") - versions = db.list_ws_template_versions("tpl_001") - assert len(versions) == 1 - # Delete template — versions should be gone too. - db.delete_ws_template("tpl_001") - assert db.list_ws_template_versions("tpl_001") == [] - - def test_create_ws_template_with_hash(self, db): - db.create_ws_template( - ws_template_id="tpl_hash", - name="hashed-template", - prompt_template="my-prompt", - prompt_template_hash="abc123hash", - ) - tpl = db.get_ws_template("tpl_hash") - assert tpl["prompt_template_hash"] == "abc123hash" - - def test_update_ws_template_hash(self, db): - db.create_ws_template(**_make_template_kwargs()) - db.update_ws_template("tpl_001", prompt_template_hash="newhash456") - tpl = db.get_ws_template("tpl_001") - assert tpl["prompt_template_hash"] == "newhash456" - - def test_create_duplicate_name(self, db): - db.create_ws_template(**_make_template_kwargs(ws_template_id="t1", name="unique")) - with pytest.raises(IntegrityError): - db.create_ws_template(**_make_template_kwargs(ws_template_id="t2", name="unique")) - - -# --------------------------------------------------------------------------- -# Versioning -# --------------------------------------------------------------------------- - - -class TestWsTemplateVersioning: - def test_update_creates_version_snapshot(self, db): - db.create_ws_template(**_make_template_kwargs()) - db.update_ws_template("tpl_001", description="Changed") - versions = db.list_ws_template_versions("tpl_001") - assert len(versions) == 1 - assert versions[0]["ws_template_id"] == "tpl_001" - assert versions[0]["version"] == 1 - - def test_version_increments_on_update(self, db): - db.create_ws_template(**_make_template_kwargs()) - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["version"] == 1 - db.update_ws_template("tpl_001", description="v2") - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["version"] == 2 - db.update_ws_template("tpl_001", description="v3") - tpl = db.get_ws_template("tpl_001") - assert tpl is not None - assert tpl["version"] == 3 - - def test_version_snapshot_contains_json(self, db): - db.create_ws_template(**_make_template_kwargs()) - db.update_ws_template("tpl_001", description="Changed") - versions = db.list_ws_template_versions("tpl_001") - snapshot = json.loads(versions[0]["snapshot"]) - # Snapshot should contain the pre-update state. - assert snapshot["description"] == "Deep research profile" - assert snapshot["name"] == "research-agent" - assert snapshot["version"] == 1 - - def test_multiple_updates_create_versions(self, db): - db.create_ws_template(**_make_template_kwargs()) - db.update_ws_template("tpl_001", description="Second") - db.update_ws_template("tpl_001", description="Third") - db.update_ws_template("tpl_001", description="Fourth") - versions = db.list_ws_template_versions("tpl_001") - assert len(versions) == 3 - # Ordered by version DESC. - assert versions[0]["version"] == 3 - assert versions[1]["version"] == 2 - assert versions[2]["version"] == 1 - - def test_list_ws_template_versions(self, db): - db.create_ws_template(**_make_template_kwargs()) - db.update_ws_template("tpl_001", description="v2") - db.update_ws_template("tpl_001", description="v3") - versions = db.list_ws_template_versions("tpl_001") - assert len(versions) == 2 - # Ordered by version DESC. - assert versions[0]["version"] == 2 - assert versions[1]["version"] == 1 - for v in versions: - assert "created" in v - assert "snapshot" in v - assert "changed_by" in v - - def test_list_ws_template_versions_empty(self, db): - assert db.list_ws_template_versions("nonexistent") == [] - - def test_create_ws_template_version_direct(self, db): - db.create_ws_template(**_make_template_kwargs()) - snapshot_data = json.dumps({"name": "manual-snapshot", "version": 99}) - db.create_ws_template_version( - "tpl_001", version=99, snapshot=snapshot_data, changed_by="admin" - ) - versions = db.list_ws_template_versions("tpl_001") - assert len(versions) == 1 - assert versions[0]["version"] == 99 - assert versions[0]["changed_by"] == "admin" - parsed = json.loads(versions[0]["snapshot"]) - assert parsed["name"] == "manual-snapshot" - - -# --------------------------------------------------------------------------- -# Workstream Integration -# --------------------------------------------------------------------------- - - -class TestWsTemplateWorkstreamIntegration: - def test_register_workstream_with_template(self, db): - db.create_ws_template(**_make_template_kwargs()) - db.register_workstream( - ws_id="ws-001", - node_id="node-1", - name="test-ws", - ws_template_id="tpl_001", - ws_template_version=1, - ) - # Verify via direct query — list_workstreams doesn't select template fields. - with db._engine.connect() as conn: - row = conn.execute( - sa.select(workstreams.c.ws_template_id, workstreams.c.ws_template_version).where( - workstreams.c.ws_id == "ws-001" - ) - ).fetchone() - assert row is not None - assert row[0] == "tpl_001" - assert row[1] == 1 - - def test_update_workstream_template(self, db): - db.register_workstream(ws_id="ws-002", node_id="node-1", name="test-ws") - # Initially defaults - with db._engine.connect() as conn: - row = conn.execute( - sa.select(workstreams.c.ws_template_id, workstreams.c.ws_template_version).where( - workstreams.c.ws_id == "ws-002" - ) - ).fetchone() - assert row[0] == "" - assert row[1] == 0 - # Update template lineage - db.update_workstream_template("ws-002", "tpl_abc", 3) - with db._engine.connect() as conn: - row = conn.execute( - sa.select(workstreams.c.ws_template_id, workstreams.c.ws_template_version).where( - workstreams.c.ws_id == "ws-002" - ) - ).fetchone() - assert row[0] == "tpl_abc" - assert row[1] == 3 diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py index df6d9c08..6794abf0 100644 --- a/turnstone/api/console_schemas.py +++ b/turnstone/api/console_schemas.py @@ -136,12 +136,7 @@ class ConsoleCreateWsRequest(BaseModel): initial_message: str = Field( default="", description="Optional first message sent after creation" ) - template: str = Field( - default="", description="Prompt template name (replaces default templates)" - ) - ws_template: str = Field( - default="", description="Workstream template name (behavioral profile)" - ) + skill: str = Field(default="", description="Skill name (replaces default skills)") class ConsoleCreateWsResponse(BaseModel): @@ -279,102 +274,80 @@ class ListToolPoliciesResponse(BaseModel): # --------------------------------------------------------------------------- -# Governance: Prompt Templates +# Governance: Skills # --------------------------------------------------------------------------- -class PromptTemplateInfo(BaseModel): - template_id: str +class SkillInfo(BaseModel): + template_id: str = Field(description="Skill ID") name: str category: str content: str - variables: str + description: str = "" + tags: list[str] = Field(default_factory=list) + variables: str = "[]" is_default: bool + activation: str = "named" org_id: str created_by: str origin: str = "manual" mcp_server: str = "" readonly: bool = False - created: str - updated: str - - -class CreatePromptTemplateRequest(BaseModel): - name: str - content: str - category: str = "general" - variables: str = "[]" - is_default: bool = False - org_id: str = "" - - -class UpdatePromptTemplateRequest(BaseModel): - name: str | None = None - content: str | None = None - category: str | None = None - variables: str | None = None - is_default: bool | None = None - - -class ListPromptTemplatesResponse(BaseModel): - templates: list[PromptTemplateInfo] - - -# --------------------------------------------------------------------------- -# Governance: Workstream Templates -# --------------------------------------------------------------------------- - - -class WsTemplateInfo(BaseModel): - ws_template_id: str - name: str - description: str - system_prompt: str - prompt_template: str - prompt_template_hash: str = "" - model: str - auto_approve: bool - auto_approve_tools: str - temperature: float | None = None - reasoning_effort: str - max_tokens: int | None = None - token_budget: int - agent_max_turns: int | None = None - notify_on_complete: str - org_id: str - created_by: str - enabled: bool - version: int - created: str - updated: str - - -class CreateWsTemplateRequest(BaseModel): - name: str - description: str = "" - system_prompt: str = "" - prompt_template: str = "" + source_url: str = "" + version: str = "1.0.0" + author: str = "" + token_estimate: int = 0 model: str = "" auto_approve: bool = False - auto_approve_tools: str = "" temperature: float | None = None reasoning_effort: str = "" max_tokens: int | None = None token_budget: int = 0 agent_max_turns: int | None = None notify_on_complete: str = "{}" - org_id: str = "" enabled: bool = True + allowed_tools: str = "[]" + created: str + updated: str -class UpdateWsTemplateRequest(BaseModel): +class CreateSkillRequest(BaseModel): + name: str + content: str + category: str = "general" + description: str = "" + tags: str = "[]" + variables: str = "[]" + is_default: bool = False + activation: str = "named" + org_id: str = "" + author: str = "" + version: str = "1.0.0" + model: str = "" + auto_approve: bool = False + temperature: float | None = None + reasoning_effort: str = "" + max_tokens: int | None = None + token_budget: int = 0 + agent_max_turns: int | None = None + notify_on_complete: str = "{}" + enabled: bool = True + allowed_tools: str = "[]" + + +class UpdateSkillRequest(BaseModel): name: str | None = None + content: str | None = None + category: str | None = None description: str | None = None - system_prompt: str | None = None - prompt_template: str | None = None + tags: str | None = None + variables: str | None = None + is_default: bool | None = None + activation: str | None = None + author: str | None = None + version: str | None = None model: str | None = None auto_approve: bool | None = None - auto_approve_tools: str | None = None temperature: float | None = None reasoning_effort: str | None = None max_tokens: int | None = None @@ -382,33 +355,29 @@ class UpdateWsTemplateRequest(BaseModel): agent_max_turns: int | None = None notify_on_complete: str | None = None enabled: bool | None = None + allowed_tools: str | None = None -class ListWsTemplatesResponse(BaseModel): - ws_templates: list[WsTemplateInfo] +class ListSkillsResponse(BaseModel): + skills: list[SkillInfo] -class WsTemplateVersionInfo(BaseModel): +# --------------------------------------------------------------------------- +# Governance: Skill Versions +# --------------------------------------------------------------------------- + + +class SkillVersionInfo(BaseModel): id: int - ws_template_id: str + skill_id: str version: int snapshot: str changed_by: str created: str -class ListWsTemplateVersionsResponse(BaseModel): - versions: list[WsTemplateVersionInfo] - - -class WsTemplateSummary(BaseModel): - name: str - description: str - model: str - - -class ListWsTemplateSummaryResponse(BaseModel): - ws_templates: list[WsTemplateSummary] +class ListSkillVersionsResponse(BaseModel): + versions: list[SkillVersionInfo] # --------------------------------------------------------------------------- diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index 382ba40b..c7a4955f 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -21,10 +21,9 @@ from turnstone.api.console_schemas import ( ConsoleHealthResponse, CreateChannelUserRequest, CreateMcpServerRequest, - CreatePromptTemplateRequest, CreateRoleRequest, + CreateSkillRequest, CreateToolPolicyRequest, - CreateWsTemplateRequest, ImportMcpConfigRequest, ImportMcpConfigResponse, ListAdminMemoriesResponse, @@ -32,39 +31,36 @@ from turnstone.api.console_schemas import ( ListChannelUsersResponse, ListMcpServersResponse, ListOrgsResponse, - ListPromptTemplatesResponse, ListRolesResponse, ListSettingSchemaResponse, ListSettingsResponse, + ListSkillsResponse, + ListSkillVersionsResponse, ListToolPoliciesResponse, ListUserRolesResponse, ListVerdictsResponse, - ListWsTemplatesResponse, - ListWsTemplateSummaryResponse, - ListWsTemplateVersionsResponse, McpReloadResponse, McpServerDetail, NodeDetailResponse, OrgInfo, - PromptTemplateInfo, RegistryInstallRequest, RegistrySearchResponse, RoleInfo, SettingInfo, SettingSchemaInfo, + SkillInfo, + SkillVersionInfo, ToolPolicyInfo, UpdateMcpServerRequest, UpdateOrgRequest, - UpdatePromptTemplateRequest, UpdateRoleRequest, UpdateSettingRequest, + UpdateSkillRequest, UpdateToolPolicyRequest, - UpdateWsTemplateRequest, UsageBreakdownItem, UsageResponse, UserRoleInfo, VerdictInfo, - WsTemplateInfo, ) from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi from turnstone.api.schemas import ( @@ -88,7 +84,10 @@ from turnstone.api.schemas import ( UpdateScheduleRequest, UserInfo, ) -from turnstone.api.server_schemas import ListPromptTemplateSummaryResponse, PromptTemplateSummary +from turnstone.api.server_schemas import ( + ListSkillSummaryResponse, + SkillSummary, +) CONSOLE_ENDPOINTS: list[EndpointSpec] = [ # --- Cluster --- @@ -482,104 +481,61 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ error_codes=[404], tags=["Admin"], ), - # --- Governance: Prompt Templates --- + # --- Governance: Skills --- EndpointSpec( - "/v1/api/admin/templates", + "/v1/api/admin/skills", "GET", - "List prompt templates", - response_model=ListPromptTemplatesResponse, + "List skills", + response_model=ListSkillsResponse, tags=["Admin"], ), EndpointSpec( - "/v1/api/admin/templates", + "/v1/api/admin/skills", "POST", - "Create a prompt template", - request_model=CreatePromptTemplateRequest, - response_model=PromptTemplateInfo, + "Create a skill", + request_model=CreateSkillRequest, + response_model=SkillInfo, error_codes=[400], tags=["Admin"], ), EndpointSpec( - "/v1/api/admin/templates/{template_id}", + "/v1/api/admin/skills/{skill_id}", + "GET", + "Get a skill by ID", + response_model=SkillInfo, + error_codes=[404], + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/skills/{skill_id}", "PUT", - "Update a prompt template", - request_model=UpdatePromptTemplateRequest, - response_model=PromptTemplateInfo, + "Update a skill", + request_model=UpdateSkillRequest, + response_model=SkillInfo, error_codes=[404], tags=["Admin"], ), EndpointSpec( - "/v1/api/admin/templates/{template_id}", + "/v1/api/admin/skills/{skill_id}", "DELETE", - "Delete a prompt template", - response_model=StatusResponse, - error_codes=[404], - tags=["Admin"], - ), - # --- Governance: Workstream Templates --- - EndpointSpec( - "/v1/api/admin/ws-templates", - "GET", - "List workstream templates", - response_model=ListWsTemplatesResponse, - tags=["Admin"], - ), - EndpointSpec( - "/v1/api/admin/ws-templates", - "POST", - "Create a workstream template", - request_model=CreateWsTemplateRequest, - response_model=WsTemplateInfo, - error_codes=[400, 409], - tags=["Admin"], - ), - EndpointSpec( - "/v1/api/admin/ws-templates/{ws_template_id}", - "GET", - "Get a workstream template", - response_model=WsTemplateInfo, + "Delete a skill", error_codes=[404], tags=["Admin"], ), EndpointSpec( - "/v1/api/admin/ws-templates/{ws_template_id}", - "PUT", - "Update a workstream template", - request_model=UpdateWsTemplateRequest, - response_model=WsTemplateInfo, - error_codes=[404, 409], + "/v1/api/admin/skills/{skill_id}/versions", + "GET", + "List version history for a skill", + response_model=ListSkillVersionsResponse, tags=["Admin"], ), + # --- Skills --- EndpointSpec( - "/v1/api/admin/ws-templates/{ws_template_id}", - "DELETE", - "Delete a workstream template", - response_model=StatusResponse, - error_codes=[404], - tags=["Admin"], - ), - EndpointSpec( - "/v1/api/admin/ws-templates/{ws_template_id}/versions", + "/v1/api/skills", "GET", - "List workstream template version history", - response_model=ListWsTemplateVersionsResponse, - error_codes=[404], - tags=["Admin"], - ), - EndpointSpec( - "/v1/api/ws-templates", - "GET", - "List enabled workstream templates (summary)", - response_model=ListWsTemplateSummaryResponse, - tags=["Workstreams"], - ), - # --- Prompt templates --- - EndpointSpec( - "/v1/api/templates", - "GET", - "List available prompt templates (summary)", - response_model=ListPromptTemplateSummaryResponse, - tags=["Templates"], + "List available skills (summary)", + response_model=ListSkillSummaryResponse, + tags=["Skills"], ), # --- Governance: Usage & Audit --- EndpointSpec( @@ -855,10 +811,6 @@ _ALL_MODELS: list[type[BaseModel]] = [ CreateToolPolicyRequest, UpdateToolPolicyRequest, ListToolPoliciesResponse, - PromptTemplateInfo, - CreatePromptTemplateRequest, - UpdatePromptTemplateRequest, - ListPromptTemplatesResponse, UsageBreakdownItem, UsageResponse, AuditEventInfo, @@ -881,8 +833,14 @@ _ALL_MODELS: list[type[BaseModel]] = [ McpReloadResponse, RegistrySearchResponse, RegistryInstallRequest, - PromptTemplateSummary, - ListPromptTemplateSummaryResponse, + SkillInfo, + SkillVersionInfo, + CreateSkillRequest, + UpdateSkillRequest, + ListSkillsResponse, + ListSkillVersionsResponse, + SkillSummary, + ListSkillSummaryResponse, ] diff --git a/turnstone/api/schemas.py b/turnstone/api/schemas.py index 7aec9255..57371d01 100644 --- a/turnstone/api/schemas.py +++ b/turnstone/api/schemas.py @@ -185,8 +185,7 @@ class CreateScheduleRequest(BaseModel): initial_message: str = Field(description="Message sent to the new workstream") auto_approve: bool = Field(default=False) auto_approve_tools: list[str] = Field(default_factory=list) - template: str = Field(default="", description="Prompt template name") - ws_template: str = Field(default="", description="Workstream template name") + skill: str = Field(default="", description="Skill name (replaces default skills)") enabled: bool = Field(default=True) @@ -203,8 +202,7 @@ class UpdateScheduleRequest(BaseModel): initial_message: str | None = None auto_approve: bool | None = None auto_approve_tools: list[str] | None = None - template: str | None = None - ws_template: str | None = None + skill: str | None = None enabled: bool | None = None @@ -222,8 +220,7 @@ class ScheduleInfo(BaseModel): initial_message: str auto_approve: bool = False auto_approve_tools: list[str] = Field(default_factory=list) - template: str = "" - ws_template: str = "" + skill: str = "" enabled: bool = True created_by: str = "" last_run: str | None = None diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index ab50ba3c..85bed08f 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -51,12 +51,7 @@ class CreateWorkstreamRequest(BaseModel): default="", description="Workstream ID to resume atomically during creation (empty = fresh start)", ) - template: str = Field( - default="", description="Prompt template name (replaces default templates)" - ) - ws_template: str = Field( - default="", description="Workstream template name to apply defaults from" - ) + skill: str = Field(default="", description="Skill name (replaces default skills)") class CreateWorkstreamResponse(BaseModel): @@ -237,18 +232,21 @@ class SearchMemoriesRequest(BaseModel): # --------------------------------------------------------------------------- -# Prompt templates (read-only listing) +# Skills # --------------------------------------------------------------------------- -class PromptTemplateSummary(BaseModel): - name: str = Field(description="Template name") - category: str = Field(default="", description="Template category") - is_default: bool = Field( - default=False, description="Whether this template is applied by default" - ) - origin: str = Field(default="manual", description="Template origin: manual or mcp") +class SkillSummary(BaseModel): + name: str = Field(description="Skill name") + category: str = Field(default="", description="Skill category") + description: str = Field(default="", description="Skill description for discovery") + tags: list[str] = Field(default_factory=list, description="Semantic tags") + is_default: bool = Field(default=False, description="Whether auto-applied to all sessions") + activation: str = Field(default="named", description="Activation mode: default, named, search") + origin: str = Field(default="manual", description="Source: manual, mcp, skills.sh, github") + author: str = Field(default="", description="Skill author") + version: str = Field(default="1.0.0", description="Skill version") -class ListPromptTemplateSummaryResponse(BaseModel): - templates: list[PromptTemplateSummary] +class ListSkillSummaryResponse(BaseModel): + skills: list[SkillSummary] diff --git a/turnstone/api/server_spec.py b/turnstone/api/server_spec.py index 754fee8b..282932a5 100644 --- a/turnstone/api/server_spec.py +++ b/turnstone/api/server_spec.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from turnstone.api.console_schemas import ListWsTemplateSummaryResponse, WsTemplateSummary from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi if TYPE_CHECKING: @@ -29,16 +28,16 @@ from turnstone.api.server_schemas import ( DashboardResponse, HealthResponse, ListMemoriesResponse, - ListPromptTemplateSummaryResponse, ListSavedWorkstreamsResponse, + ListSkillSummaryResponse, ListWorkstreamsResponse, MemoryInfo, PlanFeedbackRequest, - PromptTemplateSummary, SaveMemoryRequest, SearchMemoriesRequest, SendRequest, SendResponse, + SkillSummary, ) SERVER_ENDPOINTS: list[EndpointSpec] = [ @@ -148,21 +147,13 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [ response_model=ListSavedWorkstreamsResponse, tags=["Workstreams"], ), - # --- Prompt templates --- + # --- Skills --- EndpointSpec( - "/v1/api/templates", + "/v1/api/skills", "GET", - "List available prompt templates (summary)", - response_model=ListPromptTemplateSummaryResponse, - tags=["Templates"], - ), - # --- Workstream templates --- - EndpointSpec( - "/v1/api/ws-templates", - "GET", - "List enabled workstream templates (summary)", - response_model=ListWsTemplateSummaryResponse, - tags=["Templates"], + "List available skills (summary)", + response_model=ListSkillSummaryResponse, + tags=["Skills"], ), # --- Auth --- EndpointSpec( @@ -300,10 +291,8 @@ _ALL_MODELS: list[type[BaseModel]] = [ MemoryInfo, ListMemoriesResponse, SearchMemoriesRequest, - PromptTemplateSummary, - ListPromptTemplateSummaryResponse, - WsTemplateSummary, - ListWsTemplateSummaryResponse, + SkillSummary, + ListSkillSummaryResponse, ] diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py index 00eb40df..95869ed2 100644 --- a/turnstone/bootstrap.py +++ b/turnstone/bootstrap.py @@ -129,7 +129,7 @@ After the stack starts, the first admin user is created via: `POST /v1/api/auth/setup` with `{"username", "display_name", "password"}` This is a one-time endpoint that only works when zero users exist. -Subsequent governance setup (roles, policies, templates) uses the console admin API \ +Subsequent governance setup (roles, policies, skills) uses the console admin API \ with the JWT returned from setup. If OIDC is configured, users can also log in via the "Continue with [Provider]" button on the login page. @@ -155,8 +155,8 @@ Glob-pattern rules for tool execution. Actions: `allow`, `deny`, `ask`. \ First match by priority wins. Example: `{"name": "Block bash", "tool_pattern": "bash*", \ "action": "deny", "priority": 100}` -## Prompt Templates -Reusable system message templates with `{{variable}}` placeholders. \ +## Skills +Reusable system message content with `{{variable}}` placeholders. \ Categories like "engineering", "analysis", etc. ## Your Task @@ -181,7 +181,7 @@ DuckDuckGo Search MCP (for cluster — uses `ddgCluster` profile with \ `MCP_CONFIG=/etc/turnstone/mcp-ddg.json`, no API key needed). 8. **Generate .env**: Call `write_file` with the complete `.env` content. 9. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \ -user and any roles/policies/templates the user wants. +user and any roles/policies/skills the user wants. 10. **Finish**: Call the `finish` tool with a summary of what was configured and the \ exact commands to run next (e.g., `docker compose --profile production up -d` then `./setup.sh`). diff --git a/turnstone/channels/_config.py b/turnstone/channels/_config.py index e7dbfad9..7d53970a 100644 --- a/turnstone/channels/_config.py +++ b/turnstone/channels/_config.py @@ -21,4 +21,4 @@ class ChannelConfig: model: str = "" auto_approve: bool = False auto_approve_tools: list[str] = field(default_factory=list) - template: str = "" + skill: str = "" diff --git a/turnstone/channels/_routing.py b/turnstone/channels/_routing.py index 67d9f0ed..12396933 100644 --- a/turnstone/channels/_routing.py +++ b/turnstone/channels/_routing.py @@ -49,15 +49,13 @@ class ChannelRouter: *, auto_approve: bool = False, auto_approve_tools: list[str] | None = None, - template: str = "", - ws_template: str = "", + skill: str = "", ) -> None: self._broker = broker self._storage = storage self._auto_approve = auto_approve self._auto_approve_tools: list[str] = auto_approve_tools or [] - self._template = template - self._ws_template = ws_template + self._skill = skill self._pending: dict[str, asyncio.Event] = {} self._pending_results: dict[str, str] = {} self._global_task: asyncio.Task[None] | None = None @@ -176,8 +174,7 @@ class ChannelRouter: resume_ws=resume_ws, auto_approve=self._auto_approve, auto_approve_tools=list(self._auto_approve_tools), - template=self._template, - ws_template=self._ws_template, + skill=self._skill, ) cid = msg.correlation_id waiter = asyncio.Event() diff --git a/turnstone/channels/discord/bot.py b/turnstone/channels/discord/bot.py index 826b2fb5..7ded7f79 100644 --- a/turnstone/channels/discord/bot.py +++ b/turnstone/channels/discord/bot.py @@ -143,7 +143,7 @@ class TurnstoneBot: storage, auto_approve=config.auto_approve, auto_approve_tools=list(config.auto_approve_tools), - template=config.template, + skill=config.skill, ) self._subscribed_ws: set[str] = set() diff --git a/turnstone/cli.py b/turnstone/cli.py index 9fcef39a..04e3f3ad 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -772,9 +772,9 @@ def main() -> None: help="Developer instructions injected as developer message", ) parser.add_argument( - "--template", + "--skill", default=None, - help="Prompt template name (replaces default templates)", + help="Skill name (replaces default skills)", ) parser.add_argument( "--temperature", @@ -1030,7 +1030,7 @@ def main() -> None: model_alias: str | None = None, ws_id: str | None = None, *, - template: str | None = None, + skill: str | None = None, ) -> ChatSession: assert ui is not None, "session_factory requires a non-None UI" r_client, r_model, r_cfg = registry.resolve(model_alias) @@ -1054,7 +1054,7 @@ def main() -> None: tool_search=args.tool_search, tool_search_threshold=args.tool_search_threshold, tool_search_max_results=args.tool_search_max_results, - template=template if template is not None else args.template, + skill=skill or args.skill or None, ) # Create workstream manager and initial workstream diff --git a/turnstone/console/scheduler.py b/turnstone/console/scheduler.py index 64fe11f8..b754a9d5 100644 --- a/turnstone/console/scheduler.py +++ b/turnstone/console/scheduler.py @@ -208,8 +208,7 @@ class TaskScheduler: auto_approve=bool(task.get("auto_approve", 0)), auto_approve_tools=self._parse_tools(task), user_id=task.get("created_by", ""), - template=task.get("template", ""), - ws_template=task.get("ws_template", ""), + skill=task.get("skill", ""), ) self._broker.push_inbound(msg.to_json(), node_id=node_id) @@ -235,8 +234,7 @@ class TaskScheduler: auto_approve=bool(task.get("auto_approve", 0)), auto_approve_tools=self._parse_tools(task), user_id=task.get("created_by", ""), - template=task.get("template", ""), - ws_template=task.get("ws_template", ""), + skill=task.get("skill", ""), ) self._broker.push_inbound(msg.to_json()) diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 2fdb7ca1..c83b738e 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -370,8 +370,7 @@ async def create_workstream(request: Request) -> JSONResponse: raw_name = body.get("name", "") raw_model = body.get("model", "") raw_initial_message = body.get("initial_message", "") - raw_template = body.get("template", "") - raw_ws_template = body.get("ws_template", "") + raw_skill = body.get("skill", "") if not isinstance(raw_node_id, str): raw_node_id = "" if raw_node_id is None else None if not isinstance(raw_name, str): @@ -380,30 +379,24 @@ async def create_workstream(request: Request) -> JSONResponse: raw_model = "" if raw_model is None else None if not isinstance(raw_initial_message, str): raw_initial_message = "" if raw_initial_message is None else None - if not isinstance(raw_template, str): - raw_template = "" if raw_template is None else None - if not isinstance(raw_ws_template, str): - raw_ws_template = "" if raw_ws_template is None else None + if not isinstance(raw_skill, str): + raw_skill = "" if raw_skill is None else None if ( raw_node_id is None or raw_name is None or raw_model is None or raw_initial_message is None - or raw_template is None - or raw_ws_template is None + or raw_skill is None ): return JSONResponse( - { - "error": "node_id, name, model, initial_message, template, and ws_template must be strings" - }, + {"error": "node_id, name, model, initial_message, and skill must be strings"}, status_code=400, ) node_id = raw_node_id name = raw_name[:256] model = raw_model[:128] initial_message = raw_initial_message[:4096] - template = raw_template[:256] - ws_template = raw_ws_template[:256] + skill = raw_skill[:256] from turnstone.mq.protocol import CreateWorkstreamMessage @@ -413,8 +406,7 @@ async def create_workstream(request: Request) -> JSONResponse: name=name, model=model, initial_message=initial_message, - template=template, - ws_template=ws_template, + skill=skill, ) broker.push_inbound(msg.to_json()) log.debug("Pool dispatch: correlation_id=%s name=%r", msg.correlation_id, name) @@ -442,8 +434,7 @@ async def create_workstream(request: Request) -> JSONResponse: model=model, target_node=node_id, initial_message=initial_message, - template=template, - ws_template=ws_template, + skill=skill, ) broker.push_inbound(msg.to_json(), node_id=node_id) @@ -1263,20 +1254,15 @@ async def admin_create_schedule(request: Request) -> JSONResponse: auto_approve = bool(body.get("auto_approve", False)) raw_tools = body.get("auto_approve_tools", []) auto_approve_tools = raw_tools if isinstance(raw_tools, list) else [] - template = str(body.get("template", "")).strip()[:256] - ws_template = str(body.get("ws_template", "")).strip()[:256] + skill_name = str(body.get("skill", "")).strip()[:256] enabled = bool(body.get("enabled", True)) if not name: return JSONResponse({"error": "name is required"}, status_code=400) if not initial_message: return JSONResponse({"error": "initial_message is required"}, status_code=400) - if template and not storage.get_prompt_template_by_name(template): - return JSONResponse({"error": f"Template not found: {template}"}, status_code=400) - if ws_template and not storage.get_ws_template_by_name(ws_template): - return JSONResponse( - {"error": f"Workstream template not found: {ws_template}"}, status_code=400 - ) + if skill_name and not storage.get_prompt_template_by_name(skill_name): + return JSONResponse({"error": f"Skill not found: {skill_name}"}, status_code=400) validation_err = _validate_schedule_fields(schedule_type, cron_expr, at_time) if validation_err: @@ -1311,8 +1297,7 @@ async def admin_create_schedule(request: Request) -> JSONResponse: auto_approve_tools=auto_approve_tools, created_by=created_by, next_run=next_run if enabled else "", - template=template, - ws_template=ws_template, + skill=skill_name, ) if not enabled: @@ -1387,18 +1372,11 @@ async def admin_update_schedule(request: Request) -> JSONResponse: if "auto_approve_tools" in body: raw = body["auto_approve_tools"] updates["auto_approve_tools"] = raw if isinstance(raw, list) else [] - if "template" in body: - tpl_name = str(body["template"]).strip()[:256] - if tpl_name and not storage.get_prompt_template_by_name(tpl_name): - return JSONResponse({"error": f"Template not found: {tpl_name}"}, status_code=400) - updates["template"] = tpl_name - if "ws_template" in body: - ws_tpl_name = str(body["ws_template"]).strip()[:256] - if ws_tpl_name and not storage.get_ws_template_by_name(ws_tpl_name): - return JSONResponse( - {"error": f"Workstream template not found: {ws_tpl_name}"}, status_code=400 - ) - updates["ws_template"] = ws_tpl_name + if "skill" in body: + skill_val = str(body["skill"]).strip()[:256] + if skill_val and not storage.get_prompt_template_by_name(skill_val): + return JSONResponse({"error": f"Skill not found: {skill_val}"}, status_code=400) + updates["skill"] = skill_val if "enabled" in body: updates["enabled"] = bool(body["enabled"]) @@ -1583,13 +1561,6 @@ async def admin_cancel_watch(request: Request) -> Response: # --------------------------------------------------------------------------- -def _hash_content(content: str) -> str: - """SHA-256 hash of content for drift detection.""" - import hashlib - - return hashlib.sha256(content.encode()).hexdigest() - - def _audit_context(request: Request) -> tuple[str, str]: """Extract (user_id, ip_address) from request for audit logging. @@ -1622,12 +1593,11 @@ _VALID_PERMISSIONS = frozenset( "admin.roles", "admin.orgs", "admin.policies", - "admin.templates", + "admin.skills", "admin.audit", "admin.usage", "admin.schedules", "admin.watches", - "admin.ws_templates", "admin.judge", "admin.memories", "admin.settings", @@ -2158,22 +2128,211 @@ async def admin_delete_policy(request: Request) -> JSONResponse: return JSONResponse({"status": "ok"}) -async def admin_list_templates(request: Request) -> JSONResponse: - """GET /v1/api/admin/templates — list all prompt templates.""" +# --------------------------------------------------------------------------- +# Admin: Skills (thin layer over prompt templates with extended fields) +# --------------------------------------------------------------------------- + +_VALID_ACTIVATIONS = {"named", "default", "search"} + + +def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], JSONResponse | None]: + """Parse and validate session config fields from a skill request body. + + Returns (fields_dict, error_response). error_response is None on success. + Only includes fields that are present in the body (for partial updates). + """ + import json as _json + + fields: dict[str, Any] = {} + + if "model" in body: + fields["model"] = str(body["model"] or "").strip() + + if "temperature" in body: + temp = body["temperature"] + if temp is not None and temp != "": + try: + temp = float(temp) + if not (0.0 <= temp <= 2.0): + return {}, JSONResponse( + {"error": "temperature must be between 0 and 2"}, status_code=400 + ) + fields["temperature"] = temp + except (ValueError, TypeError): + fields["temperature"] = None + else: + fields["temperature"] = None + + if "token_budget" in body: + try: + tb = int(body.get("token_budget", 0) or 0) + except (ValueError, TypeError): + return {}, JSONResponse({"error": "token_budget must be an integer"}, status_code=400) + if tb < 0: + return {}, JSONResponse({"error": "token_budget must be non-negative"}, status_code=400) + fields["token_budget"] = tb + + if "max_tokens" in body: + mt = body["max_tokens"] + if mt is not None and mt != "": + try: + mt = int(mt) + except (ValueError, TypeError): + return {}, JSONResponse({"error": "max_tokens must be an integer"}, status_code=400) + if mt < 1: + return {}, JSONResponse({"error": "max_tokens must be positive"}, status_code=400) + fields["max_tokens"] = mt + else: + fields["max_tokens"] = None + + if "agent_max_turns" in body: + amt = body["agent_max_turns"] + if amt is not None and amt != "": + try: + amt = int(amt) + except (ValueError, TypeError): + return {}, JSONResponse( + {"error": "agent_max_turns must be an integer"}, status_code=400 + ) + if amt < 1: + return {}, JSONResponse( + {"error": "agent_max_turns must be positive"}, status_code=400 + ) + fields["agent_max_turns"] = amt + else: + fields["agent_max_turns"] = None + + if "reasoning_effort" in body: + fields["reasoning_effort"] = str(body["reasoning_effort"] or "").strip() + + if "auto_approve" in body: + fields["auto_approve"] = bool(body.get("auto_approve", False)) + + if "enabled" in body: + fields["enabled"] = bool(body.get("enabled", True)) + + if "activation" in body: + activation = str(body["activation"] or "named").strip() + if activation not in _VALID_ACTIVATIONS: + return {}, JSONResponse( + {"error": f"activation must be one of: {', '.join(sorted(_VALID_ACTIVATIONS))}"}, + status_code=400, + ) + fields["activation"] = activation + + if "notify_on_complete" in body: + nc = str(body.get("notify_on_complete", "{}")).strip() + if nc and nc != "{}": + try: + _json.loads(nc) + except (_json.JSONDecodeError, TypeError): + return {}, JSONResponse( + {"error": "notify_on_complete must be valid JSON"}, status_code=400 + ) + fields["notify_on_complete"] = nc + + if "allowed_tools" in body: + at_raw = body.get("allowed_tools", "[]") + if isinstance(at_raw, list): + fields["allowed_tools"] = _json.dumps(at_raw) + else: + at_str = str(at_raw).strip() + if at_str and not at_str.startswith("["): + at_str = _json.dumps([t.strip() for t in at_str.split(",") if t.strip()]) + try: + _json.loads(at_str or "[]") + except (ValueError, TypeError): + at_str = "[]" + fields["allowed_tools"] = at_str or "[]" + + return fields, None + + +def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]: + """Convert a storage skill dict to a JSON-safe response dict.""" + import contextlib + import json as _json + + tags: list[str] = [] + with contextlib.suppress(ValueError, TypeError): + tags = _json.loads(r.get("tags", "[]")) + return { + "template_id": r.get("template_id", ""), + "name": r.get("name", ""), + "category": r.get("category", ""), + "description": r.get("description", ""), + "content": r.get("content", ""), + "tags": tags, + "is_default": r.get("is_default", False), + "activation": r.get("activation", "named"), + "origin": r.get("origin", "manual"), + "mcp_server": r.get("mcp_server", ""), + "readonly": r.get("readonly", False), + "author": r.get("author", ""), + "version": r.get("version", "1.0.0"), + "variables": r.get("variables", "[]"), + "token_estimate": r.get("token_estimate", 0), + "source_url": r.get("source_url", ""), + "org_id": r.get("org_id", ""), + "created_by": r.get("created_by", ""), + # Session config fields + "model": r.get("model", ""), + "auto_approve": r.get("auto_approve", False), + "temperature": r.get("temperature"), + "reasoning_effort": r.get("reasoning_effort", ""), + "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", "{}"), + "enabled": r.get("enabled", True), + "allowed_tools": r.get("allowed_tools", "[]"), + "created": r.get("created", ""), + "updated": r.get("updated", ""), + } + + +async def admin_list_skills(request: Request) -> JSONResponse: + """GET /v1/api/admin/skills — list all skills.""" 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.templates") + err = require_permission(request, "admin.skills") if err: return err - return JSONResponse({"templates": storage.list_prompt_templates()}) + params = dict(request.query_params) + limit = _parse_int(params, "limit", 0, minimum=0, maximum=10000) + offset = _parse_int(params, "offset", 0, minimum=0, maximum=100000) + rows = storage.list_prompt_templates(limit=limit, offset=offset) + total = storage.count_prompt_templates() + skills = [_skill_to_response(r) for r in rows] + return JSONResponse({"skills": skills, "total": total}) -async def admin_create_template(request: Request) -> JSONResponse: - """POST /v1/api/admin/templates — create a prompt template.""" +async def admin_get_skill(request: Request) -> JSONResponse: + """GET /v1/api/admin/skills/{skill_id} — get a single skill.""" + 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"] + skill = storage.get_prompt_template(skill_id) + if skill is None: + return JSONResponse({"error": "Skill not found"}, status_code=404) + return JSONResponse(_skill_to_response(skill)) + + +async def admin_create_skill(request: Request) -> JSONResponse: + """POST /v1/api/admin/skills — create a skill.""" + import json as _json import uuid from turnstone.core.audit import record_audit @@ -2183,7 +2342,7 @@ async def admin_create_template(request: Request) -> JSONResponse: storage, err = require_storage_or_503(request) if err: return err - err = require_permission(request, "admin.templates") + err = require_permission(request, "admin.skills") if err: return err @@ -2194,24 +2353,53 @@ async def admin_create_template(request: Request) -> JSONResponse: name = str(body.get("name", "")).strip()[:256] content = str(body.get("content", "")).strip()[:32768] category = str(body.get("category", "general")).strip()[:64] + description = str(body.get("description", "")).strip()[:1024] variables = str(body.get("variables", "[]")).strip() try: - json.loads(variables) - except (json.JSONDecodeError, TypeError): + _json.loads(variables) + except (_json.JSONDecodeError, TypeError): return JSONResponse({"error": "variables must be a valid JSON array"}, status_code=400) is_default = bool(body.get("is_default", False)) org_id = str(body.get("org_id", "")).strip()[:64] + author = str(body.get("author", "")).strip()[:256] + version = str(body.get("version", "1.0.0")).strip()[:64] + + raw_tags = body.get("tags", []) + if isinstance(raw_tags, list): + tags_str = _json.dumps(raw_tags) + else: + tags_str = str(raw_tags).strip() + try: + _json.loads(tags_str) + except (ValueError, TypeError): + tags_str = "[]" + + token_estimate = len(content) // 4 if content else 0 + + # Session config fields via shared helper + session_fields, session_err = _parse_skill_session_config(body) + if session_err: + return session_err + + # Resolve activation / is_default sync + activation = session_fields.pop("activation", "") + if not activation: + activation = "default" if is_default else "named" + if activation == "default": + is_default = True if not name: return JSONResponse({"error": "name is required"}, status_code=400) if not content: return JSONResponse({"error": "content is required"}, status_code=400) + if storage.get_prompt_template_by_name(name): + return JSONResponse({"error": "Skill name already exists"}, status_code=409) audit_uid, ip = _audit_context(request) - template_id = uuid.uuid4().hex + skill_id = uuid.uuid4().hex storage.create_prompt_template( - template_id=template_id, + template_id=skill_id, name=name, category=category, content=content, @@ -2219,24 +2407,33 @@ async def admin_create_template(request: Request) -> JSONResponse: is_default=is_default, org_id=org_id, created_by=audit_uid, + description=description, + tags=tags_str, + version=version, + author=author, + activation=activation, + token_estimate=token_estimate, + **session_fields, ) record_audit( storage, audit_uid, - "template.create", - "template", - template_id, + "skill.create", + "skill", + skill_id, {"name": name}, ip, ) - template = storage.get_prompt_template(template_id) - return JSONResponse(template) + skill = storage.get_prompt_template(skill_id) + return JSONResponse(_skill_to_response(skill)) -async def admin_update_template(request: Request) -> JSONResponse: - """PUT /v1/api/admin/templates/{template_id} — update a prompt template.""" +async def admin_update_skill(request: Request) -> JSONResponse: + """PUT /v1/api/admin/skills/{skill_id} — update a skill.""" + import json as _json + from turnstone.core.audit import record_audit from turnstone.core.auth import require_permission from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 @@ -2244,57 +2441,97 @@ async def admin_update_template(request: Request) -> JSONResponse: storage, err = require_storage_or_503(request) if err: return err - err = require_permission(request, "admin.templates") + err = require_permission(request, "admin.skills") if err: return err - template_id = request.path_params["template_id"] - existing = storage.get_prompt_template(template_id) + skill_id = request.path_params["skill_id"] + existing = storage.get_prompt_template(skill_id) if existing is None: - return JSONResponse({"error": "Template not found"}, status_code=404) + return JSONResponse({"error": "Skill not found"}, status_code=404) if existing.get("readonly"): - return JSONResponse({"error": "MCP-sourced templates are read-only"}, status_code=403) + return JSONResponse({"error": "MCP-sourced skills are read-only"}, status_code=403) body = await read_json_or_400(request) if isinstance(body, JSONResponse): return body - updates: dict[str, Any] = {} + # Session config fields via shared helper + session_fields, session_err = _parse_skill_session_config(body) + if session_err: + return session_err + + updates: dict[str, Any] = dict(session_fields) if "name" in body: updates["name"] = str(body["name"]).strip()[:256] + existing_by_name = storage.get_prompt_template_by_name(updates["name"]) + if existing_by_name and existing_by_name["template_id"] != skill_id: + return JSONResponse({"error": "Skill name already exists"}, status_code=409) if "content" in body: - updates["content"] = str(body["content"]).strip()[:32768] + content = str(body["content"]).strip()[:32768] + updates["content"] = content + updates["token_estimate"] = len(content) // 4 if content else 0 if "category" in body: updates["category"] = str(body["category"]).strip()[:64] + if "description" in body: + updates["description"] = str(body["description"]).strip()[:1024] if "variables" in body: var_str = str(body["variables"]).strip() try: - json.loads(var_str) - except (json.JSONDecodeError, TypeError): + _json.loads(var_str) + except (_json.JSONDecodeError, TypeError): return JSONResponse({"error": "variables must be a valid JSON array"}, status_code=400) updates["variables"] = var_str if "is_default" in body: updates["is_default"] = bool(body["is_default"]) + if "activation" in updates and updates["activation"] == "default": + updates["is_default"] = True + if "author" in body: + updates["author"] = str(body["author"]).strip()[:256] + if "version" in body: + updates["version"] = str(body["version"]).strip()[:64] + if "tags" in body: + raw_tags = body["tags"] + if isinstance(raw_tags, list): + updates["tags"] = _json.dumps(raw_tags) + else: + tag_str = str(raw_tags).strip() + try: + _json.loads(tag_str) + except (ValueError, TypeError): + tag_str = "[]" + updates["tags"] = tag_str - storage.update_prompt_template(template_id, **updates) + # Snapshot current state for version history before applying update + existing_versions = storage.list_skill_versions(skill_id) + version_int = len(existing_versions) + 1 + audit_uid_pre, _ = _audit_context(request) + storage.create_skill_version( + skill_id=skill_id, + version=version_int, + snapshot=_json.dumps(existing, default=str), + changed_by=audit_uid_pre, + ) + + storage.update_prompt_template(skill_id, **updates) audit_uid, ip = _audit_context(request) record_audit( storage, audit_uid, - "template.update", - "template", - template_id, + "skill.update", + "skill", + skill_id, updates, ip, ) - template = storage.get_prompt_template(template_id) - return JSONResponse(template) + updated_skill = storage.get_prompt_template(skill_id) + return JSONResponse(_skill_to_response(updated_skill)) -async def admin_delete_template(request: Request) -> JSONResponse: - """DELETE /v1/api/admin/templates/{template_id} — delete a prompt template.""" +async def admin_delete_skill(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/skills/{skill_id} — delete a skill.""" from turnstone.core.audit import record_audit from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 @@ -2302,26 +2539,28 @@ async def admin_delete_template(request: Request) -> JSONResponse: storage, err = require_storage_or_503(request) if err: return err - err = require_permission(request, "admin.templates") + err = require_permission(request, "admin.skills") if err: return err - template_id = request.path_params["template_id"] - existing = storage.get_prompt_template(template_id) + skill_id = request.path_params["skill_id"] + existing = storage.get_prompt_template(skill_id) if existing is None: - return JSONResponse({"error": "Template not found"}, status_code=404) + return JSONResponse({"error": "Skill not found"}, status_code=404) if existing.get("readonly"): - return JSONResponse({"error": "MCP-sourced templates are read-only"}, status_code=403) + return JSONResponse({"error": "MCP-sourced skills are read-only"}, status_code=403) - storage.delete_prompt_template(template_id) + storage.delete_skill_resources(skill_id) + storage.delete_skill_versions(skill_id) + storage.delete_prompt_template(skill_id) audit_uid, ip = _audit_context(request) record_audit( storage, audit_uid, - "template.delete", - "template", - template_id, + "skill.delete", + "skill", + skill_id, {"name": existing.get("name", "")}, ip, ) @@ -2329,293 +2568,55 @@ async def admin_delete_template(request: Request) -> JSONResponse: return JSONResponse({"status": "ok"}) -# --------------------------------------------------------------------------- -# Admin: Workstream Templates -# --------------------------------------------------------------------------- - - -async def admin_list_ws_templates(request: Request) -> JSONResponse: - """GET /v1/api/admin/ws-templates — list all workstream templates.""" +async def admin_list_skill_versions(request: Request) -> JSONResponse: + """GET /v1/api/admin/skills/{skill_id}/versions — version history.""" 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.ws_templates") + err = require_permission(request, "admin.skills") if err: return err - return JSONResponse({"ws_templates": storage.list_ws_templates()}) - -async def admin_create_ws_template(request: Request) -> JSONResponse: - """POST /v1/api/admin/ws-templates — create a workstream template.""" - import uuid - - from turnstone.core.audit import record_audit - from turnstone.core.auth import require_permission - from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 - - storage, err = require_storage_or_503(request) - if err: - return err - err = require_permission(request, "admin.ws_templates") - if err: - return err - body = await read_json_or_400(request) - if isinstance(body, JSONResponse): - return body - - name = str(body.get("name", "")).strip()[:256] - if not name: - return JSONResponse({"error": "name is required"}, status_code=400) - if storage.get_ws_template_by_name(name) is not None: - return JSONResponse({"error": "Name already exists"}, status_code=409) - - prompt_template_ref = str(body.get("prompt_template", ""))[:256] - prompt_template_hash = "" - if prompt_template_ref: - pt = storage.get_prompt_template_by_name(prompt_template_ref) - if not pt: - return JSONResponse( - {"error": f"Prompt template not found: {prompt_template_ref}"}, status_code=400 - ) - prompt_template_hash = _hash_content(pt.get("content", "")) - - try: - temperature = float(body["temperature"]) if body.get("temperature") is not None else None - max_tokens = int(body["max_tokens"]) if body.get("max_tokens") is not None else None - token_budget = int(body.get("token_budget", 0)) - agent_max_turns = ( - int(body["agent_max_turns"]) if body.get("agent_max_turns") is not None else None - ) - except (ValueError, TypeError) as exc: - return JSONResponse({"error": f"Invalid numeric field: {exc}"}, status_code=400) - - ws_template_id = uuid.uuid4().hex - storage.create_ws_template( - ws_template_id=ws_template_id, - name=name, - description=str(body.get("description", ""))[:1024], - system_prompt=str(body.get("system_prompt", ""))[:32768], - prompt_template=prompt_template_ref, - prompt_template_hash=prompt_template_hash, - model=str(body.get("model", ""))[:128], - auto_approve=bool(body.get("auto_approve", False)), - auto_approve_tools=str(body.get("auto_approve_tools", ""))[:2048], - temperature=temperature, - reasoning_effort=str(body.get("reasoning_effort", ""))[:32], - max_tokens=max_tokens, - token_budget=token_budget, - agent_max_turns=agent_max_turns, - notify_on_complete=str(body.get("notify_on_complete", "{}"))[:4096], - org_id=str(body.get("org_id", ""))[:128], - created_by=getattr(getattr(request.state, "auth_result", None), "user_id", ""), - enabled=bool(body.get("enabled", True)), - ) - - audit_uid, ip = _audit_context(request) - record_audit( - storage, - audit_uid, - "ws_template.create", - "ws_template", - ws_template_id, - {"name": name}, - ip, - ) - - tpl = storage.get_ws_template(ws_template_id) - return JSONResponse(tpl) - - -async def admin_get_ws_template(request: Request) -> JSONResponse: - """GET /v1/api/admin/ws-templates/{ws_template_id} — get a single workstream template.""" - 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.ws_templates") - if err: - return err - ws_template_id = request.path_params["ws_template_id"] - tpl = storage.get_ws_template(ws_template_id) - if not tpl: - return JSONResponse({"error": "Not found"}, status_code=404) - return JSONResponse(tpl) - - -async def admin_update_ws_template(request: Request) -> JSONResponse: - """PUT /v1/api/admin/ws-templates/{ws_template_id} — update a workstream template.""" - from turnstone.core.audit import record_audit - from turnstone.core.auth import require_permission - from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 - - storage, err = require_storage_or_503(request) - if err: - return err - err = require_permission(request, "admin.ws_templates") - if err: - return err - ws_template_id = request.path_params["ws_template_id"] - existing = storage.get_ws_template(ws_template_id) - if not existing: - return JSONResponse({"error": "Not found"}, status_code=404) - - body = await read_json_or_400(request) - if isinstance(body, JSONResponse): - return body - - updates: dict[str, Any] = {} - if "name" in body: - new_name = str(body["name"]).strip()[:256] - if new_name != existing["name"] and storage.get_ws_template_by_name(new_name) is not None: - return JSONResponse({"error": "Name already exists"}, status_code=409) - updates["name"] = new_name - if "description" in body: - updates["description"] = str(body["description"])[:1024] - if "system_prompt" in body: - updates["system_prompt"] = str(body["system_prompt"])[:32768] - if "prompt_template" in body: - pt_ref = str(body["prompt_template"])[:256] - pt_obj = storage.get_prompt_template_by_name(pt_ref) if pt_ref else None - if pt_ref and not pt_obj: - return JSONResponse({"error": f"Prompt template not found: {pt_ref}"}, status_code=400) - updates["prompt_template"] = pt_ref - updates["prompt_template_hash"] = _hash_content(pt_obj.get("content", "")) if pt_obj else "" - if "model" in body: - updates["model"] = str(body["model"])[:128] - if "auto_approve" in body: - updates["auto_approve"] = bool(body["auto_approve"]) - if "auto_approve_tools" in body: - updates["auto_approve_tools"] = str(body["auto_approve_tools"])[:2048] - try: - if "temperature" in body: - updates["temperature"] = ( - float(body["temperature"]) if body["temperature"] is not None else None - ) - if "max_tokens" in body: - updates["max_tokens"] = ( - int(body["max_tokens"]) if body["max_tokens"] is not None else None - ) - if "token_budget" in body: - updates["token_budget"] = int(body["token_budget"]) - if "agent_max_turns" in body: - updates["agent_max_turns"] = ( - int(body["agent_max_turns"]) if body["agent_max_turns"] is not None else None - ) - except (ValueError, TypeError) as exc: - return JSONResponse({"error": f"Invalid numeric field: {exc}"}, status_code=400) - if "reasoning_effort" in body: - updates["reasoning_effort"] = str(body["reasoning_effort"])[:32] - if "notify_on_complete" in body: - updates["notify_on_complete"] = str(body["notify_on_complete"])[:4096] - if "enabled" in body: - updates["enabled"] = bool(body["enabled"]) - - changed_by = getattr(getattr(request.state, "auth_result", None), "user_id", "") - storage.update_ws_template(ws_template_id, changed_by=changed_by, **updates) - - audit_uid, ip = _audit_context(request) - record_audit( - storage, - audit_uid, - "ws_template.update", - "ws_template", - ws_template_id, - updates, - ip, - ) - - tpl = storage.get_ws_template(ws_template_id) - return JSONResponse(tpl) - - -async def admin_delete_ws_template(request: Request) -> JSONResponse: - """DELETE /v1/api/admin/ws-templates/{ws_template_id} — delete a workstream template.""" - 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.ws_templates") - if err: - return err - ws_template_id = request.path_params["ws_template_id"] - existing = storage.get_ws_template(ws_template_id) - if not existing: - return JSONResponse({"error": "Not found"}, status_code=404) - - storage.delete_ws_template(ws_template_id) - - audit_uid, ip = _audit_context(request) - record_audit( - storage, - audit_uid, - "ws_template.delete", - "ws_template", - ws_template_id, - {"name": existing["name"]}, - ip, - ) - return JSONResponse({"status": "ok"}) - - -async def admin_list_ws_template_versions(request: Request) -> JSONResponse: - """GET /v1/api/admin/ws-templates/{ws_template_id}/versions — version history.""" - 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.ws_templates") - if err: - return err - ws_template_id = request.path_params["ws_template_id"] - if not storage.get_ws_template(ws_template_id): - return JSONResponse({"error": "Not found"}, status_code=404) - versions = storage.list_ws_template_versions(ws_template_id) + skill_id = request.path_params["skill_id"] + versions = storage.list_skill_versions(skill_id) return JSONResponse({"versions": versions}) -async def list_ws_templates_summary(request: Request) -> JSONResponse: - """GET /v1/api/ws-templates — enabled workstream templates summary.""" +async def list_skills_summary(request: Request) -> JSONResponse: + """GET /v1/api/skills — list available skills (summary).""" + import contextlib + import json as _json + from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) if err: return err - templates = storage.list_ws_templates(enabled_only=True) - summary = [ - {"name": t["name"], "description": t.get("description", ""), "model": t.get("model", "")} - for t in templates - ] - return JSONResponse({"ws_templates": summary}) - - -async def list_templates_summary(request: Request) -> JSONResponse: - """GET /v1/api/templates — list available prompt templates (read scope).""" - from turnstone.core.web_helpers import require_storage_or_503 - - storage, err = require_storage_or_503(request) - if err: - return err - templates = storage.list_prompt_templates() - summaries = [ - { - "name": t["name"], - "category": t.get("category", ""), - "is_default": bool(t.get("is_default")), - "origin": t.get("origin", "manual"), - } - for t in templates - ] - return JSONResponse({"templates": summaries}) + rows = storage.list_prompt_templates() + skills = [] + for r in rows: + if not r.get("enabled", True): + continue + tags: list[str] = [] + with contextlib.suppress(ValueError, TypeError): + tags = _json.loads(r.get("tags", "[]")) + skills.append( + { + "name": r["name"], + "category": r.get("category", ""), + "description": r.get("description", ""), + "tags": tags, + "is_default": r.get("is_default", False), + "activation": r.get("activation", "named"), + "origin": r.get("origin", "manual"), + "author": r.get("author", ""), + "version": r.get("version", "1.0.0"), + } + ) + return JSONResponse({"skills": skills}) async def admin_usage(request: Request) -> JSONResponse: @@ -3961,8 +3962,7 @@ def create_app( Route("/api/cluster/node/{node_id}", cluster_node_detail), Route("/api/cluster/snapshot", cluster_snapshot), Route("/api/cluster/events", cluster_events_sse), - Route("/api/ws-templates", list_ws_templates_summary), - Route("/api/templates", list_templates_summary), + Route("/api/skills", list_skills_summary), Route("/api/auth/login", auth_login, methods=["POST"]), Route("/api/auth/logout", auth_logout, methods=["POST"]), Route("/api/auth/status", auth_status), @@ -4050,36 +4050,23 @@ def create_app( admin_delete_policy, methods=["DELETE"], ), - # Governance: Prompt templates - Route("/api/admin/templates", admin_list_templates), - Route("/api/admin/templates", admin_create_template, methods=["POST"]), + # Governance: Skills + Route("/api/admin/skills", admin_list_skills), + Route("/api/admin/skills", admin_create_skill, methods=["POST"]), + Route("/api/admin/skills/{skill_id}", admin_get_skill), Route( - "/api/admin/templates/{template_id}", - admin_update_template, + "/api/admin/skills/{skill_id}", + admin_update_skill, methods=["PUT"], ), Route( - "/api/admin/templates/{template_id}", - admin_delete_template, - methods=["DELETE"], - ), - # Governance: Workstream templates - Route("/api/admin/ws-templates", admin_list_ws_templates), - Route("/api/admin/ws-templates", admin_create_ws_template, methods=["POST"]), - Route("/api/admin/ws-templates/{ws_template_id}", admin_get_ws_template), - Route( - "/api/admin/ws-templates/{ws_template_id}", - admin_update_ws_template, - methods=["PUT"], - ), - Route( - "/api/admin/ws-templates/{ws_template_id}", - admin_delete_ws_template, + "/api/admin/skills/{skill_id}", + admin_delete_skill, methods=["DELETE"], ), Route( - "/api/admin/ws-templates/{ws_template_id}/versions", - admin_list_ws_template_versions, + "/api/admin/skills/{skill_id}/versions", + admin_list_skill_versions, ), # Governance: Memories Route("/api/admin/memories", admin_list_memories), diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 97053e3d..90cbd23c 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -59,8 +59,7 @@ function showAdmin() { watches: "admin.watches", roles: "admin.roles", policies: "admin.policies", - templates: "admin.templates", - "ws-templates": "admin.ws_templates", + skills: "admin.skills", usage: "admin.usage", audit: "admin.audit", memories: "admin.memories", @@ -189,8 +188,7 @@ function switchAdminTab(tab) { "watches", "roles", "policies", - "templates", - "ws-templates", + "skills", "usage", "audit", "memories", @@ -209,8 +207,7 @@ function switchAdminTab(tab) { if (tab === "watches") loadAdminWatches(); if (tab === "roles") loadGovRoles(); if (tab === "policies") loadGovPolicies(); - if (tab === "templates") loadGovTemplates(); - if (tab === "ws-templates") loadGovWsTemplates(); + if (tab === "skills") loadGovSkills(); if (tab === "usage") loadGovUsage(); if (tab === "audit") { _populateAuditUserFilter(); @@ -853,28 +850,6 @@ var _srTrapHandler = null; var _editScheduleTriggerEl = null; var _runsScheduleTriggerEl = null; -function _populateWsTemplateSelect(selectId) { - var sel = document.getElementById(selectId); - sel.innerHTML = ''; - return authFetch("/v1/api/ws-templates") - .then(function (r) { - return r.json(); - }) - .then(function (data) { - (data.ws_templates || []).forEach(function (t) { - var opt = document.createElement("option"); - opt.value = t.name; - var label = t.name; - if (t.model) label += " (" + t.model + ")"; - opt.textContent = label; - sel.appendChild(opt); - }); - }) - .catch(function () { - /* ignore — dropdown stays with "None" */ - }); -} - function loadAdminSchedules() { authFetch("/v1/api/admin/schedules") .then(function (r) { @@ -1072,7 +1047,6 @@ function showCreateScheduleModal() { document.getElementById("cs-node").value = ""; document.getElementById("cs-model").value = ""; document.getElementById("cs-template").value = ""; - _populateWsTemplateSelect("cs-ws-template"); document.getElementById("cs-message").value = ""; document.getElementById("cs-autoapprove").checked = false; toggleScheduleTypeFields(); @@ -1105,8 +1079,7 @@ function submitCreateSchedule() { var nodeId = (document.getElementById("cs-node").value || "").trim(); var model = (document.getElementById("cs-model").value || "").trim(); var message = (document.getElementById("cs-message").value || "").trim(); - var template = (document.getElementById("cs-template").value || "").trim(); - var wsTemplate = document.getElementById("cs-ws-template").value; + var skill = (document.getElementById("cs-template").value || "").trim(); var autoApprove = document.getElementById("cs-autoapprove").checked; var errEl = document.getElementById("create-schedule-error"); @@ -1143,8 +1116,7 @@ function submitCreateSchedule() { model: model, initial_message: message, auto_approve: autoApprove, - template: template, - ws_template: wsTemplate, + skill: skill, }), }) .then(function (r) { @@ -1211,11 +1183,7 @@ function showEditScheduleModal(taskId) { ? s.target_mode : ""; document.getElementById("es-model").value = s.model || ""; - document.getElementById("es-template").value = s.template || ""; - var _wsTemplateVal = s.ws_template || ""; - _populateWsTemplateSelect("es-ws-template").then(function () { - document.getElementById("es-ws-template").value = _wsTemplateVal; - }); + document.getElementById("es-template").value = s.skill || ""; document.getElementById("es-message").value = s.initial_message || ""; document.getElementById("es-autoapprove").checked = !!s.auto_approve; document.getElementById("es-enabled").checked = !!s.enabled; @@ -1288,8 +1256,7 @@ function submitEditSchedule() { at_time: atTime, target_mode: targetMode, model: (document.getElementById("es-model").value || "").trim(), - template: (document.getElementById("es-template").value || "").trim(), - ws_template: document.getElementById("es-ws-template").value, + skill: (document.getElementById("es-template").value || "").trim(), initial_message: ( document.getElementById("es-message").value || "" ).trim(), @@ -1868,10 +1835,6 @@ function _installTrap(overlayId, boxId, trapRef) { else if (overlayId === "create-template-overlay") hideCreateTemplateModal(); else if (overlayId === "edit-template-overlay") hideEditTemplateModal(); - else if (overlayId === "create-wst-overlay") - hideCreateWsTemplateModal(); - else if (overlayId === "edit-wst-overlay") hideEditWsTemplateModal(); - else if (overlayId === "wst-history-overlay") hideWstHistoryModal(); else if (overlayId === "memory-detail-overlay") hideMemoryDetailModal(); else if (overlayId === "mcp-create-overlay") hideCreateMcpModal(); else if (overlayId === "mcp-import-overlay") hideImportMcpModal(); @@ -1959,9 +1922,6 @@ document.addEventListener("keydown", function (e) { ["edit-policy-overlay", hideEditPolicyModal], ["create-template-overlay", hideCreateTemplateModal], ["edit-template-overlay", hideEditTemplateModal], - ["create-wst-overlay", hideCreateWsTemplateModal], - ["edit-wst-overlay", hideEditWsTemplateModal], - ["wst-history-overlay", hideWstHistoryModal], ["memory-detail-overlay", hideMemoryDetailModal], ["mcp-install-overlay", hideInstallMcpModal], ["mcp-detail-overlay", hideMcpDetailModal], diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 35e93684..dfdf57da 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -1260,15 +1260,15 @@ function showNewWsModal() { .catch(function () { /* ignore — auto is always available */ }); - // Populate template dropdown - var tplSelect = document.getElementById("new-ws-template"); + // Populate skill dropdown + var tplSelect = document.getElementById("new-ws-skill"); tplSelect.innerHTML = ''; - authFetch("/v1/api/templates") + authFetch("/v1/api/skills") .then(function (r) { return r.json(); }) .then(function (data) { - (data.templates || []).forEach(function (t) { + (data.skills || []).forEach(function (t) { var opt = document.createElement("option"); opt.value = t.name; var label = t.name; @@ -1281,26 +1281,6 @@ function showNewWsModal() { .catch(function () { /* ignore — defaults still work */ }); - // Populate profile (WS template) dropdown - var profSelect = document.getElementById("new-ws-profile"); - profSelect.innerHTML = ''; - authFetch("/v1/api/ws-templates") - .then(function (r) { - return r.json(); - }) - .then(function (data) { - (data.ws_templates || []).forEach(function (t) { - var opt = document.createElement("option"); - opt.value = t.name; - var label = t.name; - if (t.model) label += " (" + t.model + ")"; - opt.textContent = label; - profSelect.appendChild(opt); - }); - }) - .catch(function () { - /* ignore — profiles optional */ - }); document.getElementById("new-ws-name").value = ""; document.getElementById("new-ws-model").value = ""; var taskEl = document.getElementById("new-ws-task"); @@ -1362,7 +1342,7 @@ function submitNewWs() { var nodeId = document.getElementById("new-ws-node").value; var name = document.getElementById("new-ws-name").value.trim(); var model = document.getElementById("new-ws-model").value.trim(); - var template = document.getElementById("new-ws-template").value; + var skill = document.getElementById("new-ws-skill").value; var task = document.getElementById("new-ws-task").value.trim(); var errEl = document.getElementById("new-ws-error"); var btn = document.getElementById("new-ws-submit"); @@ -1376,9 +1356,7 @@ function submitNewWs() { if (name) body.name = name; if (model) body.model = model; if (task) body.initial_message = task; - if (template) body.template = template; - var profile = document.getElementById("new-ws-profile").value; - if (profile) body.ws_template = profile; + if (skill) body.skill = skill; authFetch("/v1/api/cluster/workstreams/new", { method: "POST", diff --git a/turnstone/console/static/governance.js b/turnstone/console/static/governance.js index 7fb65168..a1e8cfe4 100644 --- a/turnstone/console/static/governance.js +++ b/turnstone/console/static/governance.js @@ -1,12 +1,11 @@ -/* Governance tabs — roles, policies, templates, usage, audit */ +/* Governance tabs — roles, policies, skills, usage, audit */ // --------------------------------------------------------------------------- // Module state // --------------------------------------------------------------------------- var _govRoles = []; var _govPolicies = []; -var _govTemplates = []; -var _govWsTemplates = []; +var _govSkills = []; var _govUsageRange = "7d"; var _govUsageGroupBy = "day"; var _govAuditEvents = []; @@ -21,8 +20,6 @@ var _cpTrapHandler = null; // create policy var _epTrapHandler = null; // edit policy var _ctmTrapHandler = null; // create template var _etmTrapHandler = null; // edit template -var _cwstTrapHandler = null; // create ws template -var _ewstTrapHandler = null; // edit ws template // Trigger element refs for focus restoration var _crTriggerEl = null; @@ -32,8 +29,6 @@ var _cpTriggerEl = null; var _epTriggerEl = null; var _ctmTriggerEl = null; var _etmTriggerEl = null; -var _cwstTriggerEl = null; -var _ewstTriggerEl = null; // --------------------------------------------------------------------------- // Roles @@ -151,8 +146,7 @@ var _ALL_PERMISSIONS = [ "admin.roles", "admin.orgs", "admin.policies", - "admin.templates", - "admin.ws_templates", + "admin.skills", "admin.audit", "admin.usage", "admin.schedules", @@ -660,30 +654,29 @@ function submitEditPolicy() { } // --------------------------------------------------------------------------- -// Prompt Templates +// Skills (prompt templates) // --------------------------------------------------------------------------- -function loadGovTemplates() { - authFetch("/v1/api/admin/templates") +function loadGovSkills() { + authFetch("/v1/api/admin/skills") .then(function (r) { if (!r.ok) throw new Error("Failed"); return r.json(); }) .then(function (data) { - _govTemplates = data.templates || []; - _renderGovTemplates(_govTemplates); + _govSkills = data.skills || []; + _renderGovSkills(_govSkills); }) .catch(function () { - document.getElementById("admin-templates-table").innerHTML = - '
Failed to load templates
'; + document.getElementById("admin-skills-table").innerHTML = + '
Failed to load skills
'; }); } -function _renderGovTemplates(items) { - var el = document.getElementById("admin-templates-table"); +function _renderGovSkills(items) { + var el = document.getElementById("admin-skills-table"); if (!items.length) { - el.innerHTML = - '
No prompt templates defined
'; + el.innerHTML = '
No skills configured
'; return; } var html = ""; @@ -696,12 +689,21 @@ function _renderGovTemplates(items) { } catch (e) { vars = t.variables; } - var defBadge = t.is_default - ? 'default' - : ""; + var activationBadge = ""; + var activation = t.activation || "named"; + if (activation === "default") { + activationBadge = + 'default'; + } else if (activation === "search") { + activationBadge = 'search'; + } + var defBadge = + t.is_default && activation !== "default" + ? 'default' + : ""; var originBadge = t.origin === "mcp" - ? ' mcp:' + + ? ' mcp:' + escapeHtml(t.mcp_server) + "" : ""; @@ -714,8 +716,14 @@ function _renderGovTemplates(items) { '' + escapeHtml(t.name) + " " + + activationBadge + defBadge + originBadge + + (t.description + ? '
' + + escapeHtml(t.description) + + "" + : "") + "
" + '' + catBadge + @@ -749,21 +757,21 @@ function _renderGovTemplates(items) { var tid = this.getAttribute("data-delete-tmpl"); var tname = this.getAttribute("data-tmpl-name"); showConfirmModal( - "Delete Template", - 'Delete template "' + tname + '"?', + "Delete Skill", + 'Delete skill "' + tname + '"?', "Delete", function () { - authFetch("/v1/api/admin/templates/" + tid, { method: "DELETE" }) + authFetch("/v1/api/admin/skills/" + tid, { method: "DELETE" }) .then(function (r) { if (!r.ok) throw new Error(); return r.json(); }) .then(function () { - showToast("Template deleted"); - loadGovTemplates(); + showToast("Skill deleted"); + loadGovSkills(); }) .catch(function () { - showToast("Failed to delete template"); + showToast("Failed to delete skill"); }); }, ); @@ -799,12 +807,32 @@ function showCreateTemplateModal() { ov.style.display = "flex"; document.getElementById("ctm-name").value = ""; document.getElementById("ctm-category").value = "general"; + document.getElementById("skill-description").value = ""; + document.getElementById("skill-tags").value = ""; + document.getElementById("skill-author").value = ""; + document.getElementById("skill-activation").value = "named"; document.getElementById("ctm-content").value = ""; document.getElementById("ctm-variables").textContent = "(none)"; document.getElementById("ctm-content").oninput = function () { _updateVarsDisplay("ctm-content", "ctm-variables"); }; document.getElementById("ctm-default").checked = false; + // Session config fields + document.getElementById("csk-model").value = ""; + document.getElementById("csk-temperature").value = ""; + document.getElementById("csk-reasoning-effort").value = ""; + document.getElementById("csk-max-tokens").value = ""; + document.getElementById("csk-token-budget").value = ""; + document.getElementById("csk-agent-max-turns").value = ""; + document.getElementById("csk-auto-approve").checked = false; + document.getElementById("csk-allowed-tools").value = ""; + document.getElementById("csk-allowed-tools").disabled = false; + document.getElementById("csk-enabled").checked = true; + document + .getElementById("csk-auto-approve") + .addEventListener("change", function () { + document.getElementById("csk-allowed-tools").disabled = this.checked; + }); document.getElementById("create-template-error").style.display = "none"; document.getElementById("ctm-name").focus(); _ctmTrapHandler = _installTrap( @@ -832,16 +860,56 @@ function submitCreateTemplate() { return; } var varList = _detectTemplateVars(content); + var tagsRaw = (document.getElementById("skill-tags").value || "").trim(); + var tagsArray = tagsRaw + ? tagsRaw + .split(",") + .map(function (t) { + return t.trim(); + }) + .filter(Boolean) + : []; + // Session config fields + var csTemp = document.getElementById("csk-temperature").value.trim(); + var csMaxTok = document.getElementById("csk-max-tokens").value.trim(); + var csBudget = document.getElementById("csk-token-budget").value.trim(); + var csMaxTurns = document.getElementById("csk-agent-max-turns").value.trim(); + var csAllowed = ( + document.getElementById("csk-allowed-tools").value || "" + ).trim(); + var csAllowedArr = csAllowed + ? csAllowed + .split(",") + .map(function (t) { + return t.trim(); + }) + .filter(Boolean) + : []; document.getElementById("ctm-submit").disabled = true; - authFetch("/v1/api/admin/templates", { + authFetch("/v1/api/admin/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, category: document.getElementById("ctm-category").value, + description: ( + document.getElementById("skill-description").value || "" + ).trim(), + tags: JSON.stringify(tagsArray), + author: (document.getElementById("skill-author").value || "").trim(), + activation: document.getElementById("skill-activation").value, content: content, variables: JSON.stringify(varList), is_default: document.getElementById("ctm-default").checked, + model: document.getElementById("csk-model").value.trim(), + auto_approve: document.getElementById("csk-auto-approve").checked, + temperature: csTemp ? parseFloat(csTemp) : null, + reasoning_effort: document.getElementById("csk-reasoning-effort").value, + max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null, + token_budget: csBudget ? parseInt(csBudget, 10) : 0, + agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null, + allowed_tools: JSON.stringify(csAllowedArr), + enabled: document.getElementById("csk-enabled").checked, }), }) .then(function (r) { @@ -853,8 +921,8 @@ function submitCreateTemplate() { }) .then(function () { hideCreateTemplateModal(); - showToast("Template created"); - loadGovTemplates(); + showToast("Skill created"); + loadGovSkills(); }) .catch(function (e) { var el = document.getElementById("create-template-error"); @@ -869,9 +937,9 @@ function submitCreateTemplate() { function showEditTemplateModal(tmplId) { _etmTriggerEl = document.activeElement; var tmpl = null; - for (var i = 0; i < _govTemplates.length; i++) { - if (_govTemplates[i].template_id === tmplId) { - tmpl = _govTemplates[i]; + for (var i = 0; i < _govSkills.length; i++) { + if (_govSkills[i].template_id === tmplId) { + tmpl = _govSkills[i]; break; } } @@ -881,12 +949,56 @@ function showEditTemplateModal(tmplId) { document.getElementById("etm-id").value = tmplId; document.getElementById("etm-name").value = tmpl.name; document.getElementById("etm-category").value = tmpl.category; + document.getElementById("etm-description").value = tmpl.description || ""; + // Parse tags from JSON array to comma-separated display + var tagsDisplay = ""; + try { + var tagsList = JSON.parse(tmpl.tags || "[]"); + tagsDisplay = tagsList.join(", "); + } catch (e) { + tagsDisplay = tmpl.tags || ""; + } + document.getElementById("etm-tags").value = tagsDisplay; + document.getElementById("etm-author").value = tmpl.author || ""; + document.getElementById("etm-activation").value = tmpl.activation || "named"; document.getElementById("etm-content").value = tmpl.content; _updateVarsDisplay("etm-content", "etm-variables"); document.getElementById("etm-content").oninput = function () { _updateVarsDisplay("etm-content", "etm-variables"); }; document.getElementById("etm-default").checked = tmpl.is_default; + // Session config fields + document.getElementById("esk-model").value = tmpl.model || ""; + document.getElementById("esk-temperature").value = + tmpl.temperature != null ? tmpl.temperature : ""; + document.getElementById("esk-reasoning-effort").value = + tmpl.reasoning_effort || ""; + document.getElementById("esk-max-tokens").value = + tmpl.max_tokens != null ? tmpl.max_tokens : ""; + document.getElementById("esk-token-budget").value = tmpl.token_budget + ? tmpl.token_budget + : ""; + document.getElementById("esk-agent-max-turns").value = + tmpl.agent_max_turns != null ? tmpl.agent_max_turns : ""; + document.getElementById("esk-auto-approve").checked = + tmpl.auto_approve || false; + // allowed_tools: parse JSON array to comma-separated display + var allowedDisplay = ""; + try { + var allowed = JSON.parse(tmpl.allowed_tools || "[]"); + allowedDisplay = allowed.join(", "); + } catch (e) { + allowedDisplay = tmpl.allowed_tools || ""; + } + document.getElementById("esk-allowed-tools").value = allowedDisplay; + document.getElementById("esk-allowed-tools").disabled = + tmpl.auto_approve || false; + document.getElementById("esk-enabled").checked = tmpl.enabled !== false; + document + .getElementById("esk-auto-approve") + .addEventListener("change", function () { + document.getElementById("esk-allowed-tools").disabled = this.checked; + }); document.getElementById("edit-template-error").style.display = "none"; _etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box"); } @@ -904,16 +1016,56 @@ function submitEditTemplate() { var id = document.getElementById("etm-id").value; var content = document.getElementById("etm-content").value; var varList = _detectTemplateVars(content); + var tagsRaw = (document.getElementById("etm-tags").value || "").trim(); + var tagsArray = tagsRaw + ? tagsRaw + .split(",") + .map(function (t) { + return t.trim(); + }) + .filter(Boolean) + : []; + // Session config fields + var esTemp = document.getElementById("esk-temperature").value.trim(); + var esMaxTok = document.getElementById("esk-max-tokens").value.trim(); + var esBudget = document.getElementById("esk-token-budget").value.trim(); + var esMaxTurns = document.getElementById("esk-agent-max-turns").value.trim(); + var esAllowed = ( + document.getElementById("esk-allowed-tools").value || "" + ).trim(); + var esAllowedArr = esAllowed + ? esAllowed + .split(",") + .map(function (t) { + return t.trim(); + }) + .filter(Boolean) + : []; document.getElementById("etm-submit").disabled = true; - authFetch("/v1/api/admin/templates/" + id, { + authFetch("/v1/api/admin/skills/" + id, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: document.getElementById("etm-name").value.trim(), category: document.getElementById("etm-category").value, + description: ( + document.getElementById("etm-description").value || "" + ).trim(), + tags: JSON.stringify(tagsArray), + author: (document.getElementById("etm-author").value || "").trim(), + activation: document.getElementById("etm-activation").value, content: content, variables: JSON.stringify(varList), is_default: document.getElementById("etm-default").checked, + model: document.getElementById("esk-model").value.trim(), + auto_approve: document.getElementById("esk-auto-approve").checked, + temperature: esTemp ? parseFloat(esTemp) : null, + reasoning_effort: document.getElementById("esk-reasoning-effort").value, + max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null, + token_budget: esBudget ? parseInt(esBudget, 10) : 0, + agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null, + allowed_tools: JSON.stringify(esAllowedArr), + enabled: document.getElementById("esk-enabled").checked, }), }) .then(function (r) { @@ -925,8 +1077,8 @@ function submitEditTemplate() { }) .then(function () { hideEditTemplateModal(); - showToast("Template updated"); - loadGovTemplates(); + showToast("Skill updated"); + loadGovSkills(); }) .catch(function (e) { var el = document.getElementById("edit-template-error"); @@ -938,445 +1090,6 @@ function submitEditTemplate() { }); } -// --------------------------------------------------------------------------- -// WS Templates -// --------------------------------------------------------------------------- - -function loadGovWsTemplates() { - authFetch("/v1/api/admin/ws-templates") - .then(function (r) { - if (!r.ok) throw new Error("Failed"); - return r.json(); - }) - .then(function (data) { - _govWsTemplates = data.ws_templates || []; - _renderGovWsTemplates(_govWsTemplates); - }) - .catch(function () { - document.getElementById("admin-ws-templates-table").innerHTML = - '
Failed to load WS templates
'; - }); -} - -function _renderGovWsTemplates(items) { - var el = document.getElementById("admin-ws-templates-table"); - if (!items.length) { - el.innerHTML = - '
No workstream templates defined
'; - return; - } - var html = ""; - for (var i = 0; i < items.length; i++) { - var t = items[i]; - var modelBadge = t.model - ? '' + escapeHtml(t.model) + "" - : 'default'; - var approveBadge = t.auto_approve - ? 'auto' - : ""; - var budgetBadge = - t.token_budget > 0 - ? '' + - t.token_budget.toLocaleString() + - "" - : ""; - var enabledBadge = !t.enabled - ? ' disabled' - : ""; - html += - '
' + - '' + - escapeHtml(t.name) + - enabledBadge + - "" + - '' + - modelBadge + - "" + - '' + - approveBadge + - " " + - budgetBadge + - "" + - '' + - "v" + - t.version + - " " + - ' ' + - '' + - '' + - "
"; - } - el.innerHTML = html; - el.querySelectorAll("[data-edit-wst]").forEach(function (btn) { - btn.addEventListener("click", function () { - showEditWsTemplateModal(this.getAttribute("data-edit-wst")); - }); - }); - el.querySelectorAll("[data-delete-wst]").forEach(function (btn) { - btn.addEventListener("click", function () { - var tid = this.getAttribute("data-delete-wst"); - var tname = this.getAttribute("data-wst-name"); - showConfirmModal( - "Delete WS Template", - 'Delete workstream template "' + tname + '"?', - "Delete", - function () { - authFetch("/v1/api/admin/ws-templates/" + tid, { - method: "DELETE", - }) - .then(function (r) { - if (!r.ok) throw new Error(); - return r.json(); - }) - .then(function () { - showToast("WS template deleted"); - loadGovWsTemplates(); - }) - .catch(function () { - showToast("Failed to delete WS template"); - }); - }, - ); - }); - }); - el.querySelectorAll("[data-history-wst]").forEach(function (btn) { - btn.addEventListener("click", function () { - showWstHistoryModal(this.getAttribute("data-history-wst")); - }); - }); -} - -function toggleWstPromptSource() { - var inline = document.getElementById("cwst-src-inline").checked; - document.getElementById("cwst-inline-section").style.display = inline - ? "" - : "none"; - document.getElementById("cwst-ref-section").style.display = inline - ? "none" - : ""; -} - -function toggleEditWstPromptSource() { - var inline = document.getElementById("ewst-src-inline").checked; - document.getElementById("ewst-inline-section").style.display = inline - ? "" - : "none"; - document.getElementById("ewst-ref-section").style.display = inline - ? "none" - : ""; -} - -function _populateWstPromptTemplates(selectId) { - var sel = document.getElementById(selectId); - sel.innerHTML = ''; - return authFetch("/v1/api/admin/templates") - .then(function (r) { - return r.json(); - }) - .then(function (data) { - (data.templates || []).forEach(function (t) { - var opt = document.createElement("option"); - opt.value = t.name; - opt.textContent = t.name; - sel.appendChild(opt); - }); - }) - .catch(function () { - /* ignore */ - }); -} - -function showCreateWsTemplateModal() { - _cwstTriggerEl = document.activeElement; - var ov = document.getElementById("create-wst-overlay"); - ov.style.display = "flex"; - document.getElementById("cwst-name").value = ""; - document.getElementById("cwst-description").value = ""; - document.getElementById("cwst-system-prompt").value = ""; - document.getElementById("cwst-src-inline").checked = true; - toggleWstPromptSource(); - _populateWstPromptTemplates("cwst-prompt-template"); - document.getElementById("cwst-model").value = ""; - document.getElementById("cwst-auto-approve").checked = false; - document.getElementById("cwst-auto-approve-tools").value = ""; - document.getElementById("cwst-token-budget").value = "0"; - document.getElementById("cwst-temperature").value = ""; - document.getElementById("cwst-reasoning-effort").value = ""; - document.getElementById("cwst-max-tokens").value = ""; - document.getElementById("cwst-agent-max-turns").value = ""; - document.getElementById("cwst-enabled").checked = true; - document.getElementById("create-wst-error").style.display = "none"; - document.getElementById("cwst-name").focus(); - _cwstTrapHandler = _installTrap("create-wst-overlay", "create-wst-box"); -} - -function hideCreateWsTemplateModal() { - document.getElementById("create-wst-overlay").style.display = "none"; - _cwstTrapHandler = _removeTrap(_cwstTrapHandler); - if (_cwstTriggerEl && _cwstTriggerEl.focus) _cwstTriggerEl.focus(); - _cwstTriggerEl = null; -} - -function submitCreateWsTemplate() { - var name = document.getElementById("cwst-name").value.trim(); - if (!name) { - var e = document.getElementById("create-wst-error"); - e.textContent = "Name is required"; - e.style.display = ""; - return; - } - var isInline = document.getElementById("cwst-src-inline").checked; - document.getElementById("cwst-submit").disabled = true; - authFetch("/v1/api/admin/ws-templates", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: name, - description: document.getElementById("cwst-description").value, - system_prompt: isInline - ? document.getElementById("cwst-system-prompt").value - : "", - prompt_template: isInline - ? "" - : document.getElementById("cwst-prompt-template").value, - model: document.getElementById("cwst-model").value.trim(), - auto_approve: document.getElementById("cwst-auto-approve").checked, - auto_approve_tools: document - .getElementById("cwst-auto-approve-tools") - .value.trim(), - token_budget: parseInt( - document.getElementById("cwst-token-budget").value || "0", - 10, - ), - temperature: document.getElementById("cwst-temperature").value - ? parseFloat(document.getElementById("cwst-temperature").value) - : null, - reasoning_effort: document.getElementById("cwst-reasoning-effort").value, - max_tokens: document.getElementById("cwst-max-tokens").value - ? parseInt(document.getElementById("cwst-max-tokens").value, 10) - : null, - agent_max_turns: document.getElementById("cwst-agent-max-turns").value - ? parseInt(document.getElementById("cwst-agent-max-turns").value, 10) - : null, - enabled: document.getElementById("cwst-enabled").checked, - }), - }) - .then(function (r) { - if (!r.ok) - return r.json().then(function (d) { - throw new Error(d.error || "Failed"); - }); - return r.json(); - }) - .then(function () { - hideCreateWsTemplateModal(); - showToast("WS template created"); - loadGovWsTemplates(); - }) - .catch(function (e) { - var el = document.getElementById("create-wst-error"); - el.textContent = e.message; - el.style.display = ""; - }) - .finally(function () { - document.getElementById("cwst-submit").disabled = false; - }); -} - -function showEditWsTemplateModal(wstId) { - _ewstTriggerEl = document.activeElement; - var tpl = null; - for (var i = 0; i < _govWsTemplates.length; i++) { - if (_govWsTemplates[i].ws_template_id === wstId) { - tpl = _govWsTemplates[i]; - break; - } - } - if (!tpl) return; - var ov = document.getElementById("edit-wst-overlay"); - ov.style.display = "flex"; - document.getElementById("ewst-id").value = wstId; - document.getElementById("ewst-name").value = tpl.name; - document.getElementById("ewst-description").value = tpl.description || ""; - document.getElementById("ewst-system-prompt").value = tpl.system_prompt || ""; - // Set radio based on which field has content - if (tpl.prompt_template && !tpl.system_prompt) { - document.getElementById("ewst-src-ref").checked = true; - } else { - document.getElementById("ewst-src-inline").checked = true; - } - toggleEditWstPromptSource(); - _populateWstPromptTemplates("ewst-prompt-template").then(function () { - if (tpl.prompt_template) { - document.getElementById("ewst-prompt-template").value = - tpl.prompt_template; - } - }); - document.getElementById("ewst-model").value = tpl.model || ""; - document.getElementById("ewst-auto-approve").checked = tpl.auto_approve; - document.getElementById("ewst-auto-approve-tools").value = - tpl.auto_approve_tools || ""; - document.getElementById("ewst-token-budget").value = tpl.token_budget || 0; - document.getElementById("ewst-temperature").value = - tpl.temperature != null ? tpl.temperature : ""; - document.getElementById("ewst-reasoning-effort").value = - tpl.reasoning_effort || ""; - document.getElementById("ewst-max-tokens").value = - tpl.max_tokens != null ? tpl.max_tokens : ""; - document.getElementById("ewst-agent-max-turns").value = - tpl.agent_max_turns != null ? tpl.agent_max_turns : ""; - document.getElementById("ewst-enabled").checked = tpl.enabled; - document.getElementById("edit-wst-error").style.display = "none"; - _ewstTrapHandler = _installTrap("edit-wst-overlay", "edit-wst-box"); -} - -function hideEditWsTemplateModal() { - document.getElementById("edit-wst-overlay").style.display = "none"; - _ewstTrapHandler = _removeTrap(_ewstTrapHandler); - if (_ewstTriggerEl && _ewstTriggerEl.focus) _ewstTriggerEl.focus(); - _ewstTriggerEl = null; -} - -function submitEditWsTemplate() { - var id = document.getElementById("ewst-id").value; - var name = document.getElementById("ewst-name").value.trim(); - if (!name) { - var e = document.getElementById("edit-wst-error"); - e.textContent = "Name is required"; - e.style.display = ""; - return; - } - var isInline = document.getElementById("ewst-src-inline").checked; - document.getElementById("ewst-submit").disabled = true; - authFetch("/v1/api/admin/ws-templates/" + id, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: document.getElementById("ewst-name").value.trim(), - description: document.getElementById("ewst-description").value, - system_prompt: isInline - ? document.getElementById("ewst-system-prompt").value - : "", - prompt_template: isInline - ? "" - : document.getElementById("ewst-prompt-template").value, - model: document.getElementById("ewst-model").value.trim(), - auto_approve: document.getElementById("ewst-auto-approve").checked, - auto_approve_tools: document - .getElementById("ewst-auto-approve-tools") - .value.trim(), - token_budget: parseInt( - document.getElementById("ewst-token-budget").value || "0", - 10, - ), - temperature: document.getElementById("ewst-temperature").value - ? parseFloat(document.getElementById("ewst-temperature").value) - : null, - reasoning_effort: document.getElementById("ewst-reasoning-effort").value, - max_tokens: document.getElementById("ewst-max-tokens").value - ? parseInt(document.getElementById("ewst-max-tokens").value, 10) - : null, - agent_max_turns: document.getElementById("ewst-agent-max-turns").value - ? parseInt(document.getElementById("ewst-agent-max-turns").value, 10) - : null, - enabled: document.getElementById("ewst-enabled").checked, - }), - }) - .then(function (r) { - if (!r.ok) - return r.json().then(function (d) { - throw new Error(d.error || "Failed"); - }); - return r.json(); - }) - .then(function () { - hideEditWsTemplateModal(); - showToast("WS template updated"); - loadGovWsTemplates(); - }) - .catch(function (e) { - var el = document.getElementById("edit-wst-error"); - el.textContent = e.message; - el.style.display = ""; - }) - .finally(function () { - document.getElementById("ewst-submit").disabled = false; - }); -} - -// --------------------------------------------------------------------------- -// WS Template Version History -// --------------------------------------------------------------------------- - -var _whTrapHandler = null; -var _whTriggerEl = null; - -function showWstHistoryModal(wstId) { - _whTriggerEl = document.activeElement; - var ov = document.getElementById("wst-history-overlay"); - ov.style.display = "flex"; - document.getElementById("wst-history-content").innerHTML = - '
Loading...
'; - _whTrapHandler = _installTrap("wst-history-overlay", "wst-history-box"); - authFetch("/v1/api/admin/ws-templates/" + wstId + "/versions") - .then(function (r) { - if (!r.ok) throw new Error("Failed"); - return r.json(); - }) - .then(function (data) { - var versions = data.versions || []; - if (!versions.length) { - document.getElementById("wst-history-content").innerHTML = - '
No version history yet
'; - return; - } - var html = ""; - for (var i = 0; i < versions.length; i++) { - var v = versions[i]; - var snapshot = "{}"; - try { - snapshot = JSON.stringify(JSON.parse(v.snapshot), null, 2); - } catch (e) { - snapshot = v.snapshot; - } - html += - '
' + - '
' + - "v" + - v.version + - "" + - '' + - escapeHtml(v.changed_by || "unknown") + - " — " + - escapeHtml(v.created) + - "
" + - '
' +
-          escapeHtml(snapshot) +
-          "
"; - } - document.getElementById("wst-history-content").innerHTML = html; - }) - .catch(function () { - document.getElementById("wst-history-content").innerHTML = - '
Failed to load version history
'; - }); -} - -function hideWstHistoryModal() { - document.getElementById("wst-history-overlay").style.display = "none"; - _whTrapHandler = _removeTrap(_whTrapHandler); - if (_whTriggerEl && _whTriggerEl.focus) _whTriggerEl.focus(); - _whTriggerEl = null; -} - // --------------------------------------------------------------------------- // Usage // --------------------------------------------------------------------------- diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index e2515611..8d0b879c 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -95,8 +95,7 @@ - - +
@@ -255,11 +254,11 @@
- -