Compare commits

...

12 Commits

Author SHA1 Message Date
Patrick Buckley 087f5b49f6 Bump version to 0.5.4 2026-03-10 20:47:40 -07:00
Patrick Buckley fd507c6a3c feat: generation cancellation — stop button, cancel API, cooperative … (#40)
* feat: generation cancellation — stop button, cancel API, cooperative cancel

Add cooperative cancellation via threading.Event on ChatSession. The cancel
signal is set from outside the worker thread (HTTP handler, MQ bridge, or
Escape key) and checked at defined checkpoints: per streaming chunk, before
tool execution, inside bash commands, and at each sub-agent turn.

Core: GenerationCancelled(BaseException) exception, cancel()/_check_cancelled()
methods, partial content preservation in _stream_response, clean rollback in
send() with idle state emission (no re-raise).

Server: POST /v1/api/cancel endpoint, CancelledEvent SSE emission, worker
thread safety net.

Frontend: Stop button (■ Stop) with send/stop swap via setBusy(), Escape key
shortcut, cancelled event handler. Accessible: aria-label, focus-visible
override, light theme contrast, non-color differentiation.

MQ: CancelMessage inbound type, bridge _handle_cancel routed handler.

SDK: cancel() on Python async+sync clients, CancelledEvent in Python+TypeScript
event registries, isCancelledEvent type guard.

OpenAPI: CancelRequest schema + endpoint spec.

Docs: API reference, architecture, SDK docs updated. Diagrams: conversation
turn, tool pipeline, MQ protocol, workstream states, SDK architecture.

* fix: address PR #40 review feedback

- setBusy() now resets stopBtn.disabled so stop button is re-enabled on
  next generation after a successful cancel
- Gate cancel side effects (resolve_approval, resolve_plan, cancelled SSE
  event) on worker_thread.is_alive() to avoid spurious events when idle
- Add /v1/api/cancel endpoint and CancelRequest schema to TypeScript
  openapi-server.json to keep it in sync with Python-generated spec
2026-03-10 20:43:52 -07:00
Patrick Buckley 562c3c8ab7 docs: add governance section and missing diagrams to README 2026-03-10 19:50:07 -07:00
Patrick Buckley 4773535bb8 docs: add governance architecture diagram PNG 2026-03-10 19:41:35 -07:00
Patrick Buckley 7492816ab2 feat: governance — RBAC, tool policies, prompt templates, usage track… (#39)
* feat: governance — RBAC, tool policies, prompt templates, usage tracking, audit logging

Add comprehensive governance layer for the admin console:

- RBAC with 15 granular permissions, 3 builtin roles (admin, operator, viewer),
  custom role CRUD, user-role assignment with privilege escalation prevention
- Tool policies with glob pattern matching, priority-ordered evaluation
  (allow/deny/ask), enforced before auto-approve in WebUI.approve_tools()
- Prompt templates with variable substitution, categories, default flag
- Usage tracking: per-LLM-request token/tool metrics, aggregated queries
  (group by day/model/user), automatic 90-day pruning via scheduler
- Audit logging: append-only event trail for all admin mutations,
  filterable/paginated queries, automatic 365-day pruning, X-Forwarded-For
  aware IP extraction
- require_permission() enforced on all 35+ admin endpoints (users, tokens,
  channels, schedules, watches, roles, orgs, policies, templates, usage, audit)
- Field allowlists on storage update methods prevent mass-assignment bugs
- Self-deletion guard on admin_delete_user, delete_user cascades user_roles
- _row_to_dict helper eliminates ~400 lines of fragile positional row mapping
- _audit_context helper deduplicates 18 instances of audit boilerplate
- Migration 008: 7 new tables, 3 builtin roles, org_id on users
- Console admin panel: 5 new tabs (Roles, Policies, Templates, Usage, Audit)
  with permission-gated visibility, 7 modal dialogs, full keyboard accessibility
- Python + TypeScript SDK methods for all governance endpoints
- 120+ new tests (1554 total)

* fix: address PR #39 review feedback

- Rebuild serialized items after policy evaluation so denied/allowed
  verdicts are reflected in tool_info/approve_request SSE payloads
- Make `since` query param optional in usage OpenAPI spec (handler
  already defaults to last 7 days)
- Add response_model=StatusResponse to DELETE role/policy/template
  and POST/DELETE role assignment endpoints in OpenAPI spec
- Add missing org_id/created/updated fields to UserRoleInfo schema
- Add missing created field to AuditEventInfo schema
- Show "no permissions" empty state instead of loading inaccessible
  tab when all admin tabs are permission-gated
- Fix "13 permissions" → "15 permissions" in architecture.md and
  security.md
- Fix import sorting in test_audit.py and test_tool_policy.py

* fix: address PR #39 round 2 review feedback

- Clear stale permissions from sessionStorage on config-token login
  (auth.js _storePermissions)
- Only trust X-Forwarded-For when behind a proxy that sets
  X-Forwarded-Proto (conditional on is_secure_request trust model)
- Thread user_id from auth into WebUI.on_status for usage events
- Add created field to TS AuditEventInfo type
- Return typed Pydantic models from all SDK governance methods instead
  of dict[str, Any] — both async and sync clients
- Validate group_by param against allowed enum in admin_usage handler
- Add deterministic secondary sort (event_id DESC) to
  list_audit_events in both SQLite and PostgreSQL backends
2026-03-10 19:32:37 -07:00
Patrick Buckley d6ba1d5e25 fix: mypy no-any-return in agent context overflow handler 2026-03-10 14:11:09 -07:00
Patrick Buckley 41d1b27d34 Bump version to 0.5.3 2026-03-10 13:46:50 -07:00
Patrick Buckley 8bc284c60e fix: agent context overflow — truncate tool output, catch context errors
Agent tool outputs are now truncated to 16k chars to prevent search
results (14M+ chars observed) from blowing past the model's context
limit. On context-exceeded API errors, the agent returns its last
content instead of crashing.
2026-03-10 13:43:01 -07:00
Patrick Buckley a322d6b1d1 fix: sub-agent context — clean plan, merged task, no Qwen template error
Plan agent: own identity only (no base system prompt needed).
Task agent: base system prompt merged with task identity into a single
system message (needs tool patterns for tool execution).
Neither agent receives conversation history.

Fixes Jinja template error on Qwen models that reject system messages
appearing after non-system messages.
2026-03-10 13:36:23 -07:00
Patrick Buckley 70d495aa5b fix: per-workstream SSE fan-out — multiple consumers no longer steal … (#38)
* fix: per-workstream SSE fan-out — multiple consumers no longer steal each other's tokens

After 4d665a5 removed the single-consumer SSE lock, the shared
_event_queue let concurrent consumers (browser, bridge, console proxy)
race on Queue.get(), each receiving ~1/N of content tokens and producing
garbled streaming text.

Replace the single queue with per-client fan-out: each SSE connection
registers its own bounded queue (maxsize=500) on WebUI._listeners, and
_enqueue() copies every event to all registered queues. On eviction or
close, a ws_closed sentinel is injected so SSE generators exit promptly.

* fix: address CI failures and Copilot review feedback

- Handle ws_closed sentinel in events_sse generator (break on close)
- Guarantee sentinel delivery by evicting one item when queue is full
- Clear listeners list after injecting sentinels on cleanup
- Fix test_slow_consumer to fill only slow queue directly
- Fix ruff SIM117 (nested with), unused import, mypy unused-ignore
2026-03-10 13:25:16 -07:00
Patrick Buckley de64535221 Feat/eval improvements (#37)
* ci: add GitHub Release creation on tag push

* refactor: rename plan tool to create_plan

Rename plan → create_plan to resolve cross-provider tool selection
failures. Models consistently treated "plan" as a reasoning concept
rather than a callable tool. The new name is an unambiguous verb+noun
action. Also rename the parameter from prompt → goal for clarity,
add web_search to the default system prompt tool patterns

* feat: eval harness improvements inspired by autoresearch patterns

Major enhancements to turnstone-eval:

- Per-test timeout (--test-timeout, default 300s) and suite timeout
  (--suite-timeout) prevent stuck runs from blocking the suite
- Fast-fail skips remaining runs after ceil(n/2) consecutive zeros
- Summary table with colored PASS/WEAK/FAIL and append-only TSV output
- Progress reporting with running pass rate, token count, and ETA
- Parallel test execution via ProcessPoolExecutor (--parallel N)
- Per-role model assignment: test/optimizer/observer can use different
  models and providers (--optimizer-model, --observer-model, etc.)
  with auto-detection from base URL
- Improved optimizer and observer system prompts with structured
  failure-mode diagnosis, keep/discard rules, and trend analysis
- Fixed token counting (prompt tokens use last-turn value, not sum)
- Added math-calculation and web-search-query test cases
- Fixed multi-file-edit test (both files now contain the target string)

* fix: address Copilot review feedback

- Revert prompt token counting to sum (reflects billed usage)
- Add tool_args to fast-fail skipped run dicts for schema consistency
- Align approval_label with func_name ("create_plan")
- Add timeout to future.result() in parallel path (test_timeout + 30s)
- Document thread-leak trade-off on serial timeout path
2026-03-10 13:06:39 -07:00
Patrick Buckley 187d004033 feat: watch tool — periodic command polling within workstreams (#36)
* feat: watch tool — periodic command polling within workstreams

Add a new `watch` tool that lets the model (or user) set up periodic
polling of a shell command. Results inject as synthetic user messages
that trigger LLM turns, enabling reactive workflows like PR monitoring,
CI/CD status tracking, and deployment health checks.

Key design:
- Single tool with create/list/cancel actions
- Python expression DSL for stop conditions (restricted eval)
- Server-owned WatchRunner daemon (DB-persisted, survives eviction + restart)
- Three dispatch paths: idle, busy, and evicted workstream restore
- REST API for console visibility (GET /v1/api/watches, POST cancel)
- Migration 007, 8 storage CRUD methods, 75 new tests (1383 total)

* fix: address Copilot review — condition errors, restore deadlock, docs

- Condition eval errors now deactivate the watch immediately instead
  of silently looping until max_polls
- Restored (evicted) workstreams set auto_approve=True to prevent
  approval deadlocks with no connected user
- Tool description clarifies first-poll baseline behavior for change
  detection mode
- Diagram updated: DELETE → POST /v1/api/watches/{id}/cancel
2026-03-10 08:18:28 -07:00
83 changed files with 12314 additions and 451 deletions
+8
View File
@@ -5,6 +5,7 @@ on:
tags: ["v*"]
permissions:
contents: write
id-token: write
jobs:
@@ -19,3 +20,10 @@ jobs:
- run: pip install build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
draft: false
prerelease: ${{ contains(github.ref, '-') }}
+20 -1
View File
@@ -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 | |
+124 -6
View File
@@ -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:
@@ -460,13 +468,13 @@ The server sends an SSE comment every 5 seconds when no events are pending:
This prevents proxies and browsers from closing the connection due to
inactivity.
#### Generation mechanism
#### Multi-consumer fan-out
Each new SSE connection to a workstream increments an internal
`_sse_generation` counter. The previous SSE handler detects the generation
mismatch and exits its event loop, ensuring only one active SSE connection per
workstream at a time. The event queue is drained of stale events before the new
connection begins streaming.
Each SSE connection to a workstream receives its own delivery queue. Events
produced by the worker thread are fanned out to all registered listener queues,
so multiple consumers (browser, bridge, console proxy, SDK) can connect
simultaneously and each receives every event. On reconnect the client receives
a full history replay, so no catch-up mechanism is needed.
---
@@ -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.
@@ -774,6 +819,79 @@ Status code: `400`
---
### `GET /v1/api/watches`
List active watches on this server node. Optionally filter by workstream.
Requires `write` scope.
**Query parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|------------------------------------|
| `ws_id` | string | no | Filter to watches for this workstream. If omitted, returns all watches on the node. |
**Response:**
```json
{
"watches": [
{
"watch_id": "abc123def456...",
"ws_id": "ws-1",
"node_id": "host_a1b2",
"name": "pr-review",
"command": "gh pr view --json state",
"interval_secs": 300.0,
"stop_on": "data[\"state\"] == \"MERGED\"",
"max_polls": 100,
"poll_count": 5,
"last_output": "{\"state\": \"OPEN\"}",
"last_poll": "2026-03-09T12:00:00",
"next_poll": "2026-03-09T12:05:00",
"active": 1,
"created": "2026-03-09T11:30:00"
}
]
}
```
---
### `POST /v1/api/watches/{watch_id}/cancel`
Cancel an active watch. Sets `active=0` and clears `next_poll`.
Requires `write` scope. Verifies node ownership in multi-node deployments.
**Path parameters:**
| Parameter | Type | Description |
|------------|--------|-----------------|
| `watch_id` | string | Watch ID to cancel |
**Response (success):**
```json
{"status": "ok", "watch_id": "abc123def456..."}
```
**Error (not found):**
```json
{"error": "Watch not found"}
```
Status code: `404`
**Error (wrong node):**
```json
{"error": "Watch belongs to another node"}
```
Status code: `403`
---
### `OPTIONS` (any path)
Handles CORS preflight requests.
+39 -1
View File
@@ -44,6 +44,7 @@ turnstone/
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
mcp_client.py MCPClientManager — MCP server connections, tool discovery, dynamic refresh, async-sync bridge
tool_search.py Dynamic tool search — BM25 index, session-scoped tool visibility
watch.py WatchRunner daemon — periodic command polling, condition DSL, result dispatch
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
memory.py Persistence facade (delegates to storage backend)
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
@@ -128,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
@@ -173,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()
@@ -208,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]").
```
---
@@ -1112,7 +1121,7 @@ context manager handles startup/shutdown (health monitor, MCP client,
registry).
Each workstream's `WebUI` has:
- `_event_queue` (per-workstream SSE events, `queue.Queue`)
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
- `_global_queue` (class variable, shared, for state broadcasts)
@@ -1172,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.
@@ -1339,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.
+1 -1
View File
@@ -41,7 +41,7 @@ class "WorkstreamTerminalUI" as WsTermUI {
}
class "WebUI" as WebUI {
- _event_queue: Queue
- _listeners: list[Queue]
- _approval_event: Event
- _plan_event: Event
- _ws_prompt_tokens: int
+14
View File
@@ -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
+2
View File
@@ -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)
+7
View File
@@ -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 {
+6
View File
@@ -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)
+1
View File
@@ -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()
+166
View File
@@ -0,0 +1,166 @@
@startuml
!theme plain
title Turnstone — Watch Tool Architecture
skinparam participant {
BackgroundColor<<server>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<ui>> #E8EAF6
}
participant "ChatSession\n(session.py)" as Session <<session>>
participant "WatchRunner\n(watch.py)" as Runner <<server>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
== Create Phase ==
Session -> Session : _prepare_watch(action="create")
note right
Validates:
- command via is_command_blocked()
- poll_every → parse_duration()
- stop_on → validate_condition()
- max watches limit (5)
- duplicate name check
needs_approval = True
end note
Session -> Storage : create_watch(watch_id, ws_id,\nnode_id, command, interval,\nstop_on, max_polls, next_poll)
Session --> UI : tool_result:\n"Watch 'pr-review' created"
== Poll Phase (WatchRunner daemon, every 15s) ==
Runner -> Storage : list_due_watches(now)
Storage --> Runner : due_watches[]
note right
Filters:
active=1 AND
next_poll <= now AND
node_id matches
end note
loop for each due watch
Runner -> Runner : is_command_blocked()?
alt blocked
Runner -> Storage : update_watch(active=False)
else safe
Runner -> Runner : subprocess.run(command)
note right
timeout = tool_timeout
start_new_session = True
output truncated at 64KB
end note
Runner -> Runner : evaluate_condition(\nstop_on, output,\nexit_code, prev_output)
note right
**Variables:**
output, data, exit_code,
prev_output, changed
**Safe builtins only:**
len, str, int, sorted, ...
No import/open/exec/eval
**stop_on=None:**
fires on change (skip 1st poll)
end note
alt condition fired OR max_polls reached
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, active=False)
Runner -> Runner : format_watch_message()
Runner -> Runner : _dispatch_result(ws_id, msg)
else not fired
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, next_poll)
end
end
end
== Dispatch Phase ==
note over Runner, Session
**Three dispatch paths:**
end note
alt Path A: workstream active + idle
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
Session -> Session : _dispatch_pending_watch()\n→ self.send(message)
Session -> UI : SSE: thinking, content,\ntool calls...
note right
Watch result appears as
synthetic user message.
Model sees it and responds.
Depth guard: max 5 chains.
end note
else Path B: workstream active + busy
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
note right
Queued. Dispatched when
current send() reaches IDLE.
end note
else Path C: workstream evicted
Runner -> Runner : restore_fn(ws_id)
note right
1. mgr.create() — may evict
another idle workstream
2. session.resume(ws_id)
3. set_watch_runner()
4. register new dispatch_fn
end note
Runner -> Session : restored dispatch_fn(message)
end
== Cancel / List ==
Session -> Storage : list_watches_for_ws(ws_id)
note right : action="list" (auto-approve)
Session -> Storage : update_watch(active=False)
note right : action="cancel" (auto-approve)
== Server Lifecycle ==
note over Runner, Storage
**Startup:**
1. WatchRunner created in main() with storage + node_id
2. restore_fn closure captures WorkstreamManager
3. Initial workstream: session.set_watch_runner(runner)
4. _lifespan(): runner.start() — daemon thread begins
**New workstream:**
session.set_watch_runner(runner) in create_workstream()
→ registers dispatch_fn for ws_id
**Eviction / close:**
session.close() → runner.remove_dispatch_fn(ws_id)
Watches remain active in DB — WatchRunner uses restore_fn
**Restart recovery:**
Overdue watches fire ONE immediate poll
next_poll updated to now + interval
Normal cadence resumes
**Shutdown:**
_lifespan(): runner.stop() — joins thread
end note
== REST API ==
note over UI, Storage
**GET /v1/api/watches[?ws_id=X]**
List active watches (for node or workstream)
**POST /v1/api/watches/{watch_id}/cancel**
Cancel a watch (sets active=False)
Both require write scope
end note
@enduml
@@ -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
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c53ddce800c59f9432d7a016c7d66282a449555d452b7fe9dd393f4282f08c46
size 554721
oid sha256:760c37e67736588dadee21d500419a48e9fc50f8bdc5667e686c580022bd40e2
size 554869
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e3044c738d6d6853aab5c4990e6c67bab0165eba991a4f5bebdfc4d4a0b305ee
size 289165
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
size 319702
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:282820fe416961e735d050f86ecdc079e29824d2b3c4d5c8c174d0533d41f211
size 258045
oid sha256:a3ffd93ccb634f76560f1dd65242b89cd34443b37e355f25f29a1c63a22001be
size 265259
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:32a0665cceffcc0517265bde12cfb227688aa8585284b5e946ab23bcc52daee6
size 187650
oid sha256:d17f3feacf7bc9f64dfea19464143bc9b6ef0da5d55e6d57c0bc5a73d5724eba
size 184466
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0a3f48cca1b8408862dc4ba04fd340703346f44d84048c99e9900f48e9c7e22
size 158867
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
size 200083
+2 -2
View File
@@ -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:96176a09e65e90dadc32d5e9ed778423842be89204d2cf382225f53a90cfaf01
size 258547
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b68663599922f72d7ca21be820523a5b472c268897d194e5237bba2441c004ec
size 124497
+158
View File
@@ -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`
+2
View File
@@ -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()`):
+28
View File
@@ -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
+79 -3
View File
@@ -1,6 +1,6 @@
# Tools Reference
turnstone exposes 15 built-in tools plus any number of external MCP tools to the
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
MCP tools are discovered from configured MCP servers at startup by
@@ -46,12 +46,12 @@ schema plus turnstone-specific metadata keys:
| Name | Description |
|---------------------|-------------|
| `TOOLS` | All 15 tool definitions (sent to the model). |
| `TOOLS` | All 16 tool definitions (sent to the model). |
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 15 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 16 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
---
@@ -422,6 +422,81 @@ Provide either `username` for user-based targeting or `channel_type` +
---
### watch
Set up periodic polling of a shell command within the current workstream.
Results are injected back into the conversation as synthetic user messages,
triggering the model to respond and act. Use for monitoring CI/CD pipelines,
PR reviews, deployments, file changes, etc.
| Parameter | Type | Required | Description |
|-------------|---------|----------|-------------|
| `action` | string | yes | `create`, `list`, or `cancel`. |
| `command` | string | create | Shell command to poll periodically. |
| `poll_every`| string | no | Poll interval as duration (`30s`, `5m`, `1h`). Default: `5m`. |
| `stop_on` | string | no | Python expression for stop condition (see below). Omit for change detection. |
| `name` | string | create | Human-readable watch name (e.g. `pr-review`). Used as identifier for cancel. |
| `max_polls` | integer | no | Max poll cycles before auto-cancel. Default: 100. |
**Actions:**
- `create` — Start a new watch. Requires approval (same as bash — runs shell
commands). Persists to the `watches` table; the server-level `WatchRunner`
daemon polls every 15 seconds for due watches.
- `list` — Show all active watches in this workstream. Auto-approved.
- `cancel` — Stop a watch by name or ID prefix. Auto-approved.
**Stop condition DSL** — The `stop_on` parameter accepts a Python expression
evaluated after each poll. Available variables:
| Variable | Type | Description |
|---------------|------------|-------------|
| `output` | `str` | stdout (+stderr) of the command. |
| `data` | `Any` | `json.loads(output)`, or `None` if not valid JSON. |
| `exit_code` | `int` | Process exit code. |
| `prev_output` | `str|None` | Previous poll's stdout (`None` on first poll). |
| `changed` | `bool` | `True` if output differs from previous poll. |
Safe builtins: `len`, `str`, `int`, `float`, `bool`, `abs`, `min`, `max`,
`any`, `all`, `isinstance`, `sorted`. No `import`, `open`, `exec`, or
`eval`. Security model: equivalent to `bash` — the model already has shell
access.
**Examples:**
```
data["state"] == "MERGED"
"error" in output
exit_code != 0
changed and "ready" in output.lower()
data.get("mergedAt") is not None
```
**Lifecycle:**
1. Model calls `watch(action="create", ...)` — persisted to SQLite.
2. `WatchRunner` daemon polls for due watches every 15s.
3. Each poll runs the command, evaluates the condition.
4. When the condition fires (or max polls reached), the result is injected
as a synthetic user message and the watch auto-cancels.
5. If the workstream was evicted, it is restored before injection.
6. Watches survive server restart (overdue watches fire once on recovery).
**Constraints:**
- Max 5 active watches per workstream.
- Poll interval: 10s24h.
- Output truncated at 64 KB.
- Max 5 consecutive watch dispatches per worker thread (depth guard).
- Duplicate names rejected within the same workstream.
- **Auto-approve**: `create` requires approval; `list` and `cancel` are auto-approved.
- **Agent availability**: Main session only — not available to plan/task sub-agents.
> See [Watch Architecture](diagrams/png/18-watch-architecture.png) for the
> full poll → evaluate → dispatch flow.
---
## Summary Table
| Tool | Category | Auto-approve | agent | task_agent | primary_key |
@@ -441,6 +516,7 @@ Provide either `username` for user-based targeting or `channel_type` +
| `recall` | Memory | Yes | No | No | `query` |
| `forget` | Memory | Yes | No | No | `key` |
| `notify` | Notify | Yes | Yes | Yes | `message` |
| `watch` | Monitor | No (create) | No | No | `command` |
| `tool_search`| Search | Yes | No | No | `query` |
---
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.5.2"
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
View File
@@ -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"
},
+137
View File
@@ -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 });
}
}
+9
View File
@@ -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";
}
+20
View File
@@ -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,
+6
View File
@@ -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> {
+167
View File
@@ -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
// ---------------------------------------------------------------------------
+20 -2
View File
@@ -59,7 +59,7 @@
"user_prompt": "Change the default port from 8000 to 9000 in both server.py and config.py",
"setup": {
"files": {
"server.py": "from config import PORT\n\ndef run():\n print(f'Listening on port {PORT}')\n",
"server.py": "import socket\n\ndef run():\n sock = socket.socket()\n sock.bind(('localhost', 8000))\n print('Server running on port 8000')\n",
"config.py": "PORT = 8000\nHOST = 'localhost'\n"
}
},
@@ -126,7 +126,7 @@
"app.py": "import sqlite3\nfrom flask import Flask, jsonify\n\napp = Flask(__name__)\nDB = 'data.db'\n\ndef get_db():\n return sqlite3.connect(DB)\n\n@app.route('/users')\ndef list_users():\n db = get_db()\n users = db.execute('SELECT * FROM users').fetchall()\n db.close()\n return jsonify(users)\n\n@app.route('/users/<int:uid>')\ndef get_user(uid):\n db = get_db()\n user = db.execute('SELECT * FROM users WHERE id=?', (uid,)).fetchone()\n db.close()\n return jsonify(user)\n\nif __name__ == '__main__':\n app.run(port=8000)\n"
}
},
"expected_actions": [{ "tool": "plan" }],
"expected_actions": [{ "tool": "create_plan" }],
"match_mode": "subset"
},
{
@@ -175,6 +175,24 @@
{ "tool": "man", "args_pattern": { "page": "tar" } }
],
"match_mode": "subset"
},
{
"id": "math-calculation",
"description": "Use the math tool for precise calculations, not bash or mental math",
"user_prompt": "What is 2^64 - 1? Use the math tool to calculate it precisely.",
"expected_actions": [
{ "tool": "math", "args_pattern": { "code": "2.*64" } }
],
"match_mode": "subset"
},
{
"id": "web-search-query",
"description": "Use web_search for general knowledge lookups, not web_fetch",
"user_prompt": "Search the web for the current population of Tokyo",
"expected_actions": [
{ "tool": "web_search", "args_pattern": { "query": "Tokyo" } }
],
"match_mode": "subset"
}
]
}
+58
View File
@@ -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"]
+337
View File
@@ -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")
+761
View File
@@ -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"
+746
View File
@@ -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
+2
View File
@@ -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"}),
]
+21
View File
@@ -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)
+3 -3
View File
@@ -203,8 +203,8 @@ class TestPlanExec:
"id": tc_id,
"type": "function",
"function": {
"name": "plan",
"arguments": json.dumps({"prompt": prior_prompt}),
"name": "create_plan",
"arguments": json.dumps({"goal": prior_prompt}),
},
}
],
@@ -239,7 +239,7 @@ class TestPlanExec:
m for m in messages if m["role"] == "assistant" and m.get("tool_calls")
]
assert len(assistant_with_tc) == 1
assert assistant_with_tc[0]["tool_calls"][0]["function"]["name"] == "plan"
assert assistant_with_tc[0]["tool_calls"][0]["function"]["name"] == "create_plan"
# The real tool result is forwarded with its original content
tool_msgs = [m for m in messages if m["role"] == "tool"]
+86
View File
@@ -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"
+3 -2
View File
@@ -72,7 +72,7 @@ class TestToolsMetadata:
"""Validate the metadata extracted from JSON files."""
def test_tool_count(self):
assert len(TOOLS) == 15
assert len(TOOLS) == 16
def test_agent_tools_count(self):
assert len(AGENT_TOOLS) == 7
@@ -97,11 +97,12 @@ class TestToolsMetadata:
"web_fetch": "url",
"web_search": "query",
"task": "prompt",
"plan": "prompt",
"create_plan": "goal",
"remember": "key",
"recall": "query",
"forget": "key",
"notify": "message",
"watch": "command",
}
assert expected == PRIMARY_KEY_MAP
+8
View File
@@ -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):
+487
View File
@@ -0,0 +1,487 @@
"""Tests for the watch module — duration parsing, condition evaluation, WatchRunner."""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock
import pytest
from turnstone.core.watch import (
WatchRunner,
evaluate_condition,
format_interval,
format_watch_message,
parse_duration,
validate_condition,
)
# ---------------------------------------------------------------------------
# parse_duration
# ---------------------------------------------------------------------------
class TestParseDuration:
def test_seconds(self):
assert parse_duration("30s") == 30.0
def test_minutes(self):
assert parse_duration("5m") == 300.0
def test_hours(self):
assert parse_duration("1h") == 3600.0
def test_compound(self):
assert parse_duration("2h30m") == 9000.0
def test_bare_number(self):
assert parse_duration("90") == 90.0
def test_bare_float(self):
assert parse_duration("10.5") == 10.5
def test_whitespace(self):
assert parse_duration(" 5m ") == 300.0
def test_case_insensitive(self):
assert parse_duration("1H30M") == 5400.0
def test_empty_raises(self):
with pytest.raises(ValueError, match="empty"):
parse_duration("")
def test_invalid_raises(self):
with pytest.raises(ValueError, match="invalid duration"):
parse_duration("abc")
def test_negative_raises(self):
with pytest.raises(ValueError, match="positive"):
parse_duration("-5")
def test_zero_raises(self):
with pytest.raises(ValueError, match="positive"):
parse_duration("0")
def test_zero_duration_raises(self):
with pytest.raises(ValueError, match="positive"):
parse_duration("0s")
# ---------------------------------------------------------------------------
# validate_condition
# ---------------------------------------------------------------------------
class TestValidateCondition:
def test_valid_expression(self):
assert validate_condition('data["state"] == "MERGED"') is None
def test_valid_simple(self):
assert validate_condition('"error" in output') is None
def test_valid_compound(self):
assert validate_condition('changed and "ready" in output.lower()') is None
def test_syntax_error(self):
result = validate_condition("if True:")
assert result is not None
assert "syntax" in result.lower()
def test_incomplete_expression(self):
result = validate_condition("==")
assert result is not None
# ---------------------------------------------------------------------------
# evaluate_condition
# ---------------------------------------------------------------------------
class TestEvaluateCondition:
def test_none_first_poll_no_fire(self):
"""With stop_on=None, first poll (prev_output=None) should not fire."""
fired, reason = evaluate_condition(None, "hello", 0, None)
assert not fired
def test_none_change_detected(self):
fired, reason = evaluate_condition(None, "world", 0, "hello")
assert fired
assert "changed" in reason
def test_none_no_change(self):
fired, reason = evaluate_condition(None, "same", 0, "same")
assert not fired
def test_string_match(self):
fired, reason = evaluate_condition('"error" in output', "has error here", 0, None)
assert fired
def test_string_no_match(self):
fired, reason = evaluate_condition('"error" in output', "all good", 0, None)
assert not fired
def test_exit_code(self):
fired, reason = evaluate_condition("exit_code != 0", "fail", 1, None)
assert fired
def test_exit_code_zero(self):
fired, reason = evaluate_condition("exit_code != 0", "ok", 0, None)
assert not fired
def test_json_data(self):
output = '{"state": "MERGED"}'
fired, reason = evaluate_condition('data["state"] == "MERGED"', output, 0, None)
assert fired
def test_json_data_no_match(self):
output = '{"state": "OPEN"}'
fired, reason = evaluate_condition('data["state"] == "MERGED"', output, 0, None)
assert not fired
def test_json_data_none_for_non_json(self):
"""Non-JSON output should have data=None."""
fired, reason = evaluate_condition("data is None", "plain text", 0, None)
assert fired
def test_changed_variable(self):
fired, reason = evaluate_condition("changed", "new", 0, "old")
assert fired
def test_changed_false(self):
fired, reason = evaluate_condition("changed", "same", 0, "same")
assert not fired
def test_compound_condition(self):
fired, reason = evaluate_condition(
'changed and "ready" in output.lower()',
"System Ready",
0,
"System Starting",
)
assert fired
def test_invalid_expression_no_crash(self):
fired, reason = evaluate_condition("1/0", "hello", 0, None)
assert not fired
assert "error" in reason.lower()
def test_no_import_builtin(self):
"""__import__ should not be accessible."""
fired, reason = evaluate_condition("__import__('os')", "hello", 0, None)
assert not fired
assert "error" in reason.lower()
def test_no_open_builtin(self):
fired, reason = evaluate_condition("open('/etc/passwd')", "hello", 0, None)
assert not fired
assert "error" in reason.lower()
def test_no_exec_builtin(self):
fired, reason = evaluate_condition("exec('print(1)')", "hello", 0, None)
assert not fired
assert "error" in reason.lower()
def test_no_eval_builtin(self):
fired, reason = evaluate_condition("eval('1+1')", "hello", 0, None)
assert not fired
assert "error" in reason.lower()
def test_no_compile_builtin(self):
fired, reason = evaluate_condition("compile('1','','eval')", "hello", 0, None)
assert not fired
assert "error" in reason.lower()
def test_safe_len(self):
fired, reason = evaluate_condition("len(output) > 0", "hello", 0, None)
assert fired
def test_safe_sorted(self):
fired, reason = evaluate_condition("sorted([3,1,2]) == [1,2,3]", "x", 0, None)
assert fired
def test_data_get_method(self):
output = '{"mergedAt": "2024-01-15"}'
fired, reason = evaluate_condition('data.get("mergedAt") is not None', output, 0, None)
assert fired
def test_prev_output_available(self):
fired, reason = evaluate_condition(
"prev_output is not None and output != prev_output",
"new",
0,
"old",
)
assert fired
# ---------------------------------------------------------------------------
# format_interval
# ---------------------------------------------------------------------------
class TestFormatInterval:
def test_seconds(self):
assert format_interval(30) == "30s"
def test_exactly_60(self):
assert format_interval(60) == "1m"
def test_minutes(self):
assert format_interval(300) == "5m"
def test_exactly_3600(self):
assert format_interval(3600) == "1h"
def test_hours_and_minutes(self):
assert format_interval(5400) == "1h30m"
def test_hours_only(self):
assert format_interval(7200) == "2h"
def test_large_value(self):
assert format_interval(86400) == "24h"
# ---------------------------------------------------------------------------
# format_watch_message
# ---------------------------------------------------------------------------
class TestFormatWatchMessage:
def test_basic(self):
msg = format_watch_message(
name="pr-review",
command="gh pr view --json state",
output='{"state": "MERGED"}',
poll_count=5,
max_polls=100,
elapsed_secs=1500,
stop_on='data["state"] == "MERGED"',
is_final=True,
reason='condition met: data["state"] == "MERGED"',
)
assert "pr-review" in msg
assert "poll #5/100" in msg
assert "25m" in msg
assert "gh pr view --json state" in msg
assert "MERGED" in msg
assert "auto-cancelled" in msg.lower()
# Model should see the condition it was waiting for
assert "condition:" in msg.lower()
def test_non_final(self):
msg = format_watch_message(
name="deploy",
command="curl -s http://localhost/health",
output="ok",
poll_count=3,
max_polls=50,
elapsed_secs=90,
stop_on=None,
is_final=False,
reason="",
)
assert "deploy" in msg
assert "auto-cancelled" not in msg.lower()
# Change-detection mode should be indicated
assert "output change" in msg.lower()
def test_max_polls_final(self):
msg = format_watch_message(
name="test",
command="echo hello",
output="hello",
poll_count=100,
max_polls=100,
elapsed_secs=6000,
stop_on=None,
is_final=True,
reason="",
)
assert "max polls" in msg.lower()
# ---------------------------------------------------------------------------
# WatchRunner
# ---------------------------------------------------------------------------
class TestWatchRunner:
def _make_runner(self, storage=None, **kwargs):
if storage is None:
storage = MagicMock()
storage.list_due_watches.return_value = []
return WatchRunner(
storage=storage,
node_id="test-node",
check_interval=0.1,
tool_timeout=5,
**kwargs,
)
def test_start_stop(self):
runner = self._make_runner()
runner.start()
assert runner._thread is not None
assert runner._thread.is_alive()
runner.stop()
assert runner._thread is None
def test_tick_calls_list_due(self):
storage = MagicMock()
storage.list_due_watches.return_value = []
runner = self._make_runner(storage=storage)
runner._tick()
storage.list_due_watches.assert_called_once()
def test_poll_watch_runs_command(self):
storage = MagicMock()
storage.update_watch.return_value = True
runner = self._make_runner(storage=storage)
dispatch_fn = MagicMock()
runner.set_dispatch_fn("ws-1", dispatch_fn)
watch_row = {
"watch_id": "abc123",
"ws_id": "ws-1",
"name": "test-watch",
"command": "echo hello",
"stop_on": '"hello" in output',
"max_polls": 100,
"poll_count": 0,
"last_output": None,
"interval_secs": 60,
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
}
runner._poll_watch(watch_row)
# Should update the watch in storage
storage.update_watch.assert_called_once()
call_kwargs = storage.update_watch.call_args
assert call_kwargs[0][0] == "abc123" # watch_id
assert call_kwargs[1]["poll_count"] == 1
# Condition should fire (output contains "hello")
assert call_kwargs[1]["active"] is False # deactivated
# Should dispatch result
dispatch_fn.assert_called_once()
def test_poll_watch_no_fire_on_first_change_detection(self):
storage = MagicMock()
storage.update_watch.return_value = True
runner = self._make_runner(storage=storage)
dispatch_fn = MagicMock()
runner.set_dispatch_fn("ws-1", dispatch_fn)
watch_row = {
"watch_id": "abc123",
"ws_id": "ws-1",
"name": "test-watch",
"command": "echo hello",
"stop_on": None, # change detection
"max_polls": 100,
"poll_count": 0,
"last_output": None, # first poll
"interval_secs": 60,
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
}
runner._poll_watch(watch_row)
# First poll with change detection should not fire
dispatch_fn.assert_not_called()
call_kwargs = storage.update_watch.call_args
# Watch should remain active
assert "active" not in call_kwargs[1] or call_kwargs[1].get("active") is not False
def test_max_polls_deactivates(self):
storage = MagicMock()
storage.update_watch.return_value = True
runner = self._make_runner(storage=storage)
dispatch_fn = MagicMock()
runner.set_dispatch_fn("ws-1", dispatch_fn)
watch_row = {
"watch_id": "abc123",
"ws_id": "ws-1",
"name": "test-watch",
"command": "echo hello",
"stop_on": '"never" in output', # won't fire
"max_polls": 5,
"poll_count": 4, # next is #5 = max
"last_output": "hello\n",
"interval_secs": 60,
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
}
runner._poll_watch(watch_row)
call_kwargs = storage.update_watch.call_args
assert call_kwargs[1]["active"] is False
assert call_kwargs[1]["poll_count"] == 5
dispatch_fn.assert_called_once()
def test_blocked_command_deactivates(self):
storage = MagicMock()
storage.update_watch.return_value = True
runner = self._make_runner(storage=storage)
watch_row = {
"watch_id": "abc123",
"ws_id": "ws-1",
"name": "test-watch",
"command": "rm -rf /",
"stop_on": None,
"max_polls": 100,
"poll_count": 0,
"last_output": None,
"interval_secs": 60,
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
}
runner._poll_watch(watch_row)
storage.update_watch.assert_called_once()
call_kwargs = storage.update_watch.call_args
assert call_kwargs[0][0] == "abc123"
assert call_kwargs[1]["active"] is False
def test_dispatch_fn_registry(self):
runner = self._make_runner()
fn1 = MagicMock()
fn2 = MagicMock()
runner.set_dispatch_fn("ws-1", fn1)
runner.set_dispatch_fn("ws-2", fn2)
runner._dispatch_result("ws-1", "msg1")
fn1.assert_called_once_with("msg1")
fn2.assert_not_called()
runner.remove_dispatch_fn("ws-1")
# After removal, dispatch should try restore_fn
runner._dispatch_result("ws-1", "msg2")
fn1.assert_called_once() # still just the one call
def test_restore_fn_called_for_evicted(self):
restored_fn = MagicMock()
restore_fn = MagicMock(return_value=restored_fn)
runner = self._make_runner(restore_fn=restore_fn)
runner._dispatch_result("ws-evicted", "hello")
restore_fn.assert_called_once_with("ws-evicted")
restored_fn.assert_called_once_with("hello")
def test_run_command_success(self):
runner = self._make_runner()
output, code = runner._run_command("echo hello")
assert "hello" in output
assert code == 0
def test_run_command_failure(self):
runner = self._make_runner()
output, code = runner._run_command("exit 42")
assert code == 42
def test_run_command_timeout(self):
runner = self._make_runner()
runner._tool_timeout = 1
output, code = runner._run_command("sleep 30")
assert "timed out" in output.lower()
assert code == -1
+130
View File
@@ -0,0 +1,130 @@
"""Tests for watches storage CRUD."""
from __future__ import annotations
import pytest
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def db(tmp_path):
"""Fresh SQLite backend for each test."""
return SQLiteBackend(str(tmp_path / "test.db"))
def _make_watch_kwargs(**overrides):
"""Build default kwargs for create_watch."""
defaults = {
"watch_id": "watch_001",
"ws_id": "ws-abc",
"node_id": "node-1",
"name": "pr-review",
"command": "gh pr view --json state",
"interval_secs": 300.0,
"stop_on": 'data["state"] == "MERGED"',
"max_polls": 100,
"created_by": "model",
"next_poll": "2099-01-01T00:05:00",
}
defaults.update(overrides)
return defaults
class TestWatchCRUD:
def test_create_and_get(self, db):
db.create_watch(**_make_watch_kwargs())
w = db.get_watch("watch_001")
assert w is not None
assert w["name"] == "pr-review"
assert w["command"] == "gh pr view --json state"
assert w["interval_secs"] == 300.0
assert w["active"] == 1
assert w["poll_count"] == 0
def test_get_nonexistent(self, db):
assert db.get_watch("nope") is None
def test_create_idempotent(self, db):
db.create_watch(**_make_watch_kwargs())
db.create_watch(**_make_watch_kwargs()) # OR IGNORE
assert db.get_watch("watch_001") is not None
def test_update(self, db):
db.create_watch(**_make_watch_kwargs())
updated = db.update_watch(
"watch_001",
poll_count=5,
last_output="hello",
last_exit_code=0,
)
assert updated is True
w = db.get_watch("watch_001")
assert w["poll_count"] == 5
assert w["last_output"] == "hello"
assert w["last_exit_code"] == 0
def test_update_nonexistent(self, db):
assert db.update_watch("nope", poll_count=1) is False
def test_update_active_flag(self, db):
db.create_watch(**_make_watch_kwargs())
db.update_watch("watch_001", active=False)
w = db.get_watch("watch_001")
assert w["active"] == 0
def test_delete(self, db):
db.create_watch(**_make_watch_kwargs())
assert db.delete_watch("watch_001") is True
assert db.get_watch("watch_001") is None
def test_delete_nonexistent(self, db):
assert db.delete_watch("nope") is False
class TestWatchListQueries:
def test_list_for_ws(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="a"))
db.create_watch(**_make_watch_kwargs(watch_id="w2", ws_id="ws-1", name="b"))
db.create_watch(**_make_watch_kwargs(watch_id="w3", ws_id="ws-2", name="c"))
ws1 = db.list_watches_for_ws("ws-1")
assert len(ws1) == 2
assert {w["name"] for w in ws1} == {"a", "b"}
def test_list_for_ws_excludes_inactive(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1"))
db.update_watch("w1", active=False)
assert db.list_watches_for_ws("ws-1") == []
def test_list_for_node(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="w1", node_id="n1"))
db.create_watch(**_make_watch_kwargs(watch_id="w2", node_id="n1"))
db.create_watch(**_make_watch_kwargs(watch_id="w3", node_id="n2"))
n1 = db.list_watches_for_node("n1")
assert len(n1) == 2
def test_list_due(self, db):
# Due
db.create_watch(**_make_watch_kwargs(watch_id="w1", next_poll="2020-01-01T00:00:00"))
# Not due (far future)
db.create_watch(**_make_watch_kwargs(watch_id="w2", next_poll="2099-01-01T00:00:00"))
# Due but inactive
db.create_watch(**_make_watch_kwargs(watch_id="w3", next_poll="2020-01-01T00:00:00"))
db.update_watch("w3", active=False)
due = db.list_due_watches("2025-01-01T00:00:00")
assert len(due) == 1
assert due[0]["watch_id"] == "w1"
def test_delete_for_ws(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1"))
db.create_watch(**_make_watch_kwargs(watch_id="w2", ws_id="ws-1"))
db.create_watch(**_make_watch_kwargs(watch_id="w3", ws_id="ws-2"))
count = db.delete_watches_for_ws("ws-1")
assert count == 2
assert db.get_watch("w1") is None
assert db.get_watch("w2") is None
assert db.get_watch("w3") is not None
+111
View File
@@ -683,6 +683,117 @@ class TestWebUI:
t.join()
# ---------------------------------------------------------------------------
# WebUI SSE fan-out
# ---------------------------------------------------------------------------
class TestWebUIFanOut:
"""Verify per-client SSE fan-out on WebUI._enqueue / _register_listener."""
def test_enqueue_no_listeners(self):
"""Events silently dropped when no listeners are registered."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
ui._enqueue({"type": "content", "text": "hello"}) # should not raise
def test_enqueue_single_listener(self):
"""Single listener receives the event."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
q = ui._register_listener()
ui._enqueue({"type": "content", "text": "hello"})
assert q.get_nowait() == {"type": "content", "text": "hello"}
def test_enqueue_multiple_listeners(self):
"""All registered listeners receive an identical copy."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
q1 = ui._register_listener()
q2 = ui._register_listener()
q3 = ui._register_listener()
event = {"type": "content", "text": "world"}
ui._enqueue(event)
assert q1.get_nowait() == event
assert q2.get_nowait() == event
assert q3.get_nowait() == event
def test_unregister_stops_delivery(self):
"""After unregister, the queue receives no further events."""
import queue as queue_mod
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
q = ui._register_listener()
ui._unregister_listener(q)
ui._enqueue({"type": "content", "text": "gone"})
with pytest.raises(queue_mod.Empty):
q.get_nowait()
def test_slow_consumer_does_not_block(self):
"""A full queue doesn't block the producer or starve other listeners."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
slow = ui._register_listener()
fast = ui._register_listener()
# Fill only the slow consumer's queue directly to capacity
for i in range(500):
slow.put_nowait({"type": "content", "text": f"fill-{i}"})
assert slow.qsize() == 500
assert fast.qsize() == 0
# Enqueue via fan-out — slow drops (full), fast receives
event = {"type": "content", "text": "overflow"}
ui._enqueue(event)
assert slow.qsize() == 500 # still full, overflow dropped
assert fast.qsize() == 1
assert fast.get_nowait() == event
def test_unregister_idempotent(self):
"""Double unregister does not raise."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
q = ui._register_listener()
ui._unregister_listener(q)
ui._unregister_listener(q) # should not raise
def test_concurrent_enqueue_and_register(self):
"""Concurrent register/unregister and enqueue should not crash."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
stop = threading.Event()
def register_loop():
while not stop.is_set():
q = ui._register_listener()
ui._unregister_listener(q)
def enqueue_loop():
for i in range(500):
ui._enqueue({"type": "content", "text": f"tok-{i}"})
t1 = threading.Thread(target=register_loop)
t2 = threading.Thread(target=enqueue_loop)
t1.start()
t2.start()
t2.join()
stop.set()
t1.join()
# ---------------------------------------------------------------------------
# Integration: WorkstreamManager + session state transitions
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.5.2"
__version__ = "0.5.4"
+192
View File
@@ -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
+229
View File
@@ -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,
]
+4
View File
@@ -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")
+11
View File
@@ -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,
+12
View File
@@ -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]
File diff suppressed because it is too large Load Diff
+283 -10
View File
@@ -10,6 +10,7 @@ var _ctTrapHandler = null;
var _tcTrapHandler = null;
var _ccTrapHandler = null;
var _cfTrapHandler = null;
var _adminWatches = [];
var _confirmCallbackFn = null;
var _confirmTriggerEl = null;
@@ -28,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;
@@ -40,19 +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";
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();
}
}
// ---------------------------------------------------------------------------
@@ -98,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="' +
@@ -107,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++) {
@@ -887,6 +966,167 @@ function hideScheduleRunsModal() {
_runsScheduleTriggerEl = null;
}
// ---------------------------------------------------------------------------
// Watches
// ---------------------------------------------------------------------------
function _populateWatchNodeSelect() {
var sel = document.getElementById("admin-watch-node");
var current = sel.value;
var seen = {};
sel.innerHTML = '<option value="">All nodes</option>';
for (var i = 0; i < _adminWatches.length; i++) {
var nid = _adminWatches[i].node_id || "";
if (nid && !seen[nid]) {
seen[nid] = true;
var opt = document.createElement("option");
opt.value = nid;
opt.textContent = nid;
sel.appendChild(opt);
}
}
if (current) sel.value = current;
}
function loadAdminWatches() {
authFetch("/v1/api/admin/watches")
.then(function (r) {
if (!r.ok) throw new Error("Failed to load watches");
return r.json();
})
.then(function (data) {
_adminWatches = data.watches || [];
_populateWatchNodeSelect();
var nodeFilter = document.getElementById("admin-watch-node").value;
var filtered = _adminWatches;
if (nodeFilter) {
filtered = _adminWatches.filter(function (w) {
return w.node_id === nodeFilter;
});
}
_renderWatches(filtered);
})
.catch(function () {
document.getElementById("admin-watches-table").innerHTML =
'<div class="dashboard-empty">Failed to load watches</div>';
});
}
function _formatInterval(secs) {
if (!secs || secs <= 0) return "\u2014";
if (secs >= 3600) return Math.round(secs / 3600) + "h";
if (secs >= 60) return Math.round(secs / 60) + "m";
return secs + "s";
}
function _renderWatches(watches) {
var container = document.getElementById("admin-watches-table");
if (!watches.length) {
container.innerHTML =
'<div class="dashboard-empty">No active watches. Watches are created when workstreams use the watch tool.</div>';
return;
}
var html = "";
for (var i = 0; i < watches.length; i++) {
var w = watches[i];
var name = w.name || w.watch_id || "\u2014";
var nodeShort = (w.node_id || "").slice(0, 8);
var cmd = w.command || "";
var cmdTrunc = cmd.length > 40 ? cmd.slice(0, 40) + "\u2026" : cmd;
var interval = _formatInterval(w.interval_secs);
var pollMax = w.max_polls ? w.max_polls : "\u221e";
var pollLabel = (w.poll_count || 0) + "/" + pollMax;
var cond = w.stop_on || "on change";
var condTrunc = cond.length > 30 ? cond.slice(0, 30) + "\u2026" : cond;
var active = w.active;
var statusCls = active ? "watch-active" : "watch-completed";
var statusLabel = active ? "active" : "done";
var statusDot = active ? "\u25cf " : "\u25cb ";
var cancelBtn = active
? '<button class="admin-btn-danger" data-cancel-watch="' +
escapeHtml(w.watch_id) +
'" data-watch-node="' +
escapeHtml(w.node_id || "") +
'" data-watch-name="' +
escapeHtml(name) +
'" title="Cancel watch">cancel</button>'
: "";
html +=
'<div class="admin-row" role="listitem">' +
'<span class="admin-col admin-col-wname">' +
escapeHtml(name) +
"</span>" +
'<span class="admin-col admin-col-wnode" title="' +
escapeHtml(w.node_id || "") +
'"><code>' +
escapeHtml(nodeShort) +
"</code></span>" +
'<span class="admin-col admin-col-wcmd" title="' +
escapeHtml(cmd) +
'"><code>' +
escapeHtml(cmdTrunc) +
"</code></span>" +
'<span class="admin-col admin-col-winterval">' +
escapeHtml(interval) +
"</span>" +
'<span class="admin-col admin-col-wpoll"><code>' +
escapeHtml(pollLabel) +
"</code></span>" +
'<span class="admin-col admin-col-wcond" title="' +
escapeHtml(cond) +
'">' +
escapeHtml(condTrunc) +
"</span>" +
'<span class="admin-col admin-col-wstatus"><span class="' +
statusCls +
'">' +
statusDot +
statusLabel +
"</span></span>" +
'<span class="admin-col admin-col-actions">' +
cancelBtn +
"</span></div>";
}
container.innerHTML = html;
// Bind cancel buttons
var btns = container.querySelectorAll("[data-cancel-watch]");
for (var j = 0; j < btns.length; j++) {
btns[j].addEventListener("click", function () {
_cancelWatch(
this.getAttribute("data-cancel-watch"),
this.getAttribute("data-watch-node"),
this.getAttribute("data-watch-name"),
);
});
}
}
function _cancelWatch(watchId, nodeId, name) {
showConfirmModal(
"Cancel Watch",
"Cancel watch \u2018" + name + "\u2019? This will stop future polling.",
"Cancel watch",
function () {
authFetch(
"/v1/api/admin/watches/" + encodeURIComponent(watchId) + "/cancel",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: nodeId }),
},
)
.then(function (r) {
if (!r.ok) throw new Error("Cancel failed");
showToast("Watch '" + name + "' cancelled");
loadAdminWatches();
})
.catch(function () {
showToast("Failed to cancel watch");
});
},
);
}
// ---------------------------------------------------------------------------
// Create Channel Link Modal
// ---------------------------------------------------------------------------
@@ -1197,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();
}
};
}
@@ -1263,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
@@ -1271,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"];
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
+297
View File
@@ -81,6 +81,12 @@
<button id="tab-tokens" class="admin-tab" data-tab="tokens" role="tab" aria-selected="false" aria-controls="admin-tokens" tabindex="-1" onclick="switchAdminTab('tokens')">Tokens</button>
<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 -->
@@ -163,6 +169,142 @@
<div class="dashboard-empty">Loading schedules...</div>
</div>
</div>
<!-- Watches Tab -->
<div id="admin-watches" class="admin-panel" role="tabpanel" aria-labelledby="tab-watches" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">WATCHES</span>
<label for="admin-watch-node" class="sr-only">Filter watches by node</label>
<select id="admin-watch-node" onchange="loadAdminWatches()">
<option value="">All nodes</option>
</select>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-wname">NAME</span>
<span class="admin-col admin-col-wnode">NODE</span>
<span class="admin-col admin-col-wcmd">COMMAND</span>
<span class="admin-col admin-col-winterval">INTERVAL</span>
<span class="admin-col admin-col-wpoll">POLL</span>
<span class="admin-col admin-col-wcond">CONDITION</span>
<span class="admin-col admin-col-wstatus">STATUS</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-watches-table" role="list" aria-label="Watches" aria-live="polite">
<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>
@@ -409,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>
+280 -1
View File
@@ -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;
@@ -863,6 +868,16 @@
.sched-disabled { color: var(--fg-dim); }
.sched-expired { color: var(--accent); }
/* Watches grid: NAME | NODE | COMMAND | INTERVAL | POLL | CONDITION | STATUS | ACTIONS */
#admin-watches .admin-colheaders,
#admin-watches .admin-row {
grid-template-columns: 1.2fr 80px 1.5fr 60px 70px 1fr 70px 70px;
}
/* Watch status indicators */
.watch-active { color: var(--green); font-weight: 500; }
.watch-completed { color: var(--accent); }
/* Wide modal variant for schedule forms */
.admin-modal-wide { width: 480px; }
@@ -979,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);
@@ -1027,6 +1045,267 @@
grid-template-columns: 1fr 60px 80px 130px;
}
.admin-col-sschedule, .admin-col-starget, .admin-col-snext { display: none; }
#admin-watches .admin-colheaders, #admin-watches .admin-row {
grid-template-columns: 1.2fr 80px 70px 70px 70px;
}
.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; }
}
/* ==========================================================================
+41
View File
@@ -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)
+117 -4
View File
@@ -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,
)
@@ -390,6 +458,13 @@ def required_scope(method: str, path: str) -> str:
# Write endpoints
if method == "POST" and normalized in WRITE_PATHS:
return "write"
# Watch cancel has a path parameter: /api/watches/{id}/cancel
if (
method == "POST"
and normalized.startswith("/api/watches/")
and normalized.endswith("/cancel")
):
return "write"
# Console proxy routes: /node/{node_id}/api/{tail} or /node/{node_id}/v1/api/{tail}
if method == "POST" and normalized.startswith("/node/"):
@@ -524,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),
)
@@ -828,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(
@@ -858,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:
@@ -952,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(
@@ -961,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] = {
@@ -970,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
+80
View File
@@ -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
+546 -105
View File
@@ -15,6 +15,7 @@ import dataclasses
import json
import mimetypes
import os
import queue
import re
import signal
import subprocess
@@ -82,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"}
@@ -222,6 +236,13 @@ class ChatSession:
self._assistant_pending_tokens = 0
self.creative_mode = False
self._notify_count = 0
# Watch support: server-level runner injected via set_watch_runner()
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)
@@ -330,11 +351,32 @@ class ChatSession:
else:
self._tool_search = None
def set_watch_runner(self, runner: Any, dispatch_fn: Any = None) -> None:
"""Inject the server-level WatchRunner (called after workstream setup).
If *dispatch_fn* is provided (the server passes one that can start
worker threads), it is registered directly. Otherwise a simple
enqueue fallback is used suitable only when ``send()`` is already
active (Path A).
"""
self._watch_runner = runner
if dispatch_fn is not None:
runner.set_dispatch_fn(self._ws_id, dispatch_fn)
else:
pending = self._watch_pending
def _enqueue(msg: str) -> None:
pending.put({"message": msg})
runner.set_dispatch_fn(self._ws_id, _enqueue)
def close(self) -> None:
"""Release resources (listener registrations, etc.)."""
if self._mcp_client and self._mcp_refresh_cb:
self._mcp_client.remove_listener(self._mcp_refresh_cb)
self._mcp_refresh_cb = None
if self._watch_runner:
self._watch_runner.remove_dispatch_fn(self._ws_id)
def _handle_mcp_refresh(self, arg: str) -> None:
"""Handle ``/mcp refresh [server]``."""
@@ -521,13 +563,15 @@ class ChatSession:
" write_file(path='hello.py', content='...')\n\n"
"Find something across files → search:\n"
" search(query='test_')\n\n"
"Complex or multi-step task → plan first:\n"
" plan(prompt='refactor database from API')\n\n"
"Plan, design, or think through an approach → create_plan:\n"
" create_plan(goal='refactor database from API')\n\n"
"Run a command, git, or tests → bash:\n"
" bash(command='git log -5')\n"
" bash(command='pytest')\n\n"
"Retrieve a URL → web_fetch:\n"
" web_fetch(url='https://example.com')\n\n"
"Search the web for information → web_search:\n"
" web_search(query='current population of Tokyo')\n\n"
"Look up documentation → man:\n"
" man(page='tar')",
]
@@ -687,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:
@@ -766,6 +830,9 @@ class ChatSession:
self._title_generated = True
threading.Thread(target=self._generate_title, daemon=True).start()
self._emit_state("idle")
# Dispatch any pending watch results (chains into
# a new send() within the same worker thread).
self._dispatch_pending_watch(self._watch_dispatch_depth)
break
# Execute tool calls (potentially in parallel)
@@ -820,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
@@ -949,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:
@@ -1415,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]]]:
@@ -1435,7 +1554,7 @@ class ChatSession:
# Post-plan gate: prompt user on main thread after plan completes
for i, item in enumerate(items):
if (
item.get("func_name") == "plan"
item.get("func_name") == "create_plan"
and not item.get("error")
and not item.get("denied")
and not self.auto_approve
@@ -1515,11 +1634,12 @@ class ChatSession:
"web_search": self._prepare_web_search,
"tool_search": self._prepare_tool_search,
"task": self._prepare_task,
"plan": self._prepare_plan,
"create_plan": self._prepare_plan,
"remember": self._prepare_remember,
"recall": self._prepare_recall,
"forget": self._prepare_forget,
"notify": self._prepare_notify,
"watch": self._prepare_watch,
}
preparer = preparers.get(func_name)
if not preparer:
@@ -2064,26 +2184,26 @@ class ChatSession:
def _prepare_plan(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a planning agent for approval."""
prompt = (args.get("prompt") or "").strip()
if not prompt:
goal = args.get("goal", "").strip()
if not goal:
return {
"call_id": call_id,
"func_name": "plan",
"header": "\u2717 plan: empty prompt",
"func_name": "create_plan",
"header": "\u2717 create_plan: empty goal",
"preview": "",
"needs_approval": False,
"error": "Error: empty prompt",
"error": "Error: empty goal",
}
preview_text = prompt[:300] + ("..." if len(prompt) > 300 else "")
preview_text = goal[:300] + ("..." if len(goal) > 300 else "")
return {
"call_id": call_id,
"func_name": "plan",
"header": "\u2699 plan (planning agent)",
"func_name": "create_plan",
"header": "\u2699 create_plan (planning agent)",
"preview": f" {DIM}{preview_text}{RESET}",
"needs_approval": True,
"approval_label": "plan",
"approval_label": "create_plan",
"execute": self._exec_plan,
"prompt": prompt,
"prompt": goal,
}
def _prepare_remember(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
@@ -2253,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()
@@ -2516,7 +2645,21 @@ class ChatSession:
turn = 0
while max_tool_turns < 0 or turn < max_tool_turns:
result = _api_call(agent_messages)
self._check_cancelled()
try:
result = _api_call(agent_messages)
except Exception as e:
# Context-exceeded or other non-retryable API error.
# Return what we have so far rather than crashing.
err_str = str(e).lower()
if "context" in err_str or "token" in err_str:
self.ui.on_info(f"[{label}] context limit reached, stopping early")
# Find the last assistant content we have
for msg in reversed(agent_messages):
if msg.get("role") == "assistant" and msg.get("content"):
return str(msg["content"])
return f"({label} stopped: context limit exceeded)"
raise
# Handle truncation or content filter — stop agent early
if result.finish_reason == "length":
@@ -2544,10 +2687,11 @@ 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.
if tool_name in ("task", "plan"):
if tool_name in ("task", "create_plan"):
output = "Error: agents cannot spawn further agents"
# Guard 2: tool not in this agent's API tool list.
elif tool_name not in tool_names:
@@ -2580,6 +2724,12 @@ class ChatSession:
else:
output = f"Unknown tool: {tool_name}"
# Truncate large tool outputs to avoid blowing context limits.
# Agents operate autonomously; they can refine their queries
# if truncation loses important detail.
if isinstance(output, str) and len(output) > 16000:
output = output[:16000] + f"\n\n... (truncated from {len(output)} chars)"
agent_messages.append(
{
"role": "tool",
@@ -2628,8 +2778,13 @@ class ChatSession:
"questions — execute the work as described in the prompt."
),
}
agent_messages = list(self._agent_system_messages) + [
task_instruction,
# Task agent gets the base system prompt (tool patterns) merged
# with its own identity in a single system message. No conversation
# history — it's an autonomous sub-agent. Merged to avoid
# multi-system-message errors on models like Qwen.
base = self._agent_system_messages[0]["content"] if self._agent_system_messages else ""
agent_messages = [
{"role": "system", "content": base + "\n\n" + task_instruction["content"]},
{"role": "user", "content": prompt},
]
try:
@@ -2639,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}")
@@ -2668,7 +2823,7 @@ class ChatSession:
for i, msg in enumerate(self.messages):
if msg.get("role") == "assistant" and msg.get("tool_calls"):
for tc in msg["tool_calls"]:
if tc.get("function", {}).get("name") == "plan":
if tc.get("function", {}).get("name") == "create_plan":
tc_id = tc["id"]
for j in range(i + 1, len(self.messages)):
if (
@@ -2678,8 +2833,11 @@ class ChatSession:
prior_plan_msgs = [msg, self.messages[j]]
break
agent_messages = list(self._agent_system_messages)
agent_messages.append({"role": "system", "content": self._PLAN_IDENTITY})
# Plan agent gets its own identity only — no main session system
# prompt or conversation history. It's an autonomous sub-agent.
agent_messages: list[dict[str, Any]] = [
{"role": "system", "content": self._PLAN_IDENTITY},
]
agent_messages.extend(prior_plan_msgs)
agent_messages.append({"role": "user", "content": prompt})
@@ -2689,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}")
@@ -2957,6 +3115,289 @@ class ChatSession:
self.ui.on_tool_result(call_id, "notify", msg)
return call_id, msg
# -- Watch tool ----------------------------------------------------------
def _prepare_watch(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
from turnstone.core.watch import (
MAX_INTERVAL,
MAX_WATCHES_PER_WS,
MIN_INTERVAL,
parse_duration,
validate_condition,
)
action = args.get("action", "")
if action == "list":
return {
"call_id": call_id,
"func_name": "watch",
"header": "\u23f1 watch: list",
"preview": "",
"needs_approval": False,
"execute": self._exec_watch,
"action": "list",
}
if action == "cancel":
name = args.get("name", "")
if not name:
return {
"call_id": call_id,
"func_name": "watch",
"header": "\u2717 watch cancel: missing name",
"preview": "",
"needs_approval": False,
"error": "Error: 'name' is required for cancel",
}
return {
"call_id": call_id,
"func_name": "watch",
"header": f'\u23f1 watch: cancel "{name}"',
"preview": "",
"needs_approval": False,
"execute": self._exec_watch,
"action": "cancel",
"watch_name": name,
}
if action != "create":
return {
"call_id": call_id,
"func_name": "watch",
"header": f"\u2717 watch: unknown action '{action}'",
"preview": "",
"needs_approval": False,
"error": f"Error: unknown action '{action}'. Use create, list, or cancel.",
}
# --- action=create ---
command = sanitize_command(args.get("command", ""))
if not command:
return {
"call_id": call_id,
"func_name": "watch",
"header": "\u2717 watch create: missing command",
"preview": "",
"needs_approval": False,
"error": "Error: 'command' is required for create",
}
blocked = is_command_blocked(command)
if blocked:
return {
"call_id": call_id,
"func_name": "watch",
"header": f"\u2717 {blocked}",
"preview": "",
"needs_approval": False,
"error": blocked,
}
# Parse poll interval
poll_every_str = args.get("poll_every", "5m")
try:
interval_secs = parse_duration(poll_every_str)
except ValueError as exc:
return {
"call_id": call_id,
"func_name": "watch",
"header": f"\u2717 watch: invalid poll_every: {exc}",
"preview": "",
"needs_approval": False,
"error": f"Error: invalid poll_every: {exc}",
}
if interval_secs < MIN_INTERVAL:
return {
"call_id": call_id,
"func_name": "watch",
"header": f"\u2717 watch: interval too short (min {MIN_INTERVAL}s)",
"preview": "",
"needs_approval": False,
"error": f"Error: minimum poll interval is {MIN_INTERVAL}s",
}
if interval_secs > MAX_INTERVAL:
return {
"call_id": call_id,
"func_name": "watch",
"header": f"\u2717 watch: interval too long (max {MAX_INTERVAL}s)",
"preview": "",
"needs_approval": False,
"error": f"Error: maximum poll interval is {MAX_INTERVAL}s",
}
# Validate stop condition
stop_on = args.get("stop_on")
if stop_on is not None:
err = validate_condition(stop_on)
if err:
return {
"call_id": call_id,
"func_name": "watch",
"header": f"\u2717 watch: {err}",
"preview": "",
"needs_approval": False,
"error": f"Error: {err}",
}
# Check max watches limit and duplicate names
storage = get_storage()
existing: list[dict[str, Any]] = []
if storage:
existing = storage.list_watches_for_ws(self._ws_id)
if len(existing) >= MAX_WATCHES_PER_WS:
return {
"call_id": call_id,
"func_name": "watch",
"header": f"\u2717 watch: limit reached ({MAX_WATCHES_PER_WS})",
"preview": "",
"needs_approval": False,
"error": f"Error: maximum {MAX_WATCHES_PER_WS} active watches per workstream",
}
name = args.get("name", "")
if not name:
name = f"watch-{uuid.uuid4().hex[:4]}"
elif storage and any(w["name"] == name for w in existing):
return {
"call_id": call_id,
"func_name": "watch",
"header": f'\u2717 watch: name "{name}" already in use',
"preview": "",
"needs_approval": False,
"error": f'Error: a watch named "{name}" already exists in this workstream',
}
max_polls = args.get("max_polls", 100)
try:
max_polls = int(max_polls)
except (ValueError, TypeError):
max_polls = 100
display_cmd = command.split("\n")[0]
condition_display = f", stop_on={stop_on}" if stop_on else ", on change"
return {
"call_id": call_id,
"func_name": "watch",
"header": f'\u23f1 watch: "{name}" every {poll_every_str}',
"preview": f" {display_cmd}{condition_display}",
"needs_approval": True,
"approval_label": "watch",
"execute": self._exec_watch,
"action": "create",
"command": command,
"interval_secs": interval_secs,
"stop_on": stop_on,
"watch_name": name,
"max_polls": max_polls,
}
def _exec_watch(self, item: dict[str, Any]) -> tuple[str, str]:
from datetime import UTC, datetime, timedelta
call_id = item["call_id"]
action = item["action"]
storage = get_storage()
if action == "list":
if not storage:
msg = "No watches (storage unavailable)"
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
watches = storage.list_watches_for_ws(self._ws_id)
if not watches:
msg = "No active watches."
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
from turnstone.core.watch import format_interval
lines = []
for w in watches:
condition = w.get("stop_on") or "on change"
lines.append(
f" {w['name']} ({w['watch_id'][:8]}): "
f"every {format_interval(w['interval_secs'])}, "
f"poll #{w['poll_count']}/{w['max_polls']}, "
f"condition: {condition}, "
f"cmd: {w['command'][:60]}"
)
msg = "Active watches:\n" + "\n".join(lines)
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
if action == "cancel":
name = item.get("watch_name", "")
if not storage:
msg = "Error: storage unavailable"
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
watches = storage.list_watches_for_ws(self._ws_id)
target = None
for w in watches:
if w["name"] == name or w["watch_id"].startswith(name):
target = w
break
if target is None:
msg = f'Watch "{name}" not found.'
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
storage.update_watch(target["watch_id"], active=False, next_poll="")
msg = f'Watch "{target["name"]}" cancelled.'
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
# action == "create"
if not storage:
msg = "Error: storage unavailable"
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
watch_id = uuid.uuid4().hex
now = datetime.now(UTC)
next_poll = now + timedelta(seconds=item["interval_secs"])
storage.create_watch(
watch_id=watch_id,
ws_id=self._ws_id,
node_id=self._node_id or "",
name=item["watch_name"],
command=item["command"],
interval_secs=item["interval_secs"],
stop_on=item.get("stop_on"),
max_polls=item["max_polls"],
created_by="model",
next_poll=next_poll.strftime("%Y-%m-%dT%H:%M:%S"),
)
from turnstone.core.watch import format_interval
stop_desc = f"stop_on: {item['stop_on']}" if item.get("stop_on") else "on output change"
msg = (
f'Watch "{item["watch_name"]}" created.\n'
f" Polling every {format_interval(item['interval_secs'])}, "
f"max {item['max_polls']} polls\n"
f" Command: {item['command']}\n"
f" Condition: {stop_desc}"
)
self.ui.on_tool_result(call_id, "watch", msg)
return call_id, msg
_MAX_WATCH_CHAIN = 5 # max consecutive watch dispatches per worker thread
def _dispatch_pending_watch(self, depth: int = 0) -> None:
"""Dispatch one pending watch result as a new send() turn.
Each ``send()`` chains back here on IDLE, so multiple queued results
are processed sequentially. Depth is capped to prevent unbounded
stack growth.
"""
if depth >= self._MAX_WATCH_CHAIN:
self._watch_dispatch_depth = 0
return
try:
result = self._watch_pending.get_nowait()
except queue.Empty:
self._watch_dispatch_depth = 0 # chain ended — reset for next user turn
return
message = result.get("message", "")
if message:
self._watch_dispatch_depth = depth + 1
self.send(message)
def _exec_write_file(self, item: dict[str, Any]) -> tuple[str, str]:
"""Write content to a file, creating parent directories as needed."""
call_id = item["call_id"]
+711
View File
@@ -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))
@@ -1011,6 +1036,139 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount
# -- Watches ---------------------------------------------------------------
def create_watch(
self,
watch_id: str,
ws_id: str,
node_id: str,
name: str,
command: str,
interval_secs: float,
stop_on: str | None,
max_polls: int,
created_by: str,
next_poll: str,
) -> None:
from sqlalchemy.dialects import postgresql
from turnstone.core.storage._schema import watches
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
postgresql.insert(watches)
.values(
watch_id=watch_id,
ws_id=ws_id,
node_id=node_id,
name=name,
command=command,
interval_secs=interval_secs,
stop_on=stop_on,
max_polls=max_polls,
poll_count=0,
active=1,
created_by=created_by,
next_poll=next_poll,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_watch(self, watch_id: str) -> dict[str, Any] | None:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
row = conn.execute(sa.select(watches).where(watches.c.watch_id == watch_id)).fetchone()
if row is None:
return None
return dict(row._mapping)
def list_watches_for_ws(self, ws_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(watches)
.where((watches.c.ws_id == ws_id) & (watches.c.active == 1))
.order_by(watches.c.created.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(watches)
.where((watches.c.node_id == node_id) & (watches.c.active == 1))
.order_by(watches.c.created.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
def list_due_watches(self, now: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(watches)
.where(
(watches.c.active == 1)
& (watches.c.next_poll <= now)
& (watches.c.next_poll != "")
)
.order_by(watches.c.next_poll)
.limit(100)
).fetchall()
return [dict(r._mapping) for r in rows]
_UPDATABLE_WATCH_FIELDS = frozenset(
{
"name",
"poll_count",
"last_output",
"last_exit_code",
"last_poll",
"next_poll",
"active",
"updated",
}
)
def update_watch(self, watch_id: str, **fields: Any) -> bool:
from turnstone.core.storage._schema import watches
fields = {k: v for k, v in fields.items() if k in self._UPDATABLE_WATCH_FIELDS}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "active" in fields:
fields["active"] = 1 if fields["active"] else 0
with self._engine.connect() as conn:
result = conn.execute(
sa.update(watches).where(watches.c.watch_id == watch_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_watch(self, watch_id: str) -> bool:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
result = conn.execute(sa.delete(watches).where(watches.c.watch_id == watch_id))
conn.commit()
return result.rowcount > 0
def delete_watches_for_ws(self, ws_id: str) -> int:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
result = conn.execute(sa.delete(watches).where(watches.c.ws_id == ws_id))
conn.commit()
return result.rowcount
# -- Service registry ------------------------------------------------------
def register_service(
@@ -1083,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:
+250
View File
@@ -293,6 +293,52 @@ class StorageBackend(Protocol):
"""Delete task runs older than retention_days. Returns count deleted."""
...
# -- Watches ---------------------------------------------------------------
def create_watch(
self,
watch_id: str,
ws_id: str,
node_id: str,
name: str,
command: str,
interval_secs: float,
stop_on: str | None,
max_polls: int,
created_by: str,
next_poll: str,
) -> None:
"""Create a watch. No-op if watch_id already exists."""
...
def get_watch(self, watch_id: str) -> dict[str, Any] | None:
"""Return watch dict or None."""
...
def list_watches_for_ws(self, ws_id: str) -> list[dict[str, Any]]:
"""Return active watches for a workstream, ordered by created DESC."""
...
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
"""Return all active watches on a node, ordered by created DESC."""
...
def list_due_watches(self, now: str) -> list[dict[str, Any]]:
"""Return active watches whose next_poll <= now, ordered by next_poll."""
...
def update_watch(self, watch_id: str, **fields: Any) -> bool:
"""Update specified fields on a watch. Returns True if found."""
...
def delete_watch(self, watch_id: str) -> bool:
"""Delete a watch. Returns True if found."""
...
def delete_watches_for_ws(self, ws_id: str) -> int:
"""Delete all watches for a workstream. Returns count deleted."""
...
# -- Service registry ------------------------------------------------------
def register_service(
@@ -313,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:
+146
View File
@@ -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),
)
@@ -170,6 +171,40 @@ sa.Index("idx_scheduled_task_runs_started", scheduled_task_runs.c.started)
# Service registry
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Watches — in-session periodic command polling
# ---------------------------------------------------------------------------
watches = sa.Table(
"watches",
metadata,
sa.Column("watch_id", sa.Text, primary_key=True),
sa.Column("ws_id", sa.Text, nullable=False),
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
sa.Column("name", sa.Text, nullable=False),
sa.Column("command", sa.Text, nullable=False),
sa.Column("interval_secs", sa.Float, nullable=False),
sa.Column("stop_on", sa.Text), # Python expression, NULL = change detection
sa.Column("max_polls", sa.Integer, nullable=False, server_default="100"),
sa.Column("poll_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("last_output", sa.Text),
sa.Column("last_exit_code", sa.Integer),
sa.Column("last_poll", sa.Text), # ISO8601
sa.Column("next_poll", sa.Text), # ISO8601
sa.Column("active", 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_watches_active_next", watches.c.active, watches.c.next_poll)
sa.Index("idx_watches_ws_id", watches.c.ws_id)
sa.Index("idx_watches_node_id", watches.c.node_id)
# ---------------------------------------------------------------------------
# Service registry
# ---------------------------------------------------------------------------
services = sa.Table(
"services",
metadata,
@@ -183,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)
+694
View File
@@ -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))
@@ -1062,6 +1087,136 @@ class SQLiteBackend:
conn.commit()
return result.rowcount
# -- Watches ---------------------------------------------------------------
def create_watch(
self,
watch_id: str,
ws_id: str,
node_id: str,
name: str,
command: str,
interval_secs: float,
stop_on: str | None,
max_polls: int,
created_by: str,
next_poll: str,
) -> None:
from turnstone.core.storage._schema import watches
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(watches).prefix_with("OR IGNORE"),
{
"watch_id": watch_id,
"ws_id": ws_id,
"node_id": node_id,
"name": name,
"command": command,
"interval_secs": interval_secs,
"stop_on": stop_on,
"max_polls": max_polls,
"poll_count": 0,
"active": 1,
"created_by": created_by,
"next_poll": next_poll,
"created": now,
"updated": now,
},
)
conn.commit()
def get_watch(self, watch_id: str) -> dict[str, Any] | None:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
row = conn.execute(sa.select(watches).where(watches.c.watch_id == watch_id)).fetchone()
if row is None:
return None
return dict(row._mapping)
def list_watches_for_ws(self, ws_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(watches)
.where((watches.c.ws_id == ws_id) & (watches.c.active == 1))
.order_by(watches.c.created.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(watches)
.where((watches.c.node_id == node_id) & (watches.c.active == 1))
.order_by(watches.c.created.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
def list_due_watches(self, now: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(watches)
.where(
(watches.c.active == 1)
& (watches.c.next_poll <= now)
& (watches.c.next_poll != "")
)
.order_by(watches.c.next_poll)
.limit(100)
).fetchall()
return [dict(r._mapping) for r in rows]
_UPDATABLE_WATCH_FIELDS = frozenset(
{
"name",
"poll_count",
"last_output",
"last_exit_code",
"last_poll",
"next_poll",
"active",
"updated",
}
)
def update_watch(self, watch_id: str, **fields: Any) -> bool:
from turnstone.core.storage._schema import watches
fields = {k: v for k, v in fields.items() if k in self._UPDATABLE_WATCH_FIELDS}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "active" in fields:
fields["active"] = 1 if fields["active"] else 0
with self._engine.connect() as conn:
result = conn.execute(
sa.update(watches).where(watches.c.watch_id == watch_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_watch(self, watch_id: str) -> bool:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
result = conn.execute(sa.delete(watches).where(watches.c.watch_id == watch_id))
conn.commit()
return result.rowcount > 0
def delete_watches_for_ws(self, ws_id: str) -> int:
from turnstone.core.storage._schema import watches
with self._engine.connect() as conn:
result = conn.execute(sa.delete(watches).where(watches.c.ws_id == ws_id))
conn.commit()
return result.rowcount
# -- Service registry ------------------------------------------------------
def register_service(
@@ -1134,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,47 @@
"""Watches table for in-session periodic command polling.
Revision ID: 007
Revises: 006
Create Date: 2026-03-09
"""
import sqlalchemy as sa
from alembic import op
revision = "007"
down_revision = "006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"watches",
sa.Column("watch_id", sa.Text, primary_key=True),
sa.Column("ws_id", sa.Text, nullable=False),
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
sa.Column("name", sa.Text, nullable=False),
sa.Column("command", sa.Text, nullable=False),
sa.Column("interval_secs", sa.Float, nullable=False),
sa.Column("stop_on", sa.Text),
sa.Column("max_polls", sa.Integer, nullable=False, server_default="100"),
sa.Column("poll_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("last_output", sa.Text),
sa.Column("last_exit_code", sa.Integer),
sa.Column("last_poll", sa.Text),
sa.Column("next_poll", sa.Text),
sa.Column("active", 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_watches_active_next", "watches", ["active", "next_poll"])
op.create_index("idx_watches_ws_id", "watches", ["ws_id"])
op.create_index("idx_watches_node_id", "watches", ["node_id"])
def downgrade() -> None:
op.drop_index("idx_watches_node_id", "watches")
op.drop_index("idx_watches_ws_id", "watches")
op.drop_index("idx_watches_active_next", "watches")
op.drop_table("watches")
@@ -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")
+442
View File
@@ -0,0 +1,442 @@
"""Watch — periodic command polling within a workstream.
A watch periodically runs a shell command and injects results back into the
conversation when a stop condition is met or the output changes. The
``WatchRunner`` is a server-level daemon thread that polls the database for
due watches, runs their commands, and dispatches results.
"""
from __future__ import annotations
import contextlib
import json
import logging
import re
import subprocess
import threading
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from turnstone.core.safety import is_command_blocked, sanitize_command
if TYPE_CHECKING:
from collections.abc import Callable
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MAX_WATCHES_PER_WS = 5
MIN_INTERVAL = 10 # seconds
MAX_INTERVAL = 86_400 # 24 hours
DEFAULT_MAX_POLLS = 100
DEFAULT_INTERVAL = 300 # 5 minutes
MAX_OUTPUT_SIZE = 65_536 # truncate stored/dispatched output at 64 KB
# Safe builtins exposed to condition expressions.
_SAFE_BUILTINS: dict[str, Any] = {
"len": len,
"str": str,
"int": int,
"float": float,
"bool": bool,
"abs": abs,
"min": min,
"max": max,
"any": any,
"all": all,
"isinstance": isinstance,
"sorted": sorted,
"True": True,
"False": False,
"None": None,
}
# ---------------------------------------------------------------------------
# Duration parsing
# ---------------------------------------------------------------------------
_DURATION_RE = re.compile(r"(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?$", re.IGNORECASE)
def parse_duration(s: str) -> float:
"""Convert a duration string to seconds.
Supported formats: ``"30s"``, ``"5m"``, ``"1h"``, ``"2h30m"``,
``"90"`` (bare number = seconds).
Raises ``ValueError`` on invalid input.
"""
s = s.strip()
if not s:
raise ValueError("empty duration string")
# Bare number → seconds
try:
val = float(s)
except ValueError:
val = None
if val is not None:
if val <= 0:
raise ValueError(f"duration must be positive, got {val}")
return val
m = _DURATION_RE.match(s)
if not m or not any(m.groups()):
raise ValueError(f"invalid duration format: {s!r}")
hours = int(m.group(1) or 0)
minutes = int(m.group(2) or 0)
seconds = int(m.group(3) or 0)
total = hours * 3600 + minutes * 60 + seconds
if total <= 0:
raise ValueError(f"duration must be positive, got {total}s")
return float(total)
# ---------------------------------------------------------------------------
# Condition evaluation
# ---------------------------------------------------------------------------
def validate_condition(expr: str) -> str | None:
"""Syntax-check a condition expression.
Returns an error message string, or ``None`` if the expression is valid.
"""
try:
compile(expr, "<watch>", "eval")
except SyntaxError as exc:
return f"invalid condition syntax: {exc}"
return None
def evaluate_condition(
expr: str | None,
output: str,
exit_code: int,
prev_output: str | None,
) -> tuple[bool, str]:
"""Evaluate a stop condition.
Returns ``(fired, reason)`` where *fired* is ``True`` when the watch
should report a result and *reason* is a human-readable explanation.
"""
changed = output != prev_output
if expr is None:
# Default: fire on any change (skip first poll where prev is None)
if prev_output is None:
return False, ""
return changed, "output changed" if changed else ""
# Build data context
data: Any = None
with contextlib.suppress(json.JSONDecodeError, ValueError):
data = json.loads(output)
context = {
"output": output,
"data": data,
"exit_code": exit_code,
"prev_output": prev_output,
"changed": changed,
}
try:
result = eval(expr, {"__builtins__": _SAFE_BUILTINS}, context) # noqa: S307
if result:
return True, f"condition met: {expr}"
return False, ""
except Exception as exc:
log.warning("watch.condition_error", extra={"expr": expr, "error": str(exc)})
return False, f"condition error: {exc}"
# ---------------------------------------------------------------------------
# Message formatting
# ---------------------------------------------------------------------------
def format_watch_message(
name: str,
command: str,
output: str,
poll_count: int,
max_polls: int,
elapsed_secs: float,
stop_on: str | None,
is_final: bool,
reason: str,
) -> str:
"""Format a watch result as a synthetic user message."""
elapsed = format_interval(elapsed_secs)
lines = [f'[Watch "{name}" \u2014 poll #{poll_count}/{max_polls}, {elapsed} elapsed]']
# Show the condition so the model knows what this watch was waiting for
if stop_on:
lines.append(f"[condition: {stop_on}]")
else:
lines.append("[mode: fire on output change]")
lines.append("")
lines.append(f"$ {command}")
lines.append(output)
if is_final:
if reason:
lines.append("")
lines.append(f"[{reason} \u2014 watch auto-cancelled]")
else:
lines.append("")
lines.append("[max polls reached \u2014 watch auto-cancelled]")
return "\n".join(lines)
def format_interval(secs: float) -> str:
"""Human-readable duration (e.g. ``'5m'``, ``'1h30m'``)."""
if secs < 60:
return f"{secs:.0f}s"
if secs < 3600:
return f"{secs / 60:.0f}m"
hours = int(secs // 3600)
mins = int((secs % 3600) // 60)
if mins:
return f"{hours}h{mins}m"
return f"{hours}h"
# ---------------------------------------------------------------------------
# WatchRunner — server-level daemon thread
# ---------------------------------------------------------------------------
class WatchRunner:
"""Polls the database for due watches and dispatches results.
Runs as a daemon thread in the server process, analogous to
``TaskScheduler`` in the console.
"""
def __init__(
self,
storage: Any,
node_id: str,
*,
check_interval: float = 15.0,
tool_timeout: float = 30.0,
restore_fn: Callable[[str], Callable[[str], None] | None] | None = None,
) -> None:
self._storage = storage
self._node_id = node_id
self._check_interval = check_interval
self._tool_timeout = tool_timeout
self._restore_fn = restore_fn
self._dispatch_fns: dict[str, Callable[[str], None]] = {}
self._dispatch_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
# -- Lifecycle -----------------------------------------------------------
def start(self) -> None:
if self._thread is not None:
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, daemon=True, name="watch-runner")
self._thread.start()
log.info("watch_runner.started", extra={"node_id": self._node_id})
def stop(self) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=self._check_interval + 5)
self._thread = None
log.info("watch_runner.stopped")
# -- Dispatch function registry ------------------------------------------
def set_dispatch_fn(self, ws_id: str, fn: Callable[[str], None]) -> None:
with self._dispatch_lock:
self._dispatch_fns[ws_id] = fn
def remove_dispatch_fn(self, ws_id: str) -> None:
with self._dispatch_lock:
self._dispatch_fns.pop(ws_id, None)
# -- Main loop -----------------------------------------------------------
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._tick()
except Exception:
log.exception("watch_runner.tick_error")
self._stop_event.wait(self._check_interval)
def _tick(self) -> None:
if self._storage is None:
return
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
due = self._storage.list_due_watches(now)
for watch_row in due:
if self._stop_event.is_set():
break
# Only poll watches owned by this node
row_node = watch_row.get("node_id", "")
if row_node and row_node != self._node_id:
continue
try:
self._poll_watch(watch_row)
except Exception:
log.exception(
"watch_runner.poll_error",
extra={"watch_id": watch_row.get("watch_id")},
)
def _poll_watch(self, watch_row: dict[str, Any]) -> None:
watch_id = watch_row["watch_id"]
ws_id = watch_row["ws_id"]
command = watch_row["command"]
stop_on = watch_row.get("stop_on")
max_polls = watch_row.get("max_polls", DEFAULT_MAX_POLLS)
poll_count = watch_row.get("poll_count", 0) + 1
prev_output = watch_row.get("last_output")
created = watch_row.get("created", "")
# Safety check
blocked = is_command_blocked(command)
if blocked:
log.warning(
"watch_runner.blocked_command", extra={"watch_id": watch_id, "reason": blocked}
)
self._deactivate_watch(watch_id)
return
# Run command
output, exit_code = self._run_command(sanitize_command(command))
# Truncate to avoid unbounded storage / context window usage
if len(output) > MAX_OUTPUT_SIZE:
output = output[:MAX_OUTPUT_SIZE] + f"\n[truncated at {MAX_OUTPUT_SIZE} bytes]"
# Evaluate condition
fired, reason = evaluate_condition(stop_on, output, exit_code, prev_output)
# Treat condition evaluation errors as terminal — don't silently
# loop until max_polls while the user/model never sees the problem.
if not fired and reason.startswith("condition error:"):
fired = True
# Check max polls
is_final = fired or poll_count >= max_polls
if not fired and poll_count >= max_polls:
reason = "max polls reached"
is_final = True
now = datetime.now(UTC)
now_str = now.strftime("%Y-%m-%dT%H:%M:%S")
# Update DB
update_fields: dict[str, Any] = {
"poll_count": poll_count,
"last_output": output,
"last_exit_code": exit_code,
"last_poll": now_str,
}
if is_final:
update_fields["active"] = False
update_fields["next_poll"] = ""
else:
next_poll = now + timedelta(seconds=watch_row["interval_secs"])
update_fields["next_poll"] = next_poll.strftime("%Y-%m-%dT%H:%M:%S")
self._storage.update_watch(watch_id, **update_fields)
# Dispatch result if condition fired or final
if fired or is_final:
# Compute elapsed from created time
elapsed_secs = 0.0
if created:
try:
created_dt = datetime.fromisoformat(created).replace(tzinfo=UTC)
elapsed_secs = (now - created_dt).total_seconds()
except (ValueError, TypeError):
pass
message = format_watch_message(
name=watch_row["name"],
command=command,
output=output,
poll_count=poll_count,
max_polls=max_polls,
elapsed_secs=elapsed_secs,
stop_on=stop_on,
is_final=is_final,
reason=reason,
)
self._dispatch_result(ws_id, message)
log.debug(
"watch_runner.polled",
extra={
"watch_id": watch_id,
"poll_count": poll_count,
"fired": fired,
"is_final": is_final,
},
)
def _run_command(self, command: str) -> tuple[str, int]:
"""Run a shell command and return (stdout, exit_code)."""
try:
proc = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=self._tool_timeout,
start_new_session=True,
)
output = proc.stdout
if proc.stderr:
output = output + "\n[stderr]\n" + proc.stderr if output else proc.stderr
return output, proc.returncode
except subprocess.TimeoutExpired:
return f"[command timed out after {self._tool_timeout}s]", -1
except Exception as exc:
return f"[command failed: {exc}]", -1
def _dispatch_result(self, ws_id: str, message: str) -> None:
"""Deliver a watch result to the owning workstream."""
with self._dispatch_lock:
fn = self._dispatch_fns.get(ws_id)
if fn is not None:
try:
fn(message)
return
except Exception:
log.exception("watch_runner.dispatch_error", extra={"ws_id": ws_id})
# Workstream may be evicted — try to restore
if self._restore_fn is not None:
try:
restored_fn = self._restore_fn(ws_id)
if restored_fn is not None:
restored_fn(message)
return
except Exception:
log.exception("watch_runner.restore_error", extra={"ws_id": ws_id})
log.warning(
"watch_runner.dispatch_failed",
extra={"ws_id": ws_id, "reason": "no dispatch function and restore failed"},
)
def _deactivate_watch(self, watch_id: str) -> None:
self._storage.update_watch(watch_id, active=False, next_poll="")
+15
View File
@@ -213,6 +213,21 @@ class WorkstreamManager:
ws.ui._plan_event.set()
if hasattr(ws.ui, "_fg_event"):
ws.ui._fg_event.set()
# Notify SSE listeners so generators exit promptly
if hasattr(ws.ui, "_listeners_lock"):
import contextlib
import queue as _queue
with ws.ui._listeners_lock:
for lq in ws.ui._listeners: # type: ignore[attr-defined]
try:
lq.put_nowait({"type": "ws_closed"})
except _queue.Full:
with contextlib.suppress(_queue.Empty):
lq.get_nowait()
with contextlib.suppress(_queue.Full):
lq.put_nowait({"type": "ws_closed"})
ws.ui._listeners.clear() # type: ignore[attr-defined]
# Release MCP listener registration
if ws.session and hasattr(ws.session, "close"):
ws.session.close()
+726 -91
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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", "")
+9
View File
@@ -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,
]
}
+342
View File
@@ -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:
+6
View File
@@ -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,
+11
View File
@@ -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]:
+254 -16
View File
@@ -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,10 +80,11 @@ 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._event_queue: queue.Queue[dict[str, Any]] = queue.Queue()
self._sse_generation = 0 # incremented on each new SSE connection
self._user_id = user_id
self._listeners: list[queue.Queue[dict[str, Any]]] = []
self._listeners_lock = threading.Lock()
self._approval_event = threading.Event()
self._approval_result: tuple[bool, str | None] = (False, None)
self._pending_approval: dict[str, Any] | None = None # re-sent on SSE reconnect
@@ -102,7 +103,23 @@ class WebUI:
self._ws_activity_state: str = "" # "tool" | "approval" | "thinking" | ""
def _enqueue(self, data: dict[str, Any]) -> None:
self._event_queue.put(data)
with self._listeners_lock:
snapshot = list(self._listeners)
for lq in snapshot:
with contextlib.suppress(queue.Full):
lq.put_nowait(data)
def _register_listener(self) -> queue.Queue[dict[str, Any]]:
"""Create a per-client queue and register it as a listener."""
client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=500)
with self._listeners_lock:
self._listeners.append(client_queue)
return client_queue
def _unregister_listener(self, client_queue: queue.Queue[dict[str, Any]]) -> None:
"""Remove a client queue from the listeners list."""
with self._listeners_lock, contextlib.suppress(ValueError):
self._listeners.remove(client_queue)
def _broadcast_state(self, state: str) -> None:
"""Send a state-change event to the global SSE channel."""
@@ -182,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 {}
@@ -242,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",
@@ -253,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()
@@ -473,14 +560,8 @@ async def events_sse(request: Request) -> Response:
if not ws or not ui:
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
# Drain stale events so this client starts fresh. A race with the
# worker thread is acceptable: worst case we discard one fresh event,
# and the client catches up via the history replay below.
while not ui._event_queue.empty():
try:
ui._event_queue.get_nowait()
except queue.Empty:
break
# Each client gets its own queue — no drain needed.
client_queue = ui._register_listener()
async def event_generator() -> AsyncGenerator[dict[str, str], None]:
assert ws.session is not None
@@ -510,13 +591,16 @@ async def events_sse(request: Request) -> Response:
while True:
try:
event = await loop.run_in_executor(
None, functools.partial(ui._event_queue.get, timeout=5)
None, functools.partial(client_queue.get, timeout=5)
)
if event.get("type") == "ws_closed":
return
yield {"data": json.dumps(event)}
except queue.Empty:
pass
finally:
_metrics.record_sse_disconnect()
ui._unregister_listener(client_queue)
return EventSourceResponse(event_generator(), ping=5)
@@ -699,6 +783,35 @@ async def metrics_endpoint(request: Request) -> Response:
return Response(content, media_type="text/plain; version=0.0.4; charset=utf-8")
def _make_watch_dispatch(ws: Workstream, session: ChatSession, ui: Any) -> Any:
"""Create a dispatch function for watch results on a workstream.
Handles both idle (start worker thread) and busy (enqueue for IDLE drain)
cases. Mirrors the ``send_message`` worker-thread pattern.
"""
pending = session._watch_pending
def dispatch(msg: str) -> None:
if ws.worker_thread and ws.worker_thread.is_alive():
# Workstream is busy — queue for drain at IDLE (Path A)
pending.put({"message": msg})
return
# Workstream is idle — start a worker thread (Path B)
def run() -> None:
try:
session.send(msg)
except Exception as exc:
if ui:
ui.on_error(f"Watch error: {exc}")
t = threading.Thread(target=run, daemon=True)
ws.worker_thread = t
t.start()
return dispatch
async def send_message(request: Request) -> JSONResponse:
"""POST /v1/api/send — send a user message to the workstream."""
from turnstone.core.web_helpers import read_json_or_400
@@ -729,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"})
@@ -781,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
@@ -835,15 +979,23 @@ 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)
if skip or body.get("auto_approve", False):
ws.ui.auto_approve = True
# Register watch runner for this workstream
runner = getattr(request.app.state, "watch_runner", None)
if runner and ws.session:
ws.session.set_watch_runner(
runner, dispatch_fn=_make_watch_dispatch(ws, ws.session, ws.ui)
)
# Emit eviction event if a workstream was evicted to make room
evicted = mgr.last_evicted
if evicted is not None:
@@ -902,6 +1054,42 @@ async def close_workstream(request: Request) -> JSONResponse:
return JSONResponse({"error": "Cannot close last workstream"}, status_code=400)
async def list_watches(request: Request) -> JSONResponse:
"""GET /v1/api/watches — list active watches, optionally filtered by ws_id."""
from turnstone.core.storage._registry import get_storage
storage = get_storage()
if not storage:
return JSONResponse({"watches": []})
ws_id = request.query_params.get("ws_id")
if ws_id:
watches = storage.list_watches_for_ws(ws_id)
else:
node_id = getattr(request.app.state, "node_id", "")
watches = storage.list_watches_for_node(node_id) if node_id else []
return JSONResponse({"watches": watches})
async def cancel_watch(request: Request) -> JSONResponse:
"""POST /v1/api/watches/{watch_id}/cancel — cancel an active watch."""
from turnstone.core.storage._registry import get_storage
watch_id = request.path_params["watch_id"]
storage = get_storage()
if not storage:
return JSONResponse({"error": "Storage unavailable"}, status_code=500)
watch = storage.get_watch(watch_id)
if not watch:
return JSONResponse({"error": "Watch not found"}, status_code=404)
# Verify node ownership in multi-node deployments
node_id = getattr(request.app.state, "node_id", "")
watch_node = watch.get("node_id", "")
if watch_node and node_id and watch_node != node_id:
return JSONResponse({"error": "Watch belongs to another node"}, status_code=403)
storage.update_watch(watch_id, active=False, next_poll="")
return JSONResponse({"status": "ok", "watch_id": watch_id})
async def auth_login(request: Request) -> Response:
"""POST /v1/api/auth/login — authenticate and return JWT."""
from turnstone.core.auth import handle_auth_login
@@ -1003,8 +1191,13 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
daemon=True,
)
cleanup.start()
# Start watch runner (periodic command polling)
if app.state.watch_runner:
app.state.watch_runner.start()
yield
# Shutdown
if app.state.watch_runner:
app.state.watch_runner.stop()
if app.state.health_monitor:
app.state.health_monitor.stop()
if app.state.mcp_client:
@@ -1054,6 +1247,7 @@ def create_app(
idle_timeout: int = 0,
node_id: str = "",
cors_origins: list[str] | None = None,
watch_runner: Any = None,
) -> Starlette:
"""Create and configure the Starlette ASGI application."""
_spec = build_server_spec()
@@ -1075,8 +1269,11 @@ 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),
Route("/api/watches/{watch_id}/cancel", cancel_watch, methods=["POST"]),
Route("/api/auth/login", auth_login, methods=["POST"]),
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Route("/api/auth/status", auth_status),
@@ -1107,6 +1304,7 @@ def create_app(
app.state.registry = registry
app.state.idle_timeout = idle_timeout
app.state.node_id = node_id
app.state.watch_runner = watch_runner
from turnstone.core.auth import LoginRateLimiter
@@ -1492,11 +1690,47 @@ def main() -> None:
tool_search_max_results=args.tool_search_max_results,
)
# Create workstream manager and initial workstream
# Create WatchRunner (periodic command polling, server-level)
from turnstone.core.storage import get_storage as _get_storage
from turnstone.core.watch import WatchRunner
# Create workstream manager first (watch restore_fn captures it)
manager = WorkstreamManager(
session_factory, max_workstreams=args.max_workstreams, node_id=_node_id
)
WebUI._workstream_mgr = manager
def _watch_restore_fn(ws_id: str) -> Any:
"""Restore an evicted workstream so a watch can deliver results.
Returns a callable that starts a worker thread to send() the watch
result. Unlike the normal dispatch path (which enqueues for IDLE
drain), the restored workstream has no active send() loop, so we
must start a worker thread directly same pattern as send_message().
"""
try:
ws = manager.create(
ui_factory=lambda wid: WebUI(ws_id=wid),
)
# Restored workstreams run unattended — auto-approve tool calls
# to avoid blocking forever on approval with no connected user.
if isinstance(ws.ui, WebUI):
ws.ui.auto_approve = True
if ws.session:
ws.session.resume(ws_id)
dispatch_fn = _make_watch_dispatch(ws, ws.session, ws.ui)
ws.session.set_watch_runner(_watch_runner, dispatch_fn=dispatch_fn)
return dispatch_fn
except RuntimeError:
log.warning("watch_restore: cannot restore ws %s (all slots active)", ws_id)
return None
_watch_runner = WatchRunner(
storage=_get_storage(),
node_id=_node_id,
tool_timeout=args.tool_timeout,
restore_fn=_watch_restore_fn,
)
ws = manager.create(
name="default",
ui_factory=lambda wid: WebUI(ws_id=wid),
@@ -1507,6 +1741,9 @@ def main() -> None:
# Handle --resume
assert ws.session is not None
ws.session.set_watch_runner(
_watch_runner, dispatch_fn=_make_watch_dispatch(ws, ws.session, ws.ui)
)
if args.resume:
from turnstone.core.memory import resolve_workstream
@@ -1552,6 +1789,7 @@ def main() -> None:
idle_timeout=args.workstream_idle_timeout,
node_id=_node_id,
cors_origins=cors_origins,
watch_runner=_watch_runner,
)
log.info("Server starting on http://%s:%s", args.host, args.port)
+15 -3
View File
@@ -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();
});
+15
View File
@@ -0,0 +1,15 @@
{
"name": "create_plan",
"description": "Create a structured plan before taking action. An autonomous agent explores the available context, identifies what needs to change, and writes a step-by-step plan. Call this tool when the user asks to plan, design, or think through an approach, or when a task is complex, touches multiple areas, or has unclear scope.",
"parameters": {
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": "The goal and scope of the plan, including any constraints."
}
},
"required": ["goal"]
},
"primary_key": "goal"
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "plan",
"description": "Plan before implementing. An autonomous agent explores the codebase and writes a structured plan to .plan-<ws_id>.md (unique per workstream to avoid collisions). If a plan for this workstream already exists it is re-read and refined rather than overwritten from scratch. Use plan BEFORE writing code — when the user asks to build, add, refactor, or change something that touches multiple files or has unclear scope. The plan identifies files to modify, existing patterns to reuse, and risks to consider.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "What to plan — the goal, constraints, and scope."
}
},
"required": ["prompt"]
},
"primary_key": "prompt"
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "watch",
"description": "Set up periodic polling of a shell command within this workstream. Actions: 'create' starts a new watch, 'list' shows active watches, 'cancel' stops a watch. Watch results are injected into the conversation when the stop condition is met or output changes. Use for monitoring CI/CD, PR status, deployments, file changes, etc.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "list", "cancel"],
"description": "Action to perform."
},
"command": {
"type": "string",
"description": "Shell command to poll (required for 'create')."
},
"poll_every": {
"type": "string",
"description": "Poll interval as duration (e.g., '30s', '5m', '1h'). Default: '5m'."
},
"stop_on": {
"type": "string",
"description": "Python expression evaluated after each poll. Variables: output (str), data (parsed JSON or None), exit_code (int), prev_output (str|None), changed (bool). Truthy result fires the watch and auto-cancels. Omit for change-detection mode (first poll establishes a baseline, subsequent polls fire when output differs). Examples: 'data[\"state\"] == \"MERGED\"', '\"error\" in output', 'exit_code != 0'."
},
"name": {
"type": "string",
"description": "Human-readable name (e.g., 'pr-review'). Required for 'create', used as identifier for 'cancel'."
},
"max_polls": {
"type": "integer",
"description": "Max poll cycles before auto-cancel. Default: 100."
}
},
"required": ["action"]
},
"primary_key": "command"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web_fetch",
"description": "Fetch a URL and extract specific information from it. You must provide a question or extraction guidance — the page is fetched, analyzed, and only relevant information is returned (not raw page content).",
"description": "Fetch a URL and extract specific information from it. You must provide a question or extraction guidance. The page is fetched, analyzed, and only relevant information is returned (not raw page content).",
"parameters": {
"type": "object",
"properties": {
+47 -16
View File
@@ -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();
+2
View File
@@ -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">&#9632; 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: [
+3
View File
@@ -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