feat: workstream templates — behavioral profiles for workstream creation (#49)

* feat: workstream templates — behavioral profiles for workstream creation

Workstream templates define the complete configuration for workstream
creation: system prompt, model, auto-approve policy, per-tool
auto-approve, temperature, reasoning effort, max tokens, agent max
turns, token budget, and completion notifications. Applied once at
creation time (snapshot, not live binding). Auto-versioning captures
pre-update state on every edit.

Schema & storage:
- workstream_templates + workstream_template_versions tables (migration 011)
- ws_template_id/ws_template_version columns on workstreams table
- ws_template column on scheduled_tasks table
- Full CRUD + versioning on SQLite and PostgreSQL backends
- prompt_template_hash (SHA-256) for drift detection

Runtime:
- Template resolution before mgr.create() for model override
- Post-creation settings application (prompt, temperature, approval, budget)
- Token budget enforcement in session.send() — 80% warning, approval gate
  at 100% via __budget_override__ synthetic tool
- WebUI.auto_approve_tools server-side per-tool auto-approve
- Prompt template drift detection (hash comparison, log warning on mismatch)

Integration:
- ws_template field on CreateWorkstreamMessage, bridge, channel router,
  scheduler dispatch, MQ client
- Console admin "WS Templates" tab (11th) with CRUD, version history modal
- Profile dropdown on workstream creation modal
- WS template dropdown on scheduler create/edit modals
- Prompt template name validation on ws_template create/update
- 7 console admin API endpoints + read-only summary endpoint
- Full OpenAPI spec entries in console_spec.py
- Python SDK (sync + async) and TypeScript SDK methods
- Pydantic schemas for all request/response models

Docs & diagrams:
- New 21-ws-template-architecture.puml sequence diagram
- Updated governance, storage, MQ protocol diagrams + PNGs
- Updated architecture.md, governance.md, api-reference.md, console.md, sdk.md

48 new tests (1788 total). mypy clean. ruff clean.

* fix: address PR #49 review feedback

- auto_approve_tools uses approval_label (not just func_name) for
  consistency with tool policy evaluation
- inline system_prompt from ws_template persisted as
  _ws_template_system_prompt in workstream_config, restored on resume
  (previously lost because _template_content wasn't persisted)
- budget gate (__budget_override__) no longer bypassed by blanket
  auto_approve — requires explicit approval or tool policy allow
- diagram 21 field list corrected (removed tool_search/threshold,
  added prompt_template_hash/notify_on_complete)

* fix: address PR #49 review feedback (round 2)

- Grant admin.ws_templates permission in migration 011 (tab was hidden)
- Center WS template modals and fix radio button alignment
- Skip template validation when ws_template overrides prompt
- Guard against empty version snapshots on no-op updates
- Replace setTimeout race with Promise chain in schedule ws_template select
- Validate numeric fields in admin create/update handlers (400 not 500)
- Add ws_template to TypeScript OpenAPI specs
- Use typed Pydantic response models in SDK ws_template methods
This commit is contained in:
Patrick Buckley
2026-03-12 21:06:51 -07:00
committed by GitHub
parent f1f448277f
commit 02d9c5c797
45 changed files with 3191 additions and 25 deletions
+3
View File
@@ -765,6 +765,9 @@ All fields are optional. The body can be empty or an empty JSON object.
| `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. |
> **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.
**Response (success):**
+21 -5
View File
@@ -663,7 +663,8 @@ 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.
through the MQ protocol, along with `ws_template` (workstream template name)
which can override the model before workstream creation.
### Tool Output Truncation
@@ -1237,7 +1238,11 @@ 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.
the most available capacity if no target is specified. When a `ws_template`
field is present, the server resolves the template 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.
2. **Reverse proxy** — serves each node's server UI through the console port at
`/node/{node_id}/`. Uses `httpx.AsyncClient` to proxy HTTP and SSE traffic.
@@ -1374,6 +1379,17 @@ 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.
The console admin panel adds 5 governance tabs (Roles, Policies, Templates,
Usage, Audit) for a total of 10 tabs, all permission-gated. Both Python
and TypeScript SDKs expose governance methods on the console client.
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
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) for a total of 11 tabs, all permission-gated.
Both Python and TypeScript SDKs expose governance methods on the console
client.
+16 -2
View File
@@ -306,6 +306,18 @@ 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
@@ -395,6 +407,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.
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
@@ -407,8 +420,9 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna
### 5. Admin Panel
Accessed via the "admin" button in the header (visible when authenticated
with `approve` scope). Provides user, API token, and channel link management
with three tabs:
with `approve` scope). Provides user, API token, channel link, and workstream
template management with 11 tabs (see also [Governance](governance.md) for
the Roles, Policies, Templates, WS Templates, Usage, and Audit tabs):
**Users tab:**
+2
View File
@@ -59,6 +59,8 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
+ auto_approve_tools: list[str] = []
+ target_node: str = ""
+ initial_message: str = ""
+ template: str = ""
+ ws_template: str = ""
}
class CloseWorkstreamMessage {
+4 -1
View File
@@ -64,11 +64,14 @@ class "_schema.py" as Schema <<schema>> {
+metadata: MetaData
+memories: Table
+conversations: Table
+workstreams: Table (node_id, alias, title, state)
+workstreams: Table (node_id, alias, title,\n state, ws_template_id, ws_template_version)
+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)
--
SQLAlchemy Core
Single source of truth
@@ -26,6 +26,8 @@ package "Governance Storage" {
database "prompt_templates" 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
}
package "Runtime Enforcement" {
@@ -42,6 +44,13 @@ package "Template Runtime" {
[set_template() / /template] as tset
}
package "WS Template Runtime" {
[resolve_ws_template()] 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
}
package "Console UI" {
[Admin Panel\n10 tabs] as ui
[governance.js] as govjs
@@ -76,6 +85,14 @@ tload --> trender : template content
trender --> tsys : rendered content
tset --> tload : name or None
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
wtb --> approve : __budget_override__
wtv_db <.. wt_db : version snapshots
auth -[hidden]-> mw
mw -[hidden]-> approve
@enduml
@@ -0,0 +1,161 @@
@startuml
!theme plain
title Turnstone — Workstream Template Architecture
skinparam participant {
BackgroundColor<<admin>> #E8EAF6
BackgroundColor<<server>> #FFE0B2
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<integration>> #F3E5F5
}
participant "Admin / Console UI\n(governance.js)" as Admin <<admin>>
participant "Server\n(server.py)" as Server <<server>>
participant "ChatSession\n(session.py)" as Session <<session>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "Integration Points\n(scheduler, channel,\nbridge, MQ)" as Integrations <<integration>>
== 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()
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
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d17f3feacf7bc9f64dfea19464143bc9b6ef0da5d55e6d57c0bc5a73d5724eba
size 184466
oid sha256:2229801220548e4794baa67e27a0a39dc7968c826a28fa8144763e678c8ed733
size 192556
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5faa5335152685cf1c8bf77ed93847d751cde59e1afed651e5991113f2f0f31b
size 242670
oid sha256:fb5e7c221f6b1ee1082b37da32e65c45b5e468014cf6881a210e5b4d8a8dca8b
size 255736
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a889d4bb84c4afa3c822c3acb7021395a9463462f7aeae5382e583b783412814
size 144960
oid sha256:f4dac4948d928b4705936d73b4d159aa1e89315ec0397616ca914bbf19e7a1ce
size 206479
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c06d7086d7965eb9fe333396f027133d42507cf120bfe8dc851c009a8768ec48
size 284926
+32 -2
View File
@@ -79,6 +79,32 @@ Admin-curated system message templates injected at workstream startup:
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 (11th admin 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.
### Usage Tracking
Per-LLM-request token and tool call metrics:
@@ -99,7 +125,8 @@ Append-only trail of admin actions:
- **Events captured**: user.create, user.delete, token.create, token.revoke,
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, org.update
template.create, template.update, template.delete,
ws_template.create, ws_template.update, ws_template.delete, org.update
- **Querying**: `GET /v1/api/admin/audit` with action/user/time filters + pagination
## Database Schema
@@ -130,6 +157,7 @@ All under `/v1/api/admin/` (requires `approve` scope + granular permission).
| Tool Policies | 4 (CRUD) | `admin.policies` |
| Prompt Templates | 4 (CRUD) | `admin.templates` |
| 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` |
@@ -138,11 +166,12 @@ Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
## Admin Console UI
5 new tabs added to the admin panel (10 total):
6 new tabs added to the admin panel (11 total):
- **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
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
- **Audit** — Filterable log with relative timestamps, load-more pagination
@@ -158,6 +187,7 @@ 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`):
+8 -2
View File
@@ -69,7 +69,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|----------|--------|---------|
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
| | `dashboard()` | `DashboardResponse` |
| | `create_workstream(*, name, model, auto_approve)` | `CreateWorkstreamResponse` |
| | `create_workstream(*, name, model, auto_approve, ws_template)` | `CreateWorkstreamResponse` |
| | `close_workstream(ws_id)` | `StatusResponse` |
| **Chat** | `send(message, ws_id)` | `SendResponse` |
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
@@ -97,13 +97,19 @@ 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)` | `ConsoleCreateWsResponse` |
| | `create_workstream(*, node_id, name, model, initial_message, ws_template)` | `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` |
| **Streaming** | `stream_cluster_events()` | `Iterator[ClusterEvent]` |
| **Auth** | `login(username=..., password=...)` / `login(token="ts_xxx")` | `AuthLoginResponse` |
| | `logout()` | `StatusResponse` |
+6
View File
@@ -755,6 +755,12 @@
"description": "Prompt template name (replaces default templates)",
"title": "Template",
"type": "string"
},
"ws_template": {
"default": "",
"description": "Workstream template name (behavioral profile applied at creation)",
"title": "Ws Template",
"type": "string"
}
},
"title": "ConsoleCreateWsRequest",
+6
View File
@@ -883,6 +883,12 @@
"description": "Prompt template name (replaces default templates)",
"title": "Template",
"type": "string"
},
"ws_template": {
"default": "",
"description": "Workstream template name (behavioral profile applied at creation)",
"title": "Ws Template",
"type": "string"
}
},
"title": "CreateWorkstreamRequest",
+49
View File
@@ -17,6 +17,7 @@ import type {
CreateRoleOptions,
CreateScheduleRequest,
CreateTemplateOptions,
CreateWsTemplateOptions,
ListScheduleRunsResponse,
ListSchedulesResponse,
NodeDetailResponse,
@@ -32,10 +33,13 @@ import type {
UpdateRoleOptions,
UpdateScheduleRequest,
UpdateTemplateOptions,
UpdateWsTemplateOptions,
UsageQueryOptions,
UsageResponse,
UserRoleInfo,
WorkstreamsOptions,
WsTemplateInfo,
WsTemplateVersionInfo,
} from "./types.js";
/** Async client for the turnstone console API. */
@@ -273,6 +277,51 @@ export class TurnstoneConsole extends BaseClient {
return this.request("DELETE", `/v1/api/admin/templates/${templateId}`);
}
// -- Governance: Workstream Templates ----------------------------------------
async listWsTemplates(): Promise<WsTemplateInfo[]> {
const data = await this.request<{ ws_templates: WsTemplateInfo[] }>(
"GET",
"/v1/api/admin/ws-templates",
);
return data.ws_templates || [];
}
async createWsTemplate(
opts: CreateWsTemplateOptions,
): Promise<WsTemplateInfo> {
return this.request("POST", "/v1/api/admin/ws-templates", {
json: opts,
});
}
async getWsTemplate(wsTemplateId: string): Promise<WsTemplateInfo> {
return this.request("GET", `/v1/api/admin/ws-templates/${wsTemplateId}`);
}
async updateWsTemplate(
wsTemplateId: string,
opts: UpdateWsTemplateOptions,
): Promise<WsTemplateInfo> {
return this.request("PUT", `/v1/api/admin/ws-templates/${wsTemplateId}`, {
json: opts,
});
}
async deleteWsTemplate(wsTemplateId: string): Promise<void> {
await this.request("DELETE", `/v1/api/admin/ws-templates/${wsTemplateId}`);
}
async listWsTemplateVersions(
wsTemplateId: string,
): Promise<WsTemplateVersionInfo[]> {
const data = await this.request<{ versions: WsTemplateVersionInfo[] }>(
"GET",
`/v1/api/admin/ws-templates/${wsTemplateId}/versions`,
);
return data.versions || [];
}
// -- Governance: Usage & Audit ----------------------------------------------
async getUsage(opts: UsageQueryOptions): Promise<UsageResponse> {
+4
View File
@@ -129,6 +129,10 @@ export type {
PromptTemplateInfo,
CreateTemplateOptions,
UpdateTemplateOptions,
WsTemplateInfo,
CreateWsTemplateOptions,
UpdateWsTemplateOptions,
WsTemplateVersionInfo,
UsageBreakdownItem,
UsageResponse,
UsageQueryOptions,
+74
View File
@@ -73,6 +73,7 @@ export interface CreateWorkstreamRequest {
auto_approve?: boolean;
resume_ws?: string;
template?: string;
ws_template?: string;
}
export interface CreateWorkstreamResponse {
@@ -275,6 +276,7 @@ export interface ConsoleCreateWsRequest {
model?: string;
initial_message?: string;
template?: string;
ws_template?: string;
}
export interface ConsoleCreateWsResponse {
@@ -483,6 +485,78 @@ export interface UpdateTemplateOptions {
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
// ---------------------------------------------------------------------------
+374
View File
@@ -0,0 +1,374 @@
"""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()
# ---------------------------------------------------------------------------
# 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"}'
+329
View File
@@ -0,0 +1,329 @@
"""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
+94
View File
@@ -139,6 +139,9 @@ class ConsoleCreateWsRequest(BaseModel):
template: str = Field(
default="", description="Prompt template name (replaces default templates)"
)
ws_template: str = Field(
default="", description="Workstream template name (behavioral profile)"
)
class ConsoleCreateWsResponse(BaseModel):
@@ -317,6 +320,97 @@ 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 = ""
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
class UpdateWsTemplateRequest(BaseModel):
name: str | None = None
description: str | None = None
system_prompt: str | None = None
prompt_template: 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
token_budget: int | None = None
agent_max_turns: int | None = None
notify_on_complete: str | None = None
enabled: bool | None = None
class ListWsTemplatesResponse(BaseModel):
ws_templates: list[WsTemplateInfo]
class WsTemplateVersionInfo(BaseModel):
id: int
ws_template_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]
# ---------------------------------------------------------------------------
# Governance: Usage
# ---------------------------------------------------------------------------
+63
View File
@@ -22,6 +22,7 @@ from turnstone.api.console_schemas import (
CreatePromptTemplateRequest,
CreateRoleRequest,
CreateToolPolicyRequest,
CreateWsTemplateRequest,
ListAuditEventsResponse,
ListChannelUsersResponse,
ListOrgsResponse,
@@ -29,6 +30,9 @@ from turnstone.api.console_schemas import (
ListRolesResponse,
ListToolPoliciesResponse,
ListUserRolesResponse,
ListWsTemplatesResponse,
ListWsTemplateSummaryResponse,
ListWsTemplateVersionsResponse,
NodeDetailResponse,
OrgInfo,
PromptTemplateInfo,
@@ -38,9 +42,11 @@ from turnstone.api.console_schemas import (
UpdatePromptTemplateRequest,
UpdateRoleRequest,
UpdateToolPolicyRequest,
UpdateWsTemplateRequest,
UsageBreakdownItem,
UsageResponse,
UserRoleInfo,
WsTemplateInfo,
)
from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi
from turnstone.api.schemas import (
@@ -453,6 +459,63 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
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,
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],
tags=["Admin"],
),
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",
"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"],
),
# --- Governance: Usage & Audit ---
EndpointSpec(
"/v1/api/admin/usage",
+3
View File
@@ -176,6 +176,7 @@ class CreateScheduleRequest(BaseModel):
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")
enabled: bool = Field(default=True)
@@ -193,6 +194,7 @@ class UpdateScheduleRequest(BaseModel):
auto_approve: bool | None = None
auto_approve_tools: list[str] | None = None
template: str | None = None
ws_template: str | None = None
enabled: bool | None = None
@@ -211,6 +213,7 @@ class ScheduleInfo(BaseModel):
auto_approve: bool = False
auto_approve_tools: list[str] = Field(default_factory=list)
template: str = ""
ws_template: str = ""
enabled: bool = True
created_by: str = ""
last_run: str | None = None
+3
View File
@@ -50,6 +50,9 @@ class CreateWorkstreamRequest(BaseModel):
template: str = Field(
default="", description="Prompt template name (replaces default templates)"
)
ws_template: str = Field(
default="", description="Workstream template name to apply defaults from"
)
class CreateWorkstreamResponse(BaseModel):
+3
View File
@@ -50,12 +50,14 @@ class ChannelRouter:
auto_approve: bool = False,
auto_approve_tools: list[str] | None = None,
template: str = "",
ws_template: 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._pending: dict[str, asyncio.Event] = {}
self._pending_results: dict[str, str] = {}
self._global_task: asyncio.Task[None] | None = None
@@ -175,6 +177,7 @@ class ChannelRouter:
auto_approve=self._auto_approve,
auto_approve_tools=list(self._auto_approve_tools),
template=self._template,
ws_template=self._ws_template,
)
cid = msg.correlation_id
waiter = asyncio.Event()
+2
View File
@@ -209,6 +209,7 @@ class TaskScheduler:
auto_approve_tools=self._parse_tools(task),
user_id=task.get("created_by", ""),
template=task.get("template", ""),
ws_template=task.get("ws_template", ""),
)
self._broker.push_inbound(msg.to_json(), node_id=node_id)
@@ -235,6 +236,7 @@ class TaskScheduler:
auto_approve_tools=self._parse_tools(task),
user_id=task.get("created_by", ""),
template=task.get("template", ""),
ws_template=task.get("ws_template", ""),
)
self._broker.push_inbound(msg.to_json())
+323 -2
View File
@@ -350,6 +350,7 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_model = body.get("model", "")
raw_initial_message = body.get("initial_message", "")
raw_template = body.get("template", "")
raw_ws_template = body.get("ws_template", "")
if not isinstance(raw_node_id, str):
raw_node_id = "" if raw_node_id is None else None
if not isinstance(raw_name, str):
@@ -360,15 +361,20 @@ async def create_workstream(request: Request) -> JSONResponse:
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 (
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
):
return JSONResponse(
{"error": "node_id, name, model, initial_message, and template must be strings"},
{
"error": "node_id, name, model, initial_message, template, and ws_template must be strings"
},
status_code=400,
)
node_id = raw_node_id
@@ -376,13 +382,18 @@ async def create_workstream(request: Request) -> JSONResponse:
model = raw_model[:128]
initial_message = raw_initial_message[:4096]
template = raw_template[:256]
ws_template = raw_ws_template[:256]
from turnstone.mq.protocol import CreateWorkstreamMessage
# General pool — push to shared queue, any bridge picks it up
if node_id == "pool":
msg = CreateWorkstreamMessage(
name=name, model=model, initial_message=initial_message, template=template
name=name,
model=model,
initial_message=initial_message,
template=template,
ws_template=ws_template,
)
broker.push_inbound(msg.to_json())
log.debug("Pool dispatch: correlation_id=%s name=%r", msg.correlation_id, name)
@@ -411,6 +422,7 @@ async def create_workstream(request: Request) -> JSONResponse:
target_node=node_id,
initial_message=initial_message,
template=template,
ws_template=ws_template,
)
broker.push_inbound(msg.to_json(), node_id=node_id)
@@ -1145,6 +1157,7 @@ async def admin_create_schedule(request: Request) -> JSONResponse:
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]
enabled = bool(body.get("enabled", True))
if not name:
@@ -1153,6 +1166,10 @@ async def admin_create_schedule(request: Request) -> JSONResponse:
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
)
validation_err = _validate_schedule_fields(schedule_type, cron_expr, at_time)
if validation_err:
@@ -1188,6 +1205,7 @@ async def admin_create_schedule(request: Request) -> JSONResponse:
created_by=created_by,
next_run=next_run if enabled else "",
template=template,
ws_template=ws_template,
)
if not enabled:
@@ -1267,6 +1285,13 @@ async def admin_update_schedule(request: Request) -> JSONResponse:
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 "enabled" in body:
updates["enabled"] = bool(body["enabled"])
@@ -1440,6 +1465,13 @@ 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.
@@ -1477,6 +1509,7 @@ _VALID_PERMISSIONS = frozenset(
"admin.usage",
"admin.schedules",
"admin.watches",
"admin.ws_templates",
"tools.approve",
"workstreams.create",
"workstreams.close",
@@ -2174,6 +2207,275 @@ 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."""
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
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)
return JSONResponse({"versions": versions})
async def list_ws_templates_summary(request: Request) -> JSONResponse:
"""GET /v1/api/ws-templates — enabled workstream templates summary."""
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 admin_usage(request: Request) -> JSONResponse:
"""GET /v1/api/admin/usage — query usage data."""
from datetime import UTC, datetime, timedelta
@@ -2294,6 +2596,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/auth/login", auth_login, methods=["POST"]),
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Route("/api/auth/status", auth_status),
@@ -2382,6 +2685,24 @@ def create_app(
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,
methods=["DELETE"],
),
Route(
"/api/admin/ws-templates/{ws_template_id}/versions",
admin_list_ws_template_versions,
),
# Governance: Usage & Audit
Route("/api/admin/usage", admin_usage),
Route("/api/admin/audit", admin_audit),
+40
View File
@@ -41,6 +41,7 @@ function showAdmin() {
roles: "admin.roles",
policies: "admin.policies",
templates: "admin.templates",
"ws-templates": "admin.ws_templates",
usage: "admin.usage",
audit: "admin.audit",
};
@@ -101,6 +102,7 @@ function switchAdminTab(tab) {
"roles",
"policies",
"templates",
"ws-templates",
"usage",
"audit",
];
@@ -117,6 +119,7 @@ function switchAdminTab(tab) {
if (tab === "roles") loadGovRoles();
if (tab === "policies") loadGovPolicies();
if (tab === "templates") loadGovTemplates();
if (tab === "ws-templates") loadGovWsTemplates();
if (tab === "usage") loadGovUsage();
if (tab === "audit") {
_populateAuditUserFilter();
@@ -465,6 +468,28 @@ var _srTrapHandler = null;
var _editScheduleTriggerEl = null;
var _runsScheduleTriggerEl = null;
function _populateWsTemplateSelect(selectId) {
var sel = document.getElementById(selectId);
sel.innerHTML = '<option value="">None</option>';
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) {
@@ -662,6 +687,7 @@ 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();
@@ -695,6 +721,7 @@ function submitCreateSchedule() {
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 autoApprove = document.getElementById("cs-autoapprove").checked;
var errEl = document.getElementById("create-schedule-error");
@@ -732,6 +759,7 @@ function submitCreateSchedule() {
initial_message: message,
auto_approve: autoApprove,
template: template,
ws_template: wsTemplate,
}),
})
.then(function (r) {
@@ -799,6 +827,10 @@ function showEditScheduleModal(taskId) {
: "";
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-message").value = s.initial_message || "";
document.getElementById("es-autoapprove").checked = !!s.auto_approve;
document.getElementById("es-enabled").checked = !!s.enabled;
@@ -872,6 +904,7 @@ function submitEditSchedule() {
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,
initial_message: (
document.getElementById("es-message").value || ""
).trim(),
@@ -1450,6 +1483,10 @@ 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();
}
};
}
@@ -1525,6 +1562,9 @@ 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],
];
for (var gi = 0; gi < govOverlays.length; gi++) {
var govEl = document.getElementById(govOverlays[gi][0]);
+22
View File
@@ -1261,6 +1261,26 @@ function showNewWsModal() {
.catch(function () {
/* ignore — defaults still work */
});
// Populate profile (WS template) dropdown
var profSelect = document.getElementById("new-ws-profile");
profSelect.innerHTML = '<option value="">None</option>';
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 = "";
document.getElementById("new-ws-task").value = "";
@@ -1330,6 +1350,8 @@ function submitNewWs() {
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;
authFetch("/v1/api/cluster/workstreams/new", {
method: "POST",
+444
View File
@@ -6,6 +6,7 @@
var _govRoles = [];
var _govPolicies = [];
var _govTemplates = [];
var _govWsTemplates = [];
var _govUsageRange = "7d";
var _govUsageGroupBy = "day";
var _govAuditEvents = [];
@@ -20,6 +21,8 @@ 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;
@@ -29,6 +32,8 @@ var _cpTriggerEl = null;
var _epTriggerEl = null;
var _ctmTriggerEl = null;
var _etmTriggerEl = null;
var _cwstTriggerEl = null;
var _ewstTriggerEl = null;
// ---------------------------------------------------------------------------
// Roles
@@ -928,6 +933,445 @@ 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 =
'<div class="dashboard-empty">Failed to load WS templates</div>';
});
}
function _renderGovWsTemplates(items) {
var el = document.getElementById("admin-ws-templates-table");
if (!items.length) {
el.innerHTML =
'<div class="dashboard-empty">No workstream templates defined</div>';
return;
}
var html = "";
for (var i = 0; i < items.length; i++) {
var t = items[i];
var modelBadge = t.model
? '<span class="scope-badge">' + escapeHtml(t.model) + "</span>"
: '<span class="scope-badge">default</span>';
var approveBadge = t.auto_approve
? '<span class="scope-badge scope-approve">auto</span>'
: "";
var budgetBadge =
t.token_budget > 0
? '<span class="scope-badge scope-deny">' +
t.token_budget.toLocaleString() +
"</span>"
: "";
var enabledBadge = !t.enabled
? ' <span class="scope-badge scope-deny">disabled</span>'
: "";
html +=
'<div class="admin-row" role="listitem">' +
'<span class="admin-col admin-col-tmname">' +
escapeHtml(t.name) +
enabledBadge +
"</span>" +
'<span class="admin-col admin-col-tmcat">' +
modelBadge +
"</span>" +
'<span class="admin-col admin-col-tmvars">' +
approveBadge +
" " +
budgetBadge +
"</span>" +
'<span class="admin-col admin-col-actions">' +
"v" +
t.version +
" " +
'<button class="admin-btn-action" data-history-wst="' +
escapeHtml(t.ws_template_id) +
'">history</button> ' +
'<button class="admin-btn-action" data-edit-wst="' +
escapeHtml(t.ws_template_id) +
'">edit</button>' +
'<button class="admin-btn-danger" data-delete-wst="' +
escapeHtml(t.ws_template_id) +
'" data-wst-name="' +
escapeHtml(t.name) +
'">delete</button>' +
"</span></div>";
}
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 = '<option value="">None</option>';
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 =
'<div class="dashboard-empty">Loading...</div>';
_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 =
'<div class="dashboard-empty">No version history yet</div>';
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 +=
'<div class="admin-row" style="flex-direction:column;align-items:stretch">' +
'<div style="display:flex;justify-content:space-between;margin-bottom:4px">' +
"<strong>v" +
v.version +
"</strong>" +
'<span class="label-hint">' +
escapeHtml(v.changed_by || "unknown") +
" &mdash; " +
escapeHtml(v.created) +
"</span></div>" +
'<pre style="margin:0;padding:8px;background:var(--bg-elevated,#1a1a2e);border-radius:4px;overflow-x:auto;font-size:0.85em;max-height:200px;overflow-y:auto">' +
escapeHtml(snapshot) +
"</pre></div>";
}
document.getElementById("wst-history-content").innerHTML = html;
})
.catch(function () {
document.getElementById("wst-history-content").innerHTML =
'<div class="dashboard-empty">Failed to load version history</div>';
});
}
function hideWstHistoryModal() {
document.getElementById("wst-history-overlay").style.display = "none";
_whTrapHandler = _removeTrap(_whTrapHandler);
if (_whTriggerEl && _whTriggerEl.focus) _whTriggerEl.focus();
_whTriggerEl = null;
}
// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------
+144
View File
@@ -86,6 +86,7 @@
<button id="tab-roles" class="admin-tab" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
<button id="tab-policies" class="admin-tab" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
<button id="tab-templates" class="admin-tab" data-tab="templates" role="tab" aria-selected="false" aria-controls="admin-templates" tabindex="-1" onclick="switchAdminTab('templates')">Templates</button>
<button id="tab-ws-templates" class="admin-tab" data-tab="ws-templates" role="tab" aria-selected="false" aria-controls="admin-ws-templates" tabindex="-1" onclick="switchAdminTab('ws-templates')">WS Templates</button>
<button id="tab-usage" class="admin-tab" data-tab="usage" role="tab" aria-selected="false" aria-controls="admin-usage" tabindex="-1" onclick="switchAdminTab('usage')">Usage</button>
<button id="tab-audit" class="admin-tab" data-tab="audit" role="tab" aria-selected="false" aria-controls="admin-audit" tabindex="-1" onclick="switchAdminTab('audit')">Audit</button>
</div>
@@ -247,6 +248,23 @@
</div>
</div>
<!-- WS Templates Tab -->
<div id="admin-ws-templates" class="admin-panel" role="tabpanel" aria-labelledby="tab-ws-templates" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">WS TEMPLATES</span>
<button class="admin-action-btn" onclick="showCreateWsTemplateModal()">+ Create</button>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-tmname">NAME</span>
<span class="admin-col admin-col-tmcat">MODEL</span>
<span class="admin-col admin-col-tmvars">APPROVAL / BUDGET</span>
<span class="admin-col admin-col-actions">VER / ACTIONS</span>
</div>
<div id="admin-ws-templates-table" role="list" aria-label="Workstream templates" aria-live="polite">
<div class="dashboard-empty">Loading WS templates...</div>
</div>
</div>
<!-- Usage Tab -->
<div id="admin-usage" class="admin-panel" role="tabpanel" aria-labelledby="tab-usage" style="display:none">
<div class="admin-toolbar">
@@ -353,6 +371,10 @@ window.TURNSTONE_KB_SHORTCUTS = [
<select id="new-ws-template">
<option value="">Use defaults</option>
</select>
<label for="new-ws-profile">Profile <span class="label-hint">optional &mdash; workstream template</span></label>
<select id="new-ws-profile">
<option value="">None</option>
</select>
<label for="new-ws-task">Task <span class="label-hint">optional &mdash; sent as first message</span></label>
<textarea id="new-ws-task" rows="3" placeholder="What should this workstream work on?"></textarea>
<div id="new-ws-buttons">
@@ -490,6 +512,8 @@ window.TURNSTONE_KB_SHORTCUTS = [
<input id="cs-model" type="text" placeholder="Default model" autocomplete="off">
<label for="cs-template">Template <span class="label-hint">optional</span></label>
<input id="cs-template" type="text" placeholder="Prompt template name" autocomplete="off">
<label for="cs-ws-template">WS Template <span class="label-hint">optional &mdash; workstream profile</span></label>
<select id="cs-ws-template"><option value="">None</option></select>
<label for="cs-message">Initial message</label>
<textarea id="cs-message" rows="3" placeholder="What should the workstream do?"></textarea>
<label class="admin-checkbox"><input id="cs-autoapprove" type="checkbox"> Auto-approve tool calls</label>
@@ -538,6 +562,8 @@ window.TURNSTONE_KB_SHORTCUTS = [
<input id="es-model" type="text" autocomplete="off">
<label for="es-template">Template <span class="label-hint">optional</span></label>
<input id="es-template" type="text" autocomplete="off">
<label for="es-ws-template">WS Template <span class="label-hint">optional</span></label>
<select id="es-ws-template"><option value="">None</option></select>
<label for="es-message">Initial message</label>
<textarea id="es-message" rows="3"></textarea>
<label class="admin-checkbox"><input id="es-autoapprove" type="checkbox"> Auto-approve tool calls</label>
@@ -714,6 +740,124 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<!-- Create WS Template Modal -->
<div id="create-wst-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-wst-title">
<div id="create-wst-box" class="admin-modal admin-modal-wide">
<h2 id="create-wst-title">Create Workstream Template</h2>
<div id="create-wst-error" role="alert" aria-live="assertive"></div>
<label for="cwst-name">Name</label>
<input id="cwst-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
<label for="cwst-description">Description <span class="label-hint">optional</span></label>
<input id="cwst-description" type="text" placeholder="Brief description" autocomplete="off">
<label>System Prompt Source</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<label class="admin-checkbox" style="margin-top:0"><input id="cwst-src-inline" type="radio" name="cwst-src" value="inline" checked onchange="toggleWstPromptSource()"> Inline</label>
<label class="admin-checkbox" style="margin-top:0"><input id="cwst-src-ref" type="radio" name="cwst-src" value="ref" onchange="toggleWstPromptSource()"> Prompt Template</label>
</div>
<div id="cwst-inline-section">
<label for="cwst-system-prompt">System Prompt <span class="label-hint">inline text</span></label>
<textarea id="cwst-system-prompt" rows="4" placeholder="You are a..."></textarea>
</div>
<div id="cwst-ref-section" style="display:none">
<label for="cwst-prompt-template">Prompt Template <span class="label-hint">reference by name</span></label>
<select id="cwst-prompt-template"><option value="">None</option></select>
</div>
<label for="cwst-model">Model <span class="label-hint">optional — server default if empty</span></label>
<input id="cwst-model" type="text" placeholder="Default model" autocomplete="off">
<label class="admin-checkbox"><input id="cwst-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="cwst-auto-approve-tools">Auto-approve tools <span class="label-hint">comma-separated tool names</span></label>
<input id="cwst-auto-approve-tools" type="text" placeholder="e.g. read_file, list_directory" autocomplete="off">
<label for="cwst-token-budget">Token budget <span class="label-hint">0 = unlimited</span></label>
<input id="cwst-token-budget" type="number" value="0" min="0">
<label for="cwst-temperature">Temperature <span class="label-hint">optional — 0.0-2.0, empty = server default</span></label>
<input id="cwst-temperature" type="number" step="0.1" min="0" max="2" placeholder="Server default" autocomplete="off">
<label for="cwst-reasoning-effort">Reasoning effort <span class="label-hint">optional</span></label>
<select id="cwst-reasoning-effort">
<option value="">Server default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="none">None</option>
<option value="max">Max</option>
</select>
<label for="cwst-max-tokens">Max tokens <span class="label-hint">optional — 0 = server default</span></label>
<input id="cwst-max-tokens" type="number" min="0" placeholder="Server default" autocomplete="off">
<label for="cwst-agent-max-turns">Agent max turns <span class="label-hint">optional — 0 = server default</span></label>
<input id="cwst-agent-max-turns" type="number" min="0" placeholder="Server default" autocomplete="off">
<label class="admin-checkbox"><input id="cwst-enabled" type="checkbox" checked> Enabled</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateWsTemplateModal()">Cancel</button>
<button id="cwst-submit" class="modal-submit" onclick="submitCreateWsTemplate()">Create</button>
</div>
</div>
</div>
<!-- Edit WS Template Modal -->
<div id="edit-wst-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-wst-title">
<div id="edit-wst-box" class="admin-modal admin-modal-wide">
<h2 id="edit-wst-title">Edit Workstream Template</h2>
<div id="edit-wst-error" role="alert" aria-live="assertive"></div>
<input id="ewst-id" type="hidden">
<label for="ewst-name">Name</label>
<input id="ewst-name" type="text" autocomplete="off">
<label for="ewst-description">Description</label>
<input id="ewst-description" type="text" autocomplete="off">
<label>System Prompt Source</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<label class="admin-checkbox" style="margin-top:0"><input id="ewst-src-inline" type="radio" name="ewst-src" value="inline" checked onchange="toggleEditWstPromptSource()"> Inline</label>
<label class="admin-checkbox" style="margin-top:0"><input id="ewst-src-ref" type="radio" name="ewst-src" value="ref" onchange="toggleEditWstPromptSource()"> Prompt Template</label>
</div>
<div id="ewst-inline-section">
<label for="ewst-system-prompt">System Prompt</label>
<textarea id="ewst-system-prompt" rows="4"></textarea>
</div>
<div id="ewst-ref-section" style="display:none">
<label for="ewst-prompt-template">Prompt Template</label>
<select id="ewst-prompt-template"><option value="">None</option></select>
</div>
<label for="ewst-model">Model</label>
<input id="ewst-model" type="text" autocomplete="off">
<label class="admin-checkbox"><input id="ewst-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="ewst-auto-approve-tools">Auto-approve tools</label>
<input id="ewst-auto-approve-tools" type="text" autocomplete="off">
<label for="ewst-token-budget">Token budget</label>
<input id="ewst-token-budget" type="number" value="0" min="0">
<label for="ewst-temperature">Temperature <span class="label-hint">optional — 0.0-2.0, empty = server default</span></label>
<input id="ewst-temperature" type="number" step="0.1" min="0" max="2" placeholder="Server default" autocomplete="off">
<label for="ewst-reasoning-effort">Reasoning effort <span class="label-hint">optional</span></label>
<select id="ewst-reasoning-effort">
<option value="">Server default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="none">None</option>
<option value="max">Max</option>
</select>
<label for="ewst-max-tokens">Max tokens <span class="label-hint">optional</span></label>
<input id="ewst-max-tokens" type="number" min="0" placeholder="Server default" autocomplete="off">
<label for="ewst-agent-max-turns">Agent max turns <span class="label-hint">optional</span></label>
<input id="ewst-agent-max-turns" type="number" min="0" placeholder="Server default" autocomplete="off">
<label class="admin-checkbox"><input id="ewst-enabled" type="checkbox" checked> Enabled</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditWsTemplateModal()">Cancel</button>
<button id="ewst-submit" class="modal-submit" onclick="submitEditWsTemplate()">Save</button>
</div>
</div>
</div>
<!-- WS Template Version History Modal -->
<div id="wst-history-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="wst-history-title">
<div id="wst-history-box" class="admin-modal admin-modal-wide">
<h2 id="wst-history-title">Version History</h2>
<div id="wst-history-content">
<div class="dashboard-empty">Loading...</div>
</div>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideWstHistoryModal()">Close</button>
</div>
</div>
</div>
<script src="/static/admin.js"></script>
<script src="/static/governance.js"></script>
<script src="/static/app.js"></script>
+11 -2
View File
@@ -916,7 +916,8 @@
cursor: pointer;
margin-top: 14px;
}
.admin-modal label.admin-checkbox input[type="checkbox"] {
.admin-modal label.admin-checkbox input[type="checkbox"],
.admin-modal label.admin-checkbox input[type="radio"] {
width: auto;
margin: 0;
}
@@ -1019,7 +1020,8 @@
#create-schedule-overlay, #edit-schedule-overlay, #schedule-runs-overlay,
#create-role-overlay, #edit-role-overlay, #user-roles-overlay,
#create-policy-overlay, #edit-policy-overlay,
#create-template-overlay, #edit-template-overlay {
#create-template-overlay, #edit-template-overlay,
#create-wst-overlay, #edit-wst-overlay, #wst-history-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
@@ -1133,6 +1135,10 @@
#admin-templates .admin-row {
grid-template-columns: 1.5fr 100px 1fr 140px;
}
#admin-ws-templates .admin-colheaders,
#admin-ws-templates .admin-row {
grid-template-columns: 1.5fr 100px 1fr 180px;
}
/* ==========================================================================
Governance: Audit grid
@@ -1321,6 +1327,9 @@
grid-template-columns: 1fr 100px;
}
.admin-col-tmcat, .admin-col-tmvars { display: none; }
#admin-ws-templates .admin-colheaders, #admin-ws-templates .admin-row {
grid-template-columns: 1fr 140px;
}
#admin-audit .admin-colheaders, #admin-audit .admin-row {
grid-template-columns: 60px 1fr 100px;
}
+25
View File
@@ -71,6 +71,12 @@ def update_workstream_name(ws_id: str, name: str) -> None:
get_storage().update_workstream_name(ws_id, name)
def update_workstream_template(ws_id: str, ws_template_id: str, ws_template_version: int) -> None:
"""Set ws_template_id and ws_template_version on the workstreams row."""
with contextlib.suppress(Exception):
get_storage().update_workstream_template(ws_id, ws_template_id, ws_template_version)
def list_workstreams(node_id: str | None = None, limit: int = 100) -> list[Any]:
"""List workstreams, optionally filtered by node_id."""
try:
@@ -162,6 +168,25 @@ def get_prompt_template_by_name(name: str) -> dict[str, Any] | None:
return None
# -- Workstream templates -----------------------------------------------------
def get_ws_template_by_name(name: str) -> dict[str, Any] | None:
"""Lookup workstream template by name."""
try:
return get_storage().get_ws_template_by_name(name)
except Exception:
return None
def list_ws_templates(enabled_only: bool = False) -> list[dict[str, Any]]:
"""Return all workstream templates, optionally enabled only."""
try:
return get_storage().list_ws_templates(enabled_only=enabled_only)
except Exception:
return []
# -- Workstream metadata ------------------------------------------------------
+53
View File
@@ -257,6 +257,14 @@ class ChatSession:
self._last_usage: dict[str, int] | None = None
self._msg_tokens: list[int] = [] # parallel to self.messages
self._system_tokens = 0 # tokens for system_messages
# Workstream template metadata
self._token_budget: int = 0
self._budget_warned: bool = False
self._budget_exhausted: bool = False
self._notify_on_complete: str = "{}"
self._ws_template_id: str = ""
self._ws_template_version: int = 0
self._ws_template_system_prompt: str = "" # inline prompt from ws_template
self._assistant_pending_tokens = 0
self.creative_mode = False
self._notify_count = 0
@@ -342,6 +350,11 @@ class ChatSession:
"instructions": self.instructions or "",
"creative_mode": str(self.creative_mode),
"template": self._template_name or "",
"token_budget": str(self._token_budget),
"ws_template_id": self._ws_template_id,
"ws_template_version": str(self._ws_template_version),
"ws_template_system_prompt": self._ws_template_system_prompt,
"notify_on_complete": self._notify_on_complete,
},
)
@@ -603,6 +616,19 @@ class ChatSession:
if "template" in config:
self._template_name = config["template"] or None
self._load_templates()
if "token_budget" in config:
self._token_budget = int(config["token_budget"] or "0")
if "ws_template_id" in config:
self._ws_template_id = config["ws_template_id"]
if "ws_template_version" in config:
self._ws_template_version = int(config["ws_template_version"] or "0")
if "ws_template_system_prompt" in config:
self._ws_template_system_prompt = config["ws_template_system_prompt"]
if self._ws_template_system_prompt:
self._template_content = self._ws_template_system_prompt
self._template_name = None
if "notify_on_complete" in config:
self._notify_on_complete = config["notify_on_complete"]
self._init_system_messages()
return True
@@ -896,6 +922,24 @@ class ChatSession:
def send(self, user_input: str) -> None:
"""Send user input and handle the response loop (including tool calls)."""
# Token budget approval gate
if self._budget_exhausted:
approved, _ = self.ui.approve_tools(
[
{
"func_name": "__budget_override__",
"preview": (
f"Token budget ({self._token_budget:,}) exhausted. Approve to continue."
),
"needs_approval": True,
}
]
)
if not approved:
self.ui.on_error("Token budget exhausted. Approval required to continue.")
return
self._budget_exhausted = False
self._budget_warned = False
self._notify_count = 0
self._cancel_event.clear()
self._cancelled_partial_msg = None
@@ -1441,6 +1485,15 @@ class ChatSession:
# Stash completion_tokens for the assistant message about to be appended
self._assistant_pending_tokens = compl_tok
# Token budget tracking
if self._token_budget > 0:
total = prompt_tok + compl_tok
if not self._budget_warned and total >= self._token_budget * 0.8:
self._budget_warned = True
self.ui.on_info(f"Token budget 80% consumed ({total:,}/{self._token_budget:,})")
if total >= self._token_budget:
self._budget_exhausted = True
def _print_status_line(self) -> None:
"""Emit status info via the UI."""
if not self._last_usage:
+224
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -22,6 +23,8 @@ from turnstone.core.storage._schema import (
user_roles,
users,
workstream_config,
workstream_template_versions,
workstream_templates,
workstreams,
)
from turnstone.core.storage._sqlite import _reconstruct_messages
@@ -44,6 +47,25 @@ _ROLE_MUTABLE = frozenset({"display_name", "permissions"})
_ORG_MUTABLE = frozenset({"display_name", "settings"})
_POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
_TEMPLATE_MUTABLE = frozenset({"name", "content", "category", "variables", "is_default"})
_WS_TEMPLATE_MUTABLE = frozenset(
{
"name",
"description",
"system_prompt",
"prompt_template",
"prompt_template_hash",
"model",
"auto_approve",
"auto_approve_tools",
"temperature",
"reasoning_effort",
"max_tokens",
"token_budget",
"agent_max_turns",
"notify_on_complete",
"enabled",
}
)
class PostgreSQLBackend:
@@ -330,6 +352,8 @@ class PostgreSQLBackend:
user_id: str | None = None,
alias: str | None = None,
title: str | None = None,
ws_template_id: str = "",
ws_template_version: int = 0,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
@@ -347,6 +371,8 @@ class PostgreSQLBackend:
"state": state,
"alias": alias,
"title": title,
"ws_template_id": ws_template_id,
"ws_template_version": ws_template_version,
"created": now,
"updated": now,
},
@@ -363,6 +389,22 @@ class PostgreSQLBackend:
)
conn.commit()
def update_workstream_template(
self, ws_id: str, ws_template_id: str, ws_template_version: int
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id == ws_id)
.values(
ws_template_id=ws_template_id,
ws_template_version=ws_template_version,
updated=now,
)
)
conn.commit()
def update_workstream_name(self, ws_id: str, name: str) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
@@ -864,6 +906,7 @@ class PostgreSQLBackend:
created_by: str,
next_run: str,
template: str = "",
ws_template: str = "",
) -> None:
from sqlalchemy.dialects import postgresql
@@ -886,6 +929,7 @@ class PostgreSQLBackend:
auto_approve=1 if auto_approve else 0,
auto_approve_tools=",".join(auto_approve_tools),
template=template,
ws_template=ws_template,
enabled=1,
created_by=created_by,
next_run=next_run,
@@ -929,6 +973,7 @@ class PostgreSQLBackend:
"auto_approve",
"auto_approve_tools",
"template",
"ws_template",
"enabled",
"last_run",
"next_run",
@@ -1613,6 +1658,185 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
# -- Workstream templates --------------------------------------------------
def create_ws_template(
self,
ws_template_id: str,
name: str,
description: str = "",
system_prompt: str = "",
prompt_template: str = "",
prompt_template_hash: str = "",
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 = "",
created_by: str = "",
enabled: bool = True,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(workstream_templates),
{
"ws_template_id": ws_template_id,
"name": name,
"description": description,
"system_prompt": system_prompt,
"prompt_template": prompt_template,
"prompt_template_hash": prompt_template_hash,
"model": model,
"auto_approve": 1 if auto_approve else 0,
"auto_approve_tools": auto_approve_tools,
"temperature": temperature,
"reasoning_effort": reasoning_effort,
"max_tokens": max_tokens,
"token_budget": token_budget,
"agent_max_turns": agent_max_turns,
"notify_on_complete": notify_on_complete,
"org_id": org_id,
"created_by": created_by,
"enabled": 1 if enabled else 0,
"version": 1,
"created": now,
"updated": now,
},
)
conn.commit()
def get_ws_template(self, ws_template_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
).fetchone()
if row:
return _row_to_dict(row, "auto_approve", "enabled")
return None
def get_ws_template_by_name(self, name: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(workstream_templates).where(workstream_templates.c.name == name)
).fetchone()
if row:
return _row_to_dict(row, "auto_approve", "enabled")
return None
def list_ws_templates(
self, org_id: str = "", enabled_only: bool = False
) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(workstream_templates).order_by(workstream_templates.c.name)
if org_id:
q = q.where(workstream_templates.c.org_id == org_id)
if enabled_only:
q = q.where(workstream_templates.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "auto_approve", "enabled") for r in rows]
def update_ws_template(self, ws_template_id: str, changed_by: str = "", **fields: Any) -> bool:
with self._engine.connect() as conn:
# Snapshot current state before updating
current = conn.execute(
sa.select(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
).fetchone()
if not current:
return False
cur = _row_to_dict(current, "auto_approve", "enabled")
# Filter to allowed fields — skip snapshot if no effective changes
dropped = set(fields) - _WS_TEMPLATE_MUTABLE
if dropped:
log.warning("update_ws_template: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _WS_TEMPLATE_MUTABLE}
if not fields:
return True # Nothing to update
# Create version snapshot
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
conn.execute(
sa.insert(workstream_template_versions),
{
"ws_template_id": ws_template_id,
"version": cur["version"],
"snapshot": json.dumps(cur, default=str),
"changed_by": changed_by,
"created": now,
},
)
fields["updated"] = now
fields["version"] = cur["version"] + 1
if "auto_approve" in fields:
fields["auto_approve"] = int(fields["auto_approve"])
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
result = conn.execute(
sa.update(workstream_templates)
.where(workstream_templates.c.ws_template_id == ws_template_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_ws_template(self, ws_template_id: str) -> bool:
with self._engine.connect() as conn:
# Cascade-delete versions first
conn.execute(
sa.delete(workstream_template_versions).where(
workstream_template_versions.c.ws_template_id == ws_template_id
)
)
result = conn.execute(
sa.delete(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
)
conn.commit()
return result.rowcount > 0
def create_ws_template_version(
self,
ws_template_id: str,
version: int,
snapshot: str,
changed_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(workstream_template_versions),
{
"ws_template_id": ws_template_id,
"version": version,
"snapshot": snapshot,
"changed_by": changed_by,
"created": now,
},
)
conn.commit()
def list_ws_template_versions(self, ws_template_id: str) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(workstream_template_versions)
.where(workstream_template_versions.c.ws_template_id == ws_template_id)
.order_by(workstream_template_versions.c.version.desc())
).fetchall()
return [_row_to_dict(r) for r in rows]
# -- Usage events ----------------------------------------------------------
def record_usage_event(
+71
View File
@@ -103,6 +103,8 @@ class StorageBackend(Protocol):
user_id: str | None = None,
alias: str | None = None,
title: str | None = None,
ws_template_id: str = "",
ws_template_version: int = 0,
) -> None:
"""Create a workstreams row (no-op if already exists)."""
...
@@ -115,6 +117,12 @@ class StorageBackend(Protocol):
"""Update a workstream's display name."""
...
def update_workstream_template(
self, ws_id: str, ws_template_id: str, ws_template_version: int
) -> None:
"""Set the ws_template_id and ws_template_version on a workstream row."""
...
def delete_workstream(self, ws_id: str) -> bool:
"""Delete a workstream and all its conversations + config."""
...
@@ -248,6 +256,7 @@ class StorageBackend(Protocol):
created_by: str,
next_run: str,
template: str = "",
ws_template: str = "",
) -> None:
"""Create a scheduled task. No-op if task_id already exists."""
...
@@ -507,6 +516,68 @@ class StorageBackend(Protocol):
"""Delete a prompt template. Returns True if found."""
...
# -- Workstream templates --------------------------------------------------
def create_ws_template(
self,
ws_template_id: str,
name: str,
description: str = "",
system_prompt: str = "",
prompt_template: str = "",
prompt_template_hash: str = "",
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 = "",
created_by: str = "",
enabled: bool = True,
) -> None:
"""Create a workstream template."""
...
def get_ws_template(self, ws_template_id: str) -> dict[str, Any] | None:
"""Return workstream template dict or None."""
...
def get_ws_template_by_name(self, name: str) -> dict[str, Any] | None:
"""Lookup workstream template by name. Returns same dict or None."""
...
def list_ws_templates(
self, org_id: str = "", enabled_only: bool = False
) -> list[dict[str, Any]]:
"""Return all workstream templates ordered by name."""
...
def update_ws_template(self, ws_template_id: str, changed_by: str = "", **fields: Any) -> bool:
"""Update fields on a workstream template. Auto-snapshots version. Returns True if found."""
...
def delete_ws_template(self, ws_template_id: str) -> bool:
"""Delete a workstream template and cascade-delete versions. Returns True if found."""
...
def create_ws_template_version(
self,
ws_template_id: str,
version: int,
snapshot: str,
changed_by: str = "",
) -> None:
"""Create a version snapshot for a workstream template."""
...
def list_ws_template_versions(self, ws_template_id: str) -> list[dict[str, Any]]:
"""List version history for a workstream template, ordered by version DESC."""
...
# -- Usage events ----------------------------------------------------------
def record_usage_event(
+55
View File
@@ -42,6 +42,8 @@ workstreams = sa.Table(
sa.Column("title", sa.Text),
sa.Column("name", sa.Text, nullable=False, server_default=""),
sa.Column("state", sa.Text, nullable=False, server_default="idle"),
sa.Column("ws_template_id", sa.Text, nullable=False, server_default=""),
sa.Column("ws_template_version", sa.Integer, nullable=False, server_default="0"),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
@@ -141,6 +143,7 @@ scheduled_tasks = sa.Table(
sa.Column("auto_approve", sa.Integer, nullable=False, server_default="0"),
sa.Column("auto_approve_tools", sa.Text, nullable=False, server_default=""),
sa.Column("template", sa.Text, nullable=False, server_default=""),
sa.Column("ws_template", sa.Text, nullable=False, server_default=""),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("last_run", sa.Text),
@@ -296,6 +299,58 @@ prompt_templates = sa.Table(
sa.Column("updated", sa.Text, nullable=False),
)
# ---------------------------------------------------------------------------
# Workstream templates — behavioral profiles for workstream creation
# ---------------------------------------------------------------------------
workstream_templates = sa.Table(
"workstream_templates",
metadata,
sa.Column("ws_template_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column("system_prompt", sa.Text, nullable=False, server_default=""),
sa.Column("prompt_template", sa.Text, nullable=False, server_default=""),
sa.Column("prompt_template_hash", sa.Text, nullable=False, server_default=""),
sa.Column("model", sa.Text, nullable=False, server_default=""),
sa.Column("auto_approve", sa.Integer, nullable=False, server_default="0"),
sa.Column("auto_approve_tools", sa.Text, nullable=False, server_default=""),
sa.Column("temperature", sa.Float),
sa.Column("reasoning_effort", sa.Text, nullable=False, server_default=""),
sa.Column("max_tokens", sa.Integer),
sa.Column("token_budget", sa.Integer, nullable=False, server_default="0"),
sa.Column("agent_max_turns", sa.Integer),
sa.Column("notify_on_complete", sa.Text, nullable=False, server_default="{}"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
sa.Index("idx_ws_templates_enabled", workstream_templates.c.enabled)
sa.Index("idx_ws_templates_org", workstream_templates.c.org_id)
workstream_template_versions = sa.Table(
"workstream_template_versions",
metadata,
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("ws_template_id", sa.Text, nullable=False),
sa.Column("version", sa.Integer, nullable=False),
sa.Column("snapshot", sa.Text, nullable=False),
sa.Column("changed_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
)
sa.Index("idx_ws_tpl_versions_tpl", workstream_template_versions.c.ws_template_id)
sa.Index(
"uq_ws_tpl_versions_tpl_ver",
workstream_template_versions.c.ws_template_id,
workstream_template_versions.c.version,
unique=True,
)
usage_events = sa.Table(
"usage_events",
metadata,
+223
View File
@@ -24,6 +24,8 @@ from turnstone.core.storage._schema import (
user_roles,
users,
workstream_config,
workstream_template_versions,
workstream_templates,
workstreams,
)
@@ -60,6 +62,25 @@ _ROLE_MUTABLE = frozenset({"display_name", "permissions"})
_ORG_MUTABLE = frozenset({"display_name", "settings"})
_POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
_TEMPLATE_MUTABLE = frozenset({"name", "content", "category", "variables", "is_default"})
_WS_TEMPLATE_MUTABLE = frozenset(
{
"name",
"description",
"system_prompt",
"prompt_template",
"prompt_template_hash",
"model",
"auto_approve",
"auto_approve_tools",
"temperature",
"reasoning_effort",
"max_tokens",
"token_budget",
"agent_max_turns",
"notify_on_complete",
"enabled",
}
)
class SQLiteBackend:
@@ -402,6 +423,8 @@ class SQLiteBackend:
user_id: str | None = None,
alias: str | None = None,
title: str | None = None,
ws_template_id: str = "",
ws_template_version: int = 0,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
@@ -415,6 +438,8 @@ class SQLiteBackend:
"title": title,
"name": name,
"state": state,
"ws_template_id": ws_template_id,
"ws_template_version": ws_template_version,
"created": now,
"updated": now,
},
@@ -431,6 +456,22 @@ class SQLiteBackend:
)
conn.commit()
def update_workstream_template(
self, ws_id: str, ws_template_id: str, ws_template_version: int
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id == ws_id)
.values(
ws_template_id=ws_template_id,
ws_template_version=ws_template_version,
updated=now,
)
)
conn.commit()
def update_workstream_name(self, ws_id: str, name: str) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
@@ -917,6 +958,7 @@ class SQLiteBackend:
created_by: str,
next_run: str,
template: str = "",
ws_template: str = "",
) -> None:
from turnstone.core.storage._schema import scheduled_tasks
@@ -937,6 +979,7 @@ class SQLiteBackend:
"auto_approve": 1 if auto_approve else 0,
"auto_approve_tools": ",".join(auto_approve_tools),
"template": template,
"ws_template": ws_template,
"enabled": 1,
"created_by": created_by,
"next_run": next_run,
@@ -979,6 +1022,7 @@ class SQLiteBackend:
"auto_approve",
"auto_approve_tools",
"template",
"ws_template",
"enabled",
"last_run",
"next_run",
@@ -1647,6 +1691,185 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
# -- Workstream templates --------------------------------------------------
def create_ws_template(
self,
ws_template_id: str,
name: str,
description: str = "",
system_prompt: str = "",
prompt_template: str = "",
prompt_template_hash: str = "",
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 = "",
created_by: str = "",
enabled: bool = True,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(workstream_templates),
{
"ws_template_id": ws_template_id,
"name": name,
"description": description,
"system_prompt": system_prompt,
"prompt_template": prompt_template,
"prompt_template_hash": prompt_template_hash,
"model": model,
"auto_approve": 1 if auto_approve else 0,
"auto_approve_tools": auto_approve_tools,
"temperature": temperature,
"reasoning_effort": reasoning_effort,
"max_tokens": max_tokens,
"token_budget": token_budget,
"agent_max_turns": agent_max_turns,
"notify_on_complete": notify_on_complete,
"org_id": org_id,
"created_by": created_by,
"enabled": 1 if enabled else 0,
"version": 1,
"created": now,
"updated": now,
},
)
conn.commit()
def get_ws_template(self, ws_template_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
).fetchone()
if row:
return _row_to_dict(row, "auto_approve", "enabled")
return None
def get_ws_template_by_name(self, name: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(workstream_templates).where(workstream_templates.c.name == name)
).fetchone()
if row:
return _row_to_dict(row, "auto_approve", "enabled")
return None
def list_ws_templates(
self, org_id: str = "", enabled_only: bool = False
) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(workstream_templates).order_by(workstream_templates.c.name)
if org_id:
q = q.where(workstream_templates.c.org_id == org_id)
if enabled_only:
q = q.where(workstream_templates.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "auto_approve", "enabled") for r in rows]
def update_ws_template(self, ws_template_id: str, changed_by: str = "", **fields: Any) -> bool:
with self._engine.connect() as conn:
# Snapshot current state before updating
current = conn.execute(
sa.select(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
).fetchone()
if not current:
return False
cur = _row_to_dict(current, "auto_approve", "enabled")
# Filter to allowed fields — skip snapshot if no effective changes
dropped = set(fields) - _WS_TEMPLATE_MUTABLE
if dropped:
log.warning("update_ws_template: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _WS_TEMPLATE_MUTABLE}
if not fields:
return True # Nothing to update
# Create version snapshot
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
conn.execute(
sa.insert(workstream_template_versions),
{
"ws_template_id": ws_template_id,
"version": cur["version"],
"snapshot": json.dumps(cur, default=str),
"changed_by": changed_by,
"created": now,
},
)
fields["updated"] = now
fields["version"] = cur["version"] + 1
if "auto_approve" in fields:
fields["auto_approve"] = int(fields["auto_approve"])
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
result = conn.execute(
sa.update(workstream_templates)
.where(workstream_templates.c.ws_template_id == ws_template_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_ws_template(self, ws_template_id: str) -> bool:
with self._engine.connect() as conn:
# Cascade-delete versions first
conn.execute(
sa.delete(workstream_template_versions).where(
workstream_template_versions.c.ws_template_id == ws_template_id
)
)
result = conn.execute(
sa.delete(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
)
conn.commit()
return result.rowcount > 0
def create_ws_template_version(
self,
ws_template_id: str,
version: int,
snapshot: str,
changed_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(workstream_template_versions),
{
"ws_template_id": ws_template_id,
"version": version,
"snapshot": snapshot,
"changed_by": changed_by,
"created": now,
},
)
conn.commit()
def list_ws_template_versions(self, ws_template_id: str) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(workstream_template_versions)
.where(workstream_template_versions.c.ws_template_id == ws_template_id)
.order_by(workstream_template_versions.c.version.desc())
).fetchall()
return [_row_to_dict(r) for r in rows]
# -- Usage events ----------------------------------------------------------
def record_usage_event(
@@ -0,0 +1,91 @@
"""Create workstream_templates and workstream_template_versions tables.
Revision ID: 011
Revises: 010
Create Date: 2026-03-12
"""
import sqlalchemy as sa
from alembic import op
revision = "011"
down_revision = "010"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"workstream_templates",
sa.Column("ws_template_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column("system_prompt", sa.Text, nullable=False, server_default=""),
sa.Column("prompt_template", sa.Text, nullable=False, server_default=""),
sa.Column("prompt_template_hash", sa.Text, nullable=False, server_default=""),
sa.Column("model", sa.Text, nullable=False, server_default=""),
sa.Column("auto_approve", sa.Integer, nullable=False, server_default="0"),
sa.Column("auto_approve_tools", sa.Text, nullable=False, server_default=""),
sa.Column("temperature", sa.Float),
sa.Column("reasoning_effort", sa.Text, nullable=False, server_default=""),
sa.Column("max_tokens", sa.Integer),
sa.Column("token_budget", sa.Integer, nullable=False, server_default="0"),
sa.Column("agent_max_turns", sa.Integer),
sa.Column("notify_on_complete", sa.Text, nullable=False, server_default="{}"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
op.create_index("idx_ws_templates_enabled", "workstream_templates", ["enabled"])
op.create_index("idx_ws_templates_org", "workstream_templates", ["org_id"])
op.create_table(
"workstream_template_versions",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("ws_template_id", sa.Text, nullable=False),
sa.Column("version", sa.Integer, nullable=False),
sa.Column("snapshot", sa.Text, nullable=False),
sa.Column("changed_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
)
op.create_index("idx_ws_tpl_versions_tpl", "workstream_template_versions", ["ws_template_id"])
op.create_index(
"uq_ws_tpl_versions_tpl_ver",
"workstream_template_versions",
["ws_template_id", "version"],
unique=True,
)
# Add ws_template tracking to workstreams table
with op.batch_alter_table("workstreams") as batch_op:
batch_op.add_column(sa.Column("ws_template_id", sa.Text, nullable=False, server_default=""))
batch_op.add_column(
sa.Column("ws_template_version", sa.Integer, nullable=False, server_default="0")
)
# Add ws_template to scheduled_tasks
with op.batch_alter_table("scheduled_tasks") as batch_op:
batch_op.add_column(sa.Column("ws_template", sa.Text, nullable=False, server_default=""))
# Grant admin.ws_templates permission to the built-in admin role
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || ',admin.ws_templates' "
"WHERE role_id = 'builtin-admin' "
"AND permissions NOT LIKE '%admin.ws_templates%'"
)
)
def downgrade() -> None:
with op.batch_alter_table("scheduled_tasks") as batch_op:
batch_op.drop_column("ws_template")
with op.batch_alter_table("workstreams") as batch_op:
batch_op.drop_column("ws_template_version")
batch_op.drop_column("ws_template_id")
op.drop_table("workstream_template_versions")
op.drop_table("workstream_templates")
+5
View File
@@ -389,6 +389,7 @@ class Bridge:
resume_ws = getattr(msg, "resume_ws", "")
user_id = getattr(msg, "user_id", "")
template = getattr(msg, "template", "")
ws_template = getattr(msg, "ws_template", "")
if user_id:
log.info("bridge.create_ws user_id=%s name=%s model=%s", user_id, name, model)
ws_id, resumed = self._create_ws_on_server(
@@ -399,6 +400,7 @@ class Bridge:
model=model,
resume_ws=resume_ws,
template=template,
ws_template=ws_template,
)
# Send initial_message only when no workstream was actually resumed.
# Use the server's `resumed` response (not just the intent) so that
@@ -467,6 +469,7 @@ class Bridge:
model: str = "",
resume_ws: str = "",
template: str = "",
ws_template: str = "",
) -> tuple[str, bool]:
"""Create a workstream on the server. Returns (ws_id, resumed)."""
try:
@@ -477,6 +480,8 @@ class Bridge:
payload["resume_ws"] = resume_ws
if template:
payload["template"] = template
if ws_template:
payload["ws_template"] = ws_template
resp = self._http.post(
"/v1/api/workstreams/new",
json=payload,
+2
View File
@@ -127,6 +127,7 @@ class TurnstoneClient:
target_node: str = "",
initial_message: str = "",
template: str = "",
ws_template: str = "",
) -> str:
"""Create a workstream. Returns correlation_id."""
msg = CreateWorkstreamMessage(
@@ -136,6 +137,7 @@ class TurnstoneClient:
target_node=target_node,
initial_message=initial_message,
template=template,
ws_template=ws_template,
)
self._broker.push_inbound(msg.to_json(), node_id=target_node)
return msg.correlation_id
+1
View File
@@ -98,6 +98,7 @@ class CreateWorkstreamMessage(InboundMessage):
resume_ws: str = ""
user_id: str = ""
template: str = ""
ws_template: str = ""
@dataclass
+78
View File
@@ -26,12 +26,15 @@ from turnstone.api.console_schemas import (
ListRolesResponse,
ListToolPoliciesResponse,
ListUserRolesResponse,
ListWsTemplatesResponse,
ListWsTemplateVersionsResponse,
NodeDetailResponse,
OrgInfo,
PromptTemplateInfo,
RoleInfo,
ToolPolicyInfo,
UsageResponse,
WsTemplateInfo,
)
from turnstone.api.schemas import (
AuthLoginResponse,
@@ -127,6 +130,7 @@ class AsyncTurnstoneConsole(_BaseClient):
model: str = "",
initial_message: str = "",
template: str = "",
ws_template: str = "",
) -> ConsoleCreateWsResponse:
body: dict[str, Any] = {}
if node_id:
@@ -139,6 +143,8 @@ class AsyncTurnstoneConsole(_BaseClient):
body["initial_message"] = initial_message
if template:
body["template"] = template
if ws_template:
body["ws_template"] = ws_template
return await self._request(
"POST",
"/v1/api/cluster/workstreams/new",
@@ -465,6 +471,56 @@ class AsyncTurnstoneConsole(_BaseClient):
response_model=StatusResponse,
)
# -- governance: workstream templates ------------------------------------
async def list_ws_templates(self) -> ListWsTemplatesResponse:
"""List all workstream templates."""
return await self._request(
"GET", "/v1/api/admin/ws-templates", response_model=ListWsTemplatesResponse
)
async def create_ws_template(self, name: str, **kwargs: Any) -> WsTemplateInfo:
"""Create a workstream template."""
payload: dict[str, Any] = {"name": name, **kwargs}
return await self._request(
"POST", "/v1/api/admin/ws-templates", json_body=payload, response_model=WsTemplateInfo
)
async def get_ws_template(self, ws_template_id: str) -> WsTemplateInfo:
"""Get a workstream template by ID."""
return await self._request(
"GET",
f"/v1/api/admin/ws-templates/{ws_template_id}",
response_model=WsTemplateInfo,
)
async def update_ws_template(self, ws_template_id: str, **kwargs: Any) -> WsTemplateInfo:
"""Update a workstream template."""
return await self._request(
"PUT",
f"/v1/api/admin/ws-templates/{ws_template_id}",
json_body=kwargs,
response_model=WsTemplateInfo,
)
async def delete_ws_template(self, ws_template_id: str) -> StatusResponse:
"""Delete a workstream template."""
return await self._request(
"DELETE",
f"/v1/api/admin/ws-templates/{ws_template_id}",
response_model=StatusResponse,
)
async def list_ws_template_versions(
self, ws_template_id: str
) -> ListWsTemplateVersionsResponse:
"""List version history for a workstream template."""
return await self._request(
"GET",
f"/v1/api/admin/ws-templates/{ws_template_id}/versions",
response_model=ListWsTemplateVersionsResponse,
)
# -- governance: usage & audit -------------------------------------------
async def get_usage(
@@ -578,6 +634,7 @@ class TurnstoneConsole:
model: str = "",
initial_message: str = "",
template: str = "",
ws_template: str = "",
) -> ConsoleCreateWsResponse:
return self._runner.run(
self._async.create_workstream(
@@ -586,6 +643,7 @@ class TurnstoneConsole:
model=model,
initial_message=initial_message,
template=template,
ws_template=ws_template,
)
)
@@ -783,6 +841,26 @@ class TurnstoneConsole:
def delete_template(self, template_id: str) -> StatusResponse:
return self._runner.run(self._async.delete_template(template_id))
# -- governance: workstream templates ------------------------------------
def list_ws_templates(self) -> ListWsTemplatesResponse:
return self._runner.run(self._async.list_ws_templates())
def create_ws_template(self, name: str, **kwargs: Any) -> WsTemplateInfo:
return self._runner.run(self._async.create_ws_template(name, **kwargs))
def get_ws_template(self, ws_template_id: str) -> WsTemplateInfo:
return self._runner.run(self._async.get_ws_template(ws_template_id))
def update_ws_template(self, ws_template_id: str, **kwargs: Any) -> WsTemplateInfo:
return self._runner.run(self._async.update_ws_template(ws_template_id, **kwargs))
def delete_ws_template(self, ws_template_id: str) -> StatusResponse:
return self._runner.run(self._async.delete_ws_template(ws_template_id))
def list_ws_template_versions(self, ws_template_id: str) -> ListWsTemplateVersionsResponse:
return self._runner.run(self._async.list_ws_template_versions(ws_template_id))
# -- governance: usage & audit -------------------------------------------
def get_usage(
+5
View File
@@ -78,6 +78,7 @@ class AsyncTurnstoneServer(_BaseClient):
auto_approve: bool = False,
resume_ws: str = "",
template: str = "",
ws_template: str = "",
) -> CreateWorkstreamResponse:
body: dict[str, Any] = {}
if name:
@@ -90,6 +91,8 @@ class AsyncTurnstoneServer(_BaseClient):
body["resume_ws"] = resume_ws
if template:
body["template"] = template
if ws_template:
body["ws_template"] = ws_template
return await self._request(
"POST",
"/v1/api/workstreams/new",
@@ -322,6 +325,7 @@ class TurnstoneServer:
auto_approve: bool = False,
resume_ws: str = "",
template: str = "",
ws_template: str = "",
) -> CreateWorkstreamResponse:
return self._runner.run(
self._async.create_workstream(
@@ -330,6 +334,7 @@ class TurnstoneServer:
auto_approve=auto_approve,
resume_ws=resume_ws,
template=template,
ws_template=ws_template,
)
)
+91 -3
View File
@@ -91,6 +91,7 @@ class WebUI:
self._plan_event = threading.Event()
self._plan_result: str = ""
self.auto_approve = False
self.auto_approve_tools: set[str] = set()
# Per-workstream metrics accumulators (written by worker thread, read by metrics handler)
self._ws_lock = threading.Lock()
self._ws_prompt_tokens: int = 0
@@ -253,7 +254,20 @@ class WebUI:
log.debug("Tool policy evaluation failed", exc_info=True)
# -- End tool policy evaluation -------------------------------------------
if not pending or self.auto_approve:
# Per-tool auto-approve check (server-side, from workstream template)
if pending and self.auto_approve_tools:
pending_names = {
it.get("approval_label", "") or it.get("func_name", "")
for it in pending
if it.get("func_name")
}
if pending_names and pending_names.issubset(self.auto_approve_tools):
pending = []
# Budget override requires explicit approval — never auto-approved by
# blanket auto_approve (tool policies can still allow it explicitly).
has_budget_override = any(it.get("func_name") == "__budget_override__" for it in pending)
if not pending or (self.auto_approve and not has_budget_override):
# Track auto-approved tool activity
first = items[0] if items else {}
label = first.get("func_name", "")
@@ -1014,11 +1028,26 @@ async def create_workstream(request: Request) -> JSONResponse:
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = getattr(auth, "user_id", "") or ""
body_template = body.get("template", "")
# Resolve workstream template before creation (model override flows through)
ws_template_name = body.get("ws_template", "")
ws_tpl: dict[str, Any] | None = None
if ws_template_name:
from turnstone.core.memory import get_ws_template_by_name
ws_tpl = get_ws_template_by_name(ws_template_name)
if not ws_tpl or not ws_tpl.get("enabled"):
return JSONResponse(
{"error": f"Workstream template not found or disabled: {ws_template_name}"},
status_code=400,
)
resolved_model = body.get("model") or None
if ws_tpl and ws_tpl.get("model"):
resolved_model = ws_tpl["model"]
try:
ws = mgr.create(
name=body.get("name", ""),
ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid),
model=body.get("model") or None,
model=resolved_model,
)
assert isinstance(ws.ui, WebUI)
if skip or body.get("auto_approve", False):
@@ -1063,7 +1092,11 @@ async def create_workstream(request: Request) -> JSONResponse:
# Per-workstream template override — only when not resumed (resumed
# workstreams restore their own template from workstream_config).
if body_template and not resumed and ws.session:
# 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):
@@ -1074,6 +1107,61 @@ async def create_workstream(request: Request) -> JSONResponse:
)
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
# System prompt: inline takes precedence over prompt_template ref
if ws_tpl["system_prompt"]:
sess._template_content = ws_tpl["system_prompt"]
sess._template_name = None
sess._ws_template_system_prompt = ws_tpl["system_prompt"]
sess._init_system_messages()
elif ws_tpl["prompt_template"]:
sess.set_template(ws_tpl["prompt_template"])
# Check for prompt template content drift
if ws_tpl.get("prompt_template_hash"):
import hashlib
from turnstone.core.memory import get_prompt_template_by_name
pt = get_prompt_template_by_name(ws_tpl["prompt_template"])
if pt:
current_hash = hashlib.sha256(pt.get("content", "").encode()).hexdigest()
if current_hash != ws_tpl["prompt_template_hash"]:
log.warning(
"Prompt template '%s' content has changed since "
"WS template '%s' was last updated",
ws_tpl["prompt_template"],
ws_tpl["name"],
)
# Session settings
if ws_tpl.get("temperature") is not None:
sess.temperature = ws_tpl["temperature"]
if ws_tpl["reasoning_effort"]:
sess.reasoning_effort = ws_tpl["reasoning_effort"]
if ws_tpl.get("max_tokens") is not None:
sess.max_tokens = ws_tpl["max_tokens"]
if ws_tpl["token_budget"] > 0:
sess._token_budget = ws_tpl["token_budget"]
if ws_tpl.get("agent_max_turns") is not None:
sess.agent_max_turns = ws_tpl["agent_max_turns"]
# Approval policy
if ws_tpl["auto_approve"]:
ws.ui.auto_approve = True
if ws_tpl["auto_approve_tools"]:
ws.ui.auto_approve_tools = {
t.strip() for t in ws_tpl["auto_approve_tools"].split(",") if t.strip()
}
# Metadata
sess._notify_on_complete = ws_tpl.get("notify_on_complete", "{}")
sess._ws_template_id = ws_tpl["ws_template_id"]
sess._ws_template_version = ws_tpl["version"]
sess._save_config()
# Persist template lineage on the workstreams row
from turnstone.core.memory import update_workstream_template
update_workstream_template(ws.id, ws_tpl["ws_template_id"], ws_tpl["version"])
return JSONResponse(
{
"ws_id": ws.id,