mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 087f5b49f6 | |||
| fd507c6a3c | |||
| 562c3c8ab7 | |||
| 4773535bb8 | |||
| 7492816ab2 | |||
| d6ba1d5e25 |
@@ -17,6 +17,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
|
||||
- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use
|
||||
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
|
||||
- **Cluster dashboard** — real-time view of all nodes and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
|
||||
- **Governance & compliance** — role-based access control, tool policies, usage tracking, and append-only audit logs
|
||||
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
|
||||
|
||||
<p align="center">
|
||||
@@ -127,6 +128,23 @@ Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
|
||||
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose service topology |
|
||||
| [SDK Architecture](docs/diagrams/png/13-sdk-architecture.png) | Python + TypeScript client libraries |
|
||||
| [Storage Architecture](docs/diagrams/png/14-storage-architecture.png) | Pluggable database backends (SQLite + PostgreSQL) |
|
||||
| [Auth Architecture](docs/diagrams/png/15-auth-architecture.png) | JWT, scopes, token types, login flows |
|
||||
| [Channel Architecture](docs/diagrams/png/16-channel-architecture.png) | Discord/Slack adapter protocol and routing |
|
||||
| [Notify Flow](docs/diagrams/png/17-notify-flow.png) | Channel notification dispatch |
|
||||
| [Watch Architecture](docs/diagrams/png/18-watch-architecture.png) | Periodic command polling daemon |
|
||||
| [Governance Architecture](docs/diagrams/png/19-governance-architecture.png) | RBAC, policies, audit, usage enforcement flow |
|
||||
|
||||
### Governance
|
||||
|
||||
Turnstone includes a built-in governance layer for enterprise deployments — manage who can do what, which tools run unattended, and where every token goes.
|
||||
|
||||
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
|
||||
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
|
||||
- **Prompt templates** — reusable system messages with `{{variable}}` substitution and categories
|
||||
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
|
||||
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
|
||||
|
||||
All governance features are managed through the console admin panel (10 tabs) and the full REST API. See [docs/governance.md](docs/governance.md) for setup and configuration.
|
||||
|
||||
## Multi-node routing
|
||||
|
||||
@@ -151,7 +169,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
|
||||
## Tools
|
||||
|
||||
15 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
16 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
|
||||
| Tool | Description | Auto-approved |
|
||||
|------|-------------|:---:|
|
||||
@@ -168,6 +186,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
| `recall` | Search memories and history | yes |
|
||||
| `forget` | Remove a memory | yes |
|
||||
| `notify` | Send notifications to linked channels | yes |
|
||||
| `watch` | Periodic command polling with conditions | |
|
||||
| `task` | Spawn autonomous sub-agent | |
|
||||
| `plan` | Explore codebase, write .plan.md | |
|
||||
| `mcp__*` | External tools from MCP servers | |
|
||||
|
||||
@@ -448,6 +448,14 @@ after `/clear` or `/new` commands).
|
||||
{"type": "clear_ui"}
|
||||
```
|
||||
|
||||
**`cancelled`** -- the generation was cancelled by the user (via the Stop
|
||||
button or `POST /v1/api/cancel`). The client should finalize any in-progress
|
||||
assistant message with whatever partial content was streamed.
|
||||
|
||||
```json
|
||||
{"type": "cancelled"}
|
||||
```
|
||||
|
||||
#### Keepalive
|
||||
|
||||
The server sends an SSE comment every 5 seconds when no events are pending:
|
||||
@@ -701,6 +709,43 @@ containing the resumed session's messages.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/cancel`
|
||||
|
||||
Cancels the active generation in a workstream. Sets a cooperative cancellation
|
||||
flag that is checked at multiple points in the generation loop (per streaming
|
||||
chunk, before tool execution, inside bash commands). The session transitions to
|
||||
`idle` state and preserves any partial content already streamed.
|
||||
|
||||
If the workstream is waiting for tool approval or plan review, the pending
|
||||
prompt is automatically denied/rejected to unblock the worker thread.
|
||||
|
||||
Calling this endpoint when the workstream is already idle is a harmless no-op.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"ws_id": "abc123"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|----------------------|
|
||||
| `ws_id`| string | yes | Target workstream ID |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------------------------------------|------------------------|
|
||||
| 400 | `{"error": "No session"}` | Session not initialized|
|
||||
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/new`
|
||||
|
||||
Creates a new workstream. The server supports up to 10 concurrent workstreams.
|
||||
|
||||
@@ -129,6 +129,7 @@ A user message flows through the system as follows:
|
||||
| on_reasoning_token() / on_content_token()
|
||||
| accumulate tool_calls from deltas
|
||||
| track finish_reason
|
||||
| _check_cancelled() per chunk (cooperative cancel)
|
||||
v
|
||||
finish_reason check:
|
||||
+--- "length" --> warn, discard partial tool_calls
|
||||
@@ -174,11 +175,13 @@ Phase 2: APPROVE (serial, blocking)
|
||||
_emit_state("running")
|
||||
|
||||
Phase 3: EXECUTE (parallel)
|
||||
_check_cancelled() <-- cancellation checkpoint before execution starts
|
||||
if len(items) == 1:
|
||||
run_one(items[0])
|
||||
else:
|
||||
ThreadPoolExecutor(max_workers=4).map(run_one, items)
|
||||
Bash tool streams stdout line-by-line via ui.on_tool_output_chunk(call_id, line)
|
||||
(cancel_event also checked per line — kills process group on cancel)
|
||||
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
||||
call_id links tool_info items → streaming chunks → final result
|
||||
For plan tool: post-execution gate via ui.on_plan_review()
|
||||
@@ -209,6 +212,11 @@ The engine emits state changes via `_emit_state()` which calls
|
||||
"idle" ---> no more tool calls, turn complete
|
||||
|
|
||||
(or "error" ---> exception or KeyboardInterrupt)
|
||||
|
||||
cancel() may be called from any state. It sets a cooperative flag
|
||||
checked at each streaming chunk, before tool execution, and inside
|
||||
bash commands. The session transitions to "idle" with partial
|
||||
content preserved, emitting on_info("[Generation cancelled]").
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1173,6 +1181,10 @@ bridge auto-approves via `POST /v1/api/approve`. Otherwise, it publishes an
|
||||
`BLPOP` of a Redis response queue (`turnstone:resp:{request_id}`) until the client pushes
|
||||
a response or the approval timeout (default 3600s / 1 hour) expires.
|
||||
|
||||
**Cancellation:** The `CancelMessage` (type `"cancel"`) is a routed inbound message.
|
||||
The bridge dispatches it to `POST /v1/api/cancel` on the server owning the workstream,
|
||||
which sets the cooperative cancel flag and unblocks any pending approval/plan waits.
|
||||
|
||||
**Completion detection:** The bridge tracks which `correlation_id` maps to which
|
||||
`ws_id` for active sends. When the global SSE reports `ws_state → idle` for a tracked
|
||||
workstream, the bridge emits a synthetic `TurnCompleteEvent` with the correlation ID.
|
||||
@@ -1340,3 +1352,28 @@ gateway validates the JWT, resolves the target (username lookup via
|
||||
the appropriate `ChannelAdapter.send()`. Delivery retries up to 3 times
|
||||
with backoff, re-querying the service registry on each attempt. See
|
||||
[Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
|
||||
---
|
||||
|
||||
## Governance
|
||||
|
||||
> See also: [Governance documentation](governance.md) | [Governance Architecture diagram](diagrams/19-governance-architecture.puml)
|
||||
|
||||
Turnstone governance extends the Phase 1 auth system with role-based access
|
||||
control (RBAC), tool execution policies, prompt templates, usage tracking,
|
||||
and audit logging. The permission model has two layers: legacy scopes
|
||||
(`read`, `write`, `approve`) checked by `AuthMiddleware`, and 15 granular
|
||||
permissions checked per-endpoint by `require_permission()`. Three built-in
|
||||
roles (admin, operator, viewer) are seeded by migration 008; custom roles
|
||||
can be created with any permission subset. JWTs carry both `scopes` and
|
||||
`permissions` claims for backward compatibility.
|
||||
|
||||
Tool policies use glob pattern matching (`fnmatch`) with priority-ordered
|
||||
first-match-wins evaluation to control tool execution (allow/deny/ask).
|
||||
Prompt templates provide reusable system messages with `{{variable}}`
|
||||
substitution. Usage events are recorded per-LLM-request for token
|
||||
accounting. An append-only audit log captures all admin mutations.
|
||||
|
||||
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.
|
||||
|
||||
@@ -57,6 +57,14 @@ group loop [while tool_calls present]
|
||||
end
|
||||
end
|
||||
|
||||
note right of CS
|
||||
**Cancellation checkpoint:**
|
||||
_check_cancelled() runs per chunk.
|
||||
If cancel_event is set, raises
|
||||
GenerationCancelled — preserves
|
||||
partial content, emits idle state.
|
||||
end note
|
||||
|
||||
LLM --> CS : stream complete (usage stats)
|
||||
deactivate LLM
|
||||
|
||||
@@ -144,6 +152,12 @@ group loop [while tool_calls present]
|
||||
end
|
||||
|
||||
note right of CS : Loop back for next LLM call
|
||||
|
||||
else GenerationCancelled
|
||||
CS -> CS : Preserve partial content\nor roll back incomplete tools
|
||||
CS -> UI : on_info("[Generation cancelled]")
|
||||
CS -> UI : on_state_change("idle")
|
||||
CS --> User : return (no re-raise)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -88,6 +88,8 @@ partition "Phase 2: Approve" #FFF3E0 {
|
||||
}
|
||||
|
||||
partition "Phase 3: Execute" #E3F2FD {
|
||||
:_check_cancelled();
|
||||
note right: Cancellation checkpoint:\nraises GenerationCancelled if\ncancel event is set
|
||||
if (single tool call?) then (yes)
|
||||
:Execute sequentially:\nrun_one(items[0]);
|
||||
else (multiple)
|
||||
|
||||
@@ -79,6 +79,12 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
|
||||
type = "list_nodes"
|
||||
}
|
||||
|
||||
class CancelMessage {
|
||||
type = "cancel"
|
||||
--
|
||||
+ ws_id: str
|
||||
}
|
||||
|
||||
IM <|-- SendMessage
|
||||
IM <|-- ApproveMessage
|
||||
IM <|-- PlanFeedbackMessage
|
||||
@@ -88,6 +94,7 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
|
||||
IM <|-- ListWorkstreamsMessage
|
||||
IM <|-- HealthMessage
|
||||
IM <|-- ListNodesMessage
|
||||
IM <|-- CancelMessage
|
||||
}
|
||||
|
||||
package "Outbound Events (Bridge → Client)" #E3F2FD {
|
||||
|
||||
@@ -40,6 +40,12 @@ running --> error : Exception during\ntool execution
|
||||
|
||||
error --> thinking : New send() call\n_emit_state("thinking")
|
||||
|
||||
thinking --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
running --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
||||
|
||||
note right of thinking
|
||||
**Emitted via:**
|
||||
session._emit_state(state)
|
||||
|
||||
@@ -32,6 +32,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ approve()
|
||||
+ plan_feedback()
|
||||
+ command()
|
||||
+ cancel(ws_id)
|
||||
+ stream_events(ws_id)
|
||||
+ stream_global_events()
|
||||
+ send_and_wait()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName "IBM Plex Mono"
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Turnstone Governance Architecture
|
||||
|
||||
package "Auth Flow" {
|
||||
[Login/Token Auth] as auth
|
||||
[_load_user_permissions()] as perms
|
||||
[_permissions_to_scopes()] as scopes
|
||||
[create_jwt()] as jwt
|
||||
}
|
||||
|
||||
package "Middleware" {
|
||||
[AuthMiddleware\n(scope check)] as mw
|
||||
[require_permission()\n(granular check)] as rp
|
||||
}
|
||||
|
||||
package "Governance Storage" {
|
||||
database "roles" as roles_db
|
||||
database "user_roles" as ur_db
|
||||
database "orgs" as orgs_db
|
||||
database "tool_policies" as tp_db
|
||||
database "prompt_templates" as pt_db
|
||||
database "usage_events" as ue_db
|
||||
database "audit_events" as ae_db
|
||||
}
|
||||
|
||||
package "Runtime Enforcement" {
|
||||
[evaluate_tool_policies_batch()] as eval
|
||||
[WebUI.approve_tools()] as approve
|
||||
[record_usage_event()] as usage
|
||||
[record_audit()] as audit
|
||||
}
|
||||
|
||||
package "Console UI" {
|
||||
[Admin Panel\n10 tabs] as ui
|
||||
[governance.js] as govjs
|
||||
[sessionStorage\npermissions] as ss
|
||||
}
|
||||
|
||||
auth --> perms : user_id
|
||||
perms --> roles_db : JOIN user_roles + roles
|
||||
perms --> scopes : permission set
|
||||
scopes --> jwt : scopes + permissions
|
||||
|
||||
jwt --> mw : JWT in cookie/header
|
||||
mw --> rp : scope OK → check permission
|
||||
|
||||
rp --> ui : 403 or allow
|
||||
|
||||
eval --> tp_db : list_tool_policies()
|
||||
approve --> eval : tool names
|
||||
approve --> ae_db : (via audit)
|
||||
|
||||
usage --> ue_db : on_status()
|
||||
audit --> ae_db : admin handlers
|
||||
|
||||
govjs --> roles_db : /v1/api/admin/roles
|
||||
govjs --> tp_db : /v1/api/admin/policies
|
||||
govjs --> pt_db : /v1/api/admin/templates
|
||||
govjs --> ue_db : /v1/api/admin/usage
|
||||
govjs --> ae_db : /v1/api/admin/audit
|
||||
|
||||
auth -[hidden]-> mw
|
||||
mw -[hidden]-> approve
|
||||
@enduml
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e3044c738d6d6853aab5c4990e6c67bab0165eba991a4f5bebdfc4d4a0b305ee
|
||||
size 289165
|
||||
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
|
||||
size 319702
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:282820fe416961e735d050f86ecdc079e29824d2b3c4d5c8c174d0533d41f211
|
||||
size 258045
|
||||
oid sha256:a3ffd93ccb634f76560f1dd65242b89cd34443b37e355f25f29a1c63a22001be
|
||||
size 265259
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:32a0665cceffcc0517265bde12cfb227688aa8585284b5e946ab23bcc52daee6
|
||||
size 187650
|
||||
oid sha256:d17f3feacf7bc9f64dfea19464143bc9b6ef0da5d55e6d57c0bc5a73d5724eba
|
||||
size 184466
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e0a3f48cca1b8408862dc4ba04fd340703346f44d84048c99e9900f48e9c7e22
|
||||
size 158867
|
||||
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
|
||||
size 200083
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:435a58aa09d0e6615e78c0be62e5fd9aa6d7329b1e96619744355c42ade649c9
|
||||
size 196502
|
||||
oid sha256:e7c3e40c10425d721f833390ae3531c09af501157fd3142531ba4eba86ff719d
|
||||
size 197112
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b68663599922f72d7ca21be820523a5b472c268897d194e5237bba2441c004ec
|
||||
size 124497
|
||||
@@ -0,0 +1,158 @@
|
||||
# Governance
|
||||
|
||||
Turnstone governance provides role-based access control (RBAC), tool execution
|
||||
policies, prompt templates, usage tracking, and audit logging for the admin
|
||||
console.
|
||||
|
||||
## Architecture
|
||||
|
||||
See [diagram: 19-governance-architecture.puml](diagrams/19-governance-architecture.puml).
|
||||
|
||||
### RBAC (Roles & Permissions)
|
||||
|
||||
The permission model has two layers:
|
||||
|
||||
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
|
||||
on every request based on URL path classification.
|
||||
2. **Permissions** (granular) — 15 permission strings checked per-endpoint by
|
||||
`require_permission()`.
|
||||
|
||||
**Built-in roles** (seeded by migration 008):
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| admin | read, write, approve, admin.users, admin.roles, admin.orgs, admin.policies, admin.templates, admin.audit, admin.usage, admin.schedules, admin.watches, tools.approve, workstreams.create, workstreams.close |
|
||||
| operator | read, write, workstreams.create, workstreams.close |
|
||||
| viewer | read |
|
||||
|
||||
Custom roles can be created with any subset of the 15 valid permissions.
|
||||
|
||||
**Auth flow:**
|
||||
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
|
||||
permissions from all assigned roles
|
||||
2. `_permissions_to_scopes()` derives legacy scopes (any `admin.*` → `approve`)
|
||||
3. JWT created with both `scopes` and `permissions` claims
|
||||
4. Middleware checks scope → handler checks permission via `require_permission()`
|
||||
|
||||
### Tool Policies
|
||||
|
||||
Admin-defined rules that control tool execution:
|
||||
|
||||
- **Pattern matching**: Glob syntax via `fnmatch` (e.g., `bash*`, `file_write`, `*`)
|
||||
- **Actions**: `allow` (auto-approve), `deny` (block), `ask` (normal approval flow)
|
||||
- **Priority**: Higher priority evaluated first, first match wins
|
||||
- **Enforcement**: `evaluate_tool_policies_batch()` called in `WebUI.approve_tools()`
|
||||
before the `auto_approve` check
|
||||
|
||||
### Prompt Templates
|
||||
|
||||
Reusable system message templates with variable substitution:
|
||||
|
||||
- **Variables**: `{{variable_name}}` placeholders in content
|
||||
- **Categories**: general, engineering, support, custom
|
||||
- **Default flag**: `is_default=true` templates intended for new workstreams
|
||||
- **Storage**: `prompt_templates` table with JSON `variables` array
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
Per-LLM-request token and tool call metrics:
|
||||
|
||||
- **Recording**: `on_status()` in `WebUI` records a `usage_event` after each
|
||||
LLM response with prompt/completion tokens, tool call count, model, ws_id
|
||||
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
|
||||
and time range filtering
|
||||
- **Pruning**: `prune_usage_events(retention_days=90)` and
|
||||
`prune_audit_events(retention_days=365)` run automatically via the
|
||||
console scheduler's periodic cleanup cycle
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Append-only trail of admin actions:
|
||||
|
||||
- **Recording**: `record_audit()` helper called from all admin mutation handlers
|
||||
- **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
|
||||
- **Querying**: `GET /v1/api/admin/audit` with action/user/time filters + pagination
|
||||
|
||||
## Database Schema
|
||||
|
||||
Migration 008 adds 7 tables:
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `orgs` | Organizations (single default org for now) |
|
||||
| `roles` | Named permission bundles (3 builtin + custom) |
|
||||
| `user_roles` | User-to-role assignments (composite PK) |
|
||||
| `tool_policies` | Per-tool approve/deny/ask rules |
|
||||
| `prompt_templates` | Reusable system message templates |
|
||||
| `usage_events` | Per-request token/tool metrics |
|
||||
| `audit_events` | Admin action log |
|
||||
|
||||
Also adds `org_id` column to `users` table.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All under `/v1/api/admin/` (requires `approve` scope + granular permission).
|
||||
|
||||
| Group | Endpoints | Permission |
|
||||
|-------|-----------|------------|
|
||||
| Users / Tokens / Channels | 9 (CRUD) | `admin.users` |
|
||||
| Roles | 7 (CRUD + assignment) | `admin.roles` / `admin.users` |
|
||||
| Orgs | 3 (list, get, update) | `admin.orgs` |
|
||||
| Tool Policies | 4 (CRUD) | `admin.policies` |
|
||||
| Prompt Templates | 4 (CRUD) | `admin.templates` |
|
||||
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
|
||||
| Watches | 3 (list, create, cancel) | `admin.watches` |
|
||||
| Usage | 1 (aggregated query) | `admin.usage` |
|
||||
| Audit | 1 (paginated, filtered) | `admin.audit` |
|
||||
|
||||
Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
|
||||
|
||||
## Admin Console UI
|
||||
|
||||
5 new tabs added to the admin panel (10 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
|
||||
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
|
||||
- **Audit** — Filterable log with relative timestamps, load-more pagination
|
||||
|
||||
Tabs are permission-gated: hidden if the user lacks the required permission.
|
||||
|
||||
## SDK
|
||||
|
||||
Both Python and TypeScript console SDKs expose governance methods:
|
||||
|
||||
**Python** (`TurnstoneConsole` / `AsyncTurnstoneConsole`):
|
||||
- `list_roles()`, `create_role()`, `update_role()`, `delete_role()`
|
||||
- `list_user_roles()`, `assign_role()`, `unassign_role()`
|
||||
- `list_orgs()`, `get_org()`, `update_org()`
|
||||
- `list_policies()`, `create_policy()`, `update_policy()`, `delete_policy()`
|
||||
- `list_templates()`, `create_template()`, `update_template()`, `delete_template()`
|
||||
- `get_usage(since, group_by=...)`, `get_audit(action=..., limit=...)`
|
||||
|
||||
**TypeScript** (`TurnstoneConsole`):
|
||||
- Same methods with camelCase naming and typed interfaces
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
|
||||
and requires caller to hold a superset of the target role's permissions
|
||||
- **Permission validation**: Role create/update validates permissions against
|
||||
a 15-item allowlist (`_VALID_PERMISSIONS`)
|
||||
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
|
||||
your own account (matching the self-assignment guard on role endpoints)
|
||||
- **Field allowlists**: Storage `update_*` methods filter fields against
|
||||
allowlists (`_ROLE_MUTABLE`, `_POLICY_MUTABLE`, etc.) — handler bugs
|
||||
cannot overwrite `role_id`, `builtin`, `created`, or other protected columns
|
||||
- **Bootstrap safety**: `handle_auth_setup` fails and rolls back if admin role
|
||||
assignment fails, preventing locked-out first user
|
||||
- **API token RBAC**: `_authenticate_api_token` loads permissions from user's
|
||||
roles, ensuring API tokens are subject to RBAC enforcement
|
||||
- **Policy evaluation is fail-open**: If storage is unavailable, tool policies
|
||||
degrade to the existing approval flow (not auto-approve)
|
||||
- **Audit IP resolution**: `_audit_context()` prefers `X-Forwarded-For` for
|
||||
client IP when behind a reverse proxy, falling back to `request.client.host`
|
||||
@@ -75,6 +75,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
||||
| | `command(*, ws_id, command)` | `StatusResponse` |
|
||||
| | `cancel(ws_id)` | `StatusResponse` |
|
||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||
| | `stream_global_events()` | `Iterator[ServerEvent]` |
|
||||
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
|
||||
@@ -129,6 +130,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `error` | `ErrorEvent` | `message` |
|
||||
| `info` | `InfoEvent` | `message` |
|
||||
| `stream_end` | `StreamEndEvent` | — |
|
||||
| `cancelled` | `CancelledEvent` | — |
|
||||
|
||||
**Global events** (from `stream_global_events()`):
|
||||
|
||||
|
||||
@@ -92,6 +92,34 @@ Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
|
||||
`/static/*`, `/shared/*`, `/docs`, `/openapi.json`, `/api/auth/login`,
|
||||
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`.
|
||||
|
||||
### RBAC (Granular Permissions)
|
||||
|
||||
> See also: [Governance documentation](governance.md)
|
||||
|
||||
Scopes provide coarse endpoint-level access control. For finer-grained
|
||||
enforcement, the governance layer adds 15 named permissions checked
|
||||
per-endpoint by `require_permission()`. Permissions are bundled into
|
||||
roles; users are assigned roles via the `user_roles` join table.
|
||||
|
||||
At login, `_load_user_permissions()` aggregates all permissions from
|
||||
the user's assigned roles. `_permissions_to_scopes()` derives legacy
|
||||
scopes for backward compatibility (e.g., any `admin.*` permission
|
||||
implies the `approve` scope). The JWT carries both `scopes` and
|
||||
`permissions` claims.
|
||||
|
||||
Three built-in roles are seeded by migration 008:
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| admin | All 15 permissions |
|
||||
| operator | read, write, workstreams.create, workstreams.close |
|
||||
| viewer | read |
|
||||
|
||||
Custom roles can be created with any subset of the valid permissions.
|
||||
Role creation and update validate permissions against a static allowlist.
|
||||
Self-assignment is blocked, and assigning a role requires the caller to
|
||||
hold a superset of the target role's permissions.
|
||||
|
||||
---
|
||||
|
||||
## Login Flows
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
+104
-154
@@ -10,9 +10,7 @@
|
||||
"get": {
|
||||
"summary": "List active workstreams",
|
||||
"operationId": "v1_api_workstreams_get",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -31,9 +29,7 @@
|
||||
"get": {
|
||||
"summary": "Dashboard with workstream details and aggregates",
|
||||
"operationId": "v1_api_dashboard_get",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -52,9 +48,7 @@
|
||||
"post": {
|
||||
"summary": "Create a new workstream",
|
||||
"operationId": "v1_api_workstreams_new_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -93,9 +87,7 @@
|
||||
"post": {
|
||||
"summary": "Close a workstream",
|
||||
"operationId": "v1_api_workstreams_close_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -134,9 +126,7 @@
|
||||
"post": {
|
||||
"summary": "Send a user message",
|
||||
"operationId": "v1_api_send_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -185,9 +175,7 @@
|
||||
"post": {
|
||||
"summary": "Approve or deny a tool call",
|
||||
"operationId": "v1_api_approve_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -226,9 +214,7 @@
|
||||
"post": {
|
||||
"summary": "Respond to a plan review",
|
||||
"operationId": "v1_api_plan_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -267,9 +253,7 @@
|
||||
"post": {
|
||||
"summary": "Execute a slash command",
|
||||
"operationId": "v1_api_command_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -314,13 +298,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/cancel": {
|
||||
"post": {
|
||||
"summary": "Cancel the active generation in a workstream",
|
||||
"operationId": "v1_api_cancel_post",
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CancelRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/events": {
|
||||
"get": {
|
||||
"summary": "Per-workstream SSE event stream",
|
||||
"operationId": "v1_api_events_get",
|
||||
"tags": [
|
||||
"Streaming"
|
||||
],
|
||||
"tags": ["Streaming"],
|
||||
"description": "Opens a Server-Sent Events stream scoped to a single workstream. Returns text/event-stream. See API reference for event types.",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -354,9 +385,7 @@
|
||||
"get": {
|
||||
"summary": "Global SSE event stream",
|
||||
"operationId": "v1_api_events_global_get",
|
||||
"tags": [
|
||||
"Streaming"
|
||||
],
|
||||
"tags": ["Streaming"],
|
||||
"description": "Global Server-Sent Events stream for state-change broadcasts across all workstreams. Returns text/event-stream.",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -369,9 +398,7 @@
|
||||
"get": {
|
||||
"summary": "List saved workstreams",
|
||||
"operationId": "v1_api_workstreams_saved_get",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -390,9 +417,7 @@
|
||||
"post": {
|
||||
"summary": "Authenticate with a token",
|
||||
"operationId": "v1_api_auth_login_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -431,9 +456,7 @@
|
||||
"post": {
|
||||
"summary": "Create first admin user",
|
||||
"operationId": "v1_api_auth_setup_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -492,9 +515,7 @@
|
||||
"get": {
|
||||
"summary": "Return auth state",
|
||||
"operationId": "v1_api_auth_status_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -513,9 +534,7 @@
|
||||
"post": {
|
||||
"summary": "Clear auth cookie",
|
||||
"operationId": "v1_api_auth_logout_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -534,9 +553,7 @@
|
||||
"get": {
|
||||
"summary": "Server health check",
|
||||
"operationId": "health_get",
|
||||
"tags": [
|
||||
"Observability"
|
||||
],
|
||||
"tags": ["Observability"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -563,9 +580,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"required": ["error"],
|
||||
"title": "ErrorResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -574,9 +589,7 @@
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"examples": [
|
||||
"ok"
|
||||
],
|
||||
"examples": ["ok"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -625,19 +638,14 @@
|
||||
},
|
||||
"role": {
|
||||
"description": "Legacy role",
|
||||
"examples": [
|
||||
"full",
|
||||
"read"
|
||||
],
|
||||
"examples": ["full", "read"],
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"default": "",
|
||||
"description": "Comma-separated scopes",
|
||||
"examples": [
|
||||
"read,write,approve"
|
||||
],
|
||||
"examples": ["read,write,approve"],
|
||||
"title": "Scopes",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -648,9 +656,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role"
|
||||
],
|
||||
"required": ["role"],
|
||||
"title": "AuthLoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -673,11 +679,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"display_name",
|
||||
"password"
|
||||
],
|
||||
"required": ["username", "display_name", "password"],
|
||||
"title": "AuthSetupRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -714,10 +716,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user_id",
|
||||
"username"
|
||||
],
|
||||
"required": ["user_id", "username"],
|
||||
"title": "AuthSetupResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -737,11 +736,7 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"auth_enabled",
|
||||
"has_users",
|
||||
"setup_required"
|
||||
],
|
||||
"required": ["auth_enabled", "has_users", "setup_required"],
|
||||
"title": "AuthStatusResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -758,10 +753,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["message", "ws_id"],
|
||||
"title": "SendRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -769,17 +761,12 @@
|
||||
"properties": {
|
||||
"status": {
|
||||
"description": "'ok' or 'busy'",
|
||||
"examples": [
|
||||
"ok",
|
||||
"busy"
|
||||
],
|
||||
"examples": ["ok", "busy"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"required": ["status"],
|
||||
"title": "SendResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -815,10 +802,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"approved",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["approved", "ws_id"],
|
||||
"title": "ApproveRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -835,10 +819,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"feedback",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["feedback", "ws_id"],
|
||||
"title": "PlanFeedbackRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -855,13 +836,22 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["command", "ws_id"],
|
||||
"title": "CommandRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"CancelRequest": {
|
||||
"properties": {
|
||||
"ws_id": {
|
||||
"description": "Target workstream ID",
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["ws_id"],
|
||||
"title": "CancelRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"CreateWorkstreamRequest": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -917,10 +907,7 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_id",
|
||||
"name"
|
||||
],
|
||||
"required": ["ws_id", "name"],
|
||||
"title": "CreateWorkstreamResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -932,9 +919,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["ws_id"],
|
||||
"title": "CloseWorkstreamRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -948,9 +933,7 @@
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workstreams"
|
||||
],
|
||||
"required": ["workstreams"],
|
||||
"title": "ListWorkstreamsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -969,11 +952,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"state"
|
||||
],
|
||||
"required": ["id", "name", "state"],
|
||||
"title": "WorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -990,10 +969,7 @@
|
||||
"$ref": "#/components/schemas/DashboardAggregate"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workstreams",
|
||||
"aggregate"
|
||||
],
|
||||
"required": ["workstreams", "aggregate"],
|
||||
"title": "DashboardResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1093,11 +1069,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"state"
|
||||
],
|
||||
"required": ["id", "name", "state"],
|
||||
"title": "DashboardWorkstream",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1111,9 +1083,7 @@
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workstreams"
|
||||
],
|
||||
"required": ["workstreams"],
|
||||
"title": "ListSavedWorkstreamsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1160,22 +1130,14 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_id",
|
||||
"created",
|
||||
"updated",
|
||||
"message_count"
|
||||
],
|
||||
"required": ["ws_id", "created", "updated", "message_count"],
|
||||
"title": "SavedWorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"HealthResponse": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"examples": [
|
||||
"ok",
|
||||
"degraded"
|
||||
],
|
||||
"examples": ["ok", "degraded"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1217,36 +1179,24 @@
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"required": ["status"],
|
||||
"title": "HealthResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"BackendStatus": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"examples": [
|
||||
"up",
|
||||
"down"
|
||||
],
|
||||
"examples": ["up", "down"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"circuit_state": {
|
||||
"examples": [
|
||||
"closed",
|
||||
"open",
|
||||
"half_open"
|
||||
],
|
||||
"examples": ["closed", "open", "half_open"],
|
||||
"title": "Circuit State",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"circuit_state"
|
||||
],
|
||||
"required": ["status", "circuit_state"],
|
||||
"title": "BackendStatus",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BaseClient, type ClientOptions } from "./base.js";
|
||||
import type { ClusterEvent } from "./events.js";
|
||||
import type {
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
@@ -11,14 +13,28 @@ import type {
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreatePolicyOptions,
|
||||
CreateRoleOptions,
|
||||
CreateScheduleRequest,
|
||||
CreateTemplateOptions,
|
||||
ListScheduleRunsResponse,
|
||||
ListSchedulesResponse,
|
||||
NodeDetailResponse,
|
||||
NodesOptions,
|
||||
OrgInfo,
|
||||
PromptTemplateInfo,
|
||||
RoleInfo,
|
||||
ScheduleInfo,
|
||||
StatusResponse,
|
||||
ToolPolicyInfo,
|
||||
UpdateOrgOptions,
|
||||
UpdatePolicyOptions,
|
||||
UpdateRoleOptions,
|
||||
UpdateScheduleRequest,
|
||||
UpdateTemplateOptions,
|
||||
UsageQueryOptions,
|
||||
UsageResponse,
|
||||
UserRoleInfo,
|
||||
WorkstreamsOptions,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -157,4 +173,125 @@ export class TurnstoneConsole extends BaseClient {
|
||||
params: { limit: opts?.limit ?? 50 },
|
||||
});
|
||||
}
|
||||
|
||||
// -- Governance: Roles ------------------------------------------------------
|
||||
|
||||
async listRoles(): Promise<{ roles: RoleInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/roles");
|
||||
}
|
||||
|
||||
async createRole(opts: CreateRoleOptions): Promise<RoleInfo> {
|
||||
return this.request("POST", "/v1/api/admin/roles", { json: opts });
|
||||
}
|
||||
|
||||
async updateRole(roleId: string, opts: UpdateRoleOptions): Promise<RoleInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/roles/${roleId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRole(roleId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/roles/${roleId}`);
|
||||
}
|
||||
|
||||
async listUserRoles(userId: string): Promise<{ roles: UserRoleInfo[] }> {
|
||||
return this.request("GET", `/v1/api/admin/users/${userId}/roles`);
|
||||
}
|
||||
|
||||
async assignRole(userId: string, roleId: string): Promise<StatusResponse> {
|
||||
return this.request("POST", `/v1/api/admin/users/${userId}/roles`, {
|
||||
json: { role_id: roleId },
|
||||
});
|
||||
}
|
||||
|
||||
async unassignRole(userId: string, roleId: string): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"DELETE",
|
||||
`/v1/api/admin/users/${userId}/roles/${roleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Governance: Organizations ----------------------------------------------
|
||||
|
||||
async listOrgs(): Promise<{ orgs: OrgInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/orgs");
|
||||
}
|
||||
|
||||
async getOrg(orgId: string): Promise<OrgInfo> {
|
||||
return this.request("GET", `/v1/api/admin/orgs/${orgId}`);
|
||||
}
|
||||
|
||||
async updateOrg(orgId: string, opts: UpdateOrgOptions): Promise<OrgInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/orgs/${orgId}`, { json: opts });
|
||||
}
|
||||
|
||||
// -- Governance: Tool Policies ----------------------------------------------
|
||||
|
||||
async listPolicies(): Promise<{ policies: ToolPolicyInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/policies");
|
||||
}
|
||||
|
||||
async createPolicy(opts: CreatePolicyOptions): Promise<ToolPolicyInfo> {
|
||||
return this.request("POST", "/v1/api/admin/policies", { json: opts });
|
||||
}
|
||||
|
||||
async updatePolicy(
|
||||
policyId: string,
|
||||
opts: UpdatePolicyOptions,
|
||||
): Promise<ToolPolicyInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/policies/${policyId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deletePolicy(policyId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/policies/${policyId}`);
|
||||
}
|
||||
|
||||
// -- Governance: Prompt Templates -------------------------------------------
|
||||
|
||||
async listTemplates(): Promise<{ templates: PromptTemplateInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/templates");
|
||||
}
|
||||
|
||||
async createTemplate(
|
||||
opts: CreateTemplateOptions,
|
||||
): Promise<PromptTemplateInfo> {
|
||||
return this.request("POST", "/v1/api/admin/templates", { json: opts });
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
templateId: string,
|
||||
opts: UpdateTemplateOptions,
|
||||
): Promise<PromptTemplateInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/templates/${templateId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTemplate(templateId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/templates/${templateId}`);
|
||||
}
|
||||
|
||||
// -- Governance: Usage & Audit ----------------------------------------------
|
||||
|
||||
async getUsage(opts: UsageQueryOptions): Promise<UsageResponse> {
|
||||
const params: Record<string, string> = { since: opts.since };
|
||||
if (opts.until) params.until = opts.until;
|
||||
if (opts.user_id) params.user_id = opts.user_id;
|
||||
if (opts.model) params.model = opts.model;
|
||||
if (opts.group_by) params.group_by = opts.group_by;
|
||||
return this.request("GET", "/v1/api/admin/usage", { params });
|
||||
}
|
||||
|
||||
async getAudit(opts?: AuditQueryOptions): Promise<AuditResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts?.action) params.action = opts.action;
|
||||
if (opts?.user_id) params.user_id = opts.user_id;
|
||||
if (opts?.since) params.since = opts.since;
|
||||
if (opts?.until) params.until = opts.until;
|
||||
if (opts?.limit !== undefined) params.limit = String(opts.limit);
|
||||
if (opts?.offset !== undefined) params.offset = String(opts.offset);
|
||||
return this.request("GET", "/v1/api/admin/audit", { params });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ export interface ClearUiEvent {
|
||||
type: "clear_ui";
|
||||
}
|
||||
|
||||
export interface CancelledEvent {
|
||||
type: "cancelled";
|
||||
}
|
||||
|
||||
// Global events
|
||||
|
||||
export interface WsStateEvent {
|
||||
@@ -145,6 +149,7 @@ export type ServerEvent =
|
||||
| ErrorEvent
|
||||
| BusyErrorEvent
|
||||
| ClearUiEvent
|
||||
| CancelledEvent
|
||||
| WsStateEvent
|
||||
| WsActivityEvent
|
||||
| WsRenameEvent
|
||||
@@ -247,3 +252,7 @@ export function isApproveRequestEvent(
|
||||
export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
|
||||
return e.type === "plan_review";
|
||||
}
|
||||
|
||||
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
|
||||
return e.type === "cancelled";
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export type {
|
||||
ErrorEvent,
|
||||
BusyErrorEvent,
|
||||
ClearUiEvent,
|
||||
CancelledEvent,
|
||||
WsStateEvent,
|
||||
WsActivityEvent,
|
||||
WsRenameEvent,
|
||||
@@ -67,6 +68,7 @@ export {
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
isPlanReviewEvent,
|
||||
isCancelledEvent,
|
||||
} from "./events.js";
|
||||
|
||||
// Request/response types
|
||||
@@ -112,6 +114,24 @@ export type {
|
||||
ScheduleRunInfo,
|
||||
ListSchedulesResponse,
|
||||
ListScheduleRunsResponse,
|
||||
RoleInfo,
|
||||
CreateRoleOptions,
|
||||
UpdateRoleOptions,
|
||||
UserRoleInfo,
|
||||
OrgInfo,
|
||||
UpdateOrgOptions,
|
||||
ToolPolicyInfo,
|
||||
CreatePolicyOptions,
|
||||
UpdatePolicyOptions,
|
||||
PromptTemplateInfo,
|
||||
CreateTemplateOptions,
|
||||
UpdateTemplateOptions,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
UsageQueryOptions,
|
||||
AuditEventInfo,
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
TurnResult,
|
||||
SendAndWaitOptions,
|
||||
NodesOptions,
|
||||
|
||||
@@ -86,6 +86,12 @@ export class TurnstoneServer extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(wsId: string): Promise<StatusResponse> {
|
||||
return this.request("POST", "/v1/api/cancel", {
|
||||
json: { ws_id: wsId },
|
||||
});
|
||||
}
|
||||
|
||||
// -- Streaming ------------------------------------------------------------
|
||||
|
||||
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
|
||||
|
||||
@@ -354,6 +354,173 @@ export interface ListScheduleRunsResponse {
|
||||
runs: ScheduleRunInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Roles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RoleInfo {
|
||||
role_id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
permissions: string;
|
||||
builtin: boolean;
|
||||
org_id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleOptions {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
permissions?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleOptions {
|
||||
display_name?: string;
|
||||
permissions?: string;
|
||||
}
|
||||
|
||||
export interface UserRoleInfo extends RoleInfo {
|
||||
assigned_by: string;
|
||||
assignment_created: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Orgs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrgInfo {
|
||||
org_id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
settings: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface UpdateOrgOptions {
|
||||
display_name?: string;
|
||||
settings?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Tool Policies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolPolicyInfo {
|
||||
policy_id: string;
|
||||
name: string;
|
||||
tool_pattern: string;
|
||||
action: string;
|
||||
priority: number;
|
||||
org_id: string;
|
||||
enabled: boolean;
|
||||
created_by: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreatePolicyOptions {
|
||||
name: string;
|
||||
tool_pattern: string;
|
||||
action: string;
|
||||
priority?: number;
|
||||
org_id?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePolicyOptions {
|
||||
name?: string;
|
||||
tool_pattern?: string;
|
||||
action?: string;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Prompt Templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptTemplateInfo {
|
||||
template_id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
variables: string;
|
||||
is_default: boolean;
|
||||
org_id: string;
|
||||
created_by: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateOptions {
|
||||
name: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
variables?: string;
|
||||
is_default?: boolean;
|
||||
org_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateOptions {
|
||||
name?: string;
|
||||
content?: string;
|
||||
category?: string;
|
||||
variables?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Usage & Audit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsageBreakdownItem {
|
||||
key?: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
tool_calls_count: number;
|
||||
}
|
||||
|
||||
export interface UsageResponse {
|
||||
summary: UsageBreakdownItem[];
|
||||
breakdown: UsageBreakdownItem[];
|
||||
}
|
||||
|
||||
export interface UsageQueryOptions {
|
||||
since: string;
|
||||
until?: string;
|
||||
user_id?: string;
|
||||
model?: string;
|
||||
group_by?: string;
|
||||
}
|
||||
|
||||
export interface AuditEventInfo {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
user_id: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
detail: string;
|
||||
ip_address: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface AuditQueryOptions {
|
||||
action?: string;
|
||||
user_id?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AuditResponse {
|
||||
events: AuditEventInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK-specific types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for turnstone.core.audit."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
path = str(tmp_path / "test.db")
|
||||
backend = SQLiteBackend(path)
|
||||
yield backend
|
||||
backend.close()
|
||||
|
||||
|
||||
def test_record_audit_basic(storage):
|
||||
record_audit(
|
||||
storage, "user-1", "user.create", "user", "u123", {"username": "alice"}, "127.0.0.1"
|
||||
)
|
||||
events = storage.list_audit_events()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["user_id"] == "user-1"
|
||||
assert ev["action"] == "user.create"
|
||||
assert ev["resource_type"] == "user"
|
||||
assert ev["resource_id"] == "u123"
|
||||
assert ev["ip_address"] == "127.0.0.1"
|
||||
detail = json.loads(ev["detail"])
|
||||
assert detail["username"] == "alice"
|
||||
|
||||
|
||||
def test_record_audit_no_detail(storage):
|
||||
record_audit(storage, "user-1", "token.revoke", "token", "t456")
|
||||
events = storage.list_audit_events()
|
||||
assert len(events) == 1
|
||||
assert events[0]["detail"] == "{}"
|
||||
|
||||
|
||||
def test_record_audit_silent_on_failure():
|
||||
"""record_audit should not raise even if storage is broken."""
|
||||
|
||||
class BrokenStorage:
|
||||
def record_audit_event(self, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# Should not raise
|
||||
record_audit(BrokenStorage(), "u1", "test.action")
|
||||
|
||||
|
||||
def test_record_audit_generates_unique_ids(storage):
|
||||
record_audit(storage, "u1", "a.one")
|
||||
record_audit(storage, "u1", "a.two")
|
||||
events = storage.list_audit_events()
|
||||
assert len(events) == 2
|
||||
assert events[0]["event_id"] != events[1]["event_id"]
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Tests for generation cancellation (cooperative cancel via threading.Event)."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled
|
||||
|
||||
|
||||
class NullUI:
|
||||
"""UI adapter that records state changes and discards other output."""
|
||||
|
||||
def __init__(self):
|
||||
self.states = []
|
||||
self.infos = []
|
||||
self.stream_ends = 0
|
||||
|
||||
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):
|
||||
self.stream_ends += 1
|
||||
|
||||
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):
|
||||
self.infos.append(message)
|
||||
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
self.states.append(state)
|
||||
|
||||
def on_rename(self, name):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(ui=None, **kwargs):
|
||||
"""Helper to construct a ChatSession with minimal setup."""
|
||||
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)
|
||||
|
||||
|
||||
class TestCancelEvent:
|
||||
"""Basic cancel event mechanics."""
|
||||
|
||||
def test_cancel_sets_event(self, tmp_db):
|
||||
session = _make_session()
|
||||
assert not session._cancel_event.is_set()
|
||||
session.cancel()
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_check_cancelled_raises_when_set(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._check_cancelled()
|
||||
|
||||
def test_check_cancelled_noop_when_clear(self, tmp_db):
|
||||
session = _make_session()
|
||||
session._check_cancelled() # Should not raise
|
||||
|
||||
def test_cancel_is_idempotent(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
session.cancel() # Double call is harmless
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_event_cleared_on_send_start(self, tmp_db):
|
||||
"""send() clears a stale cancel flag before starting."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
session.cancel() # Set stale flag
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
fake_stream = iter([FakeChunk(content_delta="Hello", finish_reason="stop")])
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=fake_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# Should complete normally — cancel flag was cleared
|
||||
assert "idle" in ui.states
|
||||
|
||||
|
||||
class TestCancelDuringStreaming:
|
||||
"""Cancel while _stream_response is iterating chunks."""
|
||||
|
||||
def test_preserves_partial_content(self, tmp_db):
|
||||
"""Partial content already streamed should be preserved in messages."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
def cancelling_stream():
|
||||
"""Yield a few chunks then cancel."""
|
||||
yield FakeChunk(content_delta="Hello ")
|
||||
yield FakeChunk(content_delta="world")
|
||||
session.cancel()
|
||||
yield FakeChunk(content_delta=" — this should not appear")
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=cancelling_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# Session should be idle (not error)
|
||||
assert ui.states[-1] == "idle"
|
||||
# Check that "[Generation cancelled]" was emitted
|
||||
assert any("cancelled" in i.lower() for i in ui.infos)
|
||||
# The partial content should be preserved as an assistant message
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["content"] == "Hello world"
|
||||
# No tool_calls in the partial message
|
||||
assert "tool_calls" not in assistant_msgs[0]
|
||||
|
||||
|
||||
class TestCancelDuringToolExecution:
|
||||
"""Cancel while tools are being executed."""
|
||||
|
||||
def test_rollback_incomplete_tool_results(self, tmp_db):
|
||||
"""When cancelled during tool execution, incomplete results are rolled back."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class FakeToolDelta:
|
||||
index: int = 0
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
arguments_delta: str = ""
|
||||
|
||||
# First call: return content with a tool call
|
||||
def stream_with_tool():
|
||||
yield FakeChunk(
|
||||
tool_call_deltas=[FakeToolDelta(index=0, id="tc_1", name="bash")],
|
||||
finish_reason="",
|
||||
)
|
||||
yield FakeChunk(
|
||||
tool_call_deltas=[FakeToolDelta(index=0, arguments_delta='{"command":"echo hi"}')],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_create_stream(msgs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return stream_with_tool()
|
||||
# Should not be called a second time since cancel happens before phase 3
|
||||
raise AssertionError("Should not stream again after cancel")
|
||||
|
||||
def cancel_before_execute(tool_calls):
|
||||
"""Simulate cancel happening before tool execution."""
|
||||
session.cancel()
|
||||
raise GenerationCancelled()
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", side_effect=fake_create_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
patch.object(session, "_execute_tools", side_effect=cancel_before_execute),
|
||||
):
|
||||
session.send("run something")
|
||||
|
||||
# Session should be idle
|
||||
assert ui.states[-1] == "idle"
|
||||
# No tool result messages should remain (rolled back)
|
||||
roles = [m["role"] for m in session.messages]
|
||||
assert "tool" not in roles
|
||||
# The assistant message with tool_calls should also be rolled back
|
||||
for m in session.messages:
|
||||
if m["role"] == "assistant":
|
||||
assert "tool_calls" not in m or not m["tool_calls"]
|
||||
|
||||
|
||||
class TestCancelWhenIdle:
|
||||
"""Cancelling when no generation is active is harmless."""
|
||||
|
||||
def test_cancel_when_idle_is_noop(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
# Next send should work normally (cancel cleared at start)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
fake_stream = iter([FakeChunk(content_delta="ok", finish_reason="stop")])
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=fake_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("hello")
|
||||
|
||||
# Should complete normally
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["content"] == "ok"
|
||||
|
||||
|
||||
class TestCancelThreadSafety:
|
||||
"""Cancel from a different thread while generation is running."""
|
||||
|
||||
def test_cancel_from_another_thread(self, tmp_db):
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
barrier = threading.Event()
|
||||
|
||||
def slow_stream():
|
||||
yield FakeChunk(content_delta="Start")
|
||||
barrier.set() # Signal that streaming has started
|
||||
time.sleep(2) # Simulate slow streaming
|
||||
yield FakeChunk(content_delta=" end", finish_reason="stop")
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=slow_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
# Run send() in a thread
|
||||
error = []
|
||||
|
||||
def run():
|
||||
try:
|
||||
session.send("test")
|
||||
except Exception as e:
|
||||
error.append(e)
|
||||
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
barrier.wait(timeout=5)
|
||||
# Cancel from main thread
|
||||
session.cancel()
|
||||
t.join(timeout=5)
|
||||
|
||||
assert not error
|
||||
assert ui.states[-1] == "idle"
|
||||
assert any("cancelled" in i.lower() for i in ui.infos)
|
||||
|
||||
|
||||
class TestGenerationCancelledException:
|
||||
"""GenerationCancelled is a BaseException, not Exception."""
|
||||
|
||||
def test_is_base_exception(self):
|
||||
assert issubclass(GenerationCancelled, BaseException)
|
||||
|
||||
def test_not_caught_by_except_exception(self):
|
||||
"""Verify GenerationCancelled is NOT caught by except Exception."""
|
||||
with pytest.raises(GenerationCancelled):
|
||||
try:
|
||||
raise GenerationCancelled()
|
||||
except Exception:
|
||||
pytest.fail("GenerationCancelled was caught by except Exception")
|
||||
@@ -0,0 +1,761 @@
|
||||
"""Tests for governance admin API endpoints (roles, orgs, policies, templates, usage, audit)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
admin_assign_role,
|
||||
admin_audit,
|
||||
admin_create_policy,
|
||||
admin_create_role,
|
||||
admin_create_template,
|
||||
admin_delete_policy,
|
||||
admin_delete_role,
|
||||
admin_delete_template,
|
||||
admin_delete_user,
|
||||
admin_get_org,
|
||||
admin_list_orgs,
|
||||
admin_list_policies,
|
||||
admin_list_roles,
|
||||
admin_list_templates,
|
||||
admin_list_user_roles,
|
||||
admin_unassign_role,
|
||||
admin_update_org,
|
||||
admin_update_policy,
|
||||
admin_update_role,
|
||||
admin_update_template,
|
||||
admin_usage,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth bypass middleware — injects a full-access AuthResult on every request.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="test-admin",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
permissions=frozenset(
|
||||
{
|
||||
"read",
|
||||
"write",
|
||||
"approve",
|
||||
"admin.roles",
|
||||
"admin.users",
|
||||
"admin.orgs",
|
||||
"admin.policies",
|
||||
"admin.templates",
|
||||
"admin.usage",
|
||||
"admin.audit",
|
||||
"admin.schedules",
|
||||
"admin.watches",
|
||||
"tools.approve",
|
||||
"workstreams.create",
|
||||
"workstreams.close",
|
||||
}
|
||||
),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
"""Fresh SQLite backend for each test, seeded with test users."""
|
||||
backend = SQLiteBackend(str(tmp_path / "test.db"))
|
||||
# Seed users required by role assignment tests
|
||||
backend.create_user("test-admin", "testadmin", "Test Admin", "hash")
|
||||
backend.create_user("user-1", "user1", "User One", "hash")
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(storage):
|
||||
"""TestClient with storage and auth bypassed."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
# Roles
|
||||
Route("/api/admin/roles", admin_list_roles),
|
||||
Route("/api/admin/roles", admin_create_role, methods=["POST"]),
|
||||
Route("/api/admin/roles/{role_id}", admin_update_role, methods=["PUT"]),
|
||||
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
|
||||
# Users
|
||||
Route(
|
||||
"/api/admin/users/{user_id}",
|
||||
admin_delete_user,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# User-role assignments
|
||||
Route("/api/admin/users/{user_id}/roles", admin_list_user_roles),
|
||||
Route(
|
||||
"/api/admin/users/{user_id}/roles",
|
||||
admin_assign_role,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/users/{user_id}/roles/{role_id}",
|
||||
admin_unassign_role,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Orgs
|
||||
Route("/api/admin/orgs", admin_list_orgs),
|
||||
Route("/api/admin/orgs/{org_id}", admin_get_org),
|
||||
Route("/api/admin/orgs/{org_id}", admin_update_org, methods=["PUT"]),
|
||||
# Policies
|
||||
Route("/api/admin/policies", admin_list_policies),
|
||||
Route("/api/admin/policies", admin_create_policy, methods=["POST"]),
|
||||
Route(
|
||||
"/api/admin/policies/{policy_id}",
|
||||
admin_update_policy,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/policies/{policy_id}",
|
||||
admin_delete_policy,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Templates
|
||||
Route("/api/admin/templates", admin_list_templates),
|
||||
Route("/api/admin/templates", admin_create_template, methods=["POST"]),
|
||||
Route(
|
||||
"/api/admin/templates/{template_id}",
|
||||
admin_update_template,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/templates/{template_id}",
|
||||
admin_delete_template,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Usage & Audit
|
||||
Route("/api/admin/usage", admin_usage),
|
||||
Route("/api/admin/audit", admin_audit),
|
||||
],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _role_payload(**overrides: Any) -> dict[str, Any]:
|
||||
defaults: dict[str, Any] = {
|
||||
"name": "analyst",
|
||||
"display_name": "Data Analyst",
|
||||
"permissions": "read,write",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
def _policy_payload(**overrides: Any) -> dict[str, Any]:
|
||||
defaults: dict[str, Any] = {
|
||||
"name": "Allow bash",
|
||||
"tool_pattern": "bash_*",
|
||||
"action": "allow",
|
||||
"priority": 10,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
def _template_payload(**overrides: Any) -> dict[str, Any]:
|
||||
defaults: dict[str, Any] = {
|
||||
"name": "Greeting",
|
||||
"content": "Hello {{user}}, how can I help?",
|
||||
"category": "system",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoles:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/roles")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["roles"] == []
|
||||
|
||||
def test_create_role(self, client):
|
||||
resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
assert resp.status_code == 200
|
||||
role = resp.json()
|
||||
assert role["name"] == "analyst"
|
||||
assert role["display_name"] == "Data Analyst"
|
||||
assert role["permissions"] == "read,write"
|
||||
assert role["builtin"] is False
|
||||
assert "role_id" in role
|
||||
assert "created" in role
|
||||
|
||||
def test_create_role_missing_name(self, client):
|
||||
resp = client.post("/v1/api/admin/roles", json=_role_payload(name=""))
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_role_invalid_name(self, client):
|
||||
resp = client.post("/v1/api/admin/roles", json=_role_payload(name="bad name!@#"))
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_role_default_display_name(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/roles",
|
||||
json={"name": "ops", "permissions": ""},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
role = resp.json()
|
||||
# display_name defaults to name when not provided
|
||||
assert role["display_name"] == "ops"
|
||||
|
||||
def test_list_after_create(self, client):
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
resp = client.get("/v1/api/admin/roles")
|
||||
assert resp.status_code == 200
|
||||
roles = resp.json()["roles"]
|
||||
assert len(roles) == 1
|
||||
assert roles[0]["name"] == "analyst"
|
||||
|
||||
def test_update_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/roles/{role_id}",
|
||||
json={"display_name": "Senior Analyst", "permissions": "read,write,approve"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
role = resp.json()
|
||||
assert role["display_name"] == "Senior Analyst"
|
||||
assert role["permissions"] == "read,write,approve"
|
||||
|
||||
def test_update_nonexistent_role(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/roles/nonexistent",
|
||||
json={"display_name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_builtin_role_rejected(self, client, storage):
|
||||
# Seed a builtin role directly via storage
|
||||
storage.create_role(
|
||||
role_id="builtin-admin",
|
||||
name="admin",
|
||||
display_name="Administrator",
|
||||
permissions="*",
|
||||
builtin=True,
|
||||
)
|
||||
resp = client.put(
|
||||
"/v1/api/admin/roles/builtin-admin",
|
||||
json={"display_name": "Hacked"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "builtin" in resp.json()["error"].lower()
|
||||
|
||||
def test_delete_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
resp = client.delete(f"/v1/api/admin/roles/{role_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify gone from listing
|
||||
list_resp = client.get("/v1/api/admin/roles")
|
||||
assert list_resp.json()["roles"] == []
|
||||
|
||||
def test_delete_nonexistent_role(self, client):
|
||||
resp = client.delete("/v1/api/admin/roles/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_builtin_role_rejected(self, client, storage):
|
||||
storage.create_role(
|
||||
role_id="builtin-viewer",
|
||||
name="viewer",
|
||||
display_name="Viewer",
|
||||
permissions="read",
|
||||
builtin=True,
|
||||
)
|
||||
resp = client.delete("/v1/api/admin/roles/builtin-viewer")
|
||||
assert resp.status_code == 400
|
||||
assert "builtin" in resp.json()["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Role assignments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoleAssignments:
|
||||
def test_list_user_roles_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/users/user-1/roles")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["roles"] == []
|
||||
|
||||
def test_assign_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
resp = client.post(
|
||||
"/v1/api/admin/users/user-1/roles",
|
||||
json={"role_id": role_id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify listed
|
||||
list_resp = client.get("/v1/api/admin/users/user-1/roles")
|
||||
roles = list_resp.json()["roles"]
|
||||
assert len(roles) >= 1
|
||||
|
||||
def test_assign_role_missing_role_id(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/users/user-1/roles",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "role_id" in resp.json()["error"].lower()
|
||||
|
||||
def test_unassign_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
# Assign first
|
||||
client.post(
|
||||
"/v1/api/admin/users/user-1/roles",
|
||||
json={"role_id": role_id},
|
||||
)
|
||||
|
||||
# Now unassign
|
||||
resp = client.delete(f"/v1/api/admin/users/user-1/roles/{role_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify removed
|
||||
list_resp = client.get("/v1/api/admin/users/user-1/roles")
|
||||
assert list_resp.json()["roles"] == []
|
||||
|
||||
def test_unassign_nonexistent(self, client):
|
||||
resp = client.delete("/v1/api/admin/users/user-1/roles/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Orgs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrgs:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/orgs")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["orgs"] == []
|
||||
|
||||
def test_get_org(self, client, storage):
|
||||
storage.create_org(
|
||||
org_id="org-1",
|
||||
name="acme",
|
||||
display_name="Acme Corp",
|
||||
settings='{"theme": "dark"}',
|
||||
)
|
||||
resp = client.get("/v1/api/admin/orgs/org-1")
|
||||
assert resp.status_code == 200
|
||||
org = resp.json()
|
||||
assert org["org_id"] == "org-1"
|
||||
assert org["name"] == "acme"
|
||||
assert org["display_name"] == "Acme Corp"
|
||||
|
||||
def test_get_org_not_found(self, client):
|
||||
resp = client.get("/v1/api/admin/orgs/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_org(self, client, storage):
|
||||
storage.create_org(org_id="org-1", name="acme", display_name="Acme Corp")
|
||||
|
||||
resp = client.put(
|
||||
"/v1/api/admin/orgs/org-1",
|
||||
json={"display_name": "Acme Inc.", "settings": '{"theme": "light"}'},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
org = resp.json()
|
||||
assert org["display_name"] == "Acme Inc."
|
||||
assert org["settings"] == '{"theme": "light"}'
|
||||
|
||||
def test_update_org_not_found(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/orgs/nonexistent",
|
||||
json={"display_name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Tool policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPolicies:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/policies")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["policies"] == []
|
||||
|
||||
def test_create_policy(self, client):
|
||||
resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
assert resp.status_code == 200
|
||||
policy = resp.json()
|
||||
assert policy["name"] == "Allow bash"
|
||||
assert policy["tool_pattern"] == "bash_*"
|
||||
assert policy["action"] == "allow"
|
||||
assert policy["priority"] == 10
|
||||
assert "policy_id" in policy
|
||||
assert "created" in policy
|
||||
|
||||
def test_create_policy_missing_name(self, client):
|
||||
resp = client.post("/v1/api/admin/policies", json=_policy_payload(name=""))
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_policy_missing_tool_pattern(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/policies",
|
||||
json=_policy_payload(tool_pattern=""),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "tool_pattern" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_policy_invalid_action(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/policies",
|
||||
json=_policy_payload(action="yolo"),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "action" in resp.json()["error"].lower()
|
||||
|
||||
def test_list_after_create(self, client):
|
||||
client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
resp = client.get("/v1/api/admin/policies")
|
||||
assert resp.status_code == 200
|
||||
policies = resp.json()["policies"]
|
||||
assert len(policies) == 1
|
||||
assert policies[0]["name"] == "Allow bash"
|
||||
|
||||
def test_update_policy(self, client):
|
||||
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
policy_id = create_resp.json()["policy_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/policies/{policy_id}",
|
||||
json={"name": "Deny bash", "action": "deny", "priority": 20},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
policy = resp.json()
|
||||
assert policy["name"] == "Deny bash"
|
||||
assert policy["action"] == "deny"
|
||||
assert policy["priority"] == 20
|
||||
|
||||
def test_update_policy_invalid_action(self, client):
|
||||
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
policy_id = create_resp.json()["policy_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/policies/{policy_id}",
|
||||
json={"action": "nope"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "action" in resp.json()["error"].lower()
|
||||
|
||||
def test_update_policy_not_found(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/policies/nonexistent",
|
||||
json={"name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_policy(self, client):
|
||||
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
policy_id = create_resp.json()["policy_id"]
|
||||
|
||||
resp = client.delete(f"/v1/api/admin/policies/{policy_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify gone
|
||||
list_resp = client.get("/v1/api/admin/policies")
|
||||
assert list_resp.json()["policies"] == []
|
||||
|
||||
def test_delete_policy_not_found(self, client):
|
||||
resp = client.delete("/v1/api/admin/policies/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Prompt templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplates:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/templates")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["templates"] == []
|
||||
|
||||
def test_create_template(self, client):
|
||||
resp = client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
assert resp.status_code == 200
|
||||
tmpl = resp.json()
|
||||
assert tmpl["name"] == "Greeting"
|
||||
assert "{{user}}" in tmpl["content"]
|
||||
assert tmpl["category"] == "system"
|
||||
assert "template_id" in tmpl
|
||||
assert "created" in tmpl
|
||||
|
||||
def test_create_template_missing_name(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/templates",
|
||||
json=_template_payload(name=""),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_template_missing_content(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/templates",
|
||||
json=_template_payload(content=""),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "content" in resp.json()["error"].lower()
|
||||
|
||||
def test_list_after_create(self, client):
|
||||
client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
resp = client.get("/v1/api/admin/templates")
|
||||
assert resp.status_code == 200
|
||||
templates = resp.json()["templates"]
|
||||
assert len(templates) == 1
|
||||
assert templates[0]["name"] == "Greeting"
|
||||
|
||||
def test_update_template(self, client):
|
||||
create_resp = client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/templates/{template_id}",
|
||||
json={"name": "Welcome", "content": "Welcome, {{user}}!", "is_default": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
tmpl = resp.json()
|
||||
assert tmpl["name"] == "Welcome"
|
||||
assert tmpl["content"] == "Welcome, {{user}}!"
|
||||
assert tmpl["is_default"] is True
|
||||
|
||||
def test_update_template_not_found(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/templates/nonexistent",
|
||||
json={"name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_template(self, client):
|
||||
create_resp = client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
resp = client.delete(f"/v1/api/admin/templates/{template_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify gone
|
||||
list_resp = client.get("/v1/api/admin/templates")
|
||||
assert list_resp.json()["templates"] == []
|
||||
|
||||
def test_delete_template_not_found(self, client):
|
||||
resp = client.delete("/v1/api/admin/templates/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUsage:
|
||||
def test_usage_defaults(self, client):
|
||||
"""Query usage with no params — should return summary and breakdown."""
|
||||
resp = client.get("/v1/api/admin/usage")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "summary" in data
|
||||
assert "breakdown" in data
|
||||
# Summary is a list with at least one row
|
||||
assert isinstance(data["summary"], list)
|
||||
assert len(data["summary"]) >= 1
|
||||
# All-zeros when no data
|
||||
assert data["summary"][0]["prompt_tokens"] == 0
|
||||
|
||||
def test_usage_with_data(self, client, storage):
|
||||
"""Seed usage events and verify they appear in the query."""
|
||||
storage.record_usage_event(
|
||||
event_id="evt-1",
|
||||
user_id="user-1",
|
||||
model="gpt-5",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
tool_calls_count=2,
|
||||
)
|
||||
storage.record_usage_event(
|
||||
event_id="evt-2",
|
||||
user_id="user-1",
|
||||
model="gpt-5",
|
||||
prompt_tokens=200,
|
||||
completion_tokens=75,
|
||||
tool_calls_count=1,
|
||||
)
|
||||
resp = client.get("/v1/api/admin/usage")
|
||||
assert resp.status_code == 200
|
||||
summary = resp.json()["summary"]
|
||||
assert summary[0]["prompt_tokens"] == 300
|
||||
assert summary[0]["completion_tokens"] == 125
|
||||
assert summary[0]["tool_calls_count"] == 3
|
||||
|
||||
def test_usage_with_filters(self, client, storage):
|
||||
storage.record_usage_event(
|
||||
event_id="evt-f1",
|
||||
user_id="user-a",
|
||||
model="gpt-5",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=10,
|
||||
)
|
||||
storage.record_usage_event(
|
||||
event_id="evt-f2",
|
||||
user_id="user-b",
|
||||
model="claude-4",
|
||||
prompt_tokens=200,
|
||||
completion_tokens=20,
|
||||
)
|
||||
resp = client.get("/v1/api/admin/usage?user_id=user-a")
|
||||
assert resp.status_code == 200
|
||||
summary = resp.json()["summary"]
|
||||
assert summary[0]["prompt_tokens"] == 100
|
||||
|
||||
resp2 = client.get("/v1/api/admin/usage?model=claude-4")
|
||||
assert resp2.status_code == 200
|
||||
summary2 = resp2.json()["summary"]
|
||||
assert summary2[0]["prompt_tokens"] == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAudit:
|
||||
def test_audit_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/audit")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["events"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_audit_populated_by_mutations(self, client):
|
||||
"""Creating a role should produce an audit event."""
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
|
||||
resp = client.get("/v1/api/admin/audit")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
actions = [e["action"] for e in data["events"]]
|
||||
assert "role.create" in actions
|
||||
|
||||
def test_audit_filter_by_action(self, client):
|
||||
# Create a role and a policy to produce different audit actions
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
|
||||
resp = client.get("/v1/api/admin/audit?action=policy.create")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert all(e["action"] == "policy.create" for e in data["events"])
|
||||
|
||||
def test_audit_filter_by_user_id(self, client):
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
|
||||
resp = client.get("/v1/api/admin/audit?user_id=test-admin")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert all(e["user_id"] == "test-admin" for e in data["events"])
|
||||
|
||||
def test_audit_pagination(self, client):
|
||||
# Create several resources to produce multiple audit events
|
||||
for i in range(5):
|
||||
client.post(
|
||||
"/v1/api/admin/roles",
|
||||
json=_role_payload(name=f"role-{i}"),
|
||||
)
|
||||
|
||||
resp = client.get("/v1/api/admin/audit?limit=2&offset=0")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["events"]) == 2
|
||||
assert data["total"] >= 5
|
||||
|
||||
resp2 = client.get("/v1/api/admin/audit?limit=2&offset=2")
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert len(data2["events"]) == 2
|
||||
# The two pages should not overlap
|
||||
ids_page1 = {e["event_id"] for e in data["events"]}
|
||||
ids_page2 = {e["event_id"] for e in data2["events"]}
|
||||
assert ids_page1.isdisjoint(ids_page2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — User self-deletion guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserSelfDeletion:
|
||||
def test_cannot_delete_self(self, client):
|
||||
"""Admin should not be able to delete their own account."""
|
||||
resp = client.delete("/v1/api/admin/users/test-admin")
|
||||
assert resp.status_code == 400
|
||||
assert "own account" in resp.json()["error"].lower()
|
||||
|
||||
def test_can_delete_other_user(self, client):
|
||||
resp = client.delete("/v1/api/admin/users/user-1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
@@ -0,0 +1,746 @@
|
||||
"""Tests for governance storage operations (SQLite backend).
|
||||
|
||||
Covers RBAC roles, organizations, tool policies, prompt templates,
|
||||
usage events, and audit events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoleCRUD:
|
||||
def test_create_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
role = db.get_role("r1")
|
||||
assert role is not None
|
||||
assert role["role_id"] == "r1"
|
||||
assert role["name"] == "editor"
|
||||
assert role["display_name"] == "Editor"
|
||||
assert role["permissions"] == "read,write"
|
||||
assert role["builtin"] is False
|
||||
assert role["org_id"] == ""
|
||||
assert "created" in role
|
||||
assert "updated" in role
|
||||
|
||||
def test_create_role_idempotent(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
# Second insert with same role_id should be silently ignored.
|
||||
db.create_role("r1", "editor2", "Editor 2", "read", builtin=True, org_id="org1")
|
||||
role = db.get_role("r1")
|
||||
assert role is not None
|
||||
# Original values preserved.
|
||||
assert role["name"] == "editor"
|
||||
assert role["display_name"] == "Editor"
|
||||
|
||||
def test_get_role_by_name(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
role = db.get_role_by_name("editor")
|
||||
assert role is not None
|
||||
assert role["role_id"] == "r1"
|
||||
|
||||
def test_get_role_by_name_nonexistent(self, db):
|
||||
assert db.get_role_by_name("nope") is None
|
||||
|
||||
def test_list_roles(self, db):
|
||||
db.create_role("r2", "beta", "Beta Role", "read", builtin=False, org_id="")
|
||||
db.create_role("r1", "alpha", "Alpha Role", "write", builtin=False, org_id="")
|
||||
roles = db.list_roles()
|
||||
assert len(roles) == 2
|
||||
# Ordered by name ascending.
|
||||
assert roles[0]["name"] == "alpha"
|
||||
assert roles[1]["name"] == "beta"
|
||||
|
||||
def test_list_roles_filter_org(self, db):
|
||||
db.create_role("r1", "role_a", "A", "read", builtin=False, org_id="org1")
|
||||
db.create_role("r2", "role_b", "B", "read", builtin=False, org_id="org2")
|
||||
db.create_role("r3", "role_c", "C", "read", builtin=False, org_id="org1")
|
||||
result = db.list_roles(org_id="org1")
|
||||
assert len(result) == 2
|
||||
assert {r["role_id"] for r in result} == {"r1", "r3"}
|
||||
|
||||
def test_update_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
ok = db.update_role("r1", permissions="read,write,approve", display_name="Senior Editor")
|
||||
assert ok is True
|
||||
role = db.get_role("r1")
|
||||
assert role is not None
|
||||
assert role["permissions"] == "read,write,approve"
|
||||
assert role["display_name"] == "Senior Editor"
|
||||
|
||||
def test_update_role_nonexistent(self, db):
|
||||
assert db.update_role("missing", permissions="read") is False
|
||||
|
||||
def test_delete_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
# Verify assignment exists.
|
||||
assert len(db.list_user_roles("u1")) == 1
|
||||
ok = db.delete_role("r1")
|
||||
assert ok is True
|
||||
assert db.get_role("r1") is None
|
||||
# Cascade: user_roles for this role should be gone.
|
||||
assert len(db.list_user_roles("u1")) == 0
|
||||
|
||||
def test_delete_role_nonexistent(self, db):
|
||||
assert db.delete_role("missing") is False
|
||||
|
||||
def test_assign_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1", assigned_by="admin")
|
||||
roles = db.list_user_roles("u1")
|
||||
assert len(roles) == 1
|
||||
assert roles[0]["role_id"] == "r1"
|
||||
assert roles[0]["assigned_by"] == "admin"
|
||||
|
||||
def test_assign_role_idempotent(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
# Second assign should not raise.
|
||||
db.assign_role("u1", "r1")
|
||||
roles = db.list_user_roles("u1")
|
||||
assert len(roles) == 1
|
||||
|
||||
def test_unassign_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
ok = db.unassign_role("u1", "r1")
|
||||
assert ok is True
|
||||
assert len(db.list_user_roles("u1")) == 0
|
||||
|
||||
def test_unassign_role_nonexistent(self, db):
|
||||
assert db.unassign_role("u1", "r1") is False
|
||||
|
||||
def test_list_user_roles(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_role("r2", "viewer", "Viewer", "read", builtin=True, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1", assigned_by="admin")
|
||||
db.assign_role("u1", "r2", assigned_by="system")
|
||||
roles = db.list_user_roles("u1")
|
||||
assert len(roles) == 2
|
||||
# Each entry should have joined role fields plus assignment metadata.
|
||||
for r in roles:
|
||||
assert "role_id" in r
|
||||
assert "name" in r
|
||||
assert "permissions" in r
|
||||
assert "assigned_by" in r
|
||||
assert "assignment_created" in r
|
||||
|
||||
def test_get_user_permissions(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_role("r2", "approver", "Approver", "approve,read", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
db.assign_role("u1", "r2")
|
||||
perms = db.get_user_permissions("u1")
|
||||
assert perms == {"read", "write", "approve"}
|
||||
|
||||
def test_get_user_permissions_no_roles(self, db):
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
assert db.get_user_permissions("u1") == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organizations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrgCRUD:
|
||||
def test_create_org(self, db):
|
||||
db.create_org("org1", "acme", "Acme Corp", '{"plan":"pro"}')
|
||||
org = db.get_org("org1")
|
||||
assert org is not None
|
||||
assert org["org_id"] == "org1"
|
||||
assert org["name"] == "acme"
|
||||
assert org["display_name"] == "Acme Corp"
|
||||
assert org["settings"] == '{"plan":"pro"}'
|
||||
assert "created" in org
|
||||
assert "updated" in org
|
||||
|
||||
def test_get_org_nonexistent(self, db):
|
||||
assert db.get_org("nope") is None
|
||||
|
||||
def test_create_org_idempotent(self, db):
|
||||
db.create_org("org1", "acme", "Acme Corp")
|
||||
db.create_org("org1", "acme2", "Acme 2")
|
||||
org = db.get_org("org1")
|
||||
assert org is not None
|
||||
assert org["name"] == "acme"
|
||||
|
||||
def test_list_orgs(self, db):
|
||||
db.create_org("o2", "beta", "Beta Inc")
|
||||
db.create_org("o1", "alpha", "Alpha LLC")
|
||||
orgs = db.list_orgs()
|
||||
assert len(orgs) == 2
|
||||
# Ordered by name ascending.
|
||||
assert orgs[0]["name"] == "alpha"
|
||||
assert orgs[1]["name"] == "beta"
|
||||
|
||||
def test_update_org(self, db):
|
||||
db.create_org("org1", "acme", "Acme Corp")
|
||||
ok = db.update_org(
|
||||
"org1", display_name="Acme Corp Global", settings='{"plan":"enterprise"}'
|
||||
)
|
||||
assert ok is True
|
||||
org = db.get_org("org1")
|
||||
assert org is not None
|
||||
assert org["display_name"] == "Acme Corp Global"
|
||||
assert org["settings"] == '{"plan":"enterprise"}'
|
||||
|
||||
def test_update_org_nonexistent(self, db):
|
||||
assert db.update_org("missing", display_name="X") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool Policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolPolicyCRUD:
|
||||
def test_create_tool_policy(self, db):
|
||||
db.create_tool_policy(
|
||||
"p1",
|
||||
"deny-bash",
|
||||
"bash*",
|
||||
"deny",
|
||||
priority=100,
|
||||
org_id="org1",
|
||||
enabled=True,
|
||||
created_by="admin",
|
||||
)
|
||||
pol = db.get_tool_policy("p1")
|
||||
assert pol is not None
|
||||
assert pol["policy_id"] == "p1"
|
||||
assert pol["name"] == "deny-bash"
|
||||
assert pol["tool_pattern"] == "bash*"
|
||||
assert pol["action"] == "deny"
|
||||
assert pol["priority"] == 100
|
||||
assert pol["org_id"] == "org1"
|
||||
assert pol["enabled"] is True
|
||||
assert pol["created_by"] == "admin"
|
||||
|
||||
def test_get_tool_policy_nonexistent(self, db):
|
||||
assert db.get_tool_policy("missing") is None
|
||||
|
||||
def test_list_tool_policies_ordered_by_priority(self, db):
|
||||
db.create_tool_policy("p1", "low", "*", "allow", priority=10)
|
||||
db.create_tool_policy("p2", "high", "*", "deny", priority=100)
|
||||
db.create_tool_policy("p3", "mid", "*", "ask", priority=50)
|
||||
policies = db.list_tool_policies()
|
||||
assert len(policies) == 3
|
||||
# DESC priority order.
|
||||
assert policies[0]["priority"] == 100
|
||||
assert policies[1]["priority"] == 50
|
||||
assert policies[2]["priority"] == 10
|
||||
|
||||
def test_update_tool_policy(self, db):
|
||||
db.create_tool_policy("p1", "deny-bash", "bash*", "deny", priority=100)
|
||||
ok = db.update_tool_policy("p1", action="allow", priority=50)
|
||||
assert ok is True
|
||||
pol = db.get_tool_policy("p1")
|
||||
assert pol is not None
|
||||
assert pol["action"] == "allow"
|
||||
assert pol["priority"] == 50
|
||||
|
||||
def test_update_tool_policy_nonexistent(self, db):
|
||||
assert db.update_tool_policy("missing", action="deny") is False
|
||||
|
||||
def test_delete_tool_policy(self, db):
|
||||
db.create_tool_policy("p1", "deny-bash", "bash*", "deny", priority=100)
|
||||
ok = db.delete_tool_policy("p1")
|
||||
assert ok is True
|
||||
assert db.get_tool_policy("p1") is None
|
||||
|
||||
def test_delete_tool_policy_nonexistent(self, db):
|
||||
assert db.delete_tool_policy("missing") is False
|
||||
|
||||
def test_enabled_as_bool(self, db):
|
||||
db.create_tool_policy("p1", "on", "*", "allow", priority=0, enabled=True)
|
||||
db.create_tool_policy("p2", "off", "*", "deny", priority=0, enabled=False)
|
||||
p1 = db.get_tool_policy("p1")
|
||||
p2 = db.get_tool_policy("p2")
|
||||
assert p1 is not None
|
||||
assert p2 is not None
|
||||
assert p1["enabled"] is True
|
||||
assert isinstance(p1["enabled"], bool)
|
||||
assert p2["enabled"] is False
|
||||
assert isinstance(p2["enabled"], bool)
|
||||
|
||||
def test_list_policies_filter_org(self, db):
|
||||
db.create_tool_policy("p1", "a", "*", "allow", priority=0, org_id="org1")
|
||||
db.create_tool_policy("p2", "b", "*", "deny", priority=0, org_id="org2")
|
||||
db.create_tool_policy("p3", "c", "*", "ask", priority=0, org_id="org1")
|
||||
result = db.list_tool_policies(org_id="org1")
|
||||
assert len(result) == 2
|
||||
assert {r["policy_id"] for r in result} == {"p1", "p3"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt Templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPromptTemplateCRUD:
|
||||
def test_create_prompt_template(self, db):
|
||||
db.create_prompt_template(
|
||||
"t1",
|
||||
"greeting",
|
||||
"general",
|
||||
"Hello {{name}}!",
|
||||
variables='["name"]',
|
||||
is_default=True,
|
||||
org_id="org1",
|
||||
created_by="admin",
|
||||
)
|
||||
tpl = db.get_prompt_template("t1")
|
||||
assert tpl is not None
|
||||
assert tpl["template_id"] == "t1"
|
||||
assert tpl["name"] == "greeting"
|
||||
assert tpl["category"] == "general"
|
||||
assert tpl["content"] == "Hello {{name}}!"
|
||||
assert tpl["variables"] == '["name"]'
|
||||
assert tpl["is_default"] is True
|
||||
assert tpl["org_id"] == "org1"
|
||||
assert tpl["created_by"] == "admin"
|
||||
|
||||
def test_get_prompt_template_nonexistent(self, db):
|
||||
assert db.get_prompt_template("missing") is None
|
||||
|
||||
def test_list_prompt_templates_ordered_by_name(self, db):
|
||||
db.create_prompt_template("t2", "beta", "general", "B")
|
||||
db.create_prompt_template("t1", "alpha", "general", "A")
|
||||
templates = db.list_prompt_templates()
|
||||
assert len(templates) == 2
|
||||
assert templates[0]["name"] == "alpha"
|
||||
assert templates[1]["name"] == "beta"
|
||||
|
||||
def test_list_prompt_templates_filter_org(self, db):
|
||||
db.create_prompt_template("t1", "a", "general", "A", org_id="org1")
|
||||
db.create_prompt_template("t2", "b", "general", "B", org_id="org2")
|
||||
result = db.list_prompt_templates(org_id="org1")
|
||||
assert len(result) == 1
|
||||
assert result[0]["template_id"] == "t1"
|
||||
|
||||
def test_update_prompt_template(self, db):
|
||||
db.create_prompt_template("t1", "greeting", "general", "Hello!")
|
||||
ok = db.update_prompt_template("t1", content="Hi there!", category="custom")
|
||||
assert ok is True
|
||||
tpl = db.get_prompt_template("t1")
|
||||
assert tpl is not None
|
||||
assert tpl["content"] == "Hi there!"
|
||||
assert tpl["category"] == "custom"
|
||||
|
||||
def test_update_prompt_template_nonexistent(self, db):
|
||||
assert db.update_prompt_template("missing", content="x") is False
|
||||
|
||||
def test_delete_prompt_template(self, db):
|
||||
db.create_prompt_template("t1", "greeting", "general", "Hello!")
|
||||
ok = db.delete_prompt_template("t1")
|
||||
assert ok is True
|
||||
assert db.get_prompt_template("t1") is None
|
||||
|
||||
def test_delete_prompt_template_nonexistent(self, db):
|
||||
assert db.delete_prompt_template("missing") is False
|
||||
|
||||
def test_is_default_as_bool(self, db):
|
||||
db.create_prompt_template("t1", "default_one", "general", "D", is_default=True)
|
||||
db.create_prompt_template("t2", "not_default", "general", "N", is_default=False)
|
||||
t1 = db.get_prompt_template("t1")
|
||||
t2 = db.get_prompt_template("t2")
|
||||
assert t1 is not None
|
||||
assert t2 is not None
|
||||
assert t1["is_default"] is True
|
||||
assert isinstance(t1["is_default"], bool)
|
||||
assert t2["is_default"] is False
|
||||
assert isinstance(t2["is_default"], bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUsageEvents:
|
||||
def test_record_usage_event(self, db):
|
||||
db.record_usage_event(
|
||||
"ev1",
|
||||
user_id="u1",
|
||||
ws_id="ws1",
|
||||
node_id="n1",
|
||||
model="gpt-5",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
tool_calls_count=2,
|
||||
)
|
||||
# Verify via query_usage (no group_by returns summary).
|
||||
result = db.query_usage(since="2000-01-01T00:00:00")
|
||||
assert len(result) == 1
|
||||
assert result[0]["prompt_tokens"] == 100
|
||||
assert result[0]["completion_tokens"] == 50
|
||||
assert result[0]["tool_calls_count"] == 2
|
||||
|
||||
def test_query_usage_summary(self, db):
|
||||
db.record_usage_event("ev1", model="gpt-5", prompt_tokens=100, completion_tokens=50)
|
||||
db.record_usage_event("ev2", model="gpt-5", prompt_tokens=200, completion_tokens=75)
|
||||
result = db.query_usage(since="2000-01-01T00:00:00")
|
||||
assert len(result) == 1
|
||||
assert result[0]["prompt_tokens"] == 300
|
||||
assert result[0]["completion_tokens"] == 125
|
||||
|
||||
def test_query_usage_by_day(self, db):
|
||||
# Insert events with known timestamps by directly inserting rows.
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T14:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 25,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T14:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e3",
|
||||
"timestamp": "2026-03-02T08:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 100,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-02T08:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", group_by="day")
|
||||
assert len(result) == 2
|
||||
assert result[0]["key"] == "2026-03-01"
|
||||
assert result[0]["prompt_tokens"] == 150
|
||||
assert result[1]["key"] == "2026-03-02"
|
||||
assert result[1]["prompt_tokens"] == 200
|
||||
|
||||
def test_query_usage_by_model(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "claude-4",
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 100,
|
||||
"tool_calls_count": 1,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", group_by="model")
|
||||
assert len(result) == 2
|
||||
keys = [r["key"] for r in result]
|
||||
assert "gpt-5" in keys
|
||||
assert "claude-4" in keys
|
||||
|
||||
def test_query_usage_by_user(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "u1",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "u2",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 300,
|
||||
"completion_tokens": 150,
|
||||
"tool_calls_count": 2,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", group_by="user")
|
||||
assert len(result) == 2
|
||||
by_key = {r["key"]: r for r in result}
|
||||
assert by_key["u1"]["prompt_tokens"] == 100
|
||||
assert by_key["u2"]["prompt_tokens"] == 300
|
||||
|
||||
def test_query_usage_filter_model(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "claude-4",
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 100,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", model="gpt-5")
|
||||
assert len(result) == 1
|
||||
assert result[0]["prompt_tokens"] == 100
|
||||
|
||||
def test_prune_usage_events(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
old_ts = "2020-01-01T00:00:00"
|
||||
now_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "old",
|
||||
"timestamp": old_ts,
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"tool_calls_count": 0,
|
||||
"created": old_ts,
|
||||
},
|
||||
{
|
||||
"event_id": "new",
|
||||
"timestamp": now_ts,
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 10,
|
||||
"tool_calls_count": 0,
|
||||
"created": now_ts,
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
pruned = db.prune_usage_events(retention_days=30)
|
||||
assert pruned == 1
|
||||
# Only the recent event should remain.
|
||||
result = db.query_usage(since="2000-01-01T00:00:00")
|
||||
assert result[0]["prompt_tokens"] == 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuditEvents:
|
||||
def test_record_audit_event(self, db):
|
||||
db.record_audit_event(
|
||||
"a1",
|
||||
user_id="u1",
|
||||
action="role.create",
|
||||
resource_type="role",
|
||||
resource_id="r1",
|
||||
detail='{"name":"editor"}',
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
events = db.list_audit_events()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["event_id"] == "a1"
|
||||
assert ev["user_id"] == "u1"
|
||||
assert ev["action"] == "role.create"
|
||||
assert ev["resource_type"] == "role"
|
||||
assert ev["resource_id"] == "r1"
|
||||
assert ev["detail"] == '{"name":"editor"}'
|
||||
assert ev["ip_address"] == "127.0.0.1"
|
||||
|
||||
def test_list_audit_events(self, db):
|
||||
db.record_audit_event("a1", action="login")
|
||||
db.record_audit_event("a2", action="logout")
|
||||
events = db.list_audit_events()
|
||||
assert len(events) == 2
|
||||
# Ordered by timestamp DESC — most recent first.
|
||||
# Both created in quick succession with same-second granularity,
|
||||
# but the order should still be deterministic (DESC).
|
||||
assert {e["event_id"] for e in events} == {"a1", "a2"}
|
||||
|
||||
def test_list_audit_events_filter_action(self, db):
|
||||
db.record_audit_event("a1", action="login")
|
||||
db.record_audit_event("a2", action="logout")
|
||||
db.record_audit_event("a3", action="login")
|
||||
events = db.list_audit_events(action="login")
|
||||
assert len(events) == 2
|
||||
assert all(e["action"] == "login" for e in events)
|
||||
|
||||
def test_list_audit_events_filter_user(self, db):
|
||||
db.record_audit_event("a1", user_id="u1", action="login")
|
||||
db.record_audit_event("a2", user_id="u2", action="login")
|
||||
events = db.list_audit_events(user_id="u1")
|
||||
assert len(events) == 1
|
||||
assert events[0]["user_id"] == "u1"
|
||||
|
||||
def test_list_audit_events_pagination(self, db):
|
||||
for i in range(5):
|
||||
db.record_audit_event(f"a{i}", action="test")
|
||||
page1 = db.list_audit_events(limit=2, offset=0)
|
||||
page2 = db.list_audit_events(limit=2, offset=2)
|
||||
page3 = db.list_audit_events(limit=2, offset=4)
|
||||
assert len(page1) == 2
|
||||
assert len(page2) == 2
|
||||
assert len(page3) == 1
|
||||
# No overlap.
|
||||
ids = [e["event_id"] for e in page1 + page2 + page3]
|
||||
assert len(set(ids)) == 5
|
||||
|
||||
def test_count_audit_events(self, db):
|
||||
db.record_audit_event("a1", action="login")
|
||||
db.record_audit_event("a2", action="logout")
|
||||
db.record_audit_event("a3", action="login")
|
||||
assert db.count_audit_events() == 3
|
||||
assert db.count_audit_events(action="login") == 2
|
||||
assert db.count_audit_events(action="logout") == 1
|
||||
|
||||
def test_count_audit_events_filter_user(self, db):
|
||||
db.record_audit_event("a1", user_id="u1", action="login")
|
||||
db.record_audit_event("a2", user_id="u2", action="login")
|
||||
assert db.count_audit_events(user_id="u1") == 1
|
||||
|
||||
def test_prune_audit_events(self, db):
|
||||
from turnstone.core.storage._schema import audit_events
|
||||
|
||||
old_ts = "2020-01-01T00:00:00"
|
||||
now_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(audit_events),
|
||||
[
|
||||
{
|
||||
"event_id": "old",
|
||||
"timestamp": old_ts,
|
||||
"user_id": "",
|
||||
"action": "test",
|
||||
"resource_type": "",
|
||||
"resource_id": "",
|
||||
"detail": "{}",
|
||||
"ip_address": "",
|
||||
"created": old_ts,
|
||||
},
|
||||
{
|
||||
"event_id": "new",
|
||||
"timestamp": now_ts,
|
||||
"user_id": "",
|
||||
"action": "test",
|
||||
"resource_type": "",
|
||||
"resource_id": "",
|
||||
"detail": "{}",
|
||||
"ip_address": "",
|
||||
"created": now_ts,
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
pruned = db.prune_audit_events(retention_days=30)
|
||||
assert pruned == 1
|
||||
assert db.count_audit_events() == 1
|
||||
@@ -8,6 +8,7 @@ from turnstone.mq.protocol import (
|
||||
AckEvent,
|
||||
ApprovalRequestEvent,
|
||||
ApproveMessage,
|
||||
CancelMessage,
|
||||
CloseWorkstreamMessage,
|
||||
CommandMessage,
|
||||
ContentEvent,
|
||||
@@ -68,6 +69,7 @@ INBOUND_TYPES = [
|
||||
(ListWorkstreamsMessage, {}),
|
||||
(HealthMessage, {}),
|
||||
(ListNodesMessage, {}),
|
||||
(CancelMessage, {"ws_id": "abc"}),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
admin_create_schedule,
|
||||
admin_delete_schedule,
|
||||
@@ -15,9 +23,21 @@ from turnstone.console.server import (
|
||||
admin_list_schedules,
|
||||
admin_update_schedule,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="test-admin",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
permissions=frozenset({"admin.schedules"}),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
@@ -52,6 +72,7 @@ def client(storage):
|
||||
],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
return TestClient(app)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for turnstone.core.policy."""
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.policy import evaluate_tool_policies_batch, evaluate_tool_policy
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
path = str(tmp_path / "test.db")
|
||||
backend = SQLiteBackend(path)
|
||||
yield backend
|
||||
backend.close()
|
||||
|
||||
|
||||
def test_no_policies_returns_none(storage):
|
||||
result = evaluate_tool_policy(storage, "bash")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_exact_match_allow(storage):
|
||||
storage.create_tool_policy("p1", "allow-read", "read_file", "allow", 0)
|
||||
assert evaluate_tool_policy(storage, "read_file") == "allow"
|
||||
assert evaluate_tool_policy(storage, "write_file") is None
|
||||
|
||||
|
||||
def test_glob_match_deny(storage):
|
||||
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 0)
|
||||
assert evaluate_tool_policy(storage, "bash") == "deny"
|
||||
assert evaluate_tool_policy(storage, "bash_exec") == "deny"
|
||||
assert evaluate_tool_policy(storage, "read_file") is None
|
||||
|
||||
|
||||
def test_wildcard_match(storage):
|
||||
storage.create_tool_policy("p1", "ask-all", "*", "ask", 0)
|
||||
assert evaluate_tool_policy(storage, "anything") == "ask"
|
||||
|
||||
|
||||
def test_priority_ordering(storage):
|
||||
# Higher priority wins
|
||||
storage.create_tool_policy("p1", "allow-all", "*", "allow", 0)
|
||||
storage.create_tool_policy("p2", "deny-bash", "bash*", "deny", 100)
|
||||
assert evaluate_tool_policy(storage, "bash") == "deny" # p2 matches first (higher priority)
|
||||
assert evaluate_tool_policy(storage, "read_file") == "allow" # p1 matches
|
||||
|
||||
|
||||
def test_disabled_policy_skipped(storage):
|
||||
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 100, enabled=False)
|
||||
storage.create_tool_policy("p2", "allow-all", "*", "allow", 0)
|
||||
assert evaluate_tool_policy(storage, "bash") == "allow" # p1 disabled, falls through to p2
|
||||
|
||||
|
||||
def test_batch_evaluation(storage):
|
||||
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 100)
|
||||
storage.create_tool_policy("p2", "allow-read", "read_*", "allow", 50)
|
||||
results = evaluate_tool_policies_batch(storage, ["bash", "read_file", "write_file"])
|
||||
assert results["bash"] == "deny"
|
||||
assert results["read_file"] == "allow"
|
||||
assert results["write_file"] is None
|
||||
|
||||
|
||||
def test_storage_failure_returns_none():
|
||||
"""Graceful degradation on storage failure."""
|
||||
|
||||
class BrokenStorage:
|
||||
def list_tool_policies(self, org_id=""):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
assert evaluate_tool_policy(BrokenStorage(), "bash") is None
|
||||
|
||||
|
||||
def test_batch_storage_failure():
|
||||
class BrokenStorage:
|
||||
def list_tool_policies(self, org_id=""):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
results = evaluate_tool_policies_batch(BrokenStorage(), ["a", "b"])
|
||||
assert results == {"a": None, "b": None}
|
||||
|
||||
|
||||
def test_first_match_wins(storage):
|
||||
# Two policies match, first by priority wins
|
||||
storage.create_tool_policy("p1", "deny-bash", "bash*", "deny", 100)
|
||||
storage.create_tool_policy("p2", "allow-bash", "bash*", "allow", 50)
|
||||
assert evaluate_tool_policy(storage, "bash_exec") == "deny"
|
||||
@@ -65,6 +65,14 @@ class TestUserCRUD:
|
||||
db.delete_user("u1")
|
||||
assert len(db.list_api_tokens("u1")) == 0
|
||||
|
||||
def test_delete_cascades_user_roles(self, db):
|
||||
db.create_user("u1", "admin", "Admin", "$2b$hash")
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.assign_role("u1", "r1")
|
||||
assert len(db.list_user_roles("u1")) == 1
|
||||
db.delete_user("u1")
|
||||
assert len(db.list_user_roles("u1")) == 0
|
||||
|
||||
|
||||
class TestApiTokenCRUD:
|
||||
def test_create_and_lookup_by_hash(self, db):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.5.3"
|
||||
__version__ = "0.5.4"
|
||||
|
||||
@@ -156,3 +156,195 @@ class ConsoleHealthResponse(BaseModel):
|
||||
workstreams: int = 0
|
||||
version_drift: bool = False
|
||||
versions: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
role_id: str
|
||||
name: str
|
||||
display_name: str
|
||||
permissions: str
|
||||
builtin: bool
|
||||
org_id: str
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class CreateRoleRequest(BaseModel):
|
||||
name: str
|
||||
display_name: str = ""
|
||||
permissions: str = "read"
|
||||
|
||||
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
permissions: str | None = None
|
||||
|
||||
|
||||
class ListRolesResponse(BaseModel):
|
||||
roles: list[RoleInfo]
|
||||
|
||||
|
||||
class AssignRoleRequest(BaseModel):
|
||||
role_id: str
|
||||
|
||||
|
||||
class UserRoleInfo(BaseModel):
|
||||
role_id: str
|
||||
name: str
|
||||
display_name: str
|
||||
permissions: str
|
||||
builtin: bool
|
||||
org_id: str
|
||||
created: str
|
||||
updated: str
|
||||
assigned_by: str
|
||||
assignment_created: str
|
||||
|
||||
|
||||
class ListUserRolesResponse(BaseModel):
|
||||
roles: list[UserRoleInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Orgs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OrgInfo(BaseModel):
|
||||
org_id: str
|
||||
name: str
|
||||
display_name: str
|
||||
settings: str
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class UpdateOrgRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
settings: str | None = None
|
||||
|
||||
|
||||
class ListOrgsResponse(BaseModel):
|
||||
orgs: list[OrgInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Tool Policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ToolPolicyInfo(BaseModel):
|
||||
policy_id: str
|
||||
name: str
|
||||
tool_pattern: str
|
||||
action: str
|
||||
priority: int
|
||||
org_id: str
|
||||
enabled: bool
|
||||
created_by: str
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class CreateToolPolicyRequest(BaseModel):
|
||||
name: str
|
||||
tool_pattern: str
|
||||
action: str # allow, deny, ask
|
||||
priority: int = 0
|
||||
org_id: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class UpdateToolPolicyRequest(BaseModel):
|
||||
name: str | None = None
|
||||
tool_pattern: str | None = None
|
||||
action: str | None = None
|
||||
priority: int | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ListToolPoliciesResponse(BaseModel):
|
||||
policies: list[ToolPolicyInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Prompt Templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PromptTemplateInfo(BaseModel):
|
||||
template_id: str
|
||||
name: str
|
||||
category: str
|
||||
content: str
|
||||
variables: str
|
||||
is_default: bool
|
||||
org_id: str
|
||||
created_by: str
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class CreatePromptTemplateRequest(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
category: str = "general"
|
||||
variables: str = "[]"
|
||||
is_default: bool = False
|
||||
org_id: str = ""
|
||||
|
||||
|
||||
class UpdatePromptTemplateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
content: str | None = None
|
||||
category: str | None = None
|
||||
variables: str | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
class ListPromptTemplatesResponse(BaseModel):
|
||||
templates: list[PromptTemplateInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UsageBreakdownItem(BaseModel):
|
||||
key: str = ""
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
tool_calls_count: int = 0
|
||||
|
||||
|
||||
class UsageResponse(BaseModel):
|
||||
summary: list[UsageBreakdownItem]
|
||||
breakdown: list[UsageBreakdownItem]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditEventInfo(BaseModel):
|
||||
event_id: str
|
||||
timestamp: str
|
||||
user_id: str
|
||||
action: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
detail: str
|
||||
ip_address: str
|
||||
created: str
|
||||
|
||||
|
||||
class ListAuditEventsResponse(BaseModel):
|
||||
events: list[AuditEventInfo]
|
||||
total: int
|
||||
|
||||
@@ -8,6 +8,8 @@ if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
from turnstone.api.console_schemas import (
|
||||
AssignRoleRequest,
|
||||
AuditEventInfo,
|
||||
ClusterNodesResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterSnapshotResponse,
|
||||
@@ -15,7 +17,27 @@ from turnstone.api.console_schemas import (
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreatePromptTemplateRequest,
|
||||
CreateRoleRequest,
|
||||
CreateToolPolicyRequest,
|
||||
ListAuditEventsResponse,
|
||||
ListOrgsResponse,
|
||||
ListPromptTemplatesResponse,
|
||||
ListRolesResponse,
|
||||
ListToolPoliciesResponse,
|
||||
ListUserRolesResponse,
|
||||
NodeDetailResponse,
|
||||
OrgInfo,
|
||||
PromptTemplateInfo,
|
||||
RoleInfo,
|
||||
ToolPolicyInfo,
|
||||
UpdateOrgRequest,
|
||||
UpdatePromptTemplateRequest,
|
||||
UpdateRoleRequest,
|
||||
UpdateToolPolicyRequest,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
UserRoleInfo,
|
||||
)
|
||||
from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi
|
||||
from turnstone.api.schemas import (
|
||||
@@ -252,6 +274,191 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[404],
|
||||
tags=["Schedules"],
|
||||
),
|
||||
# --- Governance: Roles ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles",
|
||||
"GET",
|
||||
"List all roles",
|
||||
response_model=ListRolesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles",
|
||||
"POST",
|
||||
"Create a custom role",
|
||||
request_model=CreateRoleRequest,
|
||||
response_model=RoleInfo,
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles/{role_id}",
|
||||
"PUT",
|
||||
"Update a role",
|
||||
request_model=UpdateRoleRequest,
|
||||
response_model=RoleInfo,
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles/{role_id}",
|
||||
"DELETE",
|
||||
"Delete a custom role",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/roles",
|
||||
"GET",
|
||||
"List roles assigned to a user",
|
||||
response_model=ListUserRolesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/roles",
|
||||
"POST",
|
||||
"Assign a role to a user",
|
||||
request_model=AssignRoleRequest,
|
||||
response_model=StatusResponse,
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/roles/{role_id}",
|
||||
"DELETE",
|
||||
"Unassign a role from a user",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Orgs ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/orgs",
|
||||
"GET",
|
||||
"List organizations",
|
||||
response_model=ListOrgsResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/orgs/{org_id}",
|
||||
"GET",
|
||||
"Get organization details",
|
||||
response_model=OrgInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/orgs/{org_id}",
|
||||
"PUT",
|
||||
"Update organization settings",
|
||||
request_model=UpdateOrgRequest,
|
||||
response_model=OrgInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Tool Policies ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies",
|
||||
"GET",
|
||||
"List tool policies",
|
||||
response_model=ListToolPoliciesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies",
|
||||
"POST",
|
||||
"Create a tool policy",
|
||||
request_model=CreateToolPolicyRequest,
|
||||
response_model=ToolPolicyInfo,
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies/{policy_id}",
|
||||
"PUT",
|
||||
"Update a tool policy",
|
||||
request_model=UpdateToolPolicyRequest,
|
||||
response_model=ToolPolicyInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies/{policy_id}",
|
||||
"DELETE",
|
||||
"Delete a tool policy",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Prompt Templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates",
|
||||
"GET",
|
||||
"List prompt templates",
|
||||
response_model=ListPromptTemplatesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates",
|
||||
"POST",
|
||||
"Create a prompt template",
|
||||
request_model=CreatePromptTemplateRequest,
|
||||
response_model=PromptTemplateInfo,
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates/{template_id}",
|
||||
"PUT",
|
||||
"Update a prompt template",
|
||||
request_model=UpdatePromptTemplateRequest,
|
||||
response_model=PromptTemplateInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates/{template_id}",
|
||||
"DELETE",
|
||||
"Delete a prompt template",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Usage & Audit ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/usage",
|
||||
"GET",
|
||||
"Aggregated usage data",
|
||||
response_model=UsageResponse,
|
||||
query_params=[
|
||||
QueryParam("since", "Start timestamp (ISO8601, defaults to last 7 days)"),
|
||||
QueryParam("until", "End timestamp (ISO8601)"),
|
||||
QueryParam("user_id", "Filter by user"),
|
||||
QueryParam("model", "Filter by model"),
|
||||
QueryParam(
|
||||
"group_by",
|
||||
"Group results",
|
||||
enum=["day", "hour", "model", "user"],
|
||||
),
|
||||
],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/audit",
|
||||
"GET",
|
||||
"Paginated audit events",
|
||||
response_model=ListAuditEventsResponse,
|
||||
query_params=[
|
||||
QueryParam("action", "Filter by action type"),
|
||||
QueryParam("user_id", "Filter by user"),
|
||||
QueryParam("since", "Start timestamp (ISO8601)"),
|
||||
QueryParam("until", "End timestamp (ISO8601)"),
|
||||
QueryParam("limit", "Page size", schema_type="integer", default=50),
|
||||
QueryParam("offset", "Pagination offset", schema_type="integer", default=0),
|
||||
],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Observability ---
|
||||
EndpointSpec(
|
||||
"/health",
|
||||
@@ -289,6 +496,28 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ScheduleInfo,
|
||||
ListSchedulesResponse,
|
||||
ListScheduleRunsResponse,
|
||||
RoleInfo,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
ListRolesResponse,
|
||||
AssignRoleRequest,
|
||||
UserRoleInfo,
|
||||
ListUserRolesResponse,
|
||||
OrgInfo,
|
||||
UpdateOrgRequest,
|
||||
ListOrgsResponse,
|
||||
ToolPolicyInfo,
|
||||
CreateToolPolicyRequest,
|
||||
UpdateToolPolicyRequest,
|
||||
ListToolPoliciesResponse,
|
||||
PromptTemplateInfo,
|
||||
CreatePromptTemplateRequest,
|
||||
UpdatePromptTemplateRequest,
|
||||
ListPromptTemplatesResponse,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
AuditEventInfo,
|
||||
ListAuditEventsResponse,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ class CommandRequest(BaseModel):
|
||||
ws_id: str = Field(description="Target workstream ID")
|
||||
|
||||
|
||||
class CancelRequest(BaseModel):
|
||||
ws_id: str = Field(description="Target workstream ID")
|
||||
|
||||
|
||||
class CreateWorkstreamRequest(BaseModel):
|
||||
name: str = Field(default="", description="Workstream display name (auto-generated if empty)")
|
||||
model: str = Field(default="", description="Model alias from registry")
|
||||
|
||||
@@ -19,6 +19,7 @@ from turnstone.api.schemas import (
|
||||
)
|
||||
from turnstone.api.server_schemas import (
|
||||
ApproveRequest,
|
||||
CancelRequest,
|
||||
CloseWorkstreamRequest,
|
||||
CommandRequest,
|
||||
CreateWorkstreamRequest,
|
||||
@@ -103,6 +104,15 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[400, 404],
|
||||
tags=["Chat"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/cancel",
|
||||
"POST",
|
||||
"Cancel the active generation in a workstream",
|
||||
request_model=CancelRequest,
|
||||
response_model=StatusResponse,
|
||||
error_codes=[400, 404],
|
||||
tags=["Chat"],
|
||||
),
|
||||
# --- Streaming ---
|
||||
EndpointSpec(
|
||||
"/v1/api/events",
|
||||
@@ -186,6 +196,7 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ApproveRequest,
|
||||
PlanFeedbackRequest,
|
||||
CommandRequest,
|
||||
CancelRequest,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
CloseWorkstreamRequest,
|
||||
|
||||
@@ -112,6 +112,18 @@ class TaskScheduler:
|
||||
pruned = self._storage.prune_task_runs(retention_days=90)
|
||||
if pruned:
|
||||
log.info("scheduler.pruned_runs", count=pruned)
|
||||
try:
|
||||
usage_pruned = self._storage.prune_usage_events(retention_days=90)
|
||||
if usage_pruned:
|
||||
log.info("scheduler.pruned_usage", count=usage_pruned)
|
||||
except Exception:
|
||||
log.warning("scheduler.prune_usage_error", exc_info=True)
|
||||
try:
|
||||
audit_pruned = self._storage.prune_audit_events(retention_days=365)
|
||||
if audit_pruned:
|
||||
log.info("scheduler.pruned_audit", count=audit_pruned)
|
||||
except Exception:
|
||||
log.warning("scheduler.prune_audit_error", exc_info=True)
|
||||
finally:
|
||||
# Only release our own lock (safe even if TTL expired and another took it)
|
||||
self._broker._redis.eval( # type: ignore[no-untyped-call]
|
||||
|
||||
+1023
-2
File diff suppressed because it is too large
Load Diff
@@ -29,11 +29,62 @@ function showAdmin() {
|
||||
document.getElementById("breadcrumb-label").textContent = "Admin";
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
history.pushState({ view: "admin" }, "");
|
||||
loadAdminUsers();
|
||||
|
||||
// Permission gating: hide tabs the user cannot access
|
||||
var perms = sessionStorage.getItem("turnstone_permissions") || "";
|
||||
var tabPerms = {
|
||||
users: "admin.users",
|
||||
tokens: "admin.users",
|
||||
channels: "admin.users",
|
||||
schedules: "admin.schedules",
|
||||
watches: "admin.watches",
|
||||
roles: "admin.roles",
|
||||
policies: "admin.policies",
|
||||
templates: "admin.templates",
|
||||
usage: "admin.usage",
|
||||
audit: "admin.audit",
|
||||
};
|
||||
if (perms) {
|
||||
var permSet = perms.split(",");
|
||||
var tabs = document.querySelectorAll(".admin-tab");
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
var tabName = tabs[i].getAttribute("data-tab");
|
||||
var needed = tabPerms[tabName];
|
||||
if (needed && permSet.indexOf(needed) < 0) {
|
||||
tabs[i].style.display = "none";
|
||||
} else {
|
||||
tabs[i].style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to the first visible tab
|
||||
var visibleTabs = document.querySelectorAll(
|
||||
'.admin-tab:not([style*="display: none"])',
|
||||
);
|
||||
if (visibleTabs.length > 0) {
|
||||
switchAdminTab(visibleTabs[0].getAttribute("data-tab"));
|
||||
} else {
|
||||
// No tabs visible — show empty state instead of loading an inaccessible tab
|
||||
var panels = document.querySelectorAll(".admin-panel");
|
||||
for (var j = 0; j < panels.length; j++) panels[j].style.display = "none";
|
||||
var empty = document.getElementById("admin-no-permissions");
|
||||
if (!empty) {
|
||||
empty = document.createElement("div");
|
||||
empty.id = "admin-no-permissions";
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "You do not have permissions to view any admin tabs.";
|
||||
document.getElementById("view-admin").appendChild(empty);
|
||||
}
|
||||
empty.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function switchAdminTab(tab) {
|
||||
_adminTab = tab;
|
||||
// Hide no-permissions empty state if it was showing
|
||||
var noPerms = document.getElementById("admin-no-permissions");
|
||||
if (noPerms) noPerms.style.display = "none";
|
||||
var tabs = document.querySelectorAll(".admin-tab");
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
var isActive = tabs[i].getAttribute("data-tab") === tab;
|
||||
@@ -41,22 +92,36 @@ function switchAdminTab(tab) {
|
||||
tabs[i].setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
tabs[i].setAttribute("tabindex", isActive ? "0" : "-1");
|
||||
}
|
||||
document.getElementById("admin-users").style.display =
|
||||
tab === "users" ? "" : "none";
|
||||
document.getElementById("admin-tokens").style.display =
|
||||
tab === "tokens" ? "" : "none";
|
||||
document.getElementById("admin-channels").style.display =
|
||||
tab === "channels" ? "" : "none";
|
||||
document.getElementById("admin-schedules").style.display =
|
||||
tab === "schedules" ? "" : "none";
|
||||
document.getElementById("admin-watches").style.display =
|
||||
tab === "watches" ? "" : "none";
|
||||
var panels = [
|
||||
"users",
|
||||
"tokens",
|
||||
"channels",
|
||||
"schedules",
|
||||
"watches",
|
||||
"roles",
|
||||
"policies",
|
||||
"templates",
|
||||
"usage",
|
||||
"audit",
|
||||
];
|
||||
for (var p = 0; p < panels.length; p++) {
|
||||
var el = document.getElementById("admin-" + panels[p]);
|
||||
if (el) el.style.display = panels[p] === tab ? "" : "none";
|
||||
}
|
||||
|
||||
if (tab === "users") loadAdminUsers();
|
||||
if (tab === "tokens") _populateTokenUserSelect();
|
||||
if (tab === "channels") _populateChannelUserSelect();
|
||||
if (tab === "schedules") loadAdminSchedules();
|
||||
if (tab === "watches") loadAdminWatches();
|
||||
if (tab === "roles") loadGovRoles();
|
||||
if (tab === "policies") loadGovPolicies();
|
||||
if (tab === "templates") loadGovTemplates();
|
||||
if (tab === "usage") loadGovUsage();
|
||||
if (tab === "audit") {
|
||||
_populateAuditUserFilter();
|
||||
loadGovAudit();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -102,6 +167,9 @@ function _renderUsers(users) {
|
||||
escapeHtml(u.created || "").slice(0, 10) +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-actions">' +
|
||||
'<button class="admin-btn-action" data-user-roles="' +
|
||||
escapeHtml(u.user_id) +
|
||||
'" title="Manage roles">roles</button>' +
|
||||
'<button class="admin-btn-danger" data-delete-user="' +
|
||||
escapeHtml(u.user_id) +
|
||||
'" data-username="' +
|
||||
@@ -111,6 +179,13 @@ function _renderUsers(users) {
|
||||
"</div>";
|
||||
}
|
||||
container.innerHTML = html;
|
||||
// Bind roles buttons
|
||||
var roleBtns = container.querySelectorAll("[data-user-roles]");
|
||||
for (var rj = 0; rj < roleBtns.length; rj++) {
|
||||
roleBtns[rj].addEventListener("click", function () {
|
||||
showUserRolesModal(this.getAttribute("data-user-roles"));
|
||||
});
|
||||
}
|
||||
// Bind delete buttons via delegation (avoids inline JS injection)
|
||||
var btns = container.querySelectorAll("[data-delete-user]");
|
||||
for (var j = 0; j < btns.length; j++) {
|
||||
@@ -1362,6 +1437,14 @@ function _installTrap(overlayId, boxId, trapRef) {
|
||||
else if (overlayId === "edit-schedule-overlay") hideEditScheduleModal();
|
||||
else if (overlayId === "schedule-runs-overlay") hideScheduleRunsModal();
|
||||
else if (overlayId === "confirm-overlay") hideConfirmModal();
|
||||
else if (overlayId === "create-role-overlay") hideCreateRoleModal();
|
||||
else if (overlayId === "edit-role-overlay") hideEditRoleModal();
|
||||
else if (overlayId === "user-roles-overlay") hideUserRolesModal();
|
||||
else if (overlayId === "create-policy-overlay") hideCreatePolicyModal();
|
||||
else if (overlayId === "edit-policy-overlay") hideEditPolicyModal();
|
||||
else if (overlayId === "create-template-overlay")
|
||||
hideCreateTemplateModal();
|
||||
else if (overlayId === "edit-template-overlay") hideEditTemplateModal();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1428,6 +1511,24 @@ document.addEventListener("keydown", function (e) {
|
||||
hideConfirmModal();
|
||||
return;
|
||||
}
|
||||
// Governance modals
|
||||
var govOverlays = [
|
||||
["create-role-overlay", hideCreateRoleModal],
|
||||
["edit-role-overlay", hideEditRoleModal],
|
||||
["user-roles-overlay", hideUserRolesModal],
|
||||
["create-policy-overlay", hideCreatePolicyModal],
|
||||
["edit-policy-overlay", hideEditPolicyModal],
|
||||
["create-template-overlay", hideCreateTemplateModal],
|
||||
["edit-template-overlay", hideEditTemplateModal],
|
||||
];
|
||||
for (var gi = 0; gi < govOverlays.length; gi++) {
|
||||
var govEl = document.getElementById(govOverlays[gi][0]);
|
||||
if (govEl && govEl.style.display !== "none") {
|
||||
e.preventDefault();
|
||||
govOverlays[gi][1]();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Tab arrow key navigation
|
||||
@@ -1436,7 +1537,14 @@ document.addEventListener("keydown", function (e) {
|
||||
if (!tablist) return;
|
||||
tablist.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
var tabOrder = ["users", "tokens", "channels", "schedules", "watches"];
|
||||
var allTabs = document.querySelectorAll(
|
||||
'.admin-tab:not([style*="display: none"])',
|
||||
);
|
||||
var tabOrder = [];
|
||||
for (var ti = 0; ti < allTabs.length; ti++) {
|
||||
tabOrder.push(allTabs[ti].getAttribute("data-tab"));
|
||||
}
|
||||
if (tabOrder.length === 0) return;
|
||||
var idx = tabOrder.indexOf(_adminTab);
|
||||
if (e.key === "ArrowRight") idx = (idx + 1) % tabOrder.length;
|
||||
else idx = (idx - 1 + tabOrder.length) % tabOrder.length;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,11 @@
|
||||
<button id="tab-channels" class="admin-tab" data-tab="channels" role="tab" aria-selected="false" aria-controls="admin-channels" tabindex="-1" onclick="switchAdminTab('channels')">Channels</button>
|
||||
<button id="tab-schedules" class="admin-tab" data-tab="schedules" role="tab" aria-selected="false" aria-controls="admin-schedules" tabindex="-1" onclick="switchAdminTab('schedules')">Schedules</button>
|
||||
<button id="tab-watches" class="admin-tab" data-tab="watches" role="tab" aria-selected="false" aria-controls="admin-watches" tabindex="-1" onclick="switchAdminTab('watches')">Watches</button>
|
||||
<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-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>
|
||||
|
||||
<!-- Users Tab -->
|
||||
@@ -188,6 +193,118 @@
|
||||
<div class="dashboard-empty">Loading watches...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Tab -->
|
||||
<div id="admin-roles" class="admin-panel" role="tabpanel" aria-labelledby="tab-roles" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">ROLES</span>
|
||||
<button class="admin-action-btn" onclick="showCreateRoleModal()">+ Create role</button>
|
||||
</div>
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-rname">NAME</span>
|
||||
<span class="admin-col admin-col-rperms">PERMISSIONS</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-roles-table" role="list" aria-label="Roles" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading roles...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policies Tab -->
|
||||
<div id="admin-policies" class="admin-panel" role="tabpanel" aria-labelledby="tab-policies" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">TOOL POLICIES</span>
|
||||
<button class="admin-action-btn" onclick="showCreatePolicyModal()">+ Create policy</button>
|
||||
</div>
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-pname">NAME</span>
|
||||
<span class="admin-col admin-col-ppattern">PATTERN</span>
|
||||
<span class="admin-col admin-col-paction">ACTION</span>
|
||||
<span class="admin-col admin-col-ppriority">PRI</span>
|
||||
<span class="admin-col admin-col-pstatus">STATUS</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-policies-table" role="list" aria-label="Tool policies" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading policies...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates Tab -->
|
||||
<div id="admin-templates" class="admin-panel" role="tabpanel" aria-labelledby="tab-templates" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">PROMPT TEMPLATES</span>
|
||||
<button class="admin-action-btn" onclick="showCreateTemplateModal()">+ Create template</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">CATEGORY</span>
|
||||
<span class="admin-col admin-col-tmvars">VARIABLES</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-templates-table" role="list" aria-label="Prompt templates" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading 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">
|
||||
<span class="section-header" style="margin:0">USAGE</span>
|
||||
<div class="usage-range-group" role="group" aria-label="Time range">
|
||||
<button class="usage-range-btn" data-range="24h" aria-pressed="false" onclick="setUsageRange('24h')">24h</button>
|
||||
<button class="usage-range-btn active" data-range="7d" aria-pressed="true" onclick="setUsageRange('7d')">7d</button>
|
||||
<button class="usage-range-btn" data-range="30d" aria-pressed="false" onclick="setUsageRange('30d')">30d</button>
|
||||
</div>
|
||||
<div class="usage-range-group" role="group" aria-label="Group by">
|
||||
<button class="usage-group-btn active" data-group="day" aria-pressed="true" onclick="setUsageGroupBy('day')">day</button>
|
||||
<button class="usage-group-btn" data-group="model" aria-pressed="false" onclick="setUsageGroupBy('model')">model</button>
|
||||
<button class="usage-group-btn" data-group="user" aria-pressed="false" onclick="setUsageGroupBy('user')">user</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="admin-usage-content">
|
||||
<div class="dashboard-empty">Loading usage data...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Tab -->
|
||||
<div id="admin-audit" class="admin-panel" role="tabpanel" aria-labelledby="tab-audit" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">AUDIT LOG</span>
|
||||
<label for="audit-action-filter" class="sr-only">Filter by action</label>
|
||||
<select id="audit-action-filter" onchange="loadGovAudit()">
|
||||
<option value="">All actions</option>
|
||||
<option value="user.create">user.create</option>
|
||||
<option value="user.delete">user.delete</option>
|
||||
<option value="token.create">token.create</option>
|
||||
<option value="token.revoke">token.revoke</option>
|
||||
<option value="role.create">role.create</option>
|
||||
<option value="role.update">role.update</option>
|
||||
<option value="role.delete">role.delete</option>
|
||||
<option value="role.assign">role.assign</option>
|
||||
<option value="role.unassign">role.unassign</option>
|
||||
<option value="policy.create">policy.create</option>
|
||||
<option value="policy.update">policy.update</option>
|
||||
<option value="policy.delete">policy.delete</option>
|
||||
<option value="template.create">template.create</option>
|
||||
<option value="template.update">template.update</option>
|
||||
<option value="template.delete">template.delete</option>
|
||||
</select>
|
||||
<label for="audit-user-filter" class="sr-only">Filter by user</label>
|
||||
<select id="audit-user-filter" onchange="loadGovAudit()">
|
||||
<option value="">All users</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-atime">TIME</span>
|
||||
<span class="admin-col admin-col-auser">USER</span>
|
||||
<span class="admin-col admin-col-aaction">ACTION</span>
|
||||
<span class="admin-col admin-col-aresource">RESOURCE</span>
|
||||
<span class="admin-col admin-col-adetail">DETAIL</span>
|
||||
</div>
|
||||
<div id="admin-audit-table" role="list" aria-label="Audit events" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading audit log...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -434,7 +551,162 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Role Modal -->
|
||||
<div id="create-role-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-role-title">
|
||||
<div id="create-role-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="create-role-title">Create Role</h2>
|
||||
<div id="create-role-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="cr-name">Name</label>
|
||||
<input id="cr-name" type="text" placeholder="e.g. security-reviewer" autocomplete="off" spellcheck="false">
|
||||
<label for="cr-displayname">Display name</label>
|
||||
<input id="cr-displayname" type="text" placeholder="Security Reviewer" autocomplete="off">
|
||||
<fieldset class="perm-fieldset"><legend>Permissions</legend>
|
||||
<div id="cr-perms-container" role="group" aria-label="Permissions"></div>
|
||||
</fieldset>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreateRoleModal()">Cancel</button>
|
||||
<button id="cr-submit" class="modal-submit" onclick="submitCreateRole()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Modal -->
|
||||
<div id="edit-role-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-role-title">
|
||||
<div id="edit-role-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-role-title">Edit Role</h2>
|
||||
<div id="edit-role-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="er-id" type="hidden">
|
||||
<label for="er-name">Display name</label>
|
||||
<input id="er-name" type="text" autocomplete="off">
|
||||
<fieldset class="perm-fieldset"><legend>Permissions</legend>
|
||||
<div id="er-perms-container" role="group" aria-label="Permissions"></div>
|
||||
</fieldset>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditRoleModal()">Cancel</button>
|
||||
<button id="er-submit" class="modal-submit" onclick="submitEditRole()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Roles Modal -->
|
||||
<div id="user-roles-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="user-roles-title">
|
||||
<div id="user-roles-box" class="admin-modal">
|
||||
<h2 id="user-roles-title">Assign Roles</h2>
|
||||
<div id="user-roles-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="ur-user-id" type="hidden">
|
||||
<div id="ur-roles-container"></div>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideUserRolesModal()">Cancel</button>
|
||||
<button class="modal-submit" onclick="submitUserRoles()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Policy Modal -->
|
||||
<div id="create-policy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-policy-title">
|
||||
<div id="create-policy-box" class="admin-modal">
|
||||
<h2 id="create-policy-title">Create Tool Policy</h2>
|
||||
<div id="create-policy-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="cp-name">Name</label>
|
||||
<input id="cp-name" type="text" placeholder="e.g. Block shell access" autocomplete="off">
|
||||
<label for="cp-pattern">Tool pattern <span class="label-hint">glob syntax: bash*, file_write, *</span></label>
|
||||
<input id="cp-pattern" type="text" placeholder="bash*" autocomplete="off" spellcheck="false">
|
||||
<label for="cp-action">Action</label>
|
||||
<select id="cp-action">
|
||||
<option value="ask">Ask (require approval)</option>
|
||||
<option value="allow">Allow (auto-approve)</option>
|
||||
<option value="deny">Deny (block)</option>
|
||||
</select>
|
||||
<label for="cp-priority">Priority <span class="label-hint">higher = evaluated first</span></label>
|
||||
<input id="cp-priority" type="number" value="0" min="0" max="9999">
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreatePolicyModal()">Cancel</button>
|
||||
<button id="cp-submit" class="modal-submit" onclick="submitCreatePolicy()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Policy Modal -->
|
||||
<div id="edit-policy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-policy-title">
|
||||
<div id="edit-policy-box" class="admin-modal">
|
||||
<h2 id="edit-policy-title">Edit Tool Policy</h2>
|
||||
<div id="edit-policy-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="ep-id" type="hidden">
|
||||
<label for="ep-name">Name</label>
|
||||
<input id="ep-name" type="text" autocomplete="off">
|
||||
<label for="ep-pattern">Tool pattern</label>
|
||||
<input id="ep-pattern" type="text" autocomplete="off" spellcheck="false">
|
||||
<label for="ep-action">Action</label>
|
||||
<select id="ep-action">
|
||||
<option value="ask">Ask (require approval)</option>
|
||||
<option value="allow">Allow (auto-approve)</option>
|
||||
<option value="deny">Deny (block)</option>
|
||||
</select>
|
||||
<label for="ep-priority">Priority</label>
|
||||
<input id="ep-priority" type="number" value="0" min="0" max="9999">
|
||||
<label class="admin-checkbox"><input id="ep-enabled" type="checkbox" checked> Enabled</label>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditPolicyModal()">Cancel</button>
|
||||
<button id="ep-submit" class="modal-submit" onclick="submitEditPolicy()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Template Modal -->
|
||||
<div id="create-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-template-title">
|
||||
<div id="create-template-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="create-template-title">Create Prompt Template</h2>
|
||||
<div id="create-template-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="ctm-name">Name</label>
|
||||
<input id="ctm-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
|
||||
<label for="ctm-category">Category</label>
|
||||
<select id="ctm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="ctm-content">Content <span class="label-hint">system message text, use {{variable}} for placeholders</span></label>
|
||||
<textarea id="ctm-content" rows="6" placeholder="You are a helpful assistant for {{project_name}}..."></textarea>
|
||||
<label for="ctm-variables">Variables <span class="label-hint">comma-separated list</span></label>
|
||||
<input id="ctm-variables" type="text" placeholder="project_name, review_focus" autocomplete="off">
|
||||
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Set as default for new workstreams</label>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreateTemplateModal()">Cancel</button>
|
||||
<button id="ctm-submit" class="modal-submit" onclick="submitCreateTemplate()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Template Modal -->
|
||||
<div id="edit-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-template-title">
|
||||
<div id="edit-template-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-template-title">Edit Prompt Template</h2>
|
||||
<div id="edit-template-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="etm-id" type="hidden">
|
||||
<label for="etm-name">Name</label>
|
||||
<input id="etm-name" type="text" autocomplete="off">
|
||||
<label for="etm-category">Category</label>
|
||||
<select id="etm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="etm-content">Content</label>
|
||||
<textarea id="etm-content" rows="6"></textarea>
|
||||
<label for="etm-variables">Variables</label>
|
||||
<input id="etm-variables" type="text" autocomplete="off">
|
||||
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Set as default</label>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditTemplateModal()">Cancel</button>
|
||||
<button id="etm-submit" class="modal-submit" onclick="submitEditTemplate()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin.js"></script>
|
||||
<script src="/static/governance.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -709,6 +709,11 @@
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.admin-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
@@ -989,7 +994,10 @@
|
||||
.modal-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
|
||||
#create-user-overlay, #create-token-overlay, #token-created-overlay, #create-channel-overlay, #confirm-overlay,
|
||||
#create-schedule-overlay, #edit-schedule-overlay, #schedule-runs-overlay {
|
||||
#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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
@@ -1043,6 +1051,263 @@
|
||||
.admin-col-wcmd, .admin-col-wcond, .admin-col-winterval { display: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Admin tabs — horizontal scroll for 10+ tabs
|
||||
========================================================================== */
|
||||
.admin-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Roles grid
|
||||
========================================================================== */
|
||||
#admin-roles .admin-colheaders,
|
||||
#admin-roles .admin-row {
|
||||
grid-template-columns: 160px 1fr 110px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Tool Policies grid
|
||||
========================================================================== */
|
||||
#admin-policies .admin-colheaders,
|
||||
#admin-policies .admin-row {
|
||||
grid-template-columns: 1.2fr 1fr 70px 50px 80px 140px;
|
||||
}
|
||||
|
||||
/* Policy action badges */
|
||||
.policy-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.policy-allow {
|
||||
color: var(--green);
|
||||
background: var(--green-glow);
|
||||
border: 1px solid var(--green-glow);
|
||||
}
|
||||
.policy-deny {
|
||||
color: var(--red);
|
||||
background: var(--red-glow);
|
||||
border: 1px solid var(--red-glow);
|
||||
}
|
||||
.policy-ask {
|
||||
color: var(--yellow);
|
||||
background: var(--yellow-glow);
|
||||
border: 1px solid var(--yellow-glow);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Prompt Templates grid
|
||||
========================================================================== */
|
||||
#admin-templates .admin-colheaders,
|
||||
#admin-templates .admin-row {
|
||||
grid-template-columns: 1.5fr 100px 1fr 140px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Audit grid
|
||||
========================================================================== */
|
||||
#admin-audit .admin-colheaders,
|
||||
#admin-audit .admin-row {
|
||||
grid-template-columns: 80px 80px 1fr 120px 1.5fr;
|
||||
}
|
||||
|
||||
/* Audit action badges */
|
||||
.audit-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.audit-danger { color: var(--red); border-color: var(--red-glow); }
|
||||
.audit-success { color: var(--green); border-color: var(--green-glow); }
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Usage dashboard
|
||||
========================================================================== */
|
||||
.usage-summary {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 16px 0 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.usage-readout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.usage-readout-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.usage-readout-label {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* Usage bar chart */
|
||||
.usage-chart { padding-top: 4px; }
|
||||
.usage-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 60px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.usage-bar-label {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.usage-bar-track {
|
||||
height: 16px;
|
||||
background: var(--bg-highlight);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
min-width: 2px;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
.usage-bar-value {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Usage range/group buttons */
|
||||
.usage-range-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage-range-btn, .usage-group-btn {
|
||||
background: var(--bg);
|
||||
color: var(--fg-dim);
|
||||
border: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.usage-range-btn:hover, .usage-group-btn:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg);
|
||||
}
|
||||
.usage-range-btn.active, .usage-group-btn.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.usage-range-btn:focus-visible, .usage-group-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Permission grid (modal checkboxes)
|
||||
========================================================================== */
|
||||
.perm-fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.perm-fieldset legend {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-dim);
|
||||
padding: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.perm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.perm-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg);
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.perm-checkbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Responsive
|
||||
========================================================================== */
|
||||
@media (max-width: 700px) {
|
||||
#admin-roles .admin-colheaders, #admin-roles .admin-row {
|
||||
grid-template-columns: 1fr 100px;
|
||||
}
|
||||
.admin-col-rperms { display: none; }
|
||||
#admin-policies .admin-colheaders, #admin-policies .admin-row {
|
||||
grid-template-columns: 1fr 70px 50px 100px;
|
||||
}
|
||||
.admin-col-pstatus, .admin-col-ppriority { display: none; }
|
||||
#admin-templates .admin-colheaders, #admin-templates .admin-row {
|
||||
grid-template-columns: 1fr 100px;
|
||||
}
|
||||
.admin-col-tmcat, .admin-col-tmvars { display: none; }
|
||||
#admin-audit .admin-colheaders, #admin-audit .admin-row {
|
||||
grid-template-columns: 60px 1fr 100px;
|
||||
}
|
||||
.admin-col-auser, .admin-col-adetail { display: none; }
|
||||
.usage-readout-value { font-size: 18px; }
|
||||
.usage-bar-row { grid-template-columns: 70px 1fr 50px; }
|
||||
.perm-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — console-specific
|
||||
========================================================================== */
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Audit event recording helper.
|
||||
|
||||
Provides a fire-and-forget ``record_audit`` function that admin handlers
|
||||
call after mutations to create a persistent audit trail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def record_audit(
|
||||
storage: StorageBackend,
|
||||
user_id: str,
|
||||
action: str,
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
detail: dict[str, Any] | None = None,
|
||||
ip_address: str = "",
|
||||
) -> None:
|
||||
"""Record an audit event. Silently logs on failure (never raises)."""
|
||||
try:
|
||||
storage.record_audit_event(
|
||||
event_id=uuid.uuid4().hex,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
detail=json.dumps(detail) if detail else "{}",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to record audit event: %s %s", action, resource_id, exc_info=True)
|
||||
+110
-4
@@ -18,6 +18,7 @@ always accessible without authentication.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -33,7 +34,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -80,6 +81,60 @@ _ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
|
||||
"full": frozenset({"read", "write", "approve"}),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RBAC helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_user_permissions(storage: Any, user_id: str) -> set[str]:
|
||||
"""Load the union of all permissions from a user's assigned roles."""
|
||||
try:
|
||||
result: set[str] = storage.get_user_permissions(user_id)
|
||||
return result
|
||||
except Exception:
|
||||
log.warning("Failed to load permissions for user %s", user_id)
|
||||
return set()
|
||||
|
||||
|
||||
def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
|
||||
"""Derive legacy scopes from a granular permission set."""
|
||||
scopes: set[str] = set()
|
||||
if not permissions:
|
||||
scopes.add("read")
|
||||
return frozenset(scopes)
|
||||
for perm in permissions:
|
||||
if perm in VALID_SCOPES:
|
||||
scopes.update(SCOPE_HIERARCHY.get(perm, {perm}))
|
||||
# Any admin.* permission requires access to admin endpoints → approve scope
|
||||
if any(p.startswith("admin.") for p in permissions):
|
||||
scopes.update(SCOPE_HIERARCHY["approve"])
|
||||
if not scopes:
|
||||
scopes.add("read")
|
||||
return frozenset(scopes)
|
||||
|
||||
|
||||
def require_permission(request: Request, permission: str) -> JSONResponse | None:
|
||||
"""Return a 403 JSONResponse if the user lacks *permission*, else None.
|
||||
|
||||
Call from admin handlers after the middleware scope check passes.
|
||||
Config-file tokens (no user_id) are treated as full-access.
|
||||
"""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
if auth_result is None:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
# Config-file tokens (no user_id) are treated as full-access
|
||||
if not auth_result.user_id:
|
||||
return None
|
||||
if auth_result.has_permission(permission):
|
||||
return None
|
||||
return JSONResponse(
|
||||
{"error": f"Forbidden: missing '{permission}' permission"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path classification
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -104,6 +159,7 @@ WRITE_PATHS: frozenset[str] = frozenset(
|
||||
"/api/send",
|
||||
"/api/plan",
|
||||
"/api/command",
|
||||
"/api/cancel",
|
||||
"/api/workstreams/new",
|
||||
"/api/workstreams/close",
|
||||
"/api/cluster/workstreams/new",
|
||||
@@ -133,11 +189,16 @@ class AuthResult:
|
||||
user_id: str # empty string for config-file tokens
|
||||
scopes: frozenset[str]
|
||||
token_source: str # "config", "jwt", "database"
|
||||
permissions: frozenset[str] = frozenset()
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
"""Return True if this result includes *scope*."""
|
||||
return scope in self.scopes
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""Return True if this result includes *permission*."""
|
||||
return permission in self.permissions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthConfig (unchanged from before — static config-file tokens)
|
||||
@@ -249,8 +310,9 @@ def create_jwt(
|
||||
secret: str,
|
||||
expiry_hours: int = 24,
|
||||
audience: str = "",
|
||||
permissions: frozenset[str] = frozenset(),
|
||||
) -> str:
|
||||
"""Create a signed JWT with user identity and scopes."""
|
||||
"""Create a signed JWT with user identity, scopes, and permissions."""
|
||||
import jwt
|
||||
|
||||
now = int(time.time())
|
||||
@@ -264,6 +326,8 @@ def create_jwt(
|
||||
}
|
||||
if audience:
|
||||
payload["aud"] = audience
|
||||
if permissions:
|
||||
payload["permissions"] = ",".join(sorted(permissions))
|
||||
return jwt.encode(payload, secret, algorithm="HS256")
|
||||
|
||||
|
||||
@@ -293,11 +357,15 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
|
||||
user_id = payload.get("sub", "")
|
||||
scopes_str = payload.get("scopes", "")
|
||||
source = payload.get("src", "jwt")
|
||||
perms_str = payload.get("permissions", "")
|
||||
|
||||
perms = frozenset(p for p in perms_str.split(",") if p) if perms_str else frozenset()
|
||||
|
||||
return AuthResult(
|
||||
user_id=user_id,
|
||||
scopes=parse_scopes(scopes_str),
|
||||
token_source=source,
|
||||
permissions=perms,
|
||||
)
|
||||
|
||||
|
||||
@@ -531,10 +599,12 @@ def _authenticate_api_token(token: str, storage: Any) -> AuthResult | None:
|
||||
if exp_dt < now:
|
||||
return None
|
||||
|
||||
perms = _load_user_permissions(storage, row["user_id"]) if storage else set()
|
||||
return AuthResult(
|
||||
user_id=row["user_id"],
|
||||
scopes=parse_scopes(row["scopes"]),
|
||||
token_source="database",
|
||||
permissions=frozenset(perms),
|
||||
)
|
||||
|
||||
|
||||
@@ -835,10 +905,14 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
if username and password and storage is not None:
|
||||
user = storage.get_user_by_username(username)
|
||||
if user and verify_password(password, user["password_hash"]):
|
||||
# Derive scopes and permissions from assigned roles
|
||||
perms = _load_user_permissions(storage, user["user_id"])
|
||||
scopes = _permissions_to_scopes(perms)
|
||||
result = AuthResult(
|
||||
user_id=user["user_id"],
|
||||
scopes=frozenset({"read", "write", "approve"}),
|
||||
scopes=scopes,
|
||||
token_source="password",
|
||||
permissions=frozenset(perms),
|
||||
)
|
||||
elif body.get("token"):
|
||||
result = _authenticate_token(
|
||||
@@ -865,11 +939,14 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
source=result.token_source,
|
||||
secret=jwt_secret,
|
||||
audience=audience,
|
||||
permissions=result.permissions,
|
||||
)
|
||||
|
||||
role = "full" if result.has_scope("write") else "read"
|
||||
scopes_str = ",".join(sorted(result.scopes))
|
||||
resp_body: dict[str, str] = {"status": "ok", "role": role, "scopes": scopes_str}
|
||||
if result.permissions:
|
||||
resp_body["permissions"] = ",".join(sorted(result.permissions))
|
||||
if jwt_token:
|
||||
resp_body["jwt"] = jwt_token
|
||||
if result.user_id:
|
||||
@@ -959,7 +1036,33 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
if not created:
|
||||
return JSONResponse({"error": "Setup already completed"}, status_code=409)
|
||||
|
||||
scopes = frozenset({"read", "write", "approve"})
|
||||
# Assign admin role to the first user — fail setup if this breaks,
|
||||
# otherwise the admin is created with read-only access and locked out.
|
||||
try:
|
||||
storage.assign_role(user_id, "builtin-admin", "")
|
||||
except Exception:
|
||||
log.error("Failed to assign admin role to first user %s — aborting setup", user_id)
|
||||
# Roll back the user creation so setup can be retried
|
||||
with contextlib.suppress(Exception):
|
||||
storage.delete_user(user_id)
|
||||
return JSONResponse(
|
||||
{"error": "Failed to assign admin role. Ensure migrations have run."},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
# Derive permissions from roles
|
||||
perms = _load_user_permissions(storage, user_id)
|
||||
if not perms:
|
||||
log.error(
|
||||
"First user %s has no permissions after role assignment — aborting setup", user_id
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
storage.delete_user(user_id)
|
||||
return JSONResponse(
|
||||
{"error": "Failed to load permissions. Ensure migrations have run."},
|
||||
status_code=503,
|
||||
)
|
||||
scopes = _permissions_to_scopes(perms)
|
||||
jwt_token = ""
|
||||
if jwt_secret:
|
||||
jwt_token = create_jwt(
|
||||
@@ -968,6 +1071,7 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
source="password",
|
||||
secret=jwt_secret,
|
||||
audience=audience,
|
||||
permissions=frozenset(perms),
|
||||
)
|
||||
|
||||
resp_body: dict[str, str] = {
|
||||
@@ -977,6 +1081,8 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
"role": "full",
|
||||
"scopes": ",".join(sorted(scopes)),
|
||||
}
|
||||
if perms:
|
||||
resp_body["permissions"] = ",".join(sorted(perms))
|
||||
if jwt_token:
|
||||
resp_body["jwt"] = jwt_token
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tool policy evaluation engine.
|
||||
|
||||
Evaluates tool calls against admin-defined policies to determine whether
|
||||
a tool should be auto-allowed, denied, or require human approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def evaluate_tool_policy(
|
||||
storage: StorageBackend,
|
||||
tool_name: str,
|
||||
org_id: str = "",
|
||||
) -> str | None:
|
||||
"""Check tool policies for *tool_name*.
|
||||
|
||||
Policies are evaluated in priority order (highest first). The first
|
||||
matching policy wins.
|
||||
|
||||
Returns ``"allow"``, ``"deny"``, or ``"ask"`` if a policy matches,
|
||||
or ``None`` if no policy matches (caller should fall through to the
|
||||
default approval behaviour).
|
||||
"""
|
||||
try:
|
||||
policies = storage.list_tool_policies(org_id=org_id)
|
||||
except Exception:
|
||||
log.warning("Failed to load tool policies", exc_info=True)
|
||||
return None
|
||||
|
||||
for policy in policies:
|
||||
if not policy.get("enabled", True):
|
||||
continue
|
||||
pattern = policy.get("tool_pattern", "")
|
||||
if fnmatch.fnmatch(tool_name, pattern):
|
||||
action: str = policy.get("action", "ask")
|
||||
if action in ("allow", "deny", "ask"):
|
||||
return action
|
||||
log.warning("Unknown policy action %r for policy %s", action, policy.get("policy_id"))
|
||||
return "ask"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def evaluate_tool_policies_batch(
|
||||
storage: StorageBackend,
|
||||
tool_names: list[str],
|
||||
org_id: str = "",
|
||||
) -> dict[str, str | None]:
|
||||
"""Evaluate policies for multiple tools at once (single DB query).
|
||||
|
||||
Returns a dict mapping each tool name to its policy result.
|
||||
"""
|
||||
try:
|
||||
policies = storage.list_tool_policies(org_id=org_id)
|
||||
except Exception:
|
||||
log.warning("Failed to load tool policies", exc_info=True)
|
||||
return {name: None for name in tool_names}
|
||||
|
||||
results: dict[str, str | None] = {}
|
||||
for name in tool_names:
|
||||
result = None
|
||||
for policy in policies:
|
||||
if not policy.get("enabled", True):
|
||||
continue
|
||||
pattern = policy.get("tool_pattern", "")
|
||||
if fnmatch.fnmatch(name, pattern):
|
||||
action = policy.get("action", "ask")
|
||||
result = action if action in ("allow", "deny", "ask") else "ask"
|
||||
break
|
||||
results[name] = result
|
||||
return results
|
||||
+184
-85
@@ -83,6 +83,19 @@ if TYPE_CHECKING:
|
||||
StreamChunk,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cancellation support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GenerationCancelled(BaseException):
|
||||
"""Raised when generation is cancelled via ``ChatSession.cancel()``.
|
||||
|
||||
Subclasses ``BaseException`` so that broad ``except Exception`` handlers
|
||||
in tool execution code do not accidentally swallow it.
|
||||
"""
|
||||
|
||||
|
||||
# Image extensions handled as vision content (SVG excluded — it's XML text)
|
||||
_IMAGE_EXTENSIONS: frozenset[str] = frozenset(
|
||||
{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico"}
|
||||
@@ -227,6 +240,9 @@ class ChatSession:
|
||||
self._watch_runner: Any = None # WatchRunner | None
|
||||
self._watch_pending: queue.Queue[dict[str, Any]] = queue.Queue()
|
||||
self._watch_dispatch_depth = 0
|
||||
# Cooperative cancellation: set from outside to stop generation
|
||||
self._cancel_event = threading.Event()
|
||||
self._cancelled_partial_msg: dict[str, Any] | None = None
|
||||
# MCP tool integration: merge external tools with built-in
|
||||
self._mcp_client = mcp_client
|
||||
self._mcp_refresh_cb: Any = None # Callable | None (avoid import)
|
||||
@@ -715,15 +731,35 @@ class ChatSession:
|
||||
assert last_err is not None # unreachable, but satisfies type checker
|
||||
raise last_err
|
||||
|
||||
# -- Cancellation -------------------------------------------------------
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Request cancellation of the current generation.
|
||||
|
||||
Thread-safe — may be called from any thread (e.g. an HTTP handler)
|
||||
while the worker thread is inside ``send()``.
|
||||
"""
|
||||
self._cancel_event.set()
|
||||
|
||||
def _check_cancelled(self) -> None:
|
||||
"""Raise ``GenerationCancelled`` if cancellation has been requested."""
|
||||
if self._cancel_event.is_set():
|
||||
raise GenerationCancelled()
|
||||
|
||||
# -- Main generation loop ------------------------------------------------
|
||||
|
||||
def send(self, user_input: str) -> None:
|
||||
"""Send user input and handle the response loop (including tool calls)."""
|
||||
self._notify_count = 0
|
||||
self._cancel_event.clear()
|
||||
self._cancelled_partial_msg = None
|
||||
self.messages.append({"role": "user", "content": user_input})
|
||||
self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token)))
|
||||
save_message(self._ws_id, "user", user_input)
|
||||
|
||||
try:
|
||||
while True:
|
||||
self._check_cancelled()
|
||||
msgs = self._full_messages()
|
||||
|
||||
if self.debug:
|
||||
@@ -851,6 +887,40 @@ class ChatSession:
|
||||
if user_feedback:
|
||||
self.messages.append({"role": "user", "content": user_feedback})
|
||||
self._msg_tokens.append(max(1, int(len(user_feedback) / self._chars_per_token)))
|
||||
except GenerationCancelled:
|
||||
# Cooperative cancellation — preserve partial content if available.
|
||||
if self._cancelled_partial_msg:
|
||||
# _stream_response was interrupted — save partial assistant msg
|
||||
msg = self._cancelled_partial_msg
|
||||
self._cancelled_partial_msg = None
|
||||
self.messages.append(msg)
|
||||
tok_est = max(
|
||||
1,
|
||||
int(self._msg_char_count(msg) / self._chars_per_token),
|
||||
)
|
||||
self._msg_tokens.append(tok_est)
|
||||
content = msg.get("content", "")
|
||||
if content:
|
||||
save_message(self._ws_id, "assistant", content)
|
||||
else:
|
||||
# Cancelled during tool execution — roll back incomplete results
|
||||
while self.messages and self.messages[-1]["role"] == "tool":
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
while (
|
||||
self.messages
|
||||
and self.messages[-1]["role"] == "assistant"
|
||||
and self.messages[-1].get("tool_calls")
|
||||
):
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
self._cancel_event.clear()
|
||||
self.ui.on_info("[Generation cancelled]")
|
||||
self._emit_state("idle")
|
||||
# Do NOT re-raise — return normally so server worker thread
|
||||
# completes cleanly.
|
||||
except KeyboardInterrupt:
|
||||
# Remove any partial tool results, then the originating assistant
|
||||
# message with unanswered tool_calls — keep _msg_tokens in sync
|
||||
@@ -980,91 +1050,107 @@ class ChatSession:
|
||||
first_token = False
|
||||
|
||||
finish_reason = None
|
||||
for chunk in stream:
|
||||
# Track finish_reason (e.g. "stop", "length", "tool_calls")
|
||||
if chunk.finish_reason:
|
||||
finish_reason = chunk.finish_reason
|
||||
try:
|
||||
for chunk in stream:
|
||||
self._check_cancelled()
|
||||
# Track finish_reason (e.g. "stop", "length", "tool_calls")
|
||||
if chunk.finish_reason:
|
||||
finish_reason = chunk.finish_reason
|
||||
|
||||
# Accumulate usage (Anthropic sends prompt tokens in message_start
|
||||
# and completion tokens in message_delta as separate events)
|
||||
if chunk.usage:
|
||||
if self._last_usage is None:
|
||||
self._last_usage = {
|
||||
"prompt_tokens": chunk.usage.prompt_tokens,
|
||||
"completion_tokens": chunk.usage.completion_tokens,
|
||||
"total_tokens": chunk.usage.total_tokens,
|
||||
}
|
||||
else:
|
||||
self._last_usage["prompt_tokens"] = max(
|
||||
self._last_usage["prompt_tokens"], chunk.usage.prompt_tokens
|
||||
)
|
||||
self._last_usage["completion_tokens"] = max(
|
||||
self._last_usage["completion_tokens"], chunk.usage.completion_tokens
|
||||
)
|
||||
self._last_usage["total_tokens"] = (
|
||||
self._last_usage["prompt_tokens"] + self._last_usage["completion_tokens"]
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
parts = []
|
||||
if chunk.content_delta:
|
||||
parts.append(f"content={chunk.content_delta!r}")
|
||||
if chunk.reasoning_delta:
|
||||
parts.append(f"reasoning={chunk.reasoning_delta!r}")
|
||||
if chunk.tool_call_deltas:
|
||||
parts.append("tool_calls=...")
|
||||
if parts:
|
||||
self.ui.on_info(f"{GRAY}[delta: {', '.join(parts)}]{RESET}")
|
||||
|
||||
# Path 1: reasoning field (provider-normalized reasoning_delta)
|
||||
if chunk.reasoning_delta:
|
||||
_stop_spinner_once()
|
||||
reasoning_parts.append(chunk.reasoning_delta)
|
||||
in_think = True
|
||||
path1_reasoning = True
|
||||
if self.show_reasoning:
|
||||
self.ui.on_reasoning_token(chunk.reasoning_delta)
|
||||
|
||||
# Path 2: regular content (may contain <think> tags)
|
||||
if chunk.content_delta:
|
||||
_stop_spinner_once()
|
||||
# Close reasoning if transitioning from Path 1 reasoning
|
||||
if path1_reasoning:
|
||||
path1_reasoning = False
|
||||
in_think = False
|
||||
pending += chunk.content_delta
|
||||
_drain_pending()
|
||||
|
||||
# Handle tool call deltas
|
||||
if chunk.tool_call_deltas:
|
||||
_stop_spinner_once()
|
||||
# Close reasoning if transitioning from reasoning
|
||||
if in_think:
|
||||
in_think = False
|
||||
for tcd in chunk.tool_call_deltas:
|
||||
idx = tcd.index
|
||||
if idx not in tool_calls_acc:
|
||||
tool_calls_acc[idx] = {
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""},
|
||||
# Accumulate usage (Anthropic sends prompt tokens in message_start
|
||||
# and completion tokens in message_delta as separate events)
|
||||
if chunk.usage:
|
||||
if self._last_usage is None:
|
||||
self._last_usage = {
|
||||
"prompt_tokens": chunk.usage.prompt_tokens,
|
||||
"completion_tokens": chunk.usage.completion_tokens,
|
||||
"total_tokens": chunk.usage.total_tokens,
|
||||
}
|
||||
tc = tool_calls_acc[idx]
|
||||
if tcd.id:
|
||||
tc["id"] = tcd.id
|
||||
if tcd.name:
|
||||
tc["function"]["name"] = tcd.name
|
||||
if tcd.arguments_delta:
|
||||
tc["function"]["arguments"] += tcd.arguments_delta
|
||||
else:
|
||||
self._last_usage["prompt_tokens"] = max(
|
||||
self._last_usage["prompt_tokens"], chunk.usage.prompt_tokens
|
||||
)
|
||||
self._last_usage["completion_tokens"] = max(
|
||||
self._last_usage["completion_tokens"], chunk.usage.completion_tokens
|
||||
)
|
||||
self._last_usage["total_tokens"] = (
|
||||
self._last_usage["prompt_tokens"]
|
||||
+ self._last_usage["completion_tokens"]
|
||||
)
|
||||
|
||||
# Informational messages (e.g. server-side web search status)
|
||||
if chunk.info_delta:
|
||||
_stop_spinner_once()
|
||||
self.ui.on_info(f"{GRAY}{chunk.info_delta}{RESET}")
|
||||
if self.debug:
|
||||
parts = []
|
||||
if chunk.content_delta:
|
||||
parts.append(f"content={chunk.content_delta!r}")
|
||||
if chunk.reasoning_delta:
|
||||
parts.append(f"reasoning={chunk.reasoning_delta!r}")
|
||||
if chunk.tool_call_deltas:
|
||||
parts.append("tool_calls=...")
|
||||
if parts:
|
||||
self.ui.on_info(f"{GRAY}[delta: {', '.join(parts)}]{RESET}")
|
||||
|
||||
# Raw provider content blocks (for multi-turn preservation)
|
||||
if chunk.provider_blocks:
|
||||
provider_blocks = chunk.provider_blocks
|
||||
# Path 1: reasoning field (provider-normalized reasoning_delta)
|
||||
if chunk.reasoning_delta:
|
||||
_stop_spinner_once()
|
||||
reasoning_parts.append(chunk.reasoning_delta)
|
||||
in_think = True
|
||||
path1_reasoning = True
|
||||
if self.show_reasoning:
|
||||
self.ui.on_reasoning_token(chunk.reasoning_delta)
|
||||
|
||||
# Path 2: regular content (may contain <think> tags)
|
||||
if chunk.content_delta:
|
||||
_stop_spinner_once()
|
||||
# Close reasoning if transitioning from Path 1 reasoning
|
||||
if path1_reasoning:
|
||||
path1_reasoning = False
|
||||
in_think = False
|
||||
pending += chunk.content_delta
|
||||
_drain_pending()
|
||||
|
||||
# Handle tool call deltas
|
||||
if chunk.tool_call_deltas:
|
||||
_stop_spinner_once()
|
||||
# Close reasoning if transitioning from reasoning
|
||||
if in_think:
|
||||
in_think = False
|
||||
for tcd in chunk.tool_call_deltas:
|
||||
idx = tcd.index
|
||||
if idx not in tool_calls_acc:
|
||||
tool_calls_acc[idx] = {
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""},
|
||||
}
|
||||
tc = tool_calls_acc[idx]
|
||||
if tcd.id:
|
||||
tc["id"] = tcd.id
|
||||
if tcd.name:
|
||||
tc["function"]["name"] = tcd.name
|
||||
if tcd.arguments_delta:
|
||||
tc["function"]["arguments"] += tcd.arguments_delta
|
||||
|
||||
# Informational messages (e.g. server-side web search status)
|
||||
if chunk.info_delta:
|
||||
_stop_spinner_once()
|
||||
self.ui.on_info(f"{GRAY}{chunk.info_delta}{RESET}")
|
||||
|
||||
# Raw provider content blocks (for multi-turn preservation)
|
||||
if chunk.provider_blocks:
|
||||
provider_blocks = chunk.provider_blocks
|
||||
except GenerationCancelled:
|
||||
# Flush whatever was buffered and build a partial message
|
||||
if pending:
|
||||
_flush_text(pending, in_think)
|
||||
self.ui.on_stream_end()
|
||||
partial: dict[str, Any] = {"role": "assistant"}
|
||||
partial_content = "".join(content_parts)
|
||||
partial["content"] = partial_content or None
|
||||
# Deliberately omit tool_calls — they are incomplete
|
||||
if provider_blocks:
|
||||
partial["_provider_content"] = provider_blocks
|
||||
self._cancelled_partial_msg = partial
|
||||
raise
|
||||
|
||||
# Flush any remaining buffered text
|
||||
if pending:
|
||||
@@ -1446,7 +1532,9 @@ class ChatSession:
|
||||
item["denial_msg"] = user_feedback or "Denied by user"
|
||||
user_feedback = None # feedback is in the denial_msg
|
||||
|
||||
# Phase 3: execute
|
||||
# Phase 3: execute (check cancellation before starting)
|
||||
self._check_cancelled()
|
||||
|
||||
def run_one(
|
||||
item: dict[str, Any],
|
||||
) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
@@ -2285,6 +2373,15 @@ class ChatSession:
|
||||
stdout_parts.append(line)
|
||||
with contextlib.suppress(Exception):
|
||||
self.ui.on_tool_output_chunk(call_id, line)
|
||||
# Check cancellation during long-running commands
|
||||
if self._cancel_event.is_set():
|
||||
with contextlib.suppress(OSError, ProcessLookupError):
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except OSError:
|
||||
with contextlib.suppress(OSError, ProcessLookupError):
|
||||
proc.kill()
|
||||
raise GenerationCancelled()
|
||||
finally:
|
||||
timer.cancel()
|
||||
|
||||
@@ -2548,6 +2645,7 @@ class ChatSession:
|
||||
|
||||
turn = 0
|
||||
while max_tool_turns < 0 or turn < max_tool_turns:
|
||||
self._check_cancelled()
|
||||
try:
|
||||
result = _api_call(agent_messages)
|
||||
except Exception as e:
|
||||
@@ -2559,7 +2657,7 @@ class ChatSession:
|
||||
# Find the last assistant content we have
|
||||
for msg in reversed(agent_messages):
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
return msg["content"]
|
||||
return str(msg["content"])
|
||||
return f"({label} stopped: context limit exceeded)"
|
||||
raise
|
||||
|
||||
@@ -2589,6 +2687,7 @@ class ChatSession:
|
||||
# concurrent _read_files mutation from worker threads.
|
||||
tool_names = {t["function"]["name"] for t in tools}
|
||||
for tc_dict in result.tool_calls:
|
||||
self._check_cancelled()
|
||||
tool_name = tc_dict["function"]["name"]
|
||||
|
||||
# Guard 1: block recursive agent calls.
|
||||
@@ -2695,7 +2794,7 @@ class ChatSession:
|
||||
tools=self._task_tools,
|
||||
auto_tools=self._TASK_AUTO_TOOLS,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
except (KeyboardInterrupt, GenerationCancelled):
|
||||
return call_id, "(task interrupted by user)"
|
||||
except Exception as e:
|
||||
self.ui.on_info(f"[task error] {e}")
|
||||
@@ -2748,7 +2847,7 @@ class ChatSession:
|
||||
label="plan",
|
||||
reasoning_effort="high",
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
except (KeyboardInterrupt, GenerationCancelled):
|
||||
return call_id, "(plan interrupted by user)"
|
||||
except Exception as e:
|
||||
self.ui.on_info(f"[plan error] {e}")
|
||||
|
||||
@@ -10,9 +10,16 @@ import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import (
|
||||
api_tokens,
|
||||
audit_events,
|
||||
conversations,
|
||||
memories,
|
||||
metadata,
|
||||
orgs,
|
||||
prompt_templates,
|
||||
roles,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
users,
|
||||
workstream_config,
|
||||
workstreams,
|
||||
@@ -22,6 +29,23 @@ from turnstone.core.storage._sqlite import _reconstruct_messages
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
|
||||
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
|
||||
d = dict(row._mapping)
|
||||
for key in bool_fields:
|
||||
if key in d:
|
||||
d[key] = bool(d[key])
|
||||
return d
|
||||
|
||||
|
||||
# -- Field allowlists for governance update methods ---------------------------
|
||||
|
||||
_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"})
|
||||
|
||||
|
||||
class PostgreSQLBackend:
|
||||
"""PostgreSQL implementation of the StorageBackend protocol."""
|
||||
|
||||
@@ -531,6 +555,7 @@ class PostgreSQLBackend:
|
||||
from turnstone.core.storage._schema import channel_users
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
|
||||
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
|
||||
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
|
||||
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
|
||||
@@ -1216,6 +1241,559 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Roles -----------------------------------------------------------------
|
||||
|
||||
def create_role(
|
||||
self,
|
||||
role_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
permissions: str,
|
||||
builtin: bool,
|
||||
org_id: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(roles.c.role_id).where(roles.c.role_id == role_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(roles),
|
||||
{
|
||||
"role_id": role_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"permissions": permissions,
|
||||
"builtin": 1 if builtin else 0,
|
||||
"org_id": org_id,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_role(self, role_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.role_id == role_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.name == name)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(roles).order_by(roles.c.name.asc())
|
||||
if org_id:
|
||||
q = q.where(roles.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "builtin") for r in rows]
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ROLE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_role: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ROLE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(roles).where(roles.c.role_id == role_id).values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_role(self, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
|
||||
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str, assigned_by: str = "") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(user_roles.c.user_id).where(
|
||||
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
|
||||
)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(user_roles),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"role_id": role_id,
|
||||
"assigned_by": assigned_by,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(user_roles).where(
|
||||
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
roles.c.role_id,
|
||||
roles.c.name,
|
||||
roles.c.display_name,
|
||||
roles.c.permissions,
|
||||
roles.c.builtin,
|
||||
roles.c.org_id,
|
||||
roles.c.created,
|
||||
roles.c.updated,
|
||||
user_roles.c.assigned_by,
|
||||
user_roles.c.created.label("assignment_created"),
|
||||
)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"role_id": r[0],
|
||||
"name": r[1],
|
||||
"display_name": r[2],
|
||||
"permissions": r[3],
|
||||
"builtin": bool(r[4]),
|
||||
"org_id": r[5],
|
||||
"created": r[6],
|
||||
"updated": r[7],
|
||||
"assigned_by": r[8],
|
||||
"assignment_created": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_user_permissions(self, user_id: str) -> set[str]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(roles.c.permissions)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
perms: set[str] = set()
|
||||
for r in rows:
|
||||
if r[0]:
|
||||
for p in r[0].split(","):
|
||||
p = p.strip()
|
||||
if p:
|
||||
perms.add(p)
|
||||
return perms
|
||||
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
|
||||
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(orgs.c.org_id).where(orgs.c.org_id == org_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(orgs),
|
||||
{
|
||||
"org_id": org_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"settings": settings,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_org(self, org_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(orgs).where(orgs.c.org_id == org_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row)
|
||||
return None
|
||||
|
||||
def list_orgs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.select(orgs).order_by(orgs.c.name)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ORG_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_org: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ORG_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.update(orgs).where(orgs.c.org_id == org_id).values(**fields))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
|
||||
def create_tool_policy(
|
||||
self,
|
||||
policy_id: str,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int,
|
||||
org_id: str = "",
|
||||
enabled: bool = True,
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(tool_policies),
|
||||
{
|
||||
"policy_id": policy_id,
|
||||
"name": name,
|
||||
"tool_pattern": tool_pattern,
|
||||
"action": action,
|
||||
"priority": priority,
|
||||
"org_id": org_id,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"created_by": created_by,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "enabled")
|
||||
return None
|
||||
|
||||
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(tool_policies).order_by(tool_policies.c.priority.desc())
|
||||
if org_id:
|
||||
q = q.where(tool_policies.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _POLICY_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_tool_policy: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _POLICY_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = int(fields["enabled"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(tool_policies)
|
||||
.where(tool_policies.c.policy_id == policy_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_tool_policy(self, policy_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
|
||||
def create_prompt_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: str,
|
||||
category: str,
|
||||
content: str,
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
org_id: str = "",
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "is_default")
|
||||
return None
|
||||
|
||||
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(prompt_templates).order_by(prompt_templates.c.name)
|
||||
if org_id:
|
||||
q = q.where(prompt_templates.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "is_default") for r in rows]
|
||||
|
||||
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _TEMPLATE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_prompt_template: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _TEMPLATE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "is_default" in fields:
|
||||
fields["is_default"] = int(fields["is_default"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(prompt_templates)
|
||||
.where(prompt_templates.c.template_id == template_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
|
||||
def record_usage_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
node_id: str = "",
|
||||
model: str = "",
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
tool_calls_count: int = 0,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"model": model,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"tool_calls_count": tool_calls_count,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def query_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["timestamp >= :since"]
|
||||
params: dict[str, Any] = {"since": since}
|
||||
if until:
|
||||
clauses.append("timestamp <= :until")
|
||||
params["until"] = until
|
||||
if user_id:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if model:
|
||||
clauses.append("model = :model")
|
||||
params["model"] = model
|
||||
where = " AND ".join(clauses)
|
||||
|
||||
if group_by == "day":
|
||||
key_expr = "substring(timestamp from 1 for 10)"
|
||||
elif group_by == "hour":
|
||||
key_expr = "substring(timestamp from 1 for 13)"
|
||||
elif group_by == "model":
|
||||
key_expr = "model"
|
||||
elif group_by == "user":
|
||||
key_expr = "user_id"
|
||||
else:
|
||||
# No grouping — single summary row
|
||||
sql = (
|
||||
f"SELECT SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where}"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.text(sql), params).fetchone()
|
||||
if row:
|
||||
return [
|
||||
{
|
||||
"prompt_tokens": row[0] or 0,
|
||||
"completion_tokens": row[1] or 0,
|
||||
"tool_calls_count": row[2] or 0,
|
||||
}
|
||||
]
|
||||
return [{"prompt_tokens": 0, "completion_tokens": 0, "tool_calls_count": 0}]
|
||||
|
||||
sql = (
|
||||
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where} "
|
||||
f"GROUP BY {key_expr} ORDER BY key ASC"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.text(sql), params).fetchall()
|
||||
return [
|
||||
{
|
||||
"key": r[0],
|
||||
"prompt_tokens": r[1] or 0,
|
||||
"completion_tokens": r[2] or 0,
|
||||
"tool_calls_count": r[3] or 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def prune_usage_events(self, retention_days: int = 90) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
|
||||
def record_audit_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
action: str = "",
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
detail: str = "{}",
|
||||
ip_address: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(audit_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"detail": detail,
|
||||
"ip_address": ip_address,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(
|
||||
audit_events.c.event_id,
|
||||
audit_events.c.timestamp,
|
||||
audit_events.c.user_id,
|
||||
audit_events.c.action,
|
||||
audit_events.c.resource_type,
|
||||
audit_events.c.resource_id,
|
||||
audit_events.c.detail,
|
||||
audit_events.c.ip_address,
|
||||
audit_events.c.created,
|
||||
).order_by(audit_events.c.timestamp.desc(), audit_events.c.event_id.desc())
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
q = q.limit(limit).offset(offset)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0],
|
||||
"timestamp": r[1],
|
||||
"user_id": r[2],
|
||||
"action": r[3],
|
||||
"resource_type": r[4],
|
||||
"resource_id": r[5],
|
||||
"detail": r[6],
|
||||
"ip_address": r[7],
|
||||
"created": r[8],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def count_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(sa.func.count()).select_from(audit_events)
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
row = conn.execute(q).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def prune_audit_events(self, retention_days: int = 365) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -359,6 +359,210 @@ class StorageBackend(Protocol):
|
||||
"""Remove a service registration. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- Roles (RBAC) ----------------------------------------------------------
|
||||
|
||||
def create_role(
|
||||
self,
|
||||
role_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
permissions: str,
|
||||
builtin: bool,
|
||||
org_id: str,
|
||||
) -> None:
|
||||
"""Create a role. No-op if role_id already exists."""
|
||||
...
|
||||
|
||||
def get_role(self, role_id: str) -> dict[str, Any] | None:
|
||||
"""Return role dict or None."""
|
||||
...
|
||||
|
||||
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
"""Lookup role by name. Returns same dict as get_role or None."""
|
||||
...
|
||||
|
||||
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all roles, optionally filtered by org_id. Ordered by name."""
|
||||
...
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on a role. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_role(self, role_id: str) -> bool:
|
||||
"""Delete a custom role. Returns True if found."""
|
||||
...
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str, assigned_by: str) -> None:
|
||||
"""Assign a role to a user. No-op if already assigned."""
|
||||
...
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> bool:
|
||||
"""Unassign a role from a user. Returns True if existed."""
|
||||
...
|
||||
|
||||
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""List roles assigned to a user (joins user_roles with roles)."""
|
||||
...
|
||||
|
||||
def get_user_permissions(self, user_id: str) -> set[str]:
|
||||
"""Return the union of all permissions from the user's assigned roles."""
|
||||
...
|
||||
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
|
||||
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
|
||||
"""Create an organization. No-op if org_id already exists."""
|
||||
...
|
||||
|
||||
def get_org(self, org_id: str) -> dict[str, Any] | None:
|
||||
"""Return org dict or None."""
|
||||
...
|
||||
|
||||
def list_orgs(self) -> list[dict[str, Any]]:
|
||||
"""Return all organizations ordered by name."""
|
||||
...
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on an org. Returns True if found."""
|
||||
...
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
|
||||
def create_tool_policy(
|
||||
self,
|
||||
policy_id: str,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int,
|
||||
org_id: str,
|
||||
enabled: bool,
|
||||
created_by: str,
|
||||
) -> None:
|
||||
"""Create a tool policy."""
|
||||
...
|
||||
|
||||
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
"""Return tool policy dict or None."""
|
||||
...
|
||||
|
||||
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all tool policies ordered by priority DESC."""
|
||||
...
|
||||
|
||||
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on a tool policy. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_tool_policy(self, policy_id: str) -> bool:
|
||||
"""Delete a tool policy. Returns True if found."""
|
||||
...
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
|
||||
def create_prompt_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: str,
|
||||
category: str,
|
||||
content: str,
|
||||
variables: str,
|
||||
is_default: bool,
|
||||
org_id: str,
|
||||
created_by: str,
|
||||
) -> None:
|
||||
"""Create a prompt template."""
|
||||
...
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
"""Return prompt template dict or None."""
|
||||
...
|
||||
|
||||
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all prompt templates ordered by name."""
|
||||
...
|
||||
|
||||
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on a prompt template. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
"""Delete a prompt template. Returns True if found."""
|
||||
...
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
|
||||
def record_usage_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str,
|
||||
ws_id: str,
|
||||
node_id: str,
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
tool_calls_count: int,
|
||||
) -> None:
|
||||
"""Record a usage event (token counts, tool calls for one LLM request)."""
|
||||
...
|
||||
|
||||
def query_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Query aggregated usage data. group_by: 'day', 'hour', 'model', 'user'."""
|
||||
...
|
||||
|
||||
def prune_usage_events(self, retention_days: int = 90) -> int:
|
||||
"""Delete usage events older than retention_days. Returns count deleted."""
|
||||
...
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
|
||||
def record_audit_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
detail: str,
|
||||
ip_address: str,
|
||||
) -> None:
|
||||
"""Record an audit event."""
|
||||
...
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List audit events with optional filters, ordered by timestamp DESC."""
|
||||
...
|
||||
|
||||
def count_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
) -> int:
|
||||
"""Count audit events matching the filters."""
|
||||
...
|
||||
|
||||
def prune_audit_events(self, retention_days: int = 365) -> int:
|
||||
"""Delete audit events older than retention_days. Returns count deleted."""
|
||||
...
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -71,6 +71,7 @@ users = sa.Table(
|
||||
sa.Column("username", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("password_hash", sa.Text, nullable=False),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
@@ -217,3 +218,114 @@ services = sa.Table(
|
||||
)
|
||||
|
||||
sa.Index("idx_services_type_heartbeat", services.c.service_type, services.c.last_heartbeat)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance tables — RBAC, orgs, policies, templates, usage, audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
orgs = sa.Table(
|
||||
"orgs",
|
||||
metadata,
|
||||
sa.Column("org_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("settings", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
roles = sa.Table(
|
||||
"roles",
|
||||
metadata,
|
||||
sa.Column("role_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("permissions", sa.Text, nullable=False), # comma-separated
|
||||
sa.Column("builtin", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
user_roles = sa.Table(
|
||||
"user_roles",
|
||||
metadata,
|
||||
sa.Column("user_id", sa.Text, nullable=False),
|
||||
sa.Column("role_id", sa.Text, nullable=False),
|
||||
sa.Column("assigned_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.PrimaryKeyConstraint("user_id", "role_id"),
|
||||
)
|
||||
|
||||
sa.Index("idx_user_roles_role_id", user_roles.c.role_id)
|
||||
|
||||
tool_policies = sa.Table(
|
||||
"tool_policies",
|
||||
metadata,
|
||||
sa.Column("policy_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False),
|
||||
sa.Column("tool_pattern", sa.Text, nullable=False),
|
||||
sa.Column("action", sa.Text, nullable=False), # allow / deny / ask
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", 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("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
sa.Index("idx_tool_policies_priority", tool_policies.c.priority.desc())
|
||||
sa.Index("idx_tool_policies_org", tool_policies.c.org_id)
|
||||
|
||||
prompt_templates = sa.Table(
|
||||
"prompt_templates",
|
||||
metadata,
|
||||
sa.Column("template_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("category", sa.Text, nullable=False, server_default="general"),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("variables", sa.Text, nullable=False, server_default="[]"), # JSON array
|
||||
sa.Column("is_default", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
usage_events = sa.Table(
|
||||
"usage_events",
|
||||
metadata,
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("ws_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("model", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("completion_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("tool_calls_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
sa.Index("idx_usage_events_timestamp", usage_events.c.timestamp)
|
||||
sa.Index("idx_usage_events_user", usage_events.c.user_id, usage_events.c.timestamp)
|
||||
sa.Index("idx_usage_events_model", usage_events.c.model, usage_events.c.timestamp)
|
||||
sa.Index("idx_usage_events_ws", usage_events.c.ws_id)
|
||||
|
||||
audit_events = sa.Table(
|
||||
"audit_events",
|
||||
metadata,
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("action", sa.Text, nullable=False),
|
||||
sa.Column("resource_type", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("resource_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("detail", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("ip_address", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
sa.Index("idx_audit_timestamp", audit_events.c.timestamp)
|
||||
sa.Index("idx_audit_action", audit_events.c.action)
|
||||
sa.Index("idx_audit_user", audit_events.c.user_id)
|
||||
|
||||
@@ -12,9 +12,16 @@ import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import (
|
||||
api_tokens,
|
||||
audit_events,
|
||||
conversations,
|
||||
memories,
|
||||
metadata,
|
||||
orgs,
|
||||
prompt_templates,
|
||||
roles,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
users,
|
||||
workstream_config,
|
||||
workstreams,
|
||||
@@ -38,6 +45,23 @@ def _fts5_query(query: str) -> str:
|
||||
return " ".join(safe)
|
||||
|
||||
|
||||
def _row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
|
||||
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
|
||||
d = dict(row._mapping)
|
||||
for key in bool_fields:
|
||||
if key in d:
|
||||
d[key] = bool(d[key])
|
||||
return d
|
||||
|
||||
|
||||
# -- Field allowlists for governance update methods ---------------------------
|
||||
|
||||
_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"})
|
||||
|
||||
|
||||
class SQLiteBackend:
|
||||
"""SQLite implementation of the StorageBackend protocol."""
|
||||
|
||||
@@ -590,6 +614,7 @@ class SQLiteBackend:
|
||||
from turnstone.core.storage._schema import channel_users
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
|
||||
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
|
||||
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
|
||||
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
|
||||
@@ -1264,6 +1289,545 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Roles -----------------------------------------------------------------
|
||||
|
||||
def create_role(
|
||||
self,
|
||||
role_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
permissions: str,
|
||||
builtin: bool,
|
||||
org_id: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(roles).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"role_id": role_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"permissions": permissions,
|
||||
"builtin": 1 if builtin else 0,
|
||||
"org_id": org_id,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_role(self, role_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.role_id == role_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.name == name)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(roles).order_by(roles.c.name.asc())
|
||||
if org_id:
|
||||
q = q.where(roles.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "builtin") for r in rows]
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ROLE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_role: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ROLE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(roles).where(roles.c.role_id == role_id).values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_role(self, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
|
||||
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str, assigned_by: str = "") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(user_roles).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"role_id": role_id,
|
||||
"assigned_by": assigned_by,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(user_roles).where(
|
||||
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
roles.c.role_id,
|
||||
roles.c.name,
|
||||
roles.c.display_name,
|
||||
roles.c.permissions,
|
||||
roles.c.builtin,
|
||||
roles.c.org_id,
|
||||
roles.c.created,
|
||||
roles.c.updated,
|
||||
user_roles.c.assigned_by,
|
||||
user_roles.c.created.label("assignment_created"),
|
||||
)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"role_id": r[0],
|
||||
"name": r[1],
|
||||
"display_name": r[2],
|
||||
"permissions": r[3],
|
||||
"builtin": bool(r[4]),
|
||||
"org_id": r[5],
|
||||
"created": r[6],
|
||||
"updated": r[7],
|
||||
"assigned_by": r[8],
|
||||
"assignment_created": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_user_permissions(self, user_id: str) -> set[str]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(roles.c.permissions)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
perms: set[str] = set()
|
||||
for r in rows:
|
||||
if r[0]:
|
||||
for p in r[0].split(","):
|
||||
p = p.strip()
|
||||
if p:
|
||||
perms.add(p)
|
||||
return perms
|
||||
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
|
||||
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(orgs).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"org_id": org_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"settings": settings,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_org(self, org_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(orgs).where(orgs.c.org_id == org_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row)
|
||||
return None
|
||||
|
||||
def list_orgs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.select(orgs).order_by(orgs.c.name)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ORG_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_org: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ORG_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.update(orgs).where(orgs.c.org_id == org_id).values(**fields))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
|
||||
def create_tool_policy(
|
||||
self,
|
||||
policy_id: str,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int,
|
||||
org_id: str = "",
|
||||
enabled: bool = True,
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(tool_policies),
|
||||
{
|
||||
"policy_id": policy_id,
|
||||
"name": name,
|
||||
"tool_pattern": tool_pattern,
|
||||
"action": action,
|
||||
"priority": priority,
|
||||
"org_id": org_id,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"created_by": created_by,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "enabled")
|
||||
return None
|
||||
|
||||
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(tool_policies).order_by(tool_policies.c.priority.desc())
|
||||
if org_id:
|
||||
q = q.where(tool_policies.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _POLICY_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_tool_policy: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _POLICY_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = int(fields["enabled"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(tool_policies)
|
||||
.where(tool_policies.c.policy_id == policy_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_tool_policy(self, policy_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
|
||||
def create_prompt_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: str,
|
||||
category: str,
|
||||
content: str,
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
org_id: str = "",
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "is_default")
|
||||
return None
|
||||
|
||||
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(prompt_templates).order_by(prompt_templates.c.name)
|
||||
if org_id:
|
||||
q = q.where(prompt_templates.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "is_default") for r in rows]
|
||||
|
||||
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _TEMPLATE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_prompt_template: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _TEMPLATE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "is_default" in fields:
|
||||
fields["is_default"] = int(fields["is_default"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(prompt_templates)
|
||||
.where(prompt_templates.c.template_id == template_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
|
||||
def record_usage_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
node_id: str = "",
|
||||
model: str = "",
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
tool_calls_count: int = 0,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"model": model,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"tool_calls_count": tool_calls_count,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def query_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["timestamp >= :since"]
|
||||
params: dict[str, Any] = {"since": since}
|
||||
if until:
|
||||
clauses.append("timestamp <= :until")
|
||||
params["until"] = until
|
||||
if user_id:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if model:
|
||||
clauses.append("model = :model")
|
||||
params["model"] = model
|
||||
where = " AND ".join(clauses)
|
||||
|
||||
if group_by == "day":
|
||||
key_expr = "substr(timestamp, 1, 10)"
|
||||
elif group_by == "hour":
|
||||
key_expr = "substr(timestamp, 1, 13)"
|
||||
elif group_by == "model":
|
||||
key_expr = "model"
|
||||
elif group_by == "user":
|
||||
key_expr = "user_id"
|
||||
else:
|
||||
# No grouping — single summary row
|
||||
sql = (
|
||||
f"SELECT SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where}"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.text(sql), params).fetchone()
|
||||
if row:
|
||||
return [
|
||||
{
|
||||
"prompt_tokens": row[0] or 0,
|
||||
"completion_tokens": row[1] or 0,
|
||||
"tool_calls_count": row[2] or 0,
|
||||
}
|
||||
]
|
||||
return [{"prompt_tokens": 0, "completion_tokens": 0, "tool_calls_count": 0}]
|
||||
|
||||
sql = (
|
||||
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where} "
|
||||
f"GROUP BY {key_expr} ORDER BY key ASC"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.text(sql), params).fetchall()
|
||||
return [
|
||||
{
|
||||
"key": r[0],
|
||||
"prompt_tokens": r[1] or 0,
|
||||
"completion_tokens": r[2] or 0,
|
||||
"tool_calls_count": r[3] or 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def prune_usage_events(self, retention_days: int = 90) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
|
||||
def record_audit_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
action: str = "",
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
detail: str = "{}",
|
||||
ip_address: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(audit_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"detail": detail,
|
||||
"ip_address": ip_address,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(
|
||||
audit_events.c.event_id,
|
||||
audit_events.c.timestamp,
|
||||
audit_events.c.user_id,
|
||||
audit_events.c.action,
|
||||
audit_events.c.resource_type,
|
||||
audit_events.c.resource_id,
|
||||
audit_events.c.detail,
|
||||
audit_events.c.ip_address,
|
||||
audit_events.c.created,
|
||||
).order_by(audit_events.c.timestamp.desc(), audit_events.c.event_id.desc())
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
q = q.limit(limit).offset(offset)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0],
|
||||
"timestamp": r[1],
|
||||
"user_id": r[2],
|
||||
"action": r[3],
|
||||
"resource_type": r[4],
|
||||
"resource_id": r[5],
|
||||
"detail": r[6],
|
||||
"ip_address": r[7],
|
||||
"created": r[8],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def count_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(sa.func.count()).select_from(audit_events)
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
row = conn.execute(q).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def prune_audit_events(self, retention_days: int = 365) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Governance tables — RBAC roles, orgs, tool policies, prompt templates, usage, audit.
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-03-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "008"
|
||||
down_revision = "007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Built-in roles seeded on upgrade
|
||||
_ADMIN_PERMS = (
|
||||
"read,write,approve,admin.users,admin.roles,admin.orgs,"
|
||||
"admin.policies,admin.templates,admin.audit,admin.usage,"
|
||||
"admin.schedules,admin.watches,"
|
||||
"tools.approve,workstreams.create,workstreams.close"
|
||||
)
|
||||
_OPERATOR_PERMS = "read,write,workstreams.create,workstreams.close"
|
||||
_VIEWER_PERMS = "read"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
op.create_table(
|
||||
"orgs",
|
||||
sa.Column("org_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("settings", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# -- Roles -----------------------------------------------------------------
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("role_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("permissions", sa.Text, nullable=False),
|
||||
sa.Column("builtin", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# -- User ↔ Role assignments -----------------------------------------------
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column("user_id", sa.Text, nullable=False),
|
||||
sa.Column("role_id", sa.Text, nullable=False),
|
||||
sa.Column("assigned_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.PrimaryKeyConstraint("user_id", "role_id"),
|
||||
)
|
||||
op.create_index("idx_user_roles_role_id", "user_roles", ["role_id"])
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
op.create_table(
|
||||
"tool_policies",
|
||||
sa.Column("policy_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False),
|
||||
sa.Column("tool_pattern", sa.Text, nullable=False),
|
||||
sa.Column("action", sa.Text, nullable=False),
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", 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("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_tool_policies_priority", "tool_policies", [sa.text("priority DESC")])
|
||||
op.create_index("idx_tool_policies_org", "tool_policies", ["org_id"])
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
op.create_table(
|
||||
"prompt_templates",
|
||||
sa.Column("template_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("category", sa.Text, nullable=False, server_default="general"),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("variables", sa.Text, nullable=False, server_default="[]"),
|
||||
sa.Column("is_default", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"usage_events",
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("ws_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("model", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("completion_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("tool_calls_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_usage_events_timestamp", "usage_events", ["timestamp"])
|
||||
op.create_index("idx_usage_events_user", "usage_events", ["user_id", "timestamp"])
|
||||
op.create_index("idx_usage_events_model", "usage_events", ["model", "timestamp"])
|
||||
op.create_index("idx_usage_events_ws", "usage_events", ["ws_id"])
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("action", sa.Text, nullable=False),
|
||||
sa.Column("resource_type", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("resource_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("detail", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("ip_address", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_audit_timestamp", "audit_events", ["timestamp"])
|
||||
op.create_index("idx_audit_action", "audit_events", ["action"])
|
||||
op.create_index("idx_audit_user", "audit_events", ["user_id"])
|
||||
|
||||
# -- Add org_id to users ---------------------------------------------------
|
||||
with op.batch_alter_table("users") as batch_op:
|
||||
batch_op.add_column(sa.Column("org_id", sa.Text, nullable=False, server_default=""))
|
||||
|
||||
# -- Seed default org and built-in roles -----------------------------------
|
||||
conn = op.get_bind()
|
||||
import datetime
|
||||
|
||||
now_str = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO orgs (org_id, name, display_name, settings, created, updated) "
|
||||
"VALUES (:oid, :name, :dname, '{}', :now, :now)"
|
||||
),
|
||||
{"oid": "default", "name": "default", "dname": "Default", "now": now_str},
|
||||
)
|
||||
for role_id, name, dname, perms in [
|
||||
("builtin-admin", "admin", "Admin", _ADMIN_PERMS),
|
||||
("builtin-operator", "operator", "Operator", _OPERATOR_PERMS),
|
||||
("builtin-viewer", "viewer", "Viewer", _VIEWER_PERMS),
|
||||
]:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO roles (role_id, name, display_name, permissions, builtin, org_id, created, updated) "
|
||||
"VALUES (:rid, :name, :dname, :perms, 1, '', :now, :now)"
|
||||
),
|
||||
{"rid": role_id, "name": name, "dname": dname, "perms": perms, "now": now_str},
|
||||
)
|
||||
|
||||
# Assign admin role to all existing users
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO user_roles (user_id, role_id, assigned_by, created) "
|
||||
"SELECT user_id, 'builtin-admin', '', :now FROM users"
|
||||
),
|
||||
{"now": now_str},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_audit_user", "audit_events")
|
||||
op.drop_index("idx_audit_action", "audit_events")
|
||||
op.drop_index("idx_audit_timestamp", "audit_events")
|
||||
op.drop_table("audit_events")
|
||||
|
||||
op.drop_index("idx_usage_events_ws", "usage_events")
|
||||
op.drop_index("idx_usage_events_model", "usage_events")
|
||||
op.drop_index("idx_usage_events_user", "usage_events")
|
||||
op.drop_index("idx_usage_events_timestamp", "usage_events")
|
||||
op.drop_table("usage_events")
|
||||
|
||||
op.drop_table("prompt_templates")
|
||||
|
||||
op.drop_index("idx_tool_policies_org", "tool_policies")
|
||||
op.drop_index("idx_tool_policies_priority", "tool_policies")
|
||||
op.drop_table("tool_policies")
|
||||
|
||||
op.drop_index("idx_user_roles_role_id", "user_roles")
|
||||
op.drop_table("user_roles")
|
||||
op.drop_table("roles")
|
||||
op.drop_table("orgs")
|
||||
|
||||
with op.batch_alter_table("users") as batch_op:
|
||||
batch_op.drop_column("org_id")
|
||||
@@ -241,6 +241,7 @@ class Bridge:
|
||||
"command": self._handle_command,
|
||||
"create_workstream": self._handle_create_ws,
|
||||
"close_workstream": self._handle_close_ws,
|
||||
"cancel": self._handle_cancel,
|
||||
}
|
||||
# Messages that are always local (no routing needed)
|
||||
local_handlers = {
|
||||
@@ -348,6 +349,20 @@ class Bridge:
|
||||
if request_id:
|
||||
self._broker.push_response(request_id, msg.to_json())
|
||||
|
||||
def _handle_cancel(self, msg: InboundMessage) -> None:
|
||||
ws_id = getattr(msg, "ws_id", "")
|
||||
resp = self._http.post("/v1/api/cancel", json={"ws_id": ws_id})
|
||||
data = resp.json()
|
||||
self._publish_ws(
|
||||
ws_id,
|
||||
AckEvent(
|
||||
ws_id=ws_id,
|
||||
correlation_id=msg.correlation_id,
|
||||
status="ok" if data.get("status") == "ok" else "error",
|
||||
detail=data.get("error", ""),
|
||||
),
|
||||
)
|
||||
|
||||
def _handle_command(self, msg: InboundMessage) -> None:
|
||||
ws_id = getattr(msg, "ws_id", "")
|
||||
command = getattr(msg, "command", "")
|
||||
|
||||
@@ -128,6 +128,14 @@ class ListNodesMessage(InboundMessage):
|
||||
type: str = "list_nodes"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CancelMessage(InboundMessage):
|
||||
"""Cancel the active generation in a workstream."""
|
||||
|
||||
type: str = "cancel"
|
||||
ws_id: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound events (bridge → client)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -383,6 +391,7 @@ _INBOUND_REGISTRY: dict[str, type[InboundMessage]] = {
|
||||
ListWorkstreamsMessage,
|
||||
HealthMessage,
|
||||
ListNodesMessage,
|
||||
CancelMessage,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,18 @@ from turnstone.api.console_schemas import (
|
||||
ClusterWorkstreamsResponse,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
ListAuditEventsResponse,
|
||||
ListOrgsResponse,
|
||||
ListPromptTemplatesResponse,
|
||||
ListRolesResponse,
|
||||
ListToolPoliciesResponse,
|
||||
ListUserRolesResponse,
|
||||
NodeDetailResponse,
|
||||
OrgInfo,
|
||||
PromptTemplateInfo,
|
||||
RoleInfo,
|
||||
ToolPolicyInfo,
|
||||
UsageResponse,
|
||||
)
|
||||
from turnstone.api.schemas import (
|
||||
AuthLoginResponse,
|
||||
@@ -293,6 +304,211 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
response_model=ListScheduleRunsResponse,
|
||||
)
|
||||
|
||||
# -- governance: roles ---------------------------------------------------
|
||||
|
||||
async def list_roles(self) -> ListRolesResponse:
|
||||
"""List all roles."""
|
||||
return await self._request("GET", "/v1/api/admin/roles", response_model=ListRolesResponse)
|
||||
|
||||
async def create_role(
|
||||
self, name: str, display_name: str = "", permissions: str = "read"
|
||||
) -> RoleInfo:
|
||||
"""Create a custom role."""
|
||||
body: dict[str, Any] = {"name": name, "permissions": permissions}
|
||||
if display_name:
|
||||
body["display_name"] = display_name
|
||||
return await self._request(
|
||||
"POST", "/v1/api/admin/roles", json_body=body, response_model=RoleInfo
|
||||
)
|
||||
|
||||
async def update_role(self, role_id: str, **fields: Any) -> RoleInfo:
|
||||
"""Update a role's display_name and/or permissions."""
|
||||
return await self._request(
|
||||
"PUT", f"/v1/api/admin/roles/{role_id}", json_body=fields, response_model=RoleInfo
|
||||
)
|
||||
|
||||
async def delete_role(self, role_id: str) -> StatusResponse:
|
||||
"""Delete a custom role."""
|
||||
return await self._request(
|
||||
"DELETE", f"/v1/api/admin/roles/{role_id}", response_model=StatusResponse
|
||||
)
|
||||
|
||||
async def list_user_roles(self, user_id: str) -> ListUserRolesResponse:
|
||||
"""List roles assigned to a user."""
|
||||
return await self._request(
|
||||
"GET", f"/v1/api/admin/users/{user_id}/roles", response_model=ListUserRolesResponse
|
||||
)
|
||||
|
||||
async def assign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
"""Assign a role to a user."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
f"/v1/api/admin/users/{user_id}/roles",
|
||||
json_body={"role_id": role_id},
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
async def unassign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
"""Unassign a role from a user."""
|
||||
return await self._request(
|
||||
"DELETE",
|
||||
f"/v1/api/admin/users/{user_id}/roles/{role_id}",
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- governance: organizations -------------------------------------------
|
||||
|
||||
async def list_orgs(self) -> ListOrgsResponse:
|
||||
"""List organizations."""
|
||||
return await self._request("GET", "/v1/api/admin/orgs", response_model=ListOrgsResponse)
|
||||
|
||||
async def get_org(self, org_id: str) -> OrgInfo:
|
||||
"""Get organization details."""
|
||||
return await self._request("GET", f"/v1/api/admin/orgs/{org_id}", response_model=OrgInfo)
|
||||
|
||||
async def update_org(self, org_id: str, **fields: Any) -> OrgInfo:
|
||||
"""Update organization settings."""
|
||||
return await self._request(
|
||||
"PUT", f"/v1/api/admin/orgs/{org_id}", json_body=fields, response_model=OrgInfo
|
||||
)
|
||||
|
||||
# -- governance: tool policies -------------------------------------------
|
||||
|
||||
async def list_policies(self) -> ListToolPoliciesResponse:
|
||||
"""List tool policies ordered by priority."""
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/policies", response_model=ListToolPoliciesResponse
|
||||
)
|
||||
|
||||
async def create_policy(
|
||||
self,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> ToolPolicyInfo:
|
||||
"""Create a tool policy."""
|
||||
body: dict[str, Any] = {
|
||||
"name": name,
|
||||
"tool_pattern": tool_pattern,
|
||||
"action": action,
|
||||
"priority": priority,
|
||||
**kwargs,
|
||||
}
|
||||
return await self._request(
|
||||
"POST", "/v1/api/admin/policies", json_body=body, response_model=ToolPolicyInfo
|
||||
)
|
||||
|
||||
async def update_policy(self, policy_id: str, **fields: Any) -> ToolPolicyInfo:
|
||||
"""Update a tool policy."""
|
||||
return await self._request(
|
||||
"PUT",
|
||||
f"/v1/api/admin/policies/{policy_id}",
|
||||
json_body=fields,
|
||||
response_model=ToolPolicyInfo,
|
||||
)
|
||||
|
||||
async def delete_policy(self, policy_id: str) -> StatusResponse:
|
||||
"""Delete a tool policy."""
|
||||
return await self._request(
|
||||
"DELETE", f"/v1/api/admin/policies/{policy_id}", response_model=StatusResponse
|
||||
)
|
||||
|
||||
# -- governance: prompt templates ----------------------------------------
|
||||
|
||||
async def list_templates(self) -> ListPromptTemplatesResponse:
|
||||
"""List prompt templates."""
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/templates", response_model=ListPromptTemplatesResponse
|
||||
)
|
||||
|
||||
async def create_template(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> PromptTemplateInfo:
|
||||
"""Create a prompt template."""
|
||||
body: dict[str, Any] = {
|
||||
"name": name,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"variables": variables,
|
||||
"is_default": is_default,
|
||||
**kwargs,
|
||||
}
|
||||
return await self._request(
|
||||
"POST", "/v1/api/admin/templates", json_body=body, response_model=PromptTemplateInfo
|
||||
)
|
||||
|
||||
async def update_template(self, template_id: str, **fields: Any) -> PromptTemplateInfo:
|
||||
"""Update a prompt template."""
|
||||
return await self._request(
|
||||
"PUT",
|
||||
f"/v1/api/admin/templates/{template_id}",
|
||||
json_body=fields,
|
||||
response_model=PromptTemplateInfo,
|
||||
)
|
||||
|
||||
async def delete_template(self, template_id: str) -> StatusResponse:
|
||||
"""Delete a prompt template."""
|
||||
return await self._request(
|
||||
"DELETE",
|
||||
f"/v1/api/admin/templates/{template_id}",
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
async def get_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> UsageResponse:
|
||||
"""Query aggregated usage data."""
|
||||
params: dict[str, Any] = {"since": since}
|
||||
if until:
|
||||
params["until"] = until
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if model:
|
||||
params["model"] = model
|
||||
if group_by:
|
||||
params["group_by"] = group_by
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/usage", params=params, response_model=UsageResponse
|
||||
)
|
||||
|
||||
async def get_audit(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> ListAuditEventsResponse:
|
||||
"""Query paginated audit events."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if action:
|
||||
params["action"] = action
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if since:
|
||||
params["since"] = since
|
||||
if until:
|
||||
params["until"] = until
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/audit", params=params, response_model=ListAuditEventsResponse
|
||||
)
|
||||
|
||||
|
||||
class TurnstoneConsole:
|
||||
"""Synchronous client for the turnstone console API.
|
||||
@@ -469,6 +685,132 @@ class TurnstoneConsole:
|
||||
def list_schedule_runs(self, task_id: str, *, limit: int = 50) -> ListScheduleRunsResponse:
|
||||
return self._runner.run(self._async.list_schedule_runs(task_id, limit=limit))
|
||||
|
||||
# -- governance: roles ---------------------------------------------------
|
||||
|
||||
def list_roles(self) -> ListRolesResponse:
|
||||
return self._runner.run(self._async.list_roles())
|
||||
|
||||
def create_role(self, name: str, display_name: str = "", permissions: str = "read") -> RoleInfo:
|
||||
return self._runner.run(
|
||||
self._async.create_role(name, display_name=display_name, permissions=permissions)
|
||||
)
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> RoleInfo:
|
||||
return self._runner.run(self._async.update_role(role_id, **fields))
|
||||
|
||||
def delete_role(self, role_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_role(role_id))
|
||||
|
||||
def list_user_roles(self, user_id: str) -> ListUserRolesResponse:
|
||||
return self._runner.run(self._async.list_user_roles(user_id))
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.assign_role(user_id, role_id))
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.unassign_role(user_id, role_id))
|
||||
|
||||
# -- governance: organizations -------------------------------------------
|
||||
|
||||
def list_orgs(self) -> ListOrgsResponse:
|
||||
return self._runner.run(self._async.list_orgs())
|
||||
|
||||
def get_org(self, org_id: str) -> OrgInfo:
|
||||
return self._runner.run(self._async.get_org(org_id))
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> OrgInfo:
|
||||
return self._runner.run(self._async.update_org(org_id, **fields))
|
||||
|
||||
# -- governance: tool policies -------------------------------------------
|
||||
|
||||
def list_policies(self) -> ListToolPoliciesResponse:
|
||||
return self._runner.run(self._async.list_policies())
|
||||
|
||||
def create_policy(
|
||||
self,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> ToolPolicyInfo:
|
||||
return self._runner.run(
|
||||
self._async.create_policy(name, tool_pattern, action, priority=priority, **kwargs)
|
||||
)
|
||||
|
||||
def update_policy(self, policy_id: str, **fields: Any) -> ToolPolicyInfo:
|
||||
return self._runner.run(self._async.update_policy(policy_id, **fields))
|
||||
|
||||
def delete_policy(self, policy_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_policy(policy_id))
|
||||
|
||||
# -- governance: prompt templates ----------------------------------------
|
||||
|
||||
def list_templates(self) -> ListPromptTemplatesResponse:
|
||||
return self._runner.run(self._async.list_templates())
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> PromptTemplateInfo:
|
||||
return self._runner.run(
|
||||
self._async.create_template(
|
||||
name,
|
||||
content,
|
||||
category=category,
|
||||
variables=variables,
|
||||
is_default=is_default,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
def update_template(self, template_id: str, **fields: Any) -> PromptTemplateInfo:
|
||||
return self._runner.run(self._async.update_template(template_id, **fields))
|
||||
|
||||
def delete_template(self, template_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_template(template_id))
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
def get_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> UsageResponse:
|
||||
return self._runner.run(
|
||||
self._async.get_usage(
|
||||
since, until=until, user_id=user_id, model=model, group_by=group_by
|
||||
)
|
||||
)
|
||||
|
||||
def get_audit(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> ListAuditEventsResponse:
|
||||
return self._runner.run(
|
||||
self._async.get_audit(
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
)
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -149,6 +149,11 @@ class ClearUiEvent(ServerEvent):
|
||||
type: str = "clear_ui"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CancelledEvent(ServerEvent):
|
||||
type: str = "cancelled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server global events (/v1/api/events/global)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -288,6 +293,7 @@ _SERVER_REGISTRY: dict[str, type[ServerEvent]] = {
|
||||
ErrorEvent,
|
||||
BusyErrorEvent,
|
||||
ClearUiEvent,
|
||||
CancelledEvent,
|
||||
WsStateEvent,
|
||||
WsActivityEvent,
|
||||
WsRenameEvent,
|
||||
|
||||
@@ -145,6 +145,14 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
async def cancel(self, ws_id: str) -> StatusResponse:
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/cancel",
|
||||
json_body={"ws_id": ws_id},
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
async def stream_events(self, ws_id: str) -> AsyncIterator[ServerEvent]:
|
||||
@@ -343,6 +351,9 @@ class TurnstoneServer:
|
||||
def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.command(ws_id=ws_id, command=command))
|
||||
|
||||
def cancel(self, ws_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.cancel(ws_id))
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
def stream_events(self, ws_id: str) -> Iterator[ServerEvent]:
|
||||
|
||||
+108
-3
@@ -43,7 +43,7 @@ from turnstone.api.server_spec import build_server_spec
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, AuthMiddleware
|
||||
from turnstone.core.metrics import metrics as _metrics
|
||||
from turnstone.core.ratelimit import resolve_client_ip
|
||||
from turnstone.core.session import ChatSession, SessionUI # noqa: F401
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled, SessionUI # noqa: F401
|
||||
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
|
||||
from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState
|
||||
|
||||
@@ -80,8 +80,9 @@ class WebUI:
|
||||
_global_queue: queue.Queue[dict[str, Any]] | None = None
|
||||
_workstream_mgr: WorkstreamManager | None = None
|
||||
|
||||
def __init__(self, ws_id: str = "") -> None:
|
||||
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
|
||||
self.ws_id = ws_id
|
||||
self._user_id = user_id
|
||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||
self._listeners_lock = threading.Lock()
|
||||
self._approval_event = threading.Event()
|
||||
@@ -198,6 +199,55 @@ class WebUI:
|
||||
}
|
||||
)
|
||||
|
||||
# -- Tool policy evaluation -----------------------------------------------
|
||||
# Check admin-defined tool policies before the auto_approve check.
|
||||
if pending:
|
||||
try:
|
||||
from turnstone.core.policy import evaluate_tool_policies_batch
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is not None:
|
||||
tool_names = [it.get("func_name", "") for it in pending if it.get("func_name")]
|
||||
if tool_names:
|
||||
verdicts = evaluate_tool_policies_batch(storage, tool_names)
|
||||
still_pending = []
|
||||
for it in pending:
|
||||
fname = it.get("func_name", "")
|
||||
verdict = verdicts.get(fname)
|
||||
if verdict == "deny":
|
||||
it["denied"] = True
|
||||
it["denial_msg"] = (
|
||||
f"Blocked by tool policy (pattern match for '{fname}')"
|
||||
)
|
||||
elif verdict == "allow":
|
||||
it["needs_approval"] = False
|
||||
else:
|
||||
still_pending.append(it)
|
||||
# Rebuild serialized to reflect policy verdicts
|
||||
serialized = [
|
||||
{
|
||||
"call_id": it.get("call_id", ""),
|
||||
"header": it.get("header", ""),
|
||||
"preview": it.get("preview", ""),
|
||||
"func_name": it.get("func_name", ""),
|
||||
"approval_label": it.get("approval_label", it.get("func_name", "")),
|
||||
"needs_approval": it.get("needs_approval", False),
|
||||
"error": it.get("denial_msg") if it.get("denied") else None,
|
||||
}
|
||||
for it in items
|
||||
]
|
||||
# If all were resolved by policy, check if any were denied
|
||||
if not still_pending:
|
||||
any_denied = any(it.get("denied") for it in items)
|
||||
if any_denied:
|
||||
self._enqueue({"type": "tool_info", "items": serialized})
|
||||
return False, "Blocked by tool policy"
|
||||
pending = still_pending
|
||||
except Exception:
|
||||
log.debug("Tool policy evaluation failed", exc_info=True)
|
||||
# -- End tool policy evaluation -------------------------------------------
|
||||
|
||||
if not pending or self.auto_approve:
|
||||
# Track auto-approved tool activity
|
||||
first = items[0] if items else {}
|
||||
@@ -258,6 +308,7 @@ class WebUI:
|
||||
self._ws_prompt_tokens += usage["prompt_tokens"]
|
||||
self._ws_completion_tokens += usage["completion_tokens"]
|
||||
self._ws_context_ratio = total_tok / context_window if context_window > 0 else 0.0
|
||||
tool_count = sum(self._ws_tool_calls.values())
|
||||
self._enqueue(
|
||||
{
|
||||
"type": "status",
|
||||
@@ -269,6 +320,26 @@ class WebUI:
|
||||
"effort": effort,
|
||||
}
|
||||
)
|
||||
# Record usage event for governance dashboard
|
||||
try:
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is not None:
|
||||
import uuid
|
||||
|
||||
storage.record_usage_event(
|
||||
event_id=uuid.uuid4().hex,
|
||||
user_id=self._user_id,
|
||||
ws_id=self.ws_id,
|
||||
node_id="",
|
||||
model=usage.get("model", ""),
|
||||
prompt_tokens=usage["prompt_tokens"],
|
||||
completion_tokens=usage["completion_tokens"],
|
||||
tool_calls_count=tool_count,
|
||||
)
|
||||
except Exception:
|
||||
pass # Non-critical — never break the response pipeline
|
||||
|
||||
def on_plan_review(self, content: str) -> str:
|
||||
self._plan_event.clear()
|
||||
@@ -771,6 +842,10 @@ async def send_message(request: Request) -> JSONResponse:
|
||||
assert ui is not None
|
||||
try:
|
||||
session.send(message)
|
||||
except GenerationCancelled:
|
||||
# Safety net — send() normally handles this internally.
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("idle")
|
||||
except Exception as e:
|
||||
ui.on_error(f"Error: {e}")
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
@@ -823,6 +898,33 @@ async def plan_feedback(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
async def cancel_generation(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/cancel — cancel the active generation in a workstream."""
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
ws_id = body.get("ws_id")
|
||||
mgr = request.app.state.workstreams
|
||||
ws, ui = _get_ws(mgr, ws_id)
|
||||
if not ws or not ui:
|
||||
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
|
||||
session = ws.session
|
||||
if session is None:
|
||||
return JSONResponse({"error": "No session"}, status_code=400)
|
||||
# Only act if generation is actually in progress
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
# Set the cooperative cancel flag (worker thread checks at checkpoints)
|
||||
session.cancel()
|
||||
# Unblock any pending approval/plan review waits
|
||||
ui.resolve_approval(False, "Cancelled by user")
|
||||
ui.resolve_plan("reject")
|
||||
# Emit cancelled SSE event so SDK consumers get a typed signal
|
||||
ui._enqueue({"type": "cancelled"})
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
async def command(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/command — execute a slash command."""
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
@@ -877,10 +979,12 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
return body
|
||||
mgr: WorkstreamManager = request.app.state.workstreams
|
||||
skip: bool = request.app.state.skip_permissions
|
||||
auth = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
uid: str = getattr(auth, "user_id", "") or ""
|
||||
try:
|
||||
ws = mgr.create(
|
||||
name=body.get("name", ""),
|
||||
ui_factory=lambda wid: WebUI(ws_id=wid),
|
||||
ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid),
|
||||
model=body.get("model") or None,
|
||||
)
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
@@ -1165,6 +1269,7 @@ def create_app(
|
||||
Route("/api/approve", approve, methods=["POST"]),
|
||||
Route("/api/plan", plan_feedback, methods=["POST"]),
|
||||
Route("/api/command", command, methods=["POST"]),
|
||||
Route("/api/cancel", cancel_generation, methods=["POST"]),
|
||||
Route("/api/workstreams/new", create_workstream, methods=["POST"]),
|
||||
Route("/api/workstreams/close", close_workstream, methods=["POST"]),
|
||||
Route("/api/watches", list_watches),
|
||||
|
||||
@@ -274,8 +274,9 @@ function _submitLogin() {
|
||||
if (!r.ok) throw new Error("server");
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
.then(function (data) {
|
||||
_setBusy(false);
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -306,8 +307,9 @@ function _submitToken() {
|
||||
if (!r.ok) throw new Error("server");
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
.then(function (data) {
|
||||
_setBusy(false);
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -369,8 +371,9 @@ function _submitSetup() {
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
.then(function (data) {
|
||||
_setBusy(false);
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -379,6 +382,14 @@ function _submitSetup() {
|
||||
});
|
||||
}
|
||||
|
||||
function _storePermissions(data) {
|
||||
if (data && data.permissions) {
|
||||
sessionStorage.setItem("turnstone_permissions", data.permissions);
|
||||
} else {
|
||||
sessionStorage.removeItem("turnstone_permissions");
|
||||
}
|
||||
}
|
||||
|
||||
function _setBusy(busy, label) {
|
||||
_loginBusy = busy;
|
||||
var btn = document.getElementById("login-submit");
|
||||
@@ -403,6 +414,7 @@ function _onSuccess() {
|
||||
|
||||
function logout() {
|
||||
fetch("/v1/api/auth/logout", { method: "POST" }).then(function () {
|
||||
sessionStorage.removeItem("turnstone_permissions");
|
||||
if (typeof window.onLogout === "function") window.onLogout();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
+47
-16
@@ -1,6 +1,7 @@
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const inputEl = document.getElementById("input");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
const stopBtn = document.getElementById("stop-btn");
|
||||
const statusBar = document.getElementById("status-bar");
|
||||
const modelName = document.getElementById("model-name");
|
||||
const tabBar = document.getElementById("tab-bar");
|
||||
@@ -13,6 +14,14 @@ let busy = false;
|
||||
let pendingApproval = false;
|
||||
let approvalBlockEl = null;
|
||||
|
||||
function setBusy(b) {
|
||||
busy = b;
|
||||
sendBtn.disabled = b;
|
||||
sendBtn.style.display = b ? "none" : "";
|
||||
stopBtn.style.display = b ? "" : "none";
|
||||
stopBtn.disabled = !b;
|
||||
}
|
||||
|
||||
// --- Workstream state ---
|
||||
let workstreams = {}; // ws_id -> {name, state}
|
||||
let currentWsId = null;
|
||||
@@ -469,10 +478,9 @@ function switchTab(wsId) {
|
||||
currentAssistantEl = null;
|
||||
currentReasoningEl = null;
|
||||
contentBuffer = "";
|
||||
busy = false;
|
||||
setBusy(false);
|
||||
pendingApproval = false;
|
||||
approvalBlockEl = null;
|
||||
sendBtn.disabled = false;
|
||||
inputEl.disabled = false;
|
||||
|
||||
currentWsId = wsId;
|
||||
@@ -912,8 +920,7 @@ function dashboardSendMessage() {
|
||||
hideDashboard();
|
||||
input.disabled = false;
|
||||
btn.disabled = false;
|
||||
busy = true;
|
||||
sendBtn.disabled = true;
|
||||
setBusy(true);
|
||||
addUserMessage(text);
|
||||
authFetch("/v1/api/send", {
|
||||
method: "POST",
|
||||
@@ -921,8 +928,7 @@ function dashboardSendMessage() {
|
||||
body: JSON.stringify({ message: text, ws_id: data.ws_id }),
|
||||
}).catch(function (err) {
|
||||
addErrorMessage("Connection error: " + err.message);
|
||||
busy = false;
|
||||
sendBtn.disabled = false;
|
||||
setBusy(false);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
@@ -1009,6 +1015,7 @@ function handleEvent(evt) {
|
||||
switch (evt.type) {
|
||||
case "thinking_start":
|
||||
isThinking = true;
|
||||
setBusy(true);
|
||||
removeEmptyState();
|
||||
addThinkingIndicator();
|
||||
break;
|
||||
@@ -1051,8 +1058,7 @@ function handleEvent(evt) {
|
||||
currentAssistantEl = null;
|
||||
currentReasoningEl = null;
|
||||
contentBuffer = "";
|
||||
busy = false;
|
||||
sendBtn.disabled = false;
|
||||
setBusy(false);
|
||||
inputEl.focus();
|
||||
scrollToBottom(true);
|
||||
break;
|
||||
@@ -1087,14 +1093,21 @@ function handleEvent(evt) {
|
||||
|
||||
case "error":
|
||||
addErrorMessage(evt.message);
|
||||
busy = false;
|
||||
sendBtn.disabled = false;
|
||||
setBusy(false);
|
||||
break;
|
||||
|
||||
case "busy_error":
|
||||
addErrorMessage(evt.message);
|
||||
busy = false;
|
||||
sendBtn.disabled = false;
|
||||
setBusy(false);
|
||||
break;
|
||||
|
||||
case "cancelled":
|
||||
currentAssistantEl = null;
|
||||
currentReasoningEl = null;
|
||||
contentBuffer = "";
|
||||
setBusy(false);
|
||||
inputEl.focus();
|
||||
scrollToBottom(true);
|
||||
break;
|
||||
|
||||
case "connected":
|
||||
@@ -1598,8 +1611,7 @@ function sendMessage() {
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
sendBtn.disabled = true;
|
||||
setBusy(true);
|
||||
addUserMessage(text);
|
||||
inputEl.value = "";
|
||||
autoResize();
|
||||
@@ -1610,11 +1622,24 @@ function sendMessage() {
|
||||
body: JSON.stringify({ message: text, ws_id: currentWsId }),
|
||||
}).catch(function (err) {
|
||||
addErrorMessage("Connection error: " + err.message);
|
||||
busy = false;
|
||||
sendBtn.disabled = false;
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function cancelGeneration() {
|
||||
if (!busy || !currentWsId) return;
|
||||
stopBtn.disabled = true;
|
||||
authFetch("/v1/api/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ws_id: currentWsId }),
|
||||
}).catch(function (err) {
|
||||
addErrorMessage("Cancel error: " + err.message);
|
||||
stopBtn.disabled = false; // Re-enable only on error so user can retry
|
||||
});
|
||||
// On success, button stays disabled until setBusy(false) hides it
|
||||
}
|
||||
|
||||
// --- Textarea auto-resize and keyboard shortcuts ---
|
||||
function autoResize() {
|
||||
inputEl.style.height = "auto";
|
||||
@@ -1654,6 +1679,12 @@ document.addEventListener("keydown", function (e) {
|
||||
hideDashboard();
|
||||
return;
|
||||
}
|
||||
// Escape: cancel generation when busy
|
||||
if (e.key === "Escape" && busy && !pendingApproval) {
|
||||
e.preventDefault();
|
||||
cancelGeneration();
|
||||
return;
|
||||
}
|
||||
// Ctrl+D: toggle dashboard
|
||||
if (e.ctrlKey && e.key === "d") {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<div id="input-area">
|
||||
<textarea id="input" rows="1" placeholder="Type a message... (Shift+Enter for newline)" aria-label="Message input"></textarea>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
<button id="stop-btn" onclick="cancelGeneration()" style="display:none" aria-label="Stop generation">■ Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
@@ -106,6 +107,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
]},
|
||||
{ title: "Chat", keys: [
|
||||
{ desc: "Send message", badge: '<span class="kb-key">Enter</span>' },
|
||||
{ desc: "Stop generation", badge: '<span class="kb-key">Esc</span>' },
|
||||
{ desc: "New line", badge: '<span class="kb-key">Shift+Enter</span>' }
|
||||
]},
|
||||
{ title: "Navigation", keys: [
|
||||
|
||||
@@ -303,6 +303,9 @@
|
||||
}
|
||||
#input-area button:hover { filter: brightness(1.1); }
|
||||
#input-area button:disabled { opacity: 0.35; cursor: not-allowed; filter: none; }
|
||||
#stop-btn { background: var(--red, #c94040); }
|
||||
#stop-btn:focus-visible { outline: 2px solid var(--fg-bright, #e8ecf4); outline-offset: 2px; }
|
||||
[data-theme="light"] #stop-btn { color: #fff; }
|
||||
|
||||
/* ==========================================================================
|
||||
Inline approval blocks
|
||||
|
||||
Reference in New Issue
Block a user