From 376da3d084471fa8ea0e307ffedccc660409cd4e Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 15 Mar 2026 02:09:31 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20prompt=20template=20tech=20debt=20?= =?UTF-8?q?=E2=80=94=20tests,=20read-only=20endpoints,=20double-=E2=80=A6?= =?UTF-8?q?=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: prompt template tech debt — tests, read-only endpoints, double-load fix, server creation modal Close test coverage gaps for prompt templates: - Resume with deleted template: verifies graceful degradation (template_content=None, warning logged) - Threading safety: concurrent set_template/init_system_messages with no race conditions - Factory passthrough: template kwarg propagation through WorkstreamManager.create() Add read-only template listing endpoints (read scope, no content exposed): - GET /v1/api/templates — prompt template summaries (name, category, is_default, origin) - GET /v1/api/ws-templates — enabled workstream template summaries (name, description, model) - Available on both server and console; Python + TypeScript SDK methods added - Console creation modal switched from admin endpoint to read-scope endpoint Eliminate double-load inefficiency in workstream creation: - Template validation moved before mgr.create() (no create-then-rollback on invalid template) - template kwarg plumbed through WorkstreamManager.create() and session factory - _SessionFactory Protocol added for proper mypy typing Add workstream creation modal to server web UI: - Name, model, template dropdown, ws_template/profile dropdown - Instrument panel aesthetic: gradient top border, blur backdrop, amber accent - Focus trap, Escape/Enter keyboard handling, loading state, error display - WCAG AA contrast compliance, reduced-motion support * fix: add list_ws_templates SDK methods + regenerate OpenAPI snapshots Add list_ws_templates() to Python SDK (async + sync) and listWsTemplates() to TypeScript SDK for the new GET /v1/api/ws-templates server endpoint. Add WsTemplateSummary + ListWsTemplateSummaryResponse TypeScript types. Regenerate openapi-server.json and openapi-console.json snapshots. Addresses Copilot review feedback on PR #67. * fix: skip template pre-validation when resuming a workstream When resume_ws is set, the request's template field is irrelevant — resume() restores the template from workstream_config. Pre-validating a stale template name would incorrectly return 400 before the resume even runs. Addresses Copilot review feedback on PR #67. --- docs/api-reference.md | 61 +++++++ docs/diagrams/19-governance-architecture.puml | 6 + .../diagrams/21-ws-template-architecture.puml | 8 + .../png/19-governance-architecture.png | 4 +- .../png/21-ws-template-architecture.png | 4 +- sdk/typescript/openapi-console.json | 71 +++++++- sdk/typescript/openapi-server.json | 131 +++++++++++++- sdk/typescript/src/server.ts | 12 ++ sdk/typescript/src/types.ts | 29 +++ tests/test_model_registry.py | 8 +- tests/test_prompt_templates_runtime.py | 129 +++++++++++++ tests/test_workstream.py | 2 +- turnstone/api/console_spec.py | 11 ++ turnstone/api/server_schemas.py | 18 ++ turnstone/api/server_spec.py | 23 +++ turnstone/cli.py | 8 +- turnstone/console/server.py | 21 +++ turnstone/console/static/app.js | 2 +- turnstone/core/workstream.py | 24 ++- turnstone/sdk/server.py | 22 +++ turnstone/server.py | 77 ++++++-- turnstone/ui/static/app.js | 170 +++++++++++++++++- turnstone/ui/static/index.html | 20 +++ turnstone/ui/static/style.css | 139 +++++++++++++- 24 files changed, 961 insertions(+), 39 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 3674dc4d..dff0b169 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -618,6 +618,67 @@ Each saved workstream object: --- +### `GET /v1/api/templates` + +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. + +**Response:** + +```json +{ + "templates": [ + {"name": "safety-guidelines", "category": "safety", "is_default": true, "origin": "manual"}, + {"name": "mcp__server__code", "category": "", "is_default": false, "origin": "mcp"} + ] +} +``` + +Each template 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` | + +> **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). + +--- + ### `POST /v1/api/send` Sends a user message to a workstream. Spawns a daemon worker thread that calls diff --git a/docs/diagrams/19-governance-architecture.puml b/docs/diagrams/19-governance-architecture.puml index 74191d76..afa242f2 100644 --- a/docs/diagrams/19-governance-architecture.puml +++ b/docs/diagrams/19-governance-architecture.puml @@ -85,6 +85,12 @@ tload --> trender : template content trender --> tsys : rendered content tset --> tload : name or None +note right of pt_db + Read-only listing: + GET /v1/api/templates + (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 diff --git a/docs/diagrams/21-ws-template-architecture.puml b/docs/diagrams/21-ws-template-architecture.puml index eb454fb1..f2e3e29f 100644 --- a/docs/diagrams/21-ws-template-architecture.puml +++ b/docs/diagrams/21-ws-template-architecture.puml @@ -48,6 +48,14 @@ 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) diff --git a/docs/diagrams/png/19-governance-architecture.png b/docs/diagrams/png/19-governance-architecture.png index fe48bbe3..671fbf91 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:f4dac4948d928b4705936d73b4d159aa1e89315ec0397616ca914bbf19e7a1ce -size 206479 +oid sha256:3aaca1ae4c6c255dc9569f59e3ccc24f8b3bab0ac2a9b08c85e2af72d6a400c7 +size 218575 diff --git a/docs/diagrams/png/21-ws-template-architecture.png b/docs/diagrams/png/21-ws-template-architecture.png index f032b7e1..5d738076 100644 --- a/docs/diagrams/png/21-ws-template-architecture.png +++ b/docs/diagrams/png/21-ws-template-architecture.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c06d7086d7965eb9fe333396f027133d42507cf120bfe8dc851c009a8768ec48 -size 284926 +oid sha256:fadf5b07f8230ecf97805a86b308eaa9eb30516dd26900e5c9f70e6fb7562bab +size 296339 diff --git a/sdk/typescript/openapi-console.json b/sdk/typescript/openapi-console.json index f4eecde2..70fd614c 100644 --- a/sdk/typescript/openapi-console.json +++ b/sdk/typescript/openapi-console.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "turnstone Console API", - "version": "0.6.1", + "version": "0.6.2", "description": "Cluster-wide visibility and control across all turnstone nodes." }, "paths": { @@ -2052,6 +2052,27 @@ } } }, + "/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" + } + } + } + } + } + } + }, "/v1/api/admin/usage": { "get": { "summary": "Aggregated usage data", @@ -5947,6 +5968,54 @@ }, "title": "McpReloadResponse", "type": "object" + }, + "PromptTemplateSummary": { + "properties": { + "name": { + "description": "Template name", + "title": "Name", + "type": "string" + }, + "category": { + "default": "", + "description": "Template category", + "title": "Category", + "type": "string" + }, + "is_default": { + "default": false, + "description": "Whether this template is applied by default", + "title": "Is Default", + "type": "boolean" + }, + "origin": { + "default": "manual", + "description": "Template origin: manual or mcp", + "title": "Origin", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PromptTemplateSummary", + "type": "object" + }, + "ListPromptTemplateSummaryResponse": { + "properties": { + "templates": { + "items": { + "$ref": "#/components/schemas/PromptTemplateSummary" + }, + "title": "Templates", + "type": "array" + } + }, + "required": [ + "templates" + ], + "title": "ListPromptTemplateSummaryResponse", + "type": "object" } } } diff --git a/sdk/typescript/openapi-server.json b/sdk/typescript/openapi-server.json index b1edbd5d..e612782e 100644 --- a/sdk/typescript/openapi-server.json +++ b/sdk/typescript/openapi-server.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "turnstone Server API", - "version": "0.6.1", + "version": "0.6.2", "description": "Single-node workstream management, chat interaction, and real-time streaming." }, "paths": { @@ -437,6 +437,48 @@ } } }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, "/v1/api/auth/login": { "post": { "summary": "Authenticate with a token", @@ -1777,6 +1819,93 @@ ], "title": "SearchMemoriesRequest", "type": "object" + }, + "PromptTemplateSummary": { + "properties": { + "name": { + "description": "Template name", + "title": "Name", + "type": "string" + }, + "category": { + "default": "", + "description": "Template category", + "title": "Category", + "type": "string" + }, + "is_default": { + "default": false, + "description": "Whether this template is applied by default", + "title": "Is Default", + "type": "boolean" + }, + "origin": { + "default": "manual", + "description": "Template origin: manual or mcp", + "title": "Origin", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PromptTemplateSummary", + "type": "object" + }, + "ListPromptTemplateSummaryResponse": { + "properties": { + "templates": { + "items": { + "$ref": "#/components/schemas/PromptTemplateSummary" + }, + "title": "Templates", + "type": "array" + } + }, + "required": [ + "templates" + ], + "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", + "type": "object" } } } diff --git a/sdk/typescript/src/server.ts b/sdk/typescript/src/server.ts index 68950103..2fa2b4a3 100644 --- a/sdk/typescript/src/server.ts +++ b/sdk/typescript/src/server.ts @@ -11,7 +11,9 @@ import type { HealthResponse, ListMemoriesOptions, ListMemoriesResponse, + ListPromptTemplateSummaryResponse, ListSavedWorkstreamsResponse, + ListWsTemplateSummaryResponse, ListWorkstreamsResponse, MemoryInfo, SaveMemoryRequest, @@ -196,6 +198,16 @@ export class TurnstoneServer extends BaseClient { return this.request("GET", "/v1/api/workstreams/saved"); } + // -- Templates -------------------------------------------------------------- + + async listTemplates(): Promise { + return this.request("GET", "/v1/api/templates"); + } + + async listWsTemplates(): Promise { + return this.request("GET", "/v1/api/ws-templates"); + } + // -- Memories ------------------------------------------------------------- async listMemories( diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 7577e153..2cba2f62 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -143,6 +143,35 @@ export interface ListSavedWorkstreamsResponse { workstreams: SavedWorkstreamInfo[]; } +// --------------------------------------------------------------------------- +// Server API — Prompt templates +// --------------------------------------------------------------------------- + +export interface PromptTemplateSummary { + 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; +} + +export interface ListWsTemplateSummaryResponse { + ws_templates: WsTemplateSummary[]; +} + // --------------------------------------------------------------------------- // Server API — Health // --------------------------------------------------------------------------- diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py index 9f764228..37d2f49c 100644 --- a/tests/test_model_registry.py +++ b/tests/test_model_registry.py @@ -530,7 +530,9 @@ class TestWorkstreamModelParam: captured_alias = None - def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any: + def factory( + ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any + ) -> Any: nonlocal captured_alias captured_alias = model_alias mock_session = MagicMock() @@ -544,7 +546,9 @@ class TestWorkstreamModelParam: def test_create_without_model(self) -> None: captured_alias = None - def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any: + def factory( + ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any + ) -> Any: nonlocal captured_alias captured_alias = model_alias mock_session = MagicMock() diff --git a/tests/test_prompt_templates_runtime.py b/tests/test_prompt_templates_runtime.py index cf680d04..c020f473 100644 --- a/tests/test_prompt_templates_runtime.py +++ b/tests/test_prompt_templates_runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading from unittest.mock import MagicMock from turnstone.core.session import ChatSession, _render_template @@ -391,3 +392,131 @@ class TestMCPTemplates: session = _make_session(template="mcp__server__code") content = _sys_content(session) assert "MCP_EXPLICIT" in content + + +# --------------------------------------------------------------------------- +# Resume with deleted template +# --------------------------------------------------------------------------- + + +class TestResumeDeletedTemplate: + def test_resume_with_deleted_template_degrades_gracefully(self, tmp_db, capsys): + from turnstone.core.memory import save_message + from turnstone.core.storage import get_storage + + db = get_storage() + _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") + ws_id = session1.ws_id + save_message(ws_id, "user", "hello") + assert "EPHEMERAL_CONTENT" in _sys_content(session1) + + # Delete the template from storage + db.delete_prompt_template("t1") + + # Resume into a new session + session2 = _make_session() + resumed = session2.resume(ws_id) + + assert resumed + assert session2._template_name == "ephemeral-tpl" + assert session2._template_content is None + # System message should not contain the deleted template content + content = _sys_content(session2) + assert "EPHEMERAL_CONTENT" not in content + # Warning should be logged via structlog + captured = capsys.readouterr() + assert "not_found" in captured.out or "not_found" in captured.err + + +# --------------------------------------------------------------------------- +# Threading safety +# --------------------------------------------------------------------------- + + +class TestTemplateFactoryPassthrough: + def test_template_passed_through_workstream_create(self, tmp_db): + """WorkstreamManager.create(template=...) 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 + + def factory(ui, model_alias=None, ws_id=None, *, template=None): + nonlocal captured_template + captured_template = template + return _make_session(template=template) + + mgr = WorkstreamManager(factory) + ws = mgr.create(name="test", template="factory-tpl") + assert captured_template == "factory-tpl" + assert ws.session is not None + assert ws.session._template_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 factory(ui, model_alias=None, ws_id=None, *, template=None): + nonlocal captured_template + captured_template = template + return _make_session(template=template) + + from turnstone.core.workstream import WorkstreamManager + + mgr = WorkstreamManager(factory) + mgr.create(name="test") + assert captured_template is None + + +class TestTemplateThreadSafety: + def test_concurrent_template_and_system_message_init(self, tmp_db): + from turnstone.core.storage import get_storage + + db = get_storage() + _create_template(db, "t1", "thread-tpl", "THREAD_TEMPLATE", is_default=False) + + session = _make_session(template="thread-tpl") + errors: list[Exception] = [] + stop = threading.Event() + iterations = 200 + + def init_loop(): + """Simulate MCP callback repeatedly calling _init_system_messages.""" + try: + for _ in range(iterations): + if stop.is_set(): + break + session._init_system_messages() + # system_messages must always be a valid list + msgs = session.system_messages + assert isinstance(msgs, list) + assert len(msgs) > 0 + except Exception as exc: + errors.append(exc) + + t = threading.Thread(target=init_loop, daemon=True) + t.start() + + # Main thread toggles template on/off + try: + for i in range(iterations): + if i % 2 == 0: + session.set_template("thread-tpl") + else: + session.set_template(None) + finally: + stop.set() + t.join(timeout=5) + + assert not errors, f"Thread raised: {errors}" + # Final state: system_messages is a valid list + msgs = session.system_messages + assert isinstance(msgs, list) + assert len(msgs) > 0 diff --git a/tests/test_workstream.py b/tests/test_workstream.py index e1b549a7..ed2beabe 100644 --- a/tests/test_workstream.py +++ b/tests/test_workstream.py @@ -20,7 +20,7 @@ class FakeSession: self.messages = [] -def _fake_factory(ui, model_alias=None, ws_id=None): +def _fake_factory(ui, model_alias=None, ws_id=None, **kwargs): return FakeSession() diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index 05a9a1f5..39c38cf1 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -85,6 +85,7 @@ from turnstone.api.schemas import ( UpdateScheduleRequest, UserInfo, ) +from turnstone.api.server_schemas import ListPromptTemplateSummaryResponse, PromptTemplateSummary CONSOLE_ENDPOINTS: list[EndpointSpec] = [ # --- Cluster --- @@ -532,6 +533,14 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ response_model=ListWsTemplateSummaryResponse, tags=["Workstreams"], ), + # --- Prompt templates --- + EndpointSpec( + "/v1/api/templates", + "GET", + "List available prompt templates (summary)", + response_model=ListPromptTemplateSummaryResponse, + tags=["Templates"], + ), # --- Governance: Usage & Audit --- EndpointSpec( "/v1/api/admin/usage", @@ -807,6 +816,8 @@ _ALL_MODELS: list[type[BaseModel]] = [ ImportMcpConfigRequest, ImportMcpConfigResponse, McpReloadResponse, + PromptTemplateSummary, + ListPromptTemplateSummaryResponse, ] diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index 781b44a2..72ee5041 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -214,3 +214,21 @@ class SearchMemoriesRequest(BaseModel): scope: MemoryScopeFilter = Field(default="", description="Filter by scope") scope_id: str = Field(default="", description="Filter by scope_id") limit: int = Field(default=20, description="Max results (1-50)", ge=1, le=50) + + +# --------------------------------------------------------------------------- +# Prompt templates (read-only listing) +# --------------------------------------------------------------------------- + + +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 ListPromptTemplateSummaryResponse(BaseModel): + templates: list[PromptTemplateSummary] diff --git a/turnstone/api/server_spec.py b/turnstone/api/server_spec.py index 2e3e679e..37b60921 100644 --- a/turnstone/api/server_spec.py +++ b/turnstone/api/server_spec.py @@ -4,6 +4,7 @@ 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: @@ -27,10 +28,12 @@ from turnstone.api.server_schemas import ( DashboardResponse, HealthResponse, ListMemoriesResponse, + ListPromptTemplateSummaryResponse, ListSavedWorkstreamsResponse, ListWorkstreamsResponse, MemoryInfo, PlanFeedbackRequest, + PromptTemplateSummary, SaveMemoryRequest, SearchMemoriesRequest, SendRequest, @@ -144,6 +147,22 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [ response_model=ListSavedWorkstreamsResponse, tags=["Workstreams"], ), + # --- Prompt templates --- + EndpointSpec( + "/v1/api/templates", + "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"], + ), # --- Auth --- EndpointSpec( "/v1/api/auth/login", @@ -257,6 +276,10 @@ _ALL_MODELS: list[type[BaseModel]] = [ MemoryInfo, ListMemoriesResponse, SearchMemoriesRequest, + PromptTemplateSummary, + ListPromptTemplateSummaryResponse, + WsTemplateSummary, + ListWsTemplateSummaryResponse, ] diff --git a/turnstone/cli.py b/turnstone/cli.py index 865aa0d8..ac422fdb 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -1016,7 +1016,11 @@ def main() -> None: # ChatSession factory — captures shared config for creating workstreams def session_factory( - ui: SessionUI | None, model_alias: str | None = None, ws_id: str | None = None + ui: SessionUI | None, + model_alias: str | None = None, + ws_id: str | None = None, + *, + template: 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) @@ -1040,7 +1044,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=args.template, + template=template if template is not None else args.template, ) # Create workstream manager and initial workstream diff --git a/turnstone/console/server.py b/turnstone/console/server.py index b8d74f7b..a17ff75e 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -2480,6 +2480,26 @@ async def list_ws_templates_summary(request: Request) -> JSONResponse: 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}) + + async def admin_usage(request: Request) -> JSONResponse: """GET /v1/api/admin/usage — query usage data.""" from datetime import UTC, datetime, timedelta @@ -3532,6 +3552,7 @@ def create_app( 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/auth/login", auth_login, methods=["POST"]), Route("/api/auth/logout", auth_logout, methods=["POST"]), Route("/api/auth/status", auth_status), diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index dda12504..d9a419b5 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -1263,7 +1263,7 @@ function showNewWsModal() { // Populate template dropdown var tplSelect = document.getElementById("new-ws-template"); tplSelect.innerHTML = ''; - authFetch("/v1/api/admin/templates") + authFetch("/v1/api/templates") .then(function (r) { return r.json(); }) diff --git a/turnstone/core/workstream.py b/turnstone/core/workstream.py index 7a12c0ed..944975c4 100644 --- a/turnstone/core/workstream.py +++ b/turnstone/core/workstream.py @@ -16,9 +16,20 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable + from typing import Protocol from turnstone.core.session import ChatSession, SessionUI + class _SessionFactory(Protocol): + def __call__( + self, + ui: SessionUI | None, + model_alias: str | None = ..., + ws_id: str | None = ..., + *, + template: str | None = ..., + ) -> ChatSession: ... + # --------------------------------------------------------------------------- # State enum @@ -65,14 +76,14 @@ class WorkstreamManager: def __init__( self, - session_factory: Callable[[SessionUI | None, str | None, str | None], ChatSession], + session_factory: _SessionFactory, *, max_workstreams: int = 10, node_id: str | None = None, ): """ Args: - session_factory: callable(ui, model_alias, ws_id) -> ChatSession. + session_factory: callable(ui, model_alias, ws_id, *, template) -> ChatSession. Captures shared config (registry, temperature, …) so the manager can create ChatSession instances without knowing those details. *model_alias* selects a model from the @@ -85,9 +96,7 @@ class WorkstreamManager: """ if max_workstreams < 1: raise ValueError(f"max_workstreams must be >= 1, got {max_workstreams}") - self._session_factory: Callable[[SessionUI | None, str | None, str | None], ChatSession] = ( - session_factory - ) + self._session_factory: _SessionFactory = session_factory self._node_id = node_id self._max_workstreams: int = max_workstreams self._workstreams: dict[str, Workstream] = {} @@ -115,6 +124,7 @@ class WorkstreamManager: name: str = "", ui_factory: Callable[..., SessionUI] | None = None, model: str | None = None, + template: str | None = None, ) -> Workstream: """Create a new workstream. Returns the new ws. @@ -125,6 +135,8 @@ class WorkstreamManager: Args: model: Optional model alias from the registry. ``None`` uses the default model. + template: Optional prompt template name passed through to session + factory. """ # Fast-fail capacity check (avoids expensive ChatSession creation when full). first_evicted: Workstream | None = None @@ -147,7 +159,7 @@ class WorkstreamManager: ws = Workstream(name=name) if ui_factory: ws.ui = ui_factory(ws.id) - ws.session = self._session_factory(ws.ui, model, ws.id) + ws.session = self._session_factory(ws.ui, model, ws.id, template=template) # Authoritative insert under lock with re-check (another thread may # have filled capacity while we were unlocked). diff --git a/turnstone/sdk/server.py b/turnstone/sdk/server.py index b826e6f4..4f19371d 100644 --- a/turnstone/sdk/server.py +++ b/turnstone/sdk/server.py @@ -16,6 +16,7 @@ import asyncio import contextlib from typing import TYPE_CHECKING, Any +from turnstone.api.console_schemas import ListWsTemplateSummaryResponse from turnstone.api.schemas import ( AuthLoginResponse, AuthSetupResponse, @@ -27,6 +28,7 @@ from turnstone.api.server_schemas import ( DashboardResponse, HealthResponse, ListMemoriesResponse, + ListPromptTemplateSummaryResponse, ListSavedWorkstreamsResponse, ListWorkstreamsResponse, MemoryInfo, @@ -237,6 +239,18 @@ class AsyncTurnstoneServer(_BaseClient): "GET", "/v1/api/workstreams/saved", response_model=ListSavedWorkstreamsResponse ) + # -- templates ----------------------------------------------------------- + + async def list_templates(self) -> ListPromptTemplateSummaryResponse: + return await self._request( + "GET", "/v1/api/templates", response_model=ListPromptTemplateSummaryResponse + ) + + async def list_ws_templates(self) -> ListWsTemplateSummaryResponse: + return await self._request( + "GET", "/v1/api/ws-templates", response_model=ListWsTemplateSummaryResponse + ) + # -- memories ------------------------------------------------------------ async def list_memories( @@ -481,6 +495,14 @@ class TurnstoneServer: def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse: return self._runner.run(self._async.list_saved_workstreams()) + # -- templates ----------------------------------------------------------- + + def list_templates(self) -> ListPromptTemplateSummaryResponse: + return self._runner.run(self._async.list_templates()) + + def list_ws_templates(self) -> ListWsTemplateSummaryResponse: + return self._runner.run(self._async.list_ws_templates()) + # -- memories ------------------------------------------------------------ def list_memories( diff --git a/turnstone/server.py b/turnstone/server.py index c218003c..929e0953 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -890,6 +890,43 @@ async def list_saved_workstreams(request: Request) -> JSONResponse: return JSONResponse({"workstreams": result}) +async def list_templates_summary(request: Request) -> JSONResponse: + """GET /v1/api/templates — list available prompt templates (read scope).""" + from turnstone.core.storage._registry import get_storage + + try: + storage = get_storage() + except Exception: + return JSONResponse({"error": "Storage not available"}, status_code=503) + 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}) + + +async def list_ws_templates_summary(request: Request) -> JSONResponse: + """GET /v1/api/ws-templates — enabled workstream templates summary (read scope).""" + from turnstone.core.storage._registry import get_storage + + try: + storage = get_storage() + except Exception: + return JSONResponse({"error": "Storage not available"}, status_code=503) + templates = storage.list_ws_templates(enabled_only=True) + summaries = [ + {"name": t["name"], "description": t.get("description", ""), "model": t.get("model", "")} + for t in templates + ] + return JSONResponse({"ws_templates": summaries}) + + def _count_ws_states(wss: list[Workstream]) -> dict[str, int]: """Count workstream states for health/metrics endpoints.""" counts = dict.fromkeys(("idle", "thinking", "running", "attention", "error"), 0) @@ -1178,11 +1215,27 @@ async def create_workstream(request: Request) -> JSONResponse: resolved_model = body.get("model") or None if ws_tpl and ws_tpl.get("model"): resolved_model = ws_tpl["model"] + # Pre-validate prompt template before creating the workstream. + # This avoids the create-then-rollback pattern when template is invalid. + # Skip when resuming — resumed workstreams restore their own template + # from workstream_config, so the request's template is irrelevant. + ws_tpl_overrides_prompt = bool( + ws_tpl and (ws_tpl["system_prompt"] or ws_tpl["prompt_template"]) + ) + resume_ws_id = body.get("resume_ws", "") + resolved_template: str | None = None + if body_template and not ws_tpl_overrides_prompt and not resume_ws_id: + from turnstone.core.memory import get_prompt_template_by_name + + if not get_prompt_template_by_name(body_template): + return JSONResponse({"error": f"Template not found: {body_template}"}, status_code=400) + resolved_template = body_template try: ws = mgr.create( name=body.get("name", ""), ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid), model=resolved_model, + template=resolved_template, ) assert isinstance(ws.ui, WebUI) if skip or body.get("auto_approve", False): @@ -1209,7 +1262,6 @@ async def create_workstream(request: Request) -> JSONResponse: # Atomic workstream resume during creation. resumed = False message_count = 0 - resume_ws_id = body.get("resume_ws", "") if resume_ws_id and ws.session is not None: from turnstone.core.memory import get_workstream_display_name, resolve_workstream @@ -1225,23 +1277,6 @@ async def create_workstream(request: Request) -> JSONResponse: if history: ui._enqueue({"type": "history", "messages": history}) - # Per-workstream template override — only when not resumed (resumed - # workstreams restore their own template from workstream_config). - # Skip validation when the ws_template will override the prompt anyway. - ws_tpl_overrides_prompt = bool( - ws_tpl and (ws_tpl["system_prompt"] or ws_tpl["prompt_template"]) - ) - if body_template and not resumed and ws.session and not ws_tpl_overrides_prompt: - from turnstone.core.memory import get_prompt_template_by_name - - if not get_prompt_template_by_name(body_template): - # Workstream already created — close it and return error - mgr.close(ws.id) - return JSONResponse( - {"error": f"Template not found: {body_template}"}, status_code=400 - ) - ws.session.set_template(body_template) - # Apply workstream template settings (only for new workstreams) if ws_tpl and not resumed and ws.session: sess = ws.session @@ -1735,6 +1770,8 @@ def create_app( Route("/api/workstreams", list_workstreams), Route("/api/dashboard", dashboard), Route("/api/workstreams/saved", list_saved_workstreams), + Route("/api/templates", list_templates_summary), + Route("/api/ws-templates", list_ws_templates_summary), Route("/api/send", send_message, methods=["POST"]), Route("/api/approve", approve, methods=["POST"]), Route("/api/plan", plan_feedback, methods=["POST"]), @@ -2045,6 +2082,8 @@ def main() -> None: ui: SessionUI | None, model_alias: str | None = None, ws_id: str | None = None, + *, + template: str | None = None, ) -> ChatSession: assert ui is not None r_client, r_model, r_cfg = registry.resolve(model_alias) @@ -2077,7 +2116,7 @@ def main() -> None: tool_search=config_store.get("tools.search"), tool_search_threshold=config_store.get("tools.search_threshold"), tool_search_max_results=config_store.get("tools.search_max_results"), - template=args.template, + template=template if template is not None else args.template, judge_config=live_judge_config, user_id=uid, memory_config=live_memory_config, diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index e3c39872..164b7aba 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -378,20 +378,185 @@ function switchTab(wsId) { } } +// --------------------------------------------------------------------------- +// New workstream modal +// --------------------------------------------------------------------------- +var _newWsTrapHandler = null; + function newWorkstream() { + showNewWsModal(); +} + +function showNewWsModal() { + var overlay = document.getElementById("new-ws-overlay"); + overlay.style.display = "flex"; + document.body.style.overflow = "hidden"; + + // Backdrop click to dismiss + overlay.onclick = function (e) { + if (e.target === overlay) hideNewWsModal(); + }; + + // Populate model placeholder from header + var curModel = document.getElementById("model-name").textContent; + var modelInput = document.getElementById("new-ws-model"); + modelInput.placeholder = curModel || "Default model"; + modelInput.value = ""; + + // Populate template dropdown + var tplSelect = document.getElementById("new-ws-template"); + tplSelect.innerHTML = ''; + authFetch("/v1/api/templates") + .then(function (r) { + return r.json(); + }) + .then(function (data) { + (data.templates || []).forEach(function (t) { + var opt = document.createElement("option"); + opt.value = t.name; + var label = t.name; + if (t.is_default) label += " (default)"; + if (t.origin === "mcp") label += " [MCP]"; + opt.textContent = label; + tplSelect.appendChild(opt); + }); + }) + .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 */ + }); + + // Reset form + document.getElementById("new-ws-name").value = ""; + var errEl = document.getElementById("new-ws-error"); + errEl.style.display = "none"; + errEl.textContent = ""; + var submitBtn = document.getElementById("new-ws-submit"); + submitBtn.disabled = false; + submitBtn.textContent = "Create"; + + // Wire buttons + document.getElementById("new-ws-cancel").onclick = hideNewWsModal; + submitBtn.onclick = submitNewWs; + + // Focus trap + _newWsTrapHandler = function (e) { + if (e.key === "Escape") { + e.preventDefault(); + hideNewWsModal(); + return; + } + if ( + e.key === "Enter" && + e.target.tagName !== "TEXTAREA" && + e.target.tagName !== "SELECT" + ) { + e.preventDefault(); + submitNewWs(); + return; + } + if (e.key !== "Tab") return; + var box = document.getElementById("new-ws-box"); + var focusable = box.querySelectorAll( + 'input, select, button, [tabindex]:not([tabindex="-1"])', + ); + if (!focusable.length) return; + var first = focusable[0], + last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener("keydown", _newWsTrapHandler); + setTimeout(function () { + document.getElementById("new-ws-name").focus(); + }, 50); +} + +function hideNewWsModal() { + document.getElementById("new-ws-overlay").style.display = "none"; + document.body.style.overflow = ""; + if (_newWsTrapHandler) { + document.removeEventListener("keydown", _newWsTrapHandler); + _newWsTrapHandler = null; + } + document.getElementById("new-tab-btn").focus(); +} + +function submitNewWs() { + var submitBtn = document.getElementById("new-ws-submit"); + if (submitBtn.disabled) return; + submitBtn.disabled = true; + submitBtn.textContent = "Creating\u2026"; + + var body = {}; + 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 profile = document.getElementById("new-ws-profile").value; + if (name) body.name = name; + if (model) body.model = model; + if (template) body.template = template; + if (profile) body.ws_template = profile; + + var errEl = document.getElementById("new-ws-error"); + errEl.style.display = "none"; + authFetch("/v1/api/workstreams/new", { method: "POST", headers: { "Content-Type": "application/json" }, - body: "{}", + body: JSON.stringify(body), }) .then(function (r) { return r.json(); }) .then(function (data) { + if (data.error) { + errEl.textContent = data.error; + errEl.style.display = "block"; + submitBtn.disabled = false; + submitBtn.textContent = "Create"; + return; + } if (data.ws_id) { workstreams[data.ws_id] = { name: data.name, state: "idle" }; + hideNewWsModal(); switchTab(data.ws_id); } + }) + .catch(function () { + errEl.textContent = "Failed to create workstream"; + errEl.style.display = "block"; + submitBtn.disabled = false; + submitBtn.textContent = "Create"; }); } @@ -1832,6 +1997,9 @@ document // Keyboard shortcuts for inline approval + plan dialog + tabs document.addEventListener("keydown", function (e) { + // Defer to modal's own keydown handler when new-ws modal is open + var nwsOverlay = document.getElementById("new-ws-overlay"); + if (nwsOverlay && nwsOverlay.style.display !== "none") return; // Escape: close hamburger first, then dashboard if ( e.key === "Escape" && diff --git a/turnstone/ui/static/index.html b/turnstone/ui/static/index.html index 9c0627ee..bd439d32 100644 --- a/turnstone/ui/static/index.html +++ b/turnstone/ui/static/index.html @@ -73,6 +73,26 @@
+ + +