Compare commits

...

22 Commits

Author SHA1 Message Date
Patrick Buckley 57912faa52 chore: bump version to 1.2.0a4 2026-04-06 03:58:42 -07:00
Patrick Buckley 0625fac87b fix: shorten judge model dropdown default label 2026-04-06 03:57:21 -07:00
Patrick Buckley dc3a1b7a64 fix: workstream toolbar UX — relocate to tab bar, fix visibility and theme sync
- Move action buttons (refresh/edit/fork/delete) from header to tab bar,
  grouped in #ws-action-group with separators. Contextually adjacent to
  the workstream tabs they operate on.
- Toggle group visibility via CSS class (.hidden) instead of per-button
  inline style.display — makes media query overrides reliable.
- Call updateWsActionButtons() from renderTabBar() so buttons appear on
  initial load and ws_created, not just on tab switch.
- Fix theme loss between nodes: loadInterfaceSettings no longer overwrites
  localStorage with server defaults — preserves user's theme choice when
  switching nodes via console proxy.
- Add flex-shrink:0 on +/split buttons to prevent squeeze with many tabs.
2026-04-06 03:54:03 -07:00
Patrick Buckley a3140da3a5 docs: update documentation for PRs #312-#316 (#324)
- README: add Google Gemini to multi-provider feature list and requirements
- architecture.md: add GoogleProvider, update supported provider values,
  file listing, config example
- judge.md: document cancel_on_approval, fresh-client lifecycle, fallback
  delivery, Google compatibility
- settings.md: add judge.cancel_on_approval, new interface.* section
  (close_tab_action, theme), update total count
- api-reference.md: document 6 new workstream/settings endpoints,
  add judge_model to workstreams/new
- console.md: add judge model to modal fields, add keyboard shortcuts
- console_schemas.py: add judge_model field to ConsoleCreateWsRequest
- server_spec.py: add 6 new EndpointSpec entries
- diagrams: add GoogleProvider to package structure and class diagram
2026-04-06 03:43:12 -07:00
Patrick Buckley 8838bd0f8d fix: apply model.default_alias on model-reload by refreshing ConfigStore
The model-reload handler read model.default_alias from ConfigStore's
in-memory cache, which could be stale if the earlier best-effort
config-reload notification failed or hadn't arrived yet. Force a
cs.reload() from DB before reading the alias. Also publish config
changes from the console before dispatching model-reload, and
downgrade the misleading "No 'default' model alias" log to debug.
2026-04-06 03:37:35 -07:00
Patrick Buckley 7f63cd2d33 feat: add keyboard shortcuts for workstream actions (#323)
Ctrl+Shift+R  Refresh title (regenerate via LLM)
Ctrl+Shift+E  Edit title
Ctrl+Shift+F  Fork workstream
Ctrl+Shift+X  Delete workstream (X not D — avoids Chrome DevTools conflict)

Shortcuts are blocked when any modal is open (edit-title, delete-ws,
batch-delete, new-ws). Help dialog (?) updated with the new bindings.
2026-04-06 03:11:06 -07:00
Patrick Buckley 24f59a6c53 feat: add per-node metadata with auto-collection, admin API, and cons… (#318)
* feat: add per-node metadata with auto-collection, admin API, and console UI

Adds a normalized node_metadata table for structured per-node key/value
metadata with source tracking (auto/user/config).  Auto-populated fields
(hostname, OS, arch, interfaces, cpu_count) are collected at server startup
via stdlib; user-defined fields are managed through the admin API, CLI, or
config.toml [metadata] section.

Storage: migration 035, 7 new protocol methods (get, get_all, set,
set_bulk, delete, delete_by_source, filter), both SQLite and PostgreSQL
backends.  Filtering uses single-query GROUP BY/HAVING for efficiency.

Console API: GET/PUT/DELETE endpoints under /admin/nodes/{node_id}/metadata
with auto-source protection.  cluster_nodes gains meta.* query param
filtering; cluster_node_detail attaches metadata to responses.

Frontend: new Nodes admin tab with collapsible per-node sections, inline
add form, delete with confirmation.  Read-only metadata panel in node
detail drill-down.  Proper design token usage, accessibility (ARIA,
keyboard nav, screen reader labels), and mobile responsiveness.

CLI: turnstone-admin list-node-metadata, set-node-metadata, and
delete-node-metadata subcommands.

64 tests (25 storage, 19 node_info, 20 existing unaffected).

* fix: resolve CI typecheck and test failures

- Fix mypy error: use %-style format string instead of structlog kwargs
  for standard Logger.warning() in console server
- Fix test_get_nodes assertion to include new node_ids=None parameter
- Add debug logging to _collect_interfaces empty except block

* fix: address Copilot review feedback on node metadata

- Clear stale auto/config metadata before upserting on startup
- Wrap metadata filter in try/except with graceful fallback
- Add metadata field to NodeDetailResponse schema
- Use _VALID_NODE_ID regex for consistent node_id validation
- Defensive JSON decode in admin_get_node_metadata
- Switch to read_json_or_400 and require_storage_or_503 helpers
- Add SetNodeMetadataValueRequest for single-key PUT endpoint
- Add bulk GET /admin/node-metadata endpoint (replaces N+1 fetches)
- Update frontend to use single bulk metadata fetch

* feat: add admin.nodes permission scope for node metadata

- Add admin.nodes to builtin-admin role via migration 035
- Switch all node metadata handlers from admin.settings to admin.nodes
- Register admin.nodes in the admin panel permission set
- Node detail metadata panel fetches from cluster endpoint (no admin
  permission needed) instead of admin endpoint

* fix: address second round of Copilot feedback

- Replace inline onclick handlers with data-* attributes and event
  delegation to prevent JS string context XSS
- Move NodeMetadataEntry before NodeDetailResponse and use it as the
  typed metadata field (was list[dict[str, Any]])
- Clean up config metadata on shutdown (was only cleaning auto)
2026-04-06 03:08:19 -07:00
Patrick Buckley 5cbc4bc87c feat: bulk message insert for fork performance + endpoint tests (#322)
Add save_messages_bulk() to StorageBackend protocol and both backends.
Fork path now inserts all messages in a single transaction instead of
N individual save_message() calls — for a 200-message workstream this
goes from 200 connection/insert/commit cycles to 1.

FTS5 indexing is intentionally skipped for bulk fork data (historical
messages indexed on rebuild). Ordering preserved via auto-increment id
with a shared timestamp across all rows in the batch.

Also adds 22 endpoint tests covering the 6 new workstream management
endpoints (delete, open, title, refresh-title, list/update interface
settings) and 4 storage-level tests for the bulk insert path.
2026-04-06 02:54:34 -07:00
renovate[bot] eba2f29cd1 chore(deps): lock file maintenance (#320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 02:40:55 -07:00
Patrick Buckley 66c856eb6e fix: post-merge follow-ups for PRs #312-#316 (#319)
Security:
- Add write scope rules for 4 new workstream POST endpoints
  (delete, open, refresh-title, title) in required_scope() —
  both direct and console-proxied paths

Judge:
- Restore cancel_event check in inner poll loop (was removed)
- Fix fallback delivery off-by-one: items[idx+1:] not items[idx:]
- Skip empty-response retry when finish_reason=="length"
- Reset empty_retries counter after non-empty response
- Document per-turn timeout semantics in JudgeConfig

Google provider:
- Add default base_url for Gemini endpoint in create_client()
- Bump max_output_tokens 8192→65536, set token_param="max_tokens"
- Add api_key detection for googleapis.com in console detect
- Add provider badge CSS (green) and openai-compatible (dim)

Theme:
- Fix POST→PUT for settings persistence (was silently 405-ing)
- Consolidate dual localStorage keys with backwards-compat read
- Lower banner z-index 9999→200, raise login overlay to 10001
- Fix undefined --bg-input, banner contrast for WCAG AA
- Add smooth theme transition with prefers-reduced-motion override
- Console onThemeChange: add title + aria-label updates

Workstream backend:
- Restore close_workstream 400 for last-ws case (was changed to 404)
- Thread-safe _llm_verdicts via _ws_lock on all mutation sites
- Fork: persist tool_calls + provider_data in save_message
- Add get_workstream_metadata to StorageBackend protocol
- Add ChatSession.request_title_refresh() public API
- Use cs.stored_keys() instead of cs._cache
- Redact exception text in delete 500 response
- web_helpers: catch-all logs and returns 500 not 400
- Live-stream ws_created SSE includes title field

Workstream UI:
- Focus traps + Escape on edit-title and delete-ws modals
- Tab close aria-label, mobile breakpoint for action buttons
- Restore name priority (live SSE over stale API)
- Fix double-delete, fork button text, batch delete handler leak
- Optimistic title update, close-last-tab error toast
- ws_id badge show-on-hover, hover states, aria-live, emoji a11y

Console admin:
- Banner aria-labels, judge dropdown wording, detect button class
- New-ws modal Escape handler, provider defaults cross-reference
2026-04-06 02:23:00 -07:00
Patrick Buckley 40a560b39c Merge pull request #316 from sillyWillieBilly/feat/console-enhancements
feat(console): theme-aware banner, judge model support, Google provider in admin
2026-04-06 00:56:39 -07:00
Patrick Buckley bc945852f7 Merge pull request #315 from sillyWillieBilly/feat/ui-enhancements
feat: UI enhancements — workstream management, title editing, fork, delete, theme sync
2026-04-06 00:56:36 -07:00
Patrick Buckley ca70e79d43 Merge pull request #314 from sillyWillieBilly/feat/workstream-management
feat: workstream management — fork, rename, delete, open, interface settings
2026-04-06 00:56:34 -07:00
Patrick Buckley ebcfb56f0e Merge pull request #313 from sillyWillieBilly/feat/judge-improvements
feat: harden judge with fresh-client lifecycle, fallback delivery, and Google compatibility
2026-04-06 00:56:26 -07:00
Patrick Buckley 33d29e3316 Merge pull request #312 from sillyWillieBilly/feat/google-provider
feat: add Google (Gemini) provider adapter
2026-04-06 00:56:08 -07:00
renovate[bot] bfda91cd25 chore(deps): lock file maintenance (#317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 00:19:40 -07:00
William 6fe9f75c3c feat(console): theme-aware banner, judge model support, Google provider in admin
- Replace inline-style console banner with CSS classes + light/dark theme
- Node ID in banner is now a clickable link back to the node UI
- Add judge_model parameter to create_workstream flow
- Add Google to model provider list with default URL
- Provider-specific placeholder hints in model editor
- Detect results populate model name suggestions datalist
- Theme changes in admin settings apply immediately
- Persist theme selection to server via settings API
- Use workstream title field (with name fallback) in collector SSE events
- Add judge model dropdown to new-workstream modal
2026-04-06 08:14:23 +02:00
William c093df274d feat: workstream management — fork, rename, delete, open, interface settings
Add workstream forking (resume with fork=True keeps new ws_id), custom
naming via aliases, title refresh via LLM, and workstream deletion.

New server endpoints: delete, refresh-title, set-title, open-workstream,
list/update interface settings.  Verdict caching with SSE replay on
reconnect, display name fallback (alias→title→name) across all
endpoints, judge_model override per workstream, and settings_changed
broadcast on config reload.

New settings: judge.cancel_on_approval, interface.close_tab_action,
interface.theme.  Storage backends updated with name in
list_workstreams_with_history and new get_workstream_metadata method.
2026-04-06 08:14:18 +02:00
William 49cdb3d0d3 feat: UI enhancements — workstream management, title editing, fork, delete, theme sync
Add workstream action buttons in header (refresh title, edit title, fork,
delete) with supporting modals and keyboard shortcuts.

Workstream tabs: always-visible close button, ws_id badge, configurable
close-tab-action (last_used/nearest/dashboard) via interface settings.

Dashboard: batch delete mode with multi-select, saved workstream cards
with ws_id badge, open endpoint for resuming sessions.

Judge display: late-arriving verdict toast when DOM element is gone,
worst-case verdict glow across all tool calls in approval block.

Theme: server-persisted via admin settings API, real-time sync across
clients via SSE settings_changed events.

New workstream modal: judge model dropdown for per-workstream judge
model selection.
2026-04-06 08:14:14 +02:00
William 04c62f90ff feat: harden judge with fresh-client lifecycle, fallback delivery, and Google compatibility
- Create fresh HTTP client per evaluation run to avoid stale connections
- Store client factory args instead of client instance for on-demand creation
- Add cancel_on_approval config: when True, abort remaining items on user
  approval; when False (default), run all evaluations to completion
- Always deliver LLM verdicts via callback (or fallback when LLM returns None)
- Add _deliver_fallbacks helper for cancelled/incomplete evaluations
- Skip read-only tools for Google provider (requires thought_signature)
- Flatten conversation history to plaintext transcript in _prepare_context
  to avoid multi-turn role sequence errors with strict providers like Google
- Use per-turn timeout instead of shared budget so slow turns don't starve
  later ones
- Add empty-response retry logic (up to 3 retries without consuming turns)
- Enhanced structured logging throughout judge pipeline
- Update tests to match new signatures and behavioral changes
2026-04-06 08:14:09 +02:00
William 1bbaf50214 feat: add Google (Gemini) provider adapter
Add GoogleProvider that extends OpenAIChatCompletionsProvider for
Gemini models via the OpenAI-compatible /v1beta/openai/ endpoint.

- New _google.py with 2M context window defaults and vision support
- Lazy-initialized singleton in create_provider() (thread-safe)
- Route 'google' through OpenAI SDK in create_client()
- Return empty list from list_known_models() (Google models change frequently)
2026-04-06 08:14:05 +02:00
renovate[bot] 38e49b6f9c chore(deps): lock file maintenance (#311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-05 22:10:44 -07:00
53 changed files with 4788 additions and 222 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
- **Cluster dashboard** — real-time view of all nodes and workstreams with console routing proxy
- **Intent validation** — LLM judge evaluates every tool call with risk assessments and evidence
- **Governance** — RBAC, OIDC SSO, tool policies, skills, usage tracking, audit logs
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM) and Anthropic Messages API
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), Anthropic Messages API, and Google Gemini
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
<p align="center">
@@ -132,7 +132,7 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
## Requirements
- Python 3.11+
- An OpenAI-compatible API endpoint or Anthropic API key
- An OpenAI-compatible API endpoint, Anthropic API key, or Google Gemini API key
- Optional: PostgreSQL (`pip install turnstone[postgres]`), Anthropic (`pip install turnstone[anthropic]`)
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
+156
View File
@@ -857,6 +857,7 @@ All fields are optional. The body can be empty or an empty JSON object.
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
| `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). |
| `judge_model` | string | "" | Optional model alias for the judge (overrides default judge model for this workstream) |
> **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream.
@@ -914,6 +915,161 @@ Status code: `400`
---
### `POST /v1/api/workstreams/{ws_id}/delete`
Permanently delete a saved workstream and all its messages from storage.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Response (success):** `200`
```json
{"deleted": "a1b2c3d4"}
```
**Response (not found):** `404`
```json
{"error": "Workstream not found"}
```
---
### `POST /v1/api/workstreams/{ws_id}/open`
Load a saved workstream into memory with its original `ws_id`. If the
workstream is already loaded, returns immediately with `already_loaded: true`.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Response (success):** `200`
```json
{"ws_id": "a1b2c3d4", "name": "refactor"}
```
**Response (already loaded):** `200`
```json
{"ws_id": "a1b2c3d4", "name": "refactor", "already_loaded": true}
```
---
### `POST /v1/api/workstreams/{ws_id}/title`
Set a workstream title manually. The title is stored as the workstream alias.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Request body:**
```json
{"title": "JWT Authentication Refactor"}
```
| Field | Type | Required | Description |
|---------|--------|----------|------------------------|
| `title` | string | yes | New workstream title |
**Response (success):** `200`
```json
{"status": "ok", "title": "JWT Authentication Refactor"}
```
**Response (conflict):** `409`
```json
{"error": "That name is already used by another workstream"}
```
---
### `POST /v1/api/workstreams/{ws_id}/refresh-title`
Regenerate the workstream title via LLM based on conversation content.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Response (success):** `200`
```json
{"status": "ok"}
```
---
### `GET /v1/api/admin/settings`
List `interface.*` settings with their current values and sources. Requires
`read` scope on the server.
**Response:** `200`
```json
{
"settings": [
{
"key": "interface.close_tab_action",
"value": "last_used",
"source": "default",
"type": "str",
"description": "Determines which workstream to switch to after closing a tab."
}
]
}
```
---
### `POST|PUT /v1/api/admin/settings/{key}`
Update an `interface.*` setting. Only keys in the `interface` section are
accepted; other keys return `400`.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|-------------------------------------|
| `key` | string | Setting key (e.g. `interface.theme`) |
**Request body:**
```json
{"value": "light"}
```
| Field | Type | Required | Description |
|---------|------|----------|----------------|
| `value` | any | yes | New value |
**Response (success):** `200`
```json
{"status": "ok", "key": "interface.theme", "value": "light"}
```
**Error:** `400` if the key is not in the `interface` section.
---
### `GET /v1/api/watches`
List active watches on this server node. Optionally filter by workstream.
+15 -1
View File
@@ -38,6 +38,7 @@ turnstone/
_protocol.py LLMProvider protocol, ModelCapabilities, StreamChunk, CompletionResult
_openai.py OpenAIProvider — OpenAI, vLLM, llama.cpp, any compatible API
_anthropic.py AnthropicProvider — Anthropic Messages API, native streaming, thinking
_google.py GoogleProvider — Google Gemini via OpenAI-compat endpoint
__init__.py create_provider() + create_client() factory functions
workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
@@ -593,6 +594,7 @@ LLMProvider (protocol)
|
+--- OpenAIProvider --- OpenAI, vLLM, llama.cpp, any /v1/chat/completions API
+--- AnthropicProvider --- Anthropic Messages API (native streaming, thinking)
+--- GoogleProvider --- Google Gemini via /v1beta/openai/ (extends OpenAIProvider)
```
**Protocol methods:**
@@ -646,6 +648,13 @@ both streaming and non-streaming responses. The `anthropic` SDK is imported
lazily so it remains an optional dependency (`pip install
turnstone[anthropic]`).
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
the Gemini `/v1beta/openai/` endpoint. Uses a single default
`ModelCapabilities` (2M context window, 65K max output tokens,
`token_param=max_tokens`) since Google updates models frequently. No static
per-model capability table. Google's endpoint is wire-compatible with the
OpenAI SDK, so no extra dependency is needed.
**Factory functions** (`__init__.py`): `create_provider(name)` returns a
singleton provider instance (thread-safe). `create_client(name, base_url,
api_key)` creates the appropriate SDK client.
@@ -674,6 +683,10 @@ api_key = "sk-..."
model = "gpt-5"
context_window = 400000
[models.gemini]
provider = "google"
model = "gemini-2.5-pro"
[model]
default = "local"
fallback = ["claude", "openai"]
@@ -681,7 +694,8 @@ agent_model = "claude"
```
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
(default: `"openai"`). Supported values: `"openai"` and `"anthropic"`.
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
and `"openai-compatible"`.
An optional `[models.*.capabilities]` sub-table overrides per-model
`ModelCapabilities` flags (useful for local models whose capabilities
cannot be detected programmatically):
+3
View File
@@ -382,6 +382,9 @@ Triggered by the "+ new" header button. A modal dialog with:
- **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time.
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
- **Judge Model** — optional text input for the judge model alias (overrides the default judge model for this workstream).
Keyboard shortcuts: Ctrl+Shift+R (refresh title), Ctrl+Shift+E (edit title), Ctrl+Shift+F (fork), Ctrl+Shift+X (delete). Press ? for full shortcut help.
On submit, `POST /v1/api/cluster/workstreams/new` dispatches the creation request. A toast confirms success; the SSE stream delivers the `ws_created` event to update the dashboard.
+1 -1
View File
@@ -25,7 +25,7 @@ package "Entry Points" <<Rectangle>> {
' Core engine
package "turnstone/core/" <<Rectangle>> {
component [session.py\nChatSession, SessionUI] as session <<core>>
component [providers/\nLLMProvider, OpenAI, Anthropic] as providers <<core>>
component [providers/\nLLMProvider, OpenAI, Anthropic, Google] as providers <<core>>
component [workstream.py\nWorkstreamManager] as workstream <<core>>
component [tools.py\nTool loader] as tools <<core>>
component [memory.py\nPersistence facade] as memory <<core>>
+13
View File
@@ -103,6 +103,18 @@ class "AnthropicProvider" as AnthropicProv {
core/providers/_anthropic.py
}
class "GoogleProvider" as GoogleProv {
+ provider_name: str
+ get_capabilities(model) -> ModelCapabilities
--
Extends OpenAIChatCompletionsProvider
for Gemini /v1beta/openai/ endpoint.
Single default ModelCapabilities
(2M context, 65K output).
--
core/providers/_google.py
}
' ModelCapabilities
class "ModelCapabilities" as ModelCaps <<frozen>> {
+ context_window: int
@@ -360,6 +372,7 @@ SessionUI <|.. NullUI
LLMProvider <|.. OpenAIProv
LLMProvider <|.. AnthropicProv
OpenAIProv <|-- GoogleProv
ChatSession --> SessionUI : uses
ChatSession --> LLMProvider : delegates LLM calls
+12
View File
@@ -41,6 +41,7 @@ confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
max_context_ratio = 0.5 # max % of judge context window for history
timeout = 60.0 # seconds (generous for local models)
read_only_tools = true # judge can use read_file/list_directory
cancel_on_approval = false # stop judging remaining tool calls once user decides
```
All fields are optional. The judge is enabled by default; use `enabled = false`
@@ -72,6 +73,17 @@ CLI flags override `config.toml` values.
- **Cross-provider**: When both `model` and `provider` are set, the judge
creates its own LLM client. You can optionally specify `base_url` and
`api_key` for non-default endpoints.
- **Google models**: The judge supports `google` as a provider. Note that
read-only tools are disabled for Google models (the Gemini API requires
`thought_signature` in tool call round-trips which the judge's normalized
format does not preserve).
The judge creates a fresh HTTP client for each evaluation run and closes it
when done, avoiding stale connection issues across runs.
If the LLM judge fails or returns no verdict, a fallback verdict with tier
`llm_fallback` is delivered via the callback, ensuring the UI always receives
a result.
---
+3 -2
View File
@@ -49,7 +49,7 @@ connection, Redis, auth secrets, server bind address). These stay in
| Auth | `[auth]` | config.toml / env |
| Console bind | `[console]` | config.toml / env |
**ConfigStore settings** (48 settings) are loaded from the database after
**ConfigStore settings** (51 settings) are loaded from the database after
storage initialization:
| Section | Settings |
@@ -62,7 +62,8 @@ storage initialization:
| `mcp` | config_path, refresh_interval, registry_url |
| `ratelimit` | enabled, requests_per_second, burst, trusted_proxies |
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets |
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets, cancel_on_approval |
| `interface` | close_tab_action, theme |
| `skills` | discovery_url |
| `memory` | relevance_k, fetch_limit, max_content, nudge_cooldown, nudges |
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.2.0a3"
version = "1.2.0a4"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+4 -4
View File
@@ -1120,9 +1120,9 @@
}
},
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.4.tgz",
"integrity": "sha512-baBr4jUVSLJ0RPyZ2nK0zS2+W8hNHbM4hEzfvllukmRPVS3xDG5ATTNtbRXrKIOE2b8/FsPWJAOnuIxcs7g3cw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1147,7 +1147,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
+29
View File
@@ -197,6 +197,35 @@ class TestRequiredScope:
"""Only POST is elevated — GET falls through to read."""
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
# Workstream sub-resource mutations (parametric paths)
def test_ws_delete_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/delete") == "write"
def test_ws_open_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/open") == "write"
def test_ws_refresh_title_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/refresh-title") == "write"
def test_ws_title_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/title") == "write"
def test_v1_ws_delete_needs_write(self):
assert required_scope("POST", "/v1/api/workstreams/abc123/delete") == "write"
def test_proxy_ws_delete_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/delete") == "write"
def test_proxy_ws_open_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/open") == "write"
def test_proxy_ws_title_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/title") == "write"
def test_ws_get_is_still_read(self):
"""GET on workstream sub-resource is not elevated."""
assert required_scope("GET", "/api/workstreams/abc123/delete") == "read"
# ---------------------------------------------------------------------------
# TestExtractBearer
+3 -1
View File
@@ -757,7 +757,9 @@ class TestConsoleHTTPEndpoints:
assert status == 200
assert len(data["nodes"]) == 1
assert data["total"] == 1
mock_collector.get_nodes.assert_called_once_with(sort_by="activity", limit=10, offset=0)
mock_collector.get_nodes.assert_called_once_with(
sort_by="activity", limit=10, offset=0, node_ids=None
)
def test_get_workstreams(self, client, mock_collector):
status, data = self._get(
+49 -12
View File
@@ -24,6 +24,7 @@ def _make_mock_provider(
) -> MagicMock:
"""Create a mock LLM provider that returns a fixed response."""
provider = MagicMock()
provider.provider_name = "openai"
caps = MagicMock()
caps.context_window = 100_000
caps.max_output_tokens = 4096
@@ -63,6 +64,8 @@ def _make_judge(
timeout=timeout,
)
client = MagicMock()
client.base_url = "https://api.openai.com/v1"
client.api_key = "test-key"
return IntentJudge(
config=config,
session_provider=provider,
@@ -186,11 +189,16 @@ class TestErrorHandling:
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert result is None
def test_provider_error_heuristic_still_returned(self):
"""When LLM fails, heuristic verdicts are still returned from evaluate()."""
"""When LLM fails, heuristic verdicts are still returned from evaluate().
With fallback delivery, the callback *will* fire with a fallback
verdict, but heuristic verdicts are always returned synchronously.
"""
provider = _make_mock_provider(side_effect=RuntimeError("API down"))
judge = _make_judge(provider)
@@ -204,8 +212,9 @@ class TestErrorHandling:
assert len(heuristics) == 1
assert heuristics[0].tier == "heuristic"
# Callback should not have been invoked (LLM failed)
assert len(callback_results) == 0
# Fallback verdict delivered via callback
assert len(callback_results) == 1
assert callback_results[0].tier == "llm_fallback"
def test_empty_content_returns_none(self):
"""Provider returns empty content, no tool calls."""
@@ -221,9 +230,31 @@ class TestErrorHandling:
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert result is None
def test_empty_content_length_stop_no_retry(self):
"""When finish_reason is 'length', don't retry — return None immediately."""
provider = _make_mock_provider(response_content="")
result_mock = provider.create_completion.return_value
result_mock.tool_calls = None
result_mock.content = ""
result_mock.finish_reason = "length"
judge = _make_judge(provider)
with ThreadPoolExecutor(max_workers=1) as pool:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert result is None
# Should have been called exactly once — no retries
assert provider.create_completion.call_count == 1
# ---------------------------------------------------------------------------
# Multi-turn tool use
@@ -234,6 +265,7 @@ class TestMultiTurnToolUse:
def test_tool_call_then_verdict(self):
"""Provider requests read_file, then returns verdict."""
provider = MagicMock()
provider.provider_name = "openai"
caps = MagicMock()
caps.context_window = 100_000
caps.max_output_tokens = 4096
@@ -267,6 +299,7 @@ class TestMultiTurnToolUse:
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert verdict is not None
assert verdict.tier == "llm"
@@ -275,6 +308,7 @@ class TestMultiTurnToolUse:
def test_max_turns_reached(self):
"""Provider keeps requesting tools — stops at _JUDGE_MAX_TURNS."""
provider = MagicMock()
provider.provider_name = "openai"
caps = MagicMock()
caps.context_window = 100_000
caps.max_output_tokens = 4096
@@ -315,6 +349,7 @@ class TestMultiTurnToolUse:
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
assert provider.create_completion.call_count == 5
@@ -335,12 +370,12 @@ class TestContextPreparation:
result = judge._prepare_context(_make_item(), messages)
# Should have system message + some truncated history + user message
# Should have system message + single user message with transcript
assert len(result) == 2
assert result[0]["role"] == "system"
assert result[-1]["role"] == "user"
assert "pending human approval" in result[-1]["content"]
# Should be fewer messages than the original 100
assert len(result) < 102 # system + 100 + user
assert result[1]["role"] == "user"
assert "pending human approval" in result[1]["content"]
assert "Conversation context:" in result[1]["content"]
# ---------------------------------------------------------------------------
@@ -369,8 +404,8 @@ class TestConfidenceArbitration:
assert callback_results[0].tier == "llm"
assert callback_results[0].confidence == 0.95
def test_llm_lower_confidence_no_callback(self):
"""LLM confidence < heuristic confidence — no callback."""
def test_llm_lower_confidence_no_arbitration_block(self):
"""LLM confidence < heuristic — callback still invoked (all verdicts delivered)."""
provider = _make_mock_provider(response_content=_good_verdict_json(confidence=0.5))
judge = _make_judge(provider)
@@ -384,8 +419,10 @@ class TestConfidenceArbitration:
time.sleep(0.5)
assert len(heuristics) == 1
# LLM confidence (0.5) < heuristic (0.85), so no callback
assert len(callback_results) == 0
# LLM verdict is always delivered regardless of confidence comparison
assert len(callback_results) == 1
assert callback_results[0].tier == "llm"
assert callback_results[0].confidence == 0.5
# ---------------------------------------------------------------------------
+137
View File
@@ -0,0 +1,137 @@
"""Tests for auto-populated node metadata collection."""
from __future__ import annotations
import json
from unittest.mock import patch
from turnstone.core.node_info import (
_collect_interfaces,
_is_loopback_or_link_local,
collect_node_info,
)
class TestCollectNodeInfo:
def test_returns_dict(self):
info = collect_node_info()
assert isinstance(info, dict)
def test_expected_keys_present(self):
info = collect_node_info()
# These should always be available on any platform
assert "hostname" in info
assert "os" in info
assert "arch" in info
assert "python" in info
def test_values_json_serializable(self):
info = collect_node_info()
for _key, value in info.items():
serialized = json.dumps(value)
assert isinstance(serialized, str)
def test_hostname_is_string(self):
info = collect_node_info()
assert isinstance(info["hostname"], str)
assert len(info["hostname"]) > 0
def test_cpu_count_is_int(self):
info = collect_node_info()
if "cpu_count" in info:
assert isinstance(info["cpu_count"], int)
assert info["cpu_count"] > 0
def test_interfaces_is_dict(self):
info = collect_node_info()
if "interfaces" in info:
assert isinstance(info["interfaces"], dict)
for iface, ips in info["interfaces"].items():
assert isinstance(iface, str)
assert isinstance(ips, list)
def test_one_field_failure_does_not_block_others(self):
"""Individual field failures must not prevent other fields from collecting."""
with patch("turnstone.core.node_info.socket.gethostname", side_effect=OSError("boom")):
info = collect_node_info()
assert "hostname" not in info
# Other fields should still be present
assert "os" in info
assert "arch" in info
assert "python" in info
def test_none_value_excluded(self):
with patch("turnstone.core.node_info.os.cpu_count", return_value=None):
info = collect_node_info()
assert "cpu_count" not in info
assert "hostname" in info
def test_interface_failure_does_not_block_fields(self):
"""Interface collection failure must not prevent scalar fields."""
with patch(
"turnstone.core.node_info._collect_interfaces",
side_effect=RuntimeError("boom"),
):
info = collect_node_info()
assert "interfaces" not in info
assert "hostname" in info
assert "os" in info
class TestCollectInterfaces:
def test_returns_dict(self):
result = _collect_interfaces()
assert isinstance(result, dict)
def test_values_are_string_lists(self):
result = _collect_interfaces()
for label, ips in result.items():
assert isinstance(label, str)
assert isinstance(ips, list)
for ip in ips:
assert isinstance(ip, str)
def test_no_loopback_in_results(self):
result = _collect_interfaces()
for _label, ips in result.items():
for ip in ips:
assert not ip.startswith("127.")
assert ip != "::1"
assert not ip.startswith("fe80:")
def test_getaddrinfo_oserror_returns_empty(self):
with patch(
"turnstone.core.node_info.socket.getaddrinfo",
side_effect=OSError("no network"),
):
result = _collect_interfaces()
assert result == {}
def test_all_loopback_returns_empty(self):
import socket
mock_addrs = [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0)),
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 0, 0, 0)),
]
with patch("turnstone.core.node_info.socket.getaddrinfo", return_value=mock_addrs):
result = _collect_interfaces()
assert result == {}
class TestIsLoopbackOrLinkLocal:
def test_ipv4_loopback(self):
assert _is_loopback_or_link_local("127.0.0.1") is True
assert _is_loopback_or_link_local("127.0.1.1") is True
def test_ipv6_loopback(self):
assert _is_loopback_or_link_local("::1") is True
def test_link_local(self):
assert _is_loopback_or_link_local("fe80::1") is True
assert _is_loopback_or_link_local("fe80:abc::def") is True
def test_normal_addresses(self):
assert _is_loopback_or_link_local("10.0.0.5") is False
assert _is_loopback_or_link_local("192.168.1.1") is False
assert _is_loopback_or_link_local("2001:db8::1") is False
+185
View File
@@ -0,0 +1,185 @@
"""Tests for node_metadata storage methods."""
from __future__ import annotations
import json
class TestNodeMetadata:
def test_set_and_get(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("us-east-1a"))
rows = storage.get_node_metadata("node-1")
assert len(rows) == 1
assert rows[0]["key"] == "rack"
assert json.loads(rows[0]["value"]) == "us-east-1a"
assert rows[0]["source"] == "user"
def test_set_with_source(self, storage):
storage.set_node_metadata("node-1", "hostname", json.dumps("web-01"), source="auto")
rows = storage.get_node_metadata("node-1")
assert rows[0]["source"] == "auto"
def test_upsert_overwrites(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("old"))
storage.set_node_metadata("node-1", "rack", json.dumps("new"))
rows = storage.get_node_metadata("node-1")
assert len(rows) == 1
assert json.loads(rows[0]["value"]) == "new"
def test_complex_value(self, storage):
val = {"model": "A100", "count": 4}
storage.set_node_metadata("node-1", "gpu", json.dumps(val))
rows = storage.get_node_metadata("node-1")
assert json.loads(rows[0]["value"]) == val
def test_list_value(self, storage):
val = ["inference", "eval"]
storage.set_node_metadata("node-1", "roles", json.dumps(val))
rows = storage.get_node_metadata("node-1")
assert json.loads(rows[0]["value"]) == val
def test_get_empty(self, storage):
rows = storage.get_node_metadata("nonexistent")
assert rows == []
def test_get_all_node_metadata(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("a"))
storage.set_node_metadata("node-2", "rack", json.dumps("b"))
storage.set_node_metadata("node-2", "os", json.dumps("Linux"))
result = storage.get_all_node_metadata()
assert "node-1" in result
assert "node-2" in result
assert len(result["node-1"]) == 1
assert len(result["node-2"]) == 2
node2_keys = {r["key"] for r in result["node-2"]}
assert node2_keys == {"rack", "os"}
def test_get_all_empty(self, storage):
result = storage.get_all_node_metadata()
assert result == {}
def test_bulk_set(self, storage):
entries = [
("hostname", json.dumps("web-01"), "auto"),
("os", json.dumps("Linux"), "auto"),
("rack", json.dumps("us-east-1a"), "config"),
]
storage.set_node_metadata_bulk("node-1", entries)
rows = storage.get_node_metadata("node-1")
assert len(rows) == 3
keys = {r["key"] for r in rows}
assert keys == {"hostname", "os", "rack"}
def test_bulk_set_upsert(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("old"), source="config")
entries = [("rack", json.dumps("new"), "config")]
storage.set_node_metadata_bulk("node-1", entries)
rows = storage.get_node_metadata("node-1")
assert len(rows) == 1
assert json.loads(rows[0]["value"]) == "new"
def test_delete(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("a"))
deleted = storage.delete_node_metadata("node-1", "rack")
assert deleted is True
assert storage.get_node_metadata("node-1") == []
def test_delete_nonexistent(self, storage):
deleted = storage.delete_node_metadata("node-1", "nope")
assert deleted is False
def test_delete_by_source(self, storage):
storage.set_node_metadata("node-1", "hostname", json.dumps("h"), source="auto")
storage.set_node_metadata("node-1", "os", json.dumps("Linux"), source="auto")
storage.set_node_metadata("node-1", "rack", json.dumps("a"), source="user")
count = storage.delete_node_metadata_by_source("node-1", "auto")
assert count == 2
rows = storage.get_node_metadata("node-1")
assert len(rows) == 1
assert rows[0]["key"] == "rack"
def test_delete_by_source_empty(self, storage):
count = storage.delete_node_metadata_by_source("node-1", "auto")
assert count == 0
def test_filter_single_key(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("us-east-1a"))
storage.set_node_metadata("node-2", "rack", json.dumps("us-west-2a"))
result = storage.filter_nodes_by_metadata({"rack": json.dumps("us-east-1a")})
assert result == {"node-1"}
def test_filter_multiple_keys(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("a"))
storage.set_node_metadata("node-1", "os", json.dumps("Linux"))
storage.set_node_metadata("node-2", "rack", json.dumps("a"))
storage.set_node_metadata("node-2", "os", json.dumps("Windows"))
result = storage.filter_nodes_by_metadata(
{
"rack": json.dumps("a"),
"os": json.dumps("Linux"),
}
)
assert result == {"node-1"}
def test_filter_no_match(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("a"))
result = storage.filter_nodes_by_metadata({"rack": json.dumps("z")})
assert result == set()
def test_filter_empty_filters(self, storage):
result = storage.filter_nodes_by_metadata({})
assert result == set()
def test_filter_partial_intersection_eliminates_all(self, storage):
"""First filter matches 2 nodes, second filter matches neither."""
storage.set_node_metadata("node-1", "rack", json.dumps("a"))
storage.set_node_metadata("node-2", "rack", json.dumps("a"))
storage.set_node_metadata("node-1", "os", json.dumps("Linux"))
storage.set_node_metadata("node-2", "os", json.dumps("Linux"))
result = storage.filter_nodes_by_metadata(
{"rack": json.dumps("a"), "region": json.dumps("eu")}
)
assert result == set()
def test_upsert_preserves_created(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("old"))
rows = storage.get_node_metadata("node-1")
first_created = rows[0]["created"]
storage.set_node_metadata("node-1", "rack", json.dumps("new"))
rows = storage.get_node_metadata("node-1")
assert rows[0]["created"] == first_created
assert json.loads(rows[0]["value"]) == "new"
def test_bulk_set_empty_list(self, storage):
storage.set_node_metadata_bulk("node-1", [])
rows = storage.get_node_metadata("node-1")
assert rows == []
def test_ordered_by_key(self, storage):
storage.set_node_metadata("node-1", "zz", json.dumps("last"))
storage.set_node_metadata("node-1", "aa", json.dumps("first"))
rows = storage.get_node_metadata("node-1")
assert rows[0]["key"] == "aa"
assert rows[1]["key"] == "zz"
def test_upsert_changes_source(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("a"), source="auto")
storage.set_node_metadata("node-1", "rack", json.dumps("a"), source="user")
rows = storage.get_node_metadata("node-1")
assert rows[0]["source"] == "user"
def test_delete_by_source_does_not_affect_other_nodes(self, storage):
storage.set_node_metadata("node-1", "hostname", json.dumps("h1"), source="auto")
storage.set_node_metadata("node-2", "hostname", json.dumps("h2"), source="auto")
storage.delete_node_metadata_by_source("node-1", "auto")
rows = storage.get_node_metadata("node-2")
assert len(rows) == 1
assert rows[0]["key"] == "hostname"
def test_filter_returns_multiple_matches(self, storage):
storage.set_node_metadata("node-1", "rack", json.dumps("a"))
storage.set_node_metadata("node-2", "rack", json.dumps("a"))
storage.set_node_metadata("node-3", "rack", json.dumps("b"))
result = storage.filter_nodes_by_metadata({"rack": json.dumps("a")})
assert result == {"node-1", "node-2"}
+66
View File
@@ -1074,6 +1074,72 @@ class TestProviderFactory:
p2 = create_provider("openai")
assert p1 is p2
# -- Google provider -------------------------------------------------------
def test_create_provider_google(self) -> None:
from turnstone.core.providers import create_provider
from turnstone.core.providers._google import GoogleProvider
provider = create_provider("google")
assert isinstance(provider, GoogleProvider)
assert provider.provider_name == "google"
def test_create_provider_google_singleton(self) -> None:
from turnstone.core.providers import create_provider
p1 = create_provider("google")
p2 = create_provider("google")
assert p1 is p2
@patch("openai.OpenAI")
def test_create_client_google_default_base_url(self, mock_openai_cls: MagicMock) -> None:
from turnstone.core.providers import create_client
from turnstone.core.providers._google import GOOGLE_DEFAULT_BASE_URL
mock_openai_cls.return_value = MagicMock()
create_client("google", base_url="", api_key="test-key")
mock_openai_cls.assert_called_once_with(
base_url=GOOGLE_DEFAULT_BASE_URL, api_key="test-key"
)
@patch("openai.OpenAI")
def test_create_client_google_custom_base_url(self, mock_openai_cls: MagicMock) -> None:
from turnstone.core.providers import create_client
mock_openai_cls.return_value = MagicMock()
create_client("google", base_url="http://custom:8080/v1", api_key="k")
mock_openai_cls.assert_called_once_with(base_url="http://custom:8080/v1", api_key="k")
def test_google_capabilities_defaults(self) -> None:
from turnstone.core.providers import create_provider
provider = create_provider("google")
caps = provider.get_capabilities("gemini-2.5-pro")
assert caps.context_window == 2_000_000
assert caps.max_output_tokens == 65_536
assert caps.token_param == "max_tokens"
assert caps.supports_temperature is True
assert caps.supports_vision is True
def test_google_capabilities_same_for_all_models(self) -> None:
from turnstone.core.providers import create_provider
provider = create_provider("google")
c1 = provider.get_capabilities("gemini-2.5-pro")
c2 = provider.get_capabilities("gemini-2.0-flash")
c3 = provider.get_capabilities("")
assert c1 is c2 is c3
def test_list_known_models_google_empty(self) -> None:
from turnstone.core.providers import list_known_models
assert list_known_models("google") == []
def test_lookup_model_capabilities_google_returns_none(self) -> None:
from turnstone.core.providers import lookup_model_capabilities
assert lookup_model_capabilities("google", "gemini-2.5-pro") is None
# ===========================================================================
# TestDataclasses
+1 -1
View File
@@ -132,7 +132,7 @@ class TestListWorkstreamsWithHistory:
save_message("sess1", "user", "hello")
save_message("sess1", "assistant", "hi")
rows = list_workstreams_with_history()
assert rows[0][5] == 2 # msg_count
assert rows[0][6] == 2 # msg_count (after ws_id, alias, title, name, created, updated)
def test_respects_limit(self, tmp_db):
for i in range(5):
+51 -2
View File
@@ -96,6 +96,55 @@ class TestSaveAndLoadMessages:
assert backend.load_messages("nonexistent") == []
class TestSaveMessagesBulk:
def test_bulk_roundtrip(self, backend):
backend.register_workstream("s1")
backend.save_messages_bulk(
[
{"ws_id": "s1", "role": "user", "content": "hello"},
{"ws_id": "s1", "role": "assistant", "content": "hi there"},
{"ws_id": "s1", "role": "user", "content": "bye"},
]
)
msgs = backend.load_messages("s1")
assert len(msgs) == 3
assert msgs[0]["content"] == "hello"
assert msgs[2]["content"] == "bye"
def test_bulk_preserves_tool_calls(self, backend):
import json
backend.register_workstream("s1")
tc = json.dumps(
[{"id": "c1", "type": "function", "function": {"name": "bash", "arguments": "{}"}}]
)
backend.save_messages_bulk(
[
{"ws_id": "s1", "role": "user", "content": "do it"},
{"ws_id": "s1", "role": "assistant", "content": None, "tool_calls": tc},
{"ws_id": "s1", "role": "tool", "content": "ok", "tool_call_id": "c1"},
]
)
msgs = backend.load_messages("s1")
assert len(msgs) == 3
assert msgs[1]["tool_calls"][0]["id"] == "c1"
def test_bulk_empty_is_noop(self, backend):
backend.save_messages_bulk([])
def test_bulk_updates_workstream_timestamp(self, backend):
backend.register_workstream("s1")
# Save a message to establish an initial updated timestamp
backend.save_message("s1", "user", "seed")
rows_before = backend.list_workstreams_with_history()
updated_before = rows_before[0][5] # updated column
backend.save_messages_bulk([{"ws_id": "s1", "role": "user", "content": "bulk"}])
rows_after = backend.list_workstreams_with_history()
updated_after = rows_after[0][5]
assert updated_after >= updated_before
class TestListWorkstreamsWithHistory:
def test_lists_workstreams_with_messages(self, backend):
backend.register_workstream("s1")
@@ -274,9 +323,9 @@ class TestWorkstreams:
backend.save_message("ws1", "user", "hello")
rows = backend.list_workstreams_with_history()
assert len(rows) == 1
# Columns: ws_id, alias, title, created, updated, count, node_id
# Columns: ws_id, alias, title, name, created, updated, count, node_id
assert rows[0][0] == "ws1"
assert rows[0][6] == "node-a"
assert rows[0][7] == "node-a"
# -- Structured memory touch ---------------------------------------------------
+393
View File
@@ -0,0 +1,393 @@
"""Tests for workstream management endpoints added in PRs #314-#315."""
from __future__ import annotations
import queue
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
from turnstone.server import (
delete_workstream_endpoint,
list_interface_settings,
open_workstream,
refresh_workstream_title,
set_workstream_title,
update_interface_setting,
)
# ---------------------------------------------------------------------------
# Auth bypass middleware
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"read", "write", "approve"}),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture
def _inject_storage(storage):
"""Swap global storage registry for the test backend."""
import turnstone.core.storage._registry as reg
old = reg._storage
reg._storage = storage
yield storage
reg._storage = old
@pytest.fixture
def delete_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route(
"/api/workstreams/{ws_id}/delete",
delete_workstream_endpoint,
methods=["POST"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
return TestClient(app)
@pytest.fixture
def title_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route(
"/api/workstreams/{ws_id}/title",
set_workstream_title,
methods=["POST"],
),
Route(
"/api/workstreams/{ws_id}/refresh-title",
refresh_workstream_title,
methods=["POST"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
mock_mgr = MagicMock()
app.state.workstreams = mock_mgr
return TestClient(app), mock_mgr
@pytest.fixture
def open_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route(
"/api/workstreams/{ws_id}/open",
open_workstream,
methods=["POST"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
mock_mgr = MagicMock()
app.state.workstreams = mock_mgr
gq: queue.Queue[dict[str, Any]] = queue.Queue()
app.state.global_queue = gq
return TestClient(app), mock_mgr, gq
@pytest.fixture
def settings_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route("/api/admin/settings", list_interface_settings),
Route(
"/api/admin/settings/{key:path}",
update_interface_setting,
methods=["POST", "PUT"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.config_store = None
app.state.global_queue = queue.Queue()
return TestClient(app)
# ===========================================================================
# DELETE workstream
# ===========================================================================
class TestDeleteWorkstream:
def test_delete_success(self, delete_client, storage):
storage.register_workstream("ws-abc", "node-1", name="test")
r = delete_client.post("/v1/api/workstreams/ws-abc/delete")
assert r.status_code == 200
assert r.json()["deleted"] == "ws-abc"
def test_delete_not_found(self, delete_client):
r = delete_client.post("/v1/api/workstreams/nonexistent/delete")
assert r.status_code == 404
assert "not found" in r.json()["error"].lower()
def test_delete_error_redacted(self, delete_client):
"""500 response should not leak exception internals."""
with patch(
"turnstone.core.memory.delete_workstream",
side_effect=RuntimeError("secret internal detail"),
):
r = delete_client.post("/v1/api/workstreams/ws-abc/delete")
assert r.status_code == 500
assert "Delete failed" in r.json()["error"]
assert "secret" not in r.json()["error"]
# ===========================================================================
# SET title
# ===========================================================================
class TestSetWorkstreamTitle:
def test_set_title_success(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-abc", "node-1", name="test")
mock_ws = MagicMock()
mock_mgr.get.return_value = mock_ws
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={"title": "New Title"},
)
assert r.status_code == 200
assert r.json()["title"] == "New Title"
def test_set_title_empty(self, title_client):
client, _ = title_client
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={"title": ""},
)
assert r.status_code == 400
assert "required" in r.json()["error"].lower()
def test_set_title_missing_body(self, title_client):
client, _ = title_client
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={},
)
assert r.status_code == 400
def test_set_title_truncation(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-abc", "node-1", name="test")
mock_mgr.get.return_value = MagicMock()
long_title = "x" * 200
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={"title": long_title},
)
assert r.status_code == 200
assert len(r.json()["title"]) <= 80
def test_set_title_alias_conflict(self, title_client, storage):
client, _ = title_client
storage.register_workstream("ws-1", "node-1", name="first")
storage.register_workstream("ws-2", "node-1", name="second")
storage.set_workstream_alias("ws-1", "taken-name")
r = client.post(
"/v1/api/workstreams/ws-2/title",
json={"title": "taken-name"},
)
assert r.status_code == 409
# ===========================================================================
# REFRESH title
# ===========================================================================
class TestRefreshWorkstreamTitle:
def test_refresh_success(self, title_client):
client, mock_mgr = title_client
mock_ws = MagicMock()
mock_ws.session = MagicMock()
mock_mgr.get.return_value = mock_ws
with patch("turnstone.core.memory.get_workstream_display_name", return_value="Old Title"):
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
assert r.status_code == 200
mock_ws.session.request_title_refresh.assert_called_once_with("Old Title")
def test_refresh_not_found(self, title_client):
client, mock_mgr = title_client
mock_mgr.get.return_value = None
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
assert r.status_code == 404
def test_refresh_no_session(self, title_client):
client, mock_mgr = title_client
mock_ws = MagicMock()
mock_ws.session = None
mock_mgr.get.return_value = mock_ws
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
assert r.status_code == 404
# ===========================================================================
# OPEN workstream
# ===========================================================================
class TestOpenWorkstream:
@patch("turnstone.core.memory.resolve_workstream")
def test_open_already_loaded(self, mock_resolve, open_client):
client, mock_mgr, gq = open_client
mock_resolve.return_value = "ws-abc"
mock_ws = MagicMock()
mock_ws.id = "ws-abc"
mock_mgr.get.return_value = mock_ws
with patch("turnstone.core.memory.get_workstream_display_name", return_value="My WS"):
r = client.post("/v1/api/workstreams/ws-abc/open")
assert r.status_code == 200
assert r.json()["already_loaded"] is True
assert r.json()["ws_id"] == "ws-abc"
@patch("turnstone.core.memory.resolve_workstream")
def test_open_not_found(self, mock_resolve, open_client):
client, mock_mgr, gq = open_client
mock_resolve.return_value = None
r = client.post("/v1/api/workstreams/nonexistent/open")
assert r.status_code == 404
@patch("turnstone.core.memory.resolve_workstream")
def test_open_no_storage_row(self, mock_resolve, open_client, _inject_storage):
client, mock_mgr, gq = open_client
mock_resolve.return_value = "ws-abc"
mock_mgr.get.return_value = None # not loaded
# Storage has no row for ws-abc
r = client.post("/v1/api/workstreams/ws-abc/open")
assert r.status_code == 404
assert "storage" in r.json()["error"].lower()
# ===========================================================================
# LIST interface settings
# ===========================================================================
class TestListInterfaceSettings:
def test_list_defaults(self, settings_client):
r = settings_client.get("/v1/api/admin/settings")
assert r.status_code == 200
settings = r.json()["settings"]
keys = [s["key"] for s in settings]
assert "interface.theme" in keys
assert "interface.close_tab_action" in keys
# All should be defaults when no config store
for s in settings:
assert s["source"] == "default"
def test_list_only_interface_keys(self, settings_client):
r = settings_client.get("/v1/api/admin/settings")
settings = r.json()["settings"]
for s in settings:
assert s["key"].startswith("interface.")
# ===========================================================================
# UPDATE interface setting
# ===========================================================================
class TestUpdateInterfaceSetting:
def test_update_theme(self, settings_client, _inject_storage):
r = settings_client.post(
"/v1/api/admin/settings/interface.theme",
json={"value": "light"},
)
assert r.status_code == 200
assert r.json()["value"] == "light"
def test_update_via_put(self, settings_client, _inject_storage):
r = settings_client.put(
"/v1/api/admin/settings/interface.theme",
json={"value": "dark"},
)
assert r.status_code == 200
assert r.json()["value"] == "dark"
def test_reject_non_interface_key(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/judge.enabled",
json={"value": True},
)
assert r.status_code == 400
assert "interface" in r.json()["error"].lower()
def test_reject_unknown_key(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/interface.nonexistent",
json={"value": "x"},
)
assert r.status_code == 400
assert "unknown" in r.json()["error"].lower()
def test_reject_missing_value(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/interface.theme",
json={},
)
assert r.status_code == 400
assert "value" in r.json()["error"].lower()
def test_reject_invalid_choice(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/interface.theme",
json={"value": "neon-pink"},
)
assert r.status_code == 400
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.2.0a3"
__version__ = "1.2.0a4"
+84
View File
@@ -293,6 +293,74 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
def _cmd_list_node_metadata(args: argparse.Namespace) -> None:
"""List metadata for a node."""
import json
storage = _get_storage()
rows = storage.get_node_metadata(args.node_id)
if not rows:
print(f"No metadata for node: {args.node_id}")
return
print(f"{'KEY':<20s} {'VALUE':<40s} {'SOURCE':<8s} {'UPDATED':<20s}")
print("-" * 88)
for r in rows:
val = r["value"]
try:
parsed = json.loads(val)
val_str = json.dumps(parsed) if isinstance(parsed, (dict, list)) else str(parsed)
except (json.JSONDecodeError, TypeError):
val_str = val
if len(val_str) > 38:
val_str = val_str[:35] + "..."
key_str = r["key"]
if len(key_str) > 18:
key_str = key_str[:15] + "..."
print(f"{key_str:<20s} {val_str:<40s} {r['source']:<8s} {r['updated']:<20s}")
def _cmd_set_node_metadata(args: argparse.Namespace) -> None:
"""Set a metadata key on a node."""
import json
storage = _get_storage()
# Check for auto-source conflict
existing = storage.get_node_metadata(args.node_id)
for r in existing:
if r["key"] == args.key and r["source"] == "auto":
print(f"Error: cannot overwrite auto-populated key: {args.key}", file=sys.stderr)
sys.exit(1)
# Try JSON parse, fall back to string
try:
value = json.loads(args.value)
except (json.JSONDecodeError, TypeError):
value = args.value
storage.set_node_metadata(args.node_id, args.key, json.dumps(value), source="user")
print(f"Set {args.key}={json.dumps(value)} on {args.node_id}")
def _cmd_delete_node_metadata(args: argparse.Namespace) -> None:
"""Delete a metadata key from a node."""
storage = _get_storage()
existing = storage.get_node_metadata(args.node_id)
for r in existing:
if r["key"] == args.key and r["source"] == "auto":
print(f"Error: cannot delete auto-populated key: {args.key}", file=sys.stderr)
sys.exit(1)
deleted = storage.delete_node_metadata(args.node_id, args.key)
if deleted:
print(f"Deleted {args.key} from {args.node_id}")
else:
print(f"Key not found: {args.key} on {args.node_id}", file=sys.stderr)
sys.exit(1)
def _discover_console_url() -> str:
"""Discover console URL from the services table."""
from turnstone.core.storage import get_storage
@@ -378,6 +446,19 @@ def main() -> None:
p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
p_tlslist.add_argument("--console-url", default="", help="Console URL")
# Node metadata commands
p_lnm = sub.add_parser("list-node-metadata", help="List metadata for a node")
p_lnm.add_argument("node_id", help="Node ID")
p_snm = sub.add_parser("set-node-metadata", help="Set a metadata key on a node")
p_snm.add_argument("node_id", help="Node ID")
p_snm.add_argument("key", help="Metadata key")
p_snm.add_argument("value", help="Value (JSON or plain string)")
p_dnm = sub.add_parser("delete-node-metadata", help="Delete a metadata key from a node")
p_dnm.add_argument("node_id", help="Node ID")
p_dnm.add_argument("key", help="Metadata key")
args = parser.parse_args()
if not args.command:
parser.print_help()
@@ -393,5 +474,8 @@ def main() -> None:
"tls-issue": _cmd_tls_issue,
"tls-ca-cert": _cmd_tls_ca_cert,
"tls-list": _cmd_tls_list,
"list-node-metadata": _cmd_list_node_metadata,
"set-node-metadata": _cmd_set_node_metadata,
"delete-node-metadata": _cmd_delete_node_metadata,
}
dispatch[args.command](args)
+37
View File
@@ -90,6 +90,12 @@ class ClusterWorkstreamsResponse(BaseModel):
# ---------------------------------------------------------------------------
class NodeMetadataEntry(BaseModel):
key: str
value: Any
source: str = "user"
class NodeDetailResponse(BaseModel):
node_id: str
server_url: str = ""
@@ -97,6 +103,7 @@ class NodeDetailResponse(BaseModel):
workstreams: list[ClusterWorkstreamInfo] = []
aggregate: dict[str, int] = Field(default_factory=dict)
reachable: bool = True
metadata: list[NodeMetadataEntry] = Field(default_factory=list)
# ---------------------------------------------------------------------------
@@ -140,6 +147,9 @@ class ConsoleCreateWsRequest(BaseModel):
resume_ws: str = Field(
default="", description="Workstream ID to resume (loads previous conversation)"
)
judge_model: str = Field(
default="", description="Override judge model alias for this workstream"
)
class ConsoleCreateWsResponse(BaseModel):
@@ -898,3 +908,30 @@ class RouteCreateResponse(BaseModel):
ws_id: str = ""
node_url: str = ""
node_id: str = ""
# ---------------------------------------------------------------------------
# Node metadata
# ---------------------------------------------------------------------------
class NodeMetadataResponse(BaseModel):
node_id: str
metadata: list[NodeMetadataEntry] = Field(default_factory=list)
class SetNodeMetadataValueRequest(BaseModel):
"""Request body for PUT /admin/nodes/{node_id}/metadata/{key}."""
value: Any
class SetNodeMetadataRequest(BaseModel):
"""Single entry in a bulk metadata set."""
key: str
value: Any
class BulkSetNodeMetadataRequest(BaseModel):
entries: list[SetNodeMetadataRequest] = Field(default_factory=list)
+41
View File
@@ -12,6 +12,7 @@ from turnstone.api.console_schemas import (
AssignRoleRequest,
AuditEventInfo,
AvailableModelInfo,
BulkSetNodeMetadataRequest,
ChannelUserInfo,
ClusterNodesResponse,
ClusterOverviewResponse,
@@ -55,6 +56,7 @@ from turnstone.api.console_schemas import (
ModelDefinitionInfo,
ModelReloadResponse,
NodeDetailResponse,
NodeMetadataResponse,
OrgInfo,
OutputAssessmentInfo,
RegistryInstallRequest,
@@ -62,6 +64,7 @@ from turnstone.api.console_schemas import (
RoleInfo,
RouteCreateResponse,
RouteResponse,
SetNodeMetadataValueRequest,
SettingInfo,
SettingSchemaInfo,
SkillDiscoverResponse,
@@ -977,6 +980,44 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[404],
tags=["Admin"],
),
# --- Admin: Node metadata ---
EndpointSpec(
"/v1/api/admin/node-metadata",
"GET",
"Get metadata for all nodes (bulk)",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/nodes/{node_id}/metadata",
"GET",
"Get all metadata for a node",
response_model=NodeMetadataResponse,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/nodes/{node_id}/metadata",
"PUT",
"Bulk set user metadata for a node",
request_model=BulkSetNodeMetadataRequest,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/nodes/{node_id}/metadata/{key}",
"PUT",
"Set a single metadata key for a node",
request_model=SetNodeMetadataValueRequest,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/nodes/{node_id}/metadata/{key}",
"DELETE",
"Delete a single metadata key for a node",
error_codes=[400, 404],
tags=["Admin"],
),
# --- Admin: TLS / ACME ---
EndpointSpec(
"/v1/api/admin/tls/ca",
+49
View File
@@ -144,6 +144,34 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
"Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
tags=["Streaming"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/delete",
"POST",
"Permanently delete a saved workstream",
error_codes=[400, 404, 500],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/open",
"POST",
"Load a saved workstream into memory",
error_codes=[400, 404, 500],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/title",
"POST",
"Set workstream title manually",
error_codes=[400, 409],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/refresh-title",
"POST",
"Regenerate workstream title via LLM",
error_codes=[404],
tags=["Workstreams"],
),
# --- Saved workstreams ---
EndpointSpec(
"/v1/api/workstreams/saved",
@@ -269,6 +297,27 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
error_codes=[404],
tags=["Memories"],
),
# --- Admin settings ---
EndpointSpec(
"/v1/api/admin/settings",
"GET",
"List interface.* settings with values and sources",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/settings/{key}",
"PUT",
"Update an interface.* setting",
error_codes=[400, 503],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/settings/{key}",
"POST",
"Update an interface.* setting (alias for PUT)",
error_codes=[400, 503],
tags=["Admin"],
),
# --- Observability ---
EndpointSpec(
"/health",
+14 -5
View File
@@ -394,7 +394,8 @@ class ClusterCollector:
{
"type": "ws_created",
"ws_id": ws_id,
"name": ws.get("name", ""),
"name": ws.get("title", "") or ws.get("name", ""),
"title": ws.get("title", ""),
"node_id": node_id,
}
)
@@ -418,8 +419,8 @@ class ClusterCollector:
"content": new_w.get("content", ""),
}
)
old_name = old_ws.get("name", "")
new_name = new_w.get("name", "")
old_name = old_ws.get("title", "") or old_ws.get("name", "")
new_name = new_w.get("title", "") or new_w.get("name", "")
if old_name != new_name and new_name:
pending.append({"type": "ws_rename", "ws_id": ws_id, "name": new_name})
node.workstreams = new_ws
@@ -505,7 +506,8 @@ class ClusterCollector:
{
"type": "ws_created",
"ws_id": ws_id,
"name": data.get("name", ""),
"name": data.get("title", "") or data.get("name", ""),
"title": data.get("title", ""),
"node_id": node_id,
}
)
@@ -607,15 +609,22 @@ class ClusterCollector:
}
def get_nodes(
self, sort_by: str = "activity", limit: int | None = 100, offset: int = 0
self,
sort_by: str = "activity",
limit: int | None = 100,
offset: int = 0,
node_ids: set[str] | None = None,
) -> tuple[list[dict[str, Any]], int]:
"""Return sorted, paginated node list with per-node counts.
Pass ``limit=None`` to return all nodes (no pagination).
Pass ``node_ids`` to restrict results to the given set.
"""
with self._lock:
items = []
for node in self._nodes.values():
if node_ids is not None and node.node_id not in node_ids:
continue
ws_states = {
"running": 0,
"thinking": 0,
+323 -27
View File
@@ -129,21 +129,37 @@ _JS_PROXY_SHIM = """\
"""
_CONSOLE_BANNER_TEMPLATE = (
'<div style="background:#111827;border-bottom:1px solid rgba(229,160,66,0.3);'
"padding:6px 20px;font-family:'IBM Plex Mono',monospace;font-size:12px;"
'display:flex;align-items:center;gap:12px;position:relative;z-index:9999">'
'<a href="/" style="color:#8a93ad;text-decoration:none;font-weight:500;'
'padding:2px 0" '
"onmouseover=\"this.style.color='#e5a042'\" "
"onmouseout=\"this.style.color='#8a93ad'\">"
"&larr; Console</a>"
'<span style="color:#3b4463">\u2502</span>'
'<span style="color:#8a93ad;font-size:11px">NODE_ID_PLACEHOLDER</span>'
'<div class="console-banner">'
'<a href="/" class="console-banner-link" aria-label="Return to console">&larr; Console</a>'
'<span class="console-banner-sep">\u2502</span>'
'<a href="NODE_LINK_PLACEHOLDER" class="console-banner-node"'
' aria-label="Node: NODE_ID_PLACEHOLDER">'
"NODE_ID_PLACEHOLDER</a>"
"</div>"
)
# Injected <style> offsets fixed-position overlays below the console banner.
_CONSOLE_PROXY_STYLE = "<style>.dashboard-overlay{top:32px!important}</style>"
# Injected <style>: offsets fixed-position overlays and provides theme-aware
# banner styling so the banner adapts to light/dark without inline colours.
_CONSOLE_PROXY_STYLE = (
"<style>"
".dashboard-overlay{top:32px!important}"
".console-banner{background:#111827;border-bottom:1px solid rgba(229,160,66,0.3);"
"padding:6px 20px;font-family:'IBM Plex Mono',monospace;font-size:12px;"
"display:flex;align-items:center;gap:12px;position:relative;z-index:200}"
".console-banner-link,.console-banner-node{color:#9aa0b8;text-decoration:none}"
".console-banner-link{font-weight:500;padding:2px 0}"
".console-banner-node{font-size:11px}"
".console-banner-sep{color:#3b4463}"
".console-banner-link:hover,.console-banner-node:hover{color:#e5a042}"
':root[data-theme="light"] .console-banner{background:#f8fafc;'
"border-bottom-color:rgba(229,160,66,0.5)}"
':root[data-theme="light"] .console-banner-link,'
':root[data-theme="light"] .console-banner-node{color:#64748b}'
':root[data-theme="light"] .console-banner-sep{color:#cbd5e1}'
':root[data-theme="light"] .console-banner-link:hover,'
':root[data-theme="light"] .console-banner-node:hover{color:#8c5e1b}'
"</style>"
)
_VALID_NODE_ID = re.compile(r"^[a-zA-Z0-9._-]+$")
@@ -234,7 +250,35 @@ async def cluster_nodes(request: Request) -> JSONResponse:
sort_by = params.get("sort", "activity")
limit = _parse_int(params, "limit", 100, minimum=1, maximum=1000)
offset = _parse_int(params, "offset", 0)
nodes, total = collector.get_nodes(sort_by=sort_by, limit=limit, offset=offset)
# Extract meta.* filters for node metadata filtering
meta_filters = {k[5:]: v for k, v in params.items() if k.startswith("meta.") and k[5:]}
node_ids: set[str] | None = None
if meta_filters:
import json as _mf_json
storage = getattr(request.app.state, "auth_storage", None)
if storage is not None:
# Values in the DB are JSON-encoded. Try to use raw value if it is
# already valid JSON (e.g. meta.cpu_count=4), otherwise wrap as string.
encoded = {}
for mk, mv in meta_filters.items():
try:
_mf_json.loads(mv)
encoded[mk] = mv
except (ValueError, TypeError):
encoded[mk] = _mf_json.dumps(mv)
try:
node_ids = storage.filter_nodes_by_metadata(encoded)
except Exception:
log.warning("cluster.metadata_filter_failed", exc_info=True)
node_ids = None # fall back to unfiltered
if node_ids is not None and not node_ids:
return JSONResponse({"nodes": [], "total": 0})
nodes, total = collector.get_nodes(
sort_by=sort_by, limit=limit, offset=offset, node_ids=node_ids
)
return JSONResponse({"nodes": nodes, "total": total})
@@ -270,12 +314,34 @@ async def cluster_workstreams(request: Request) -> JSONResponse:
async def cluster_node_detail(request: Request) -> JSONResponse:
collector: ClusterCollector = request.app.state.collector
node_id = request.path_params["node_id"]
if not node_id or "/" in node_id or len(node_id) > 256:
return JSONResponse({"error": "Invalid node ID"}, status_code=400)
nv = _validate_node_id(node_id)
if nv:
return nv
detail = collector.get_node_detail(node_id)
if detail:
return JSONResponse(detail)
return JSONResponse({"error": "Node not found"}, status_code=404)
if not detail:
return JSONResponse({"error": "Node not found"}, status_code=404)
# Attach metadata if available
import json as _nd_json
storage = getattr(request.app.state, "auth_storage", None)
if storage is not None:
try:
raw = storage.get_node_metadata(node_id)
entries = []
for r in raw:
try:
val = _nd_json.loads(r["value"])
except (ValueError, TypeError):
val = r["value"]
entries.append({"key": r["key"], "value": val, "source": r["source"]})
detail["metadata"] = entries
except Exception:
log.warning("cluster.node_metadata_load_failed node_id=%s", node_id, exc_info=True)
detail["metadata"] = []
else:
detail["metadata"] = []
return JSONResponse(detail)
async def cluster_snapshot(request: Request) -> JSONResponse:
@@ -446,6 +512,7 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_node_id = body.get("node_id", "")
raw_name = body.get("name", "")
raw_model = body.get("model", "")
raw_judge_model = body.get("judge_model", "")
raw_initial_message = body.get("initial_message", "")
raw_skill = body.get("skill", "")
raw_resume_ws = body.get("resume_ws", "")
@@ -455,6 +522,8 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_name = "" if raw_name is None else None
if not isinstance(raw_model, str):
raw_model = "" if raw_model is None else None
if not isinstance(raw_judge_model, str):
raw_judge_model = "" if raw_judge_model is None else None
if not isinstance(raw_initial_message, str):
raw_initial_message = "" if raw_initial_message is None else None
if not isinstance(raw_skill, str):
@@ -465,19 +534,21 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_node_id is None
or raw_name is None
or raw_model is None
or raw_judge_model is None
or raw_initial_message is None
or raw_skill is None
or raw_resume_ws is None
):
return JSONResponse(
{
"error": "node_id, name, model, initial_message, skill, and resume_ws must be strings"
"error": "node_id, name, model, judge_model, initial_message, skill, and resume_ws must be strings"
},
status_code=400,
)
node_id = raw_node_id
name = raw_name[:256]
model = raw_model[:128]
judge_model = raw_judge_model[:128]
initial_message = raw_initial_message[:4096]
skill = raw_skill[:256]
resume_ws = raw_resume_ws[:64]
@@ -509,6 +580,7 @@ async def create_workstream(request: Request) -> JSONResponse:
ws_body = {
"name": name,
"model": model,
"judge_model": judge_model,
"initial_message": initial_message,
"skill": skill,
"resume_ws": resume_ws,
@@ -918,7 +990,9 @@ async def proxy_index(request: Request) -> Response:
page = page.replace('href="/shared/', f'href="{prefix}/shared/')
page = page.replace('src="/shared/', f'src="{prefix}/shared/')
# Inject console-return banner + proxy shim after <body>
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", html.escape(node_id))
banner = _CONSOLE_BANNER_TEMPLATE.replace(
"NODE_ID_PLACEHOLDER", html.escape(node_id)
).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/"))
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
@@ -999,7 +1073,7 @@ async def proxy_api(request: Request) -> Response:
if request.method == "GET" and path in ("events", "events/global"):
return await _proxy_sse(request, server_url, path, api_prefix=api_prefix)
if request.method == "POST":
if request.method in ("POST", "PUT"):
return await _proxy_post(request, server_url, path, api_prefix=api_prefix)
return await _proxy_get(request, server_url, f"{api_prefix}/{path}")
@@ -1036,7 +1110,7 @@ async def _proxy_get(request: Request, server_url: str, path: str) -> Response:
async def _proxy_post(
request: Request, server_url: str, path: str, *, api_prefix: str = "api"
) -> Response:
"""Forward a POST request to the target server."""
"""Forward a POST/PUT request to the target server."""
client: httpx.AsyncClient = request.app.state.proxy_client
body = await request.body()
content_type = request.headers.get("content-type", "application/json")
@@ -1044,16 +1118,21 @@ async def _proxy_post(
if request.url.query:
target += f"?{request.url.query}"
try:
post_headers = {"Content-Type": content_type}
post_headers.update(_proxy_auth_headers(request))
resp = await client.post(target, content=body, headers=post_headers)
headers = {"Content-Type": content_type}
headers.update(_proxy_auth_headers(request))
resp = await client.request(
request.method,
target,
content=body,
headers=headers,
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
except httpx.HTTPError as exc:
log.debug("Proxy POST error for %s/%s: %s", api_prefix, path, exc)
log.debug("Proxy %s error for %s/%s: %s", request.method, api_prefix, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
@@ -2215,6 +2294,7 @@ _VALID_PERMISSIONS = frozenset(
"admin.watches",
"admin.judge",
"admin.memories",
"admin.nodes",
"admin.settings",
"admin.mcp",
"admin.models",
@@ -5151,7 +5231,13 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
# ---------------------------------------------------------------------------
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible"})
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "google"})
# Keep in sync with turnstone.core.providers._google.GOOGLE_DEFAULT_BASE_URL
_PROVIDER_DEFAULT_URLS: dict[str, str] = {
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"google": "https://generativelanguage.googleapis.com/v1beta/openai/",
}
def _mask_model_secrets(model: dict[str, Any]) -> dict[str, Any]:
@@ -5551,6 +5637,10 @@ async def admin_model_reload(request: Request) -> JSONResponse:
if err:
return err
# Ensure config (including model.default_alias) is fresh on all nodes
# before they rebuild their model registries.
await _publish_config_change(request)
results = await _notify_nodes_model_reload(request)
return JSONResponse({"status": "ok", "results": results})
@@ -5591,6 +5681,10 @@ async def admin_detect_model(request: Request) -> JSONResponse:
if not base_url:
base_url = row.get("base_url", "")
# Apply provider default URL if still empty
if not base_url:
base_url = _PROVIDER_DEFAULT_URLS.get(provider, "")
# For commercial endpoints an api_key is required
_normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else ""
_hostname = (urllib.parse.urlparse(_normalized).hostname or "") if _normalized else ""
@@ -5600,6 +5694,7 @@ async def admin_detect_model(request: Request) -> JSONResponse:
or _hostname.endswith(".openai.com")
or _hostname == "api.anthropic.com"
or _hostname.endswith(".anthropic.com")
or _hostname.endswith(".googleapis.com")
):
return JSONResponse({"error": "api_key is required"}, status_code=400)
@@ -6877,6 +6972,189 @@ async def admin_validate_regex(request: Request) -> JSONResponse:
return JSONResponse({"valid": True})
def _validate_node_id(node_id: str) -> JSONResponse | None:
"""Return an error response if node_id is invalid, else None."""
if not node_id or len(node_id) > 256 or not _VALID_NODE_ID.match(node_id):
return JSONResponse({"error": "Invalid node ID"}, status_code=400)
return None
async def admin_get_all_node_metadata(request: Request) -> JSONResponse:
"""GET /v1/api/admin/node-metadata — metadata for all nodes."""
import json as _anm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
storage, serr = require_storage_or_503(request)
if serr:
return serr
all_meta = storage.get_all_node_metadata()
result: dict[str, list[dict[str, Any]]] = {}
for nid, rows in all_meta.items():
entries = []
for r in rows:
try:
val = _anm_json.loads(r["value"])
except (ValueError, TypeError):
val = r["value"]
entries.append({"key": r["key"], "value": val, "source": r["source"]})
result[nid] = entries
return JSONResponse({"nodes": result})
async def admin_get_node_metadata(request: Request) -> JSONResponse:
"""GET /v1/api/admin/nodes/{node_id}/metadata — all metadata for a node."""
import json as _nm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
storage, serr = require_storage_or_503(request)
if serr:
return serr
rows = storage.get_node_metadata(node_id)
metadata = []
for r in rows:
try:
val = _nm_json.loads(r["value"])
except (ValueError, TypeError):
val = r["value"]
metadata.append({"key": r["key"], "value": val, "source": r["source"]})
return JSONResponse({"node_id": node_id, "metadata": metadata})
async def admin_set_node_metadata(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/nodes/{node_id}/metadata — bulk set user metadata."""
import json as _nm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
entries = body.get("entries", [])
if not entries:
return JSONResponse({"error": "No entries provided"}, status_code=400)
storage, serr = require_storage_or_503(request)
if serr:
return serr
# Validate entries
existing = {r["key"]: r["source"] for r in storage.get_node_metadata(node_id)}
for e in entries:
key = e.get("key", "")
if not key:
return JSONResponse({"error": "Empty key"}, status_code=400)
if len(key) > 128:
return JSONResponse(
{"error": f"Key too long (max 128): {key[:32]}..."}, status_code=400
)
if "value" not in e:
return JSONResponse({"error": f"Missing value for key: {key}"}, status_code=400)
if existing.get(key) == "auto":
return JSONResponse(
{"error": f"Cannot overwrite auto-populated key: {key}"},
status_code=400,
)
bulk = [(e["key"], _nm_json.dumps(e["value"]), "user") for e in entries]
storage.set_node_metadata_bulk(node_id, bulk)
return JSONResponse({"ok": True, "count": len(bulk)})
async def admin_set_node_metadata_key(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/nodes/{node_id}/metadata/{key} — set single key."""
import json as _nm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
key = request.path_params["key"]
if not key:
return JSONResponse({"error": "Empty key"}, status_code=400)
if len(key) > 128:
return JSONResponse({"error": "Key too long (max 128)"}, status_code=400)
storage, serr = require_storage_or_503(request)
if serr:
return serr
existing = storage.get_node_metadata(node_id)
for r in existing:
if r["key"] == key and r["source"] == "auto":
return JSONResponse(
{"error": f"Cannot overwrite auto-populated key: {key}"},
status_code=400,
)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
if "value" not in body:
return JSONResponse({"error": "Missing value"}, status_code=400)
storage.set_node_metadata(node_id, key, _nm_json.dumps(body["value"]), source="user")
return JSONResponse({"ok": True})
async def admin_delete_node_metadata_key(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/nodes/{node_id}/metadata/{key} — delete single key."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
key = request.path_params["key"]
if not key:
return JSONResponse({"error": "Empty key"}, status_code=400)
storage, serr = require_storage_or_503(request)
if serr:
return serr
existing = storage.get_node_metadata(node_id)
for r in existing:
if r["key"] == key and r["source"] == "auto":
return JSONResponse(
{"error": f"Cannot delete auto-populated key: {key}"},
status_code=400,
)
deleted = storage.delete_node_metadata(node_id, key)
if not deleted:
return JSONResponse({"error": "Key not found"}, status_code=404)
return JSONResponse({"ok": True})
async def admin_ring_status(request: Request) -> JSONResponse:
"""GET /v1/api/admin/ring/status — hash ring rebalancer status."""
from turnstone.core.auth import require_permission
@@ -7409,6 +7687,24 @@ def create_app(
admin_rescan_skill,
methods=["POST"],
),
# Node metadata
Route("/api/admin/node-metadata", admin_get_all_node_metadata),
Route(
"/api/admin/nodes/{node_id}/metadata/{key}",
admin_set_node_metadata_key,
methods=["PUT"],
),
Route(
"/api/admin/nodes/{node_id}/metadata/{key}",
admin_delete_node_metadata_key,
methods=["DELETE"],
),
Route("/api/admin/nodes/{node_id}/metadata", admin_get_node_metadata),
Route(
"/api/admin/nodes/{node_id}/metadata",
admin_set_node_metadata,
methods=["PUT"],
),
# Hash ring
Route("/api/admin/ring/status", admin_ring_status),
Route(
+308 -1
View File
@@ -239,6 +239,7 @@ function switchAdminTab(tab) {
"audit",
"memories",
"models",
"node-metadata",
"settings",
"tls",
"mcp",
@@ -265,6 +266,7 @@ function switchAdminTab(tab) {
}
if (tab === "memories") loadAdminMemories();
if (tab === "models") loadAdminModels();
if (tab === "node-metadata") loadAdminNodeMetadata();
if (tab === "settings") loadSettings();
if (tab === "tls") loadTlsCerts();
if (tab === "mcp") loadAdminMcp();
@@ -3044,6 +3046,29 @@ function _saveSettingValue(key) {
showToast(
"Saved " + key + (restartBadge ? " \u2014 restart required" : ""),
);
// If this is a theme setting, apply it immediately. Don't call
// onThemeChange — it would fire a redundant PUT since the settings
// save above already persisted the value.
if (key === "interface.theme") {
var isLight = value === "light";
document.documentElement.dataset.theme = isLight ? "light" : "";
localStorage.setItem(
"turnstone_interface.theme",
isLight ? "light" : "dark",
);
var themeBtn = document.getElementById("theme-toggle");
if (themeBtn) {
themeBtn.textContent = isLight ? "\u2600" : "\u263E";
themeBtn.title = isLight
? "Switch to dark theme"
: "Switch to light theme";
themeBtn.setAttribute(
"aria-label",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
}
})
.catch(function (err) {
if (saveBtn) {
@@ -4392,7 +4417,11 @@ function _renderModels(items) {
var providerCls =
m.provider === "anthropic"
? "model-provider-anthropic"
: "model-provider-openai";
: m.provider === "google"
? "model-provider-google"
: m.provider === "openai-compatible"
? "model-provider-compat"
: "model-provider-openai";
// Build row via DOM
var row = document.createElement("div");
@@ -4570,6 +4599,7 @@ function showCreateModelModal() {
document.getElementById("model-detect-btn").disabled = false;
document.getElementById("model-detect-btn").textContent = "Detect";
_refreshModelSuggestions();
_applyProviderDefaults();
document.getElementById("model-alias").focus();
_modelCreateTrap = _installTrap("model-create-overlay", "model-create-box");
}
@@ -4606,6 +4636,7 @@ function showEditModelModal(definitionId) {
if (caps === "{}") caps = "";
document.getElementById("model-capabilities").value = caps;
document.getElementById("model-enabled").checked = m.enabled !== false;
_applyProviderDefaults();
})
.catch(function () {
showToast("Failed to load model details");
@@ -4780,6 +4811,18 @@ function detectModel() {
}
resultDiv.appendChild(_detectResultLine(msg, "yellow"));
}
if (d.available_models && d.available_models.length > 0) {
var dl = document.getElementById("model-name-suggestions");
if (dl) {
dl.textContent = "";
d.available_models.forEach(function (m) {
var opt = document.createElement("option");
opt.value = m;
dl.appendChild(opt);
});
}
}
if (d.context_window) {
resultDiv.appendChild(
_detectResultLine(
@@ -4859,6 +4902,37 @@ function _onModelFieldChange() {
});
}, 500);
}
/* Provider-specific placeholder hints for base_url and model ID fields.
Keep URLs in sync with _PROVIDER_DEFAULT_URLS in console/server.py
and GOOGLE_DEFAULT_BASE_URL in core/providers/_google.py. */
var _providerDefaults = {
openai: {
urlPlaceholder: "https://api.openai.com/v1",
modelPlaceholder: "gpt-5",
},
anthropic: {
urlPlaceholder: "https://api.anthropic.com",
modelPlaceholder: "claude-",
},
google: {
urlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/",
modelPlaceholder: "gemini-",
},
"openai-compatible": {
urlPlaceholder: "e.g. https://your-provider.com/v1",
modelPlaceholder: "GLM5",
},
};
/* Update placeholders when provider changes. */
function _applyProviderDefaults() {
var provider = document.getElementById("model-provider").value;
var def = _providerDefaults[provider];
if (!def) return;
document.getElementById("model-base-url").placeholder = def.urlPlaceholder;
document.getElementById("model-name").placeholder = def.modelPlaceholder;
}
/* Populate the model name datalist with known model prefixes for the
selected provider. Called on page load and provider change. */
function _refreshModelSuggestions() {
@@ -4894,6 +4968,7 @@ function _refreshModelSuggestions() {
provEl.addEventListener("change", _onModelFieldChange);
provEl.addEventListener("change", _refreshModelSuggestions);
provEl.addEventListener("change", _clearDetectResult);
provEl.addEventListener("change", _applyProviderDefaults);
}
/* Clear stale detect results when probe-relevant inputs change */
["model-base-url", "model-api-key"].forEach(function (id) {
@@ -4933,3 +5008,235 @@ function reloadModelNodes() {
btn.textContent = "Sync to Nodes";
});
}
// ---------------------------------------------------------------------------
// Node Metadata tab
// ---------------------------------------------------------------------------
var _nodeMetaCache = {};
function loadAdminNodeMetadata() {
var container = document.getElementById("admin-node-metadata-content");
if (!container) return;
container.innerHTML = '<div class="dashboard-empty">Loading\u2026</div>';
// Single bulk fetch for all node metadata
authFetch("/v1/api/admin/node-metadata")
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (data) {
_nodeMetaCache = data.nodes || {};
_renderNodeMetadata();
})
.catch(function () {
container.innerHTML =
'<div class="dashboard-empty">Failed to load node metadata</div>';
});
}
function _renderNodeMetadata() {
var container = document.getElementById("admin-node-metadata-content");
if (!container) return;
var nodeIds = Object.keys(_nodeMetaCache).sort();
if (!nodeIds.length) {
container.innerHTML =
'<div class="dashboard-empty">No nodes registered</div>';
return;
}
var html = "";
nodeIds.forEach(function (nid) {
var meta = _nodeMetaCache[nid] || [];
html +=
'<div class="settings-section" data-section="nm-' +
escapeHtml(nid) +
'" data-collapsed>';
html +=
'<div class="settings-section-header" onclick="_toggleSettingsSection(this)" ';
html += 'onkeydown="_onSettingsHeaderKey(event,this)" ';
html += 'role="button" tabindex="0" aria-expanded="false" ';
html += 'aria-controls="nm-body-' + escapeHtml(nid) + '">';
html +=
"<span>" +
escapeHtml(nid) +
" <small>(" +
meta.length +
" keys)</small></span>";
html += "</div>";
html +=
'<div class="settings-section-body" id="nm-body-' +
escapeHtml(nid) +
'">';
// Table of metadata — all values passed through escapeHtml()
if (meta.length) {
html += '<table class="nm-table">';
html +=
'<caption class="sr-only">Metadata for node ' +
escapeHtml(nid) +
"</caption>";
html += '<thead><tr><th scope="col">Key</th>';
html += '<th scope="col">Value</th>';
html += '<th scope="col">Source</th>';
html +=
'<th scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>';
meta.forEach(function (m) {
var valStr =
typeof m.value === "object"
? JSON.stringify(m.value)
: String(m.value);
var isAuto = m.source === "auto";
html += "<tr>";
html += '<td class="nm-key">' + escapeHtml(m.key) + "</td>";
html +=
'<td class="nm-val" title="' +
escapeHtml(valStr) +
'">' +
escapeHtml(valStr) +
"</td>";
html +=
'<td><span class="nm-source-badge nm-source-' +
escapeHtml(m.source) +
'">' +
escapeHtml(m.source) +
"</span></td>";
html += "<td>";
if (!isAuto) {
html +=
'<button class="admin-btn-danger nm-del-btn" aria-label="Delete ' +
escapeHtml(m.key) +
'" data-node="' +
escapeHtml(nid) +
'" data-key="' +
escapeHtml(m.key) +
'">Del</button>';
}
html += "</td></tr>";
});
html += "</tbody></table>";
} else {
html +=
'<div class="dashboard-empty" style="padding:8px">No metadata</div>';
}
// Add metadata form
html += '<div class="nm-add-row">';
html +=
'<input id="nm-key-' +
escapeHtml(nid) +
'" type="text" placeholder="key" aria-label="Metadata key">';
html +=
'<input id="nm-val-' +
escapeHtml(nid) +
'" type="text" placeholder="value (JSON or string)" aria-label="Metadata value">';
html +=
'<button class="admin-btn-action nm-add-btn" data-node="' +
escapeHtml(nid) +
'" style="white-space:nowrap">Add</button>';
html += "</div>";
html += "</div></div>";
});
container.innerHTML = html;
// Bind button handlers (data-* attrs carry node/key context)
var delBtns = container.querySelectorAll(".nm-del-btn");
for (var d = 0; d < delBtns.length; d++) {
delBtns[d].addEventListener("click", function () {
_deleteNodeMeta(
this.getAttribute("data-node"),
this.getAttribute("data-key"),
);
});
}
var addBtns = container.querySelectorAll(".nm-add-btn");
for (var a = 0; a < addBtns.length; a++) {
addBtns[a].addEventListener("click", function () {
_addNodeMeta(this.getAttribute("data-node"));
});
}
}
function _addNodeMeta(nodeId) {
var keyEl = document.getElementById("nm-key-" + nodeId);
var valEl = document.getElementById("nm-val-" + nodeId);
if (!keyEl || !valEl) return;
var key = keyEl.value.trim();
var rawVal = valEl.value.trim();
if (!key) {
showToast("Key is required", "error");
return;
}
var value;
try {
value = JSON.parse(rawVal);
} catch (e) {
value = rawVal;
}
authFetch(
"/v1/api/admin/nodes/" +
encodeURIComponent(nodeId) +
"/metadata/" +
encodeURIComponent(key),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: value }),
},
)
.then(function (r) {
if (!r.ok)
return r
.json()
.catch(function () {
return {};
})
.then(function (d) {
throw new Error(d.error || "Failed");
});
showToast("Metadata set");
loadAdminNodeMetadata();
})
.catch(function (e) {
showToast(e.message, "error");
});
}
function _deleteNodeMeta(nodeId, key) {
showConfirmModal(
"Delete Metadata",
'Delete key "' + key + '" from node ' + nodeId + "?",
"Delete",
function () {
authFetch(
"/v1/api/admin/nodes/" +
encodeURIComponent(nodeId) +
"/metadata/" +
encodeURIComponent(key),
{
method: "DELETE",
},
)
.then(function (r) {
if (!r.ok)
return r
.json()
.catch(function () {
return {};
})
.then(function (d) {
throw new Error(d.error || "Failed");
});
showToast("Metadata deleted");
loadAdminNodeMetadata();
})
.catch(function (e) {
showToast(e.message, "error");
});
},
);
}
+107 -6
View File
@@ -10,14 +10,35 @@ window.onLogout = function () {
};
window.onThemeChange = function (next) {
var btn = document.getElementById("theme-toggle");
if (btn) btn.textContent = next === "light" ? "\u2600" : "\u263E";
if (btn) {
var isLight = next === "light";
btn.textContent = isLight ? "\u2600" : "\u263E";
btn.title = isLight ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute(
"aria-label",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
// Persist to server so admin settings and node UIs see the change
var themeValue = next === "light" ? "light" : "dark";
authFetch("/v1/api/admin/settings/interface.theme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: themeValue }),
}).catch(function () {});
};
// Set initial theme button text
// Set initial theme button text and aria
(function () {
var btn = document.getElementById("theme-toggle");
if (btn)
btn.textContent =
document.documentElement.dataset.theme === "light" ? "\u2600" : "\u263E";
if (btn) {
var isLight = document.documentElement.dataset.theme === "light";
btn.textContent = isLight ? "\u2600" : "\u263E";
btn.title = isLight ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute(
"aria-label",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
})();
// --- State ---
@@ -946,6 +967,7 @@ function drillDownToNode(nodeId, serverUrl) {
'<div class="dashboard-empty">Loading workstreams...</div>';
loadNodeDetail(nodeId);
}
_loadNodeMetadataPanel(nodeId);
document.getElementById("breadcrumb-home").focus();
if (!_navigatingFromPopstate)
history.pushState(
@@ -1114,7 +1136,7 @@ function renderWsTable(container, wsList) {
// NAME
var nameCell = document.createElement("span");
nameCell.className = "dash-cell-name";
nameCell.textContent = ws.name || ws.id || "";
nameCell.textContent = ws.name || ws.title || ws.id || "";
main.appendChild(nameCell);
// MODEL
@@ -1281,11 +1303,20 @@ function showNewWsModal() {
});
// Populate model dropdown
var modelSelect = document.getElementById("new-ws-model");
var judgeSelect = document.getElementById("new-ws-judge");
modelSelect.textContent = "";
judgeSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "Default model";
modelSelect.appendChild(defaultOpt);
var defaultJudgeOpt = document.createElement("option");
defaultJudgeOpt.value = "";
defaultJudgeOpt.textContent = "Default (agent model)";
judgeSelect.appendChild(defaultJudgeOpt);
authFetch("/v1/api/models")
.then(function (r) {
return r.json();
@@ -1297,6 +1328,11 @@ function showNewWsModal() {
opt.textContent =
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
modelSelect.appendChild(opt);
var jOpt = document.createElement("option");
jOpt.value = m.alias;
jOpt.textContent = opt.textContent;
judgeSelect.appendChild(jOpt);
});
})
.catch(function () {
@@ -1304,6 +1340,7 @@ function showNewWsModal() {
});
document.getElementById("new-ws-name").value = "";
modelSelect.value = "";
judgeSelect.value = "";
var taskEl = document.getElementById("new-ws-task");
taskEl.value = "";
var mod =
@@ -1323,6 +1360,11 @@ function showNewWsModal() {
if (_newWsTrapHandler)
document.removeEventListener("keydown", _newWsTrapHandler);
_newWsTrapHandler = function (e) {
if (e.key === "Escape") {
e.preventDefault();
hideNewWsModal();
return;
}
if (e.key === "Tab") {
var box = document.getElementById("new-ws-box");
var focusable = box.querySelectorAll("select, input, textarea, button");
@@ -1363,6 +1405,7 @@ function submitNewWs() {
var nodeId = document.getElementById("new-ws-node").value;
var name = document.getElementById("new-ws-name").value.trim();
var model = document.getElementById("new-ws-model").value.trim();
var judgeModel = document.getElementById("new-ws-judge").value.trim();
var skill = document.getElementById("new-ws-skill").value;
var task = document.getElementById("new-ws-task").value.trim();
var errEl = document.getElementById("new-ws-error");
@@ -1376,6 +1419,7 @@ function submitNewWs() {
if (nodeId) body.node_id = nodeId;
if (name) body.name = name;
if (model) body.model = model;
if (judgeModel) body.judge_model = judgeModel;
if (task) body.initial_message = task;
if (skill) body.skill = skill;
@@ -1441,3 +1485,60 @@ function _ensureSSE() {
history.replaceState({ view: "overview" }, "");
initLogin();
loadOverview();
// --- Node Metadata Panel (read-only in node detail view) ---
function _loadNodeMetadataPanel(nodeId) {
var section = document.getElementById("node-metadata-section");
var table = document.getElementById("node-metadata-table");
if (!section || !table) return;
section.style.display = "none";
table.textContent = "";
authFetch("/v1/api/cluster/node/" + encodeURIComponent(nodeId))
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (data) {
if (!data || !data.metadata || !data.metadata.length) return;
section.style.display = "";
var tbl = document.createElement("table");
tbl.className = "nm-table";
var thead = document.createElement("thead");
var hr = document.createElement("tr");
["Key", "Value", "Source"].forEach(function (h) {
var th = document.createElement("th");
th.setAttribute("scope", "col");
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tbl.appendChild(thead);
var tbody = document.createElement("tbody");
data.metadata.forEach(function (m) {
var tr = document.createElement("tr");
var tdKey = document.createElement("td");
tdKey.className = "nm-key";
tdKey.textContent = m.key;
tr.appendChild(tdKey);
var tdVal = document.createElement("td");
tdVal.className = "nm-val";
tdVal.textContent =
typeof m.value === "object"
? JSON.stringify(m.value)
: String(m.value);
tdVal.title = tdVal.textContent;
tr.appendChild(tdVal);
var tdSrc = document.createElement("td");
var badge = document.createElement("span");
badge.className = "nm-source-badge nm-source-" + m.source;
badge.textContent = m.source;
tdSrc.appendChild(badge);
tr.appendChild(tdSrc);
tbody.appendChild(tr);
});
tbl.appendChild(tbody);
table.appendChild(tbl);
})
.catch(function () {
/* silent — metadata is supplementary */
});
}
+23 -1
View File
@@ -53,6 +53,12 @@
<span class="dash-col dash-col-ctx">CTX</span>
</div>
<div id="node-ws-table" class="dash-table" role="group" aria-label="Workstreams" aria-live="polite"></div>
<div id="node-metadata-section" style="margin-top:16px;display:none">
<div class="dash-header">
<span class="dash-header-title">METADATA</span>
</div>
<div id="node-metadata-table" style="font-size:.85rem"></div>
</div>
<a id="node-link" class="node-link">Open node UI</a>
</div>
@@ -112,6 +118,7 @@
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
<button id="tab-models" class="admin-nav" data-tab="models" role="tab" aria-selected="false" aria-controls="admin-models" tabindex="-1" onclick="switchAdminTab('models')">Models</button>
<button id="tab-node-metadata" class="admin-nav" data-tab="node-metadata" role="tab" aria-selected="false" aria-controls="admin-node-metadata" tabindex="-1" onclick="switchAdminTab('node-metadata')">Nodes</button>
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
<button id="tab-tls" class="admin-nav" data-tab="tls" role="tab" aria-selected="false" aria-controls="admin-tls" tabindex="-1" onclick="switchAdminTab('tls')">TLS</button>
</div>
@@ -655,6 +662,16 @@
</div>
</div>
<!-- Node Metadata Tab -->
<div id="admin-node-metadata" class="admin-panel" role="tabpanel" aria-labelledby="tab-node-metadata" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">NODE METADATA</span>
</div>
<div id="admin-node-metadata-content" role="list" aria-label="Node metadata" aria-live="polite">
<div class="dashboard-empty">Loading&hellip;</div>
</div>
</div>
<!-- Settings Tab -->
<div id="admin-settings" class="admin-panel" role="tabpanel" aria-labelledby="tab-settings" style="display:none">
<div class="admin-toolbar">
@@ -791,6 +808,10 @@ window.TURNSTONE_KB_SHORTCUTS = [
<select id="new-ws-skill">
<option value="">Use defaults</option>
</select>
<label for="new-ws-judge">Judge Model <span class="label-hint">optional</span></label>
<select id="new-ws-judge">
<option value="">Default (agent model)</option>
</select>
<div id="new-ws-buttons">
<button id="new-ws-cancel" onclick="hideNewWsModal()">Cancel</button>
<button id="new-ws-submit" onclick="submitNewWs()">Create</button>
@@ -1521,6 +1542,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
<select id="model-provider">
<option value="openai">openai</option>
<option value="anthropic">anthropic</option>
<option value="google">google</option>
<option value="openai-compatible">openai-compatible</option>
</select>
<label for="model-base-url">Base URL <span style="font-weight:400;text-transform:none">(empty = provider default)</span></label>
@@ -1535,7 +1557,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
<label style="margin:0;font-size:12px;color:var(--fg-dim)"><input type="checkbox" id="model-enabled" checked style="margin-right:5px">Enabled</label>
</div>
<div id="model-detect-area" style="margin-top:14px">
<button type="button" id="model-detect-btn" class="modal-cancel" onclick="detectModel()" style="width:auto;padding:7px 16px;font-size:12px" title="Probe endpoint to verify connectivity and discover models">Detect</button>
<button type="button" id="model-detect-btn" class="admin-action-btn" onclick="detectModel()" style="width:auto;padding:7px 16px;font-size:12px" title="Probe endpoint to verify connectivity and discover models">Detect</button>
<div id="model-detect-result" role="status" aria-live="polite" style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);word-break:break-word"></div>
</div>
<div class="modal-buttons">
+87
View File
@@ -2468,6 +2468,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.model-provider-badge{display:inline-block;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:2px;background:var(--bg-highlight);border:1px solid var(--border)}
.model-provider-openai{color:var(--blue);border-color:rgba(56,189,248,.2)}
.model-provider-anthropic{color:var(--magenta);border-color:rgba(192,132,252,.25)}
.model-provider-google{color:var(--green);border-color:rgba(52,211,153,.2)}
.model-provider-compat{color:var(--fg-dim);border-color:var(--border-strong)}
/* Model source badge */
.scope-db{color:var(--blue);border-color:rgba(56,189,248,.2)}
@@ -2497,3 +2499,88 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.mcp-reg-card-repo { transition: none; }
.mcp-sync-pending, .model-sync-pending { animation: none; }
}
/* Node metadata */
.nm-source-badge {
display: inline-block;
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nm-source-auto { background: var(--green-glow); color: var(--green); }
.nm-source-user { background: var(--cyan-glow); color: var(--cyan); }
.nm-source-config { background: var(--yellow-glow); color: var(--yellow); }
.nm-table { width: 100%; border-collapse: collapse; }
.nm-table th {
font-family: var(--font-display);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
padding: 4px 8px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.nm-table td {
padding: 4px 8px;
font-size: 12px;
color: var(--fg);
border-bottom: 1px solid var(--border);
}
.nm-key {
font-family: var(--font-mono);
color: var(--fg-bright);
}
.nm-val {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nm-add-row {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0;
}
.nm-add-row input[type="text"] {
padding: 5px 8px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--fg);
font: inherit;
font-size: 12px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.nm-add-row input[type="text"]:first-of-type { width: 120px; }
.nm-add-row input[type="text"]:nth-of-type(2) { flex: 1; }
.nm-add-row input[type="text"]:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-dim);
}
.nm-add-row input[type="text"]::placeholder {
color: var(--fg-dim);
opacity: 0.6;
}
.nm-add-row input[type="text"]:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 700px) {
.nm-add-row { flex-wrap: wrap; }
.nm-add-row input[type="text"] { width: 100% !important; flex: none; }
.nm-val { max-width: 150px; }
}
@media (prefers-reduced-motion: reduce) {
.nm-add-row input[type="text"] { transition: none; }
}
+15
View File
@@ -434,6 +434,13 @@ def required_scope(method: str, path: str) -> str:
and normalized.endswith("/cancel")
):
return "write"
# Workstream sub-resource mutations: /api/workstreams/{ws_id}/{action}
if (
method == "POST"
and normalized.startswith("/api/workstreams/")
and normalized.rsplit("/", 1)[-1] in {"delete", "open", "refresh-title", "title"}
):
return "write"
# Memory delete: /api/memories/{name}
if method == "DELETE" and normalized.startswith("/api/memories/"):
return "write"
@@ -446,6 +453,14 @@ def required_scope(method: str, path: str) -> str:
return "approve"
if proxied in WRITE_PATHS:
return "write"
# Parametric workstream sub-resource mutations
if proxied.startswith("/api/workstreams/") and proxied.rsplit("/", 1)[-1] in {
"delete",
"open",
"refresh-title",
"title",
}:
return "write"
return "read"
+234 -53
View File
@@ -72,16 +72,23 @@ class IntentVerdict:
@dataclass
class JudgeConfig:
"""Configuration for the intent validation judge."""
"""Configuration for the intent validation judge.
The *timeout* value applies **per turn**, not as a total budget across
all turns. With the default of 60 s and a maximum of 5 turns, a
single tool-call evaluation can take up to 300 s in the worst case
(e.g. a multi-turn tool-use exchange with a slow local model).
"""
enabled: bool = True
model: str = "" # empty = use session model
confidence_threshold: float = 0.7
max_context_ratio: float = 0.5
timeout: float = 60.0
timeout: float = 60.0 # per-turn timeout in seconds (see class docstring)
read_only_tools: bool = True
output_guard: bool = True
redact_secrets: bool = True
cancel_on_approval: bool = False # True = abort remaining items on user approval
# ---------------------------------------------------------------------------
@@ -910,7 +917,10 @@ class IntentJudge:
if model_registry.has_alias(config.model):
client, model_name, _ = model_registry.resolve(config.model)
self._provider = model_registry.get_provider(config.model)
self._client = client
self._client_factory_args = self._extract_client_config(
client,
self._provider.provider_name,
)
self._model = model_name
caps = self._provider.get_capabilities(self._model)
self._judge_context_window = caps.context_window
@@ -921,17 +931,38 @@ class IntentJudge:
if not resolved and config.model:
# Model name override with session provider
self._provider = session_provider
self._client = session_client
self._client_factory_args = self._extract_client_config(
session_client,
session_provider.provider_name,
)
self._model = config.model
caps = self._provider.get_capabilities(self._model)
self._judge_context_window = caps.context_window
elif not resolved:
# Self-consistency: same model as session
self._provider = session_provider
self._client = session_client
self._client_factory_args = self._extract_client_config(
session_client,
session_provider.provider_name,
)
self._model = session_model
self._judge_context_window = context_window
# -- Client lifecycle helpers -------------------------------------------
@staticmethod
def _extract_client_config(client: Any, provider_name: str) -> dict[str, str]:
"""Extract connection config from an existing SDK client for re-creation."""
base_url = str(getattr(client, "base_url", getattr(client, "_base_url", "")))
api_key = getattr(client, "api_key", "") or ""
return {"provider_name": provider_name, "base_url": base_url, "api_key": api_key}
def _create_client(self) -> Any:
"""Create a fresh HTTP client for a judge evaluation run."""
from turnstone.core.providers import create_client
return create_client(**self._client_factory_args)
def evaluate(
self,
items: list[dict[str, Any]],
@@ -995,26 +1026,76 @@ class IntentJudge:
callback: Callable[[IntentVerdict], None],
cancel_event: threading.Event | None = None,
) -> None:
"""Daemon thread: run LLM judge for each item and invoke callback."""
# Evaluation-scoped executor — avoids sharing mutable state with
# other daemon threads from concurrent evaluate() calls.
"""Daemon thread: run LLM judge for each item and invoke callback.
When ``cancel_on_approval`` is True, remaining evaluations are
aborted as soon as the user approves/denies. When False (default),
every evaluation runs to completion so all verdicts are delivered.
"""
client = self._create_client()
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
try:
for idx, (item, h_verdict) in enumerate(zip(items, heuristic_verdicts, strict=True)):
if cancel_event and cancel_event.is_set():
log.debug("judge.cancelled", remaining=len(items) - idx)
if cancel_event and cancel_event.is_set() and self._config.cancel_on_approval:
log.info("judge.cancelled", remaining=len(items) - idx)
self._deliver_fallbacks(
items[idx:],
heuristic_verdicts[idx:],
callback,
"judge cancelled by user approval",
)
return
try:
llm_verdict = self._evaluate_single(item, messages, cancel_event, executor)
if cancel_event and cancel_event.is_set():
return
# Arbitrate: only callback when LLM upgrades the heuristic
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
llm_verdict = self._evaluate_single(
item,
messages,
cancel_event,
executor,
client,
)
if llm_verdict:
log.info(
"judge.verdict.llm",
recommendation=llm_verdict.recommendation,
confidence=llm_verdict.confidence,
call_id=llm_verdict.call_id,
)
callback(llm_verdict)
# else: heuristic already delivered, no duplicate callback
else:
fallback = IntentVerdict(
verdict_id=h_verdict.verdict_id,
call_id=h_verdict.call_id,
func_name=h_verdict.func_name,
func_args=h_verdict.func_args,
intent_summary=h_verdict.intent_summary,
risk_level=h_verdict.risk_level,
confidence=h_verdict.confidence,
recommendation=h_verdict.recommendation,
reasoning=h_verdict.reasoning + " (LLM judge did not return a verdict)",
evidence=h_verdict.evidence,
tier="llm_fallback",
judge_model=self._model,
latency_ms=h_verdict.latency_ms,
)
log.info(
"judge.verdict.fallback",
recommendation=fallback.recommendation,
confidence=fallback.confidence,
call_id=fallback.call_id,
)
callback(fallback)
# After delivering this item's verdict, check if we should
# abort remaining items due to user approval.
if cancel_event and cancel_event.is_set() and self._config.cancel_on_approval:
log.info("judge.cancelled.after_eval", call_id=item.get("call_id", ""))
self._deliver_fallbacks(
items[idx + 1 :],
heuristic_verdicts[idx + 1 :],
callback,
"judge cancelled by user approval",
)
return
except _ExecutorPoisonedError:
# Timeout left the worker stuck — replace the executor
# so subsequent items don't queue behind it.
executor.shutdown(wait=False, cancel_futures=True)
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
except Exception:
@@ -1024,6 +1105,37 @@ class IntentJudge:
)
finally:
executor.shutdown(wait=False, cancel_futures=True)
try:
if hasattr(client, "close"):
client.close()
except Exception:
pass
def _deliver_fallbacks(
self,
remaining_items: list[dict[str, Any]],
remaining_verdicts: list[IntentVerdict],
callback: Callable[[IntentVerdict], None],
reason: str,
) -> None:
"""Deliver heuristic fallback verdicts for items the judge didn't complete."""
for _item, h_verdict in zip(remaining_items, remaining_verdicts, strict=True):
fallback = IntentVerdict(
verdict_id=h_verdict.verdict_id,
call_id=h_verdict.call_id,
func_name=h_verdict.func_name,
func_args=h_verdict.func_args,
intent_summary=h_verdict.intent_summary,
risk_level=h_verdict.risk_level,
confidence=h_verdict.confidence,
recommendation=h_verdict.recommendation,
reasoning=h_verdict.reasoning + f" ({reason})",
evidence=h_verdict.evidence,
tier="llm_fallback",
judge_model=self._model,
latency_ms=h_verdict.latency_ms,
)
callback(fallback)
def _evaluate_single(
self,
@@ -1031,6 +1143,7 @@ class IntentJudge:
messages: list[dict[str, Any]],
cancel_event: threading.Event | None,
executor: ThreadPoolExecutor,
client: Any,
) -> IntentVerdict | None:
"""Run LLM judge for a single tool call. Returns verdict or None."""
start = time.monotonic()
@@ -1052,17 +1165,25 @@ class IntentJudge:
# Prepare tools (only if read_only_tools enabled).
# Pass raw OpenAI-format schemas — create_completion handles conversion.
# Google's API requires thought_signature in function call round-trips
# which our normalized tool_calls don't preserve, so skip tools for Google.
tools: list[dict[str, Any]] | None = None
if self._config.read_only_tools:
tools = _JUDGE_TOOL_SCHEMAS
if self._config.read_only_tools and self._provider.provider_name != "google":
tools = list(_JUDGE_TOOL_SCHEMAS)
# Multi-turn judge loop
timeout_budget = self._config.timeout
result = None # will hold the last CompletionResult
empty_retries = 0 # track consecutive empty responses for retry
turn = 0
for turn in range(_JUDGE_MAX_TURNS):
if cancel_event and cancel_event.is_set():
return None
while turn < _JUDGE_MAX_TURNS:
log.info(
"judge.turn.start",
turn=turn + 1,
max_turns=_JUDGE_MAX_TURNS,
func_name=func_name,
call_id=call_id[:8],
)
turn_start = time.monotonic()
@@ -1082,14 +1203,13 @@ class IntentJudge:
}
)
# Per-call timeout: cap each API call to the remaining budget.
# create_completion() is blocking and the SDK default timeout is
# 10 minutes — far too long for an advisory judge on local models.
per_call_timeout = max(timeout_budget, 5.0) # at least 5s
# Per-turn timeout: each turn gets a fresh budget so local
# models aren't penalised for slow earlier turns.
per_call_timeout = max(self._config.timeout, 5.0) # at least 5s
try:
future = executor.submit(
self._provider.create_completion,
client=self._client,
client=client,
model=self._model,
messages=judge_messages,
tools=None if is_last_turn else tools,
@@ -1113,27 +1233,38 @@ class IntentJudge:
except TimeoutError:
pass # loop back to check remaining/cancel
except TimeoutError:
log.warning("Judge LLM call timed out on turn %d (%.0fs)", turn, per_call_timeout)
raise _ExecutorPoisonedError from None
except Exception:
log.exception("Judge LLM call failed on turn %d", turn)
return None
turn_elapsed = time.monotonic() - turn_start
timeout_budget -= turn_elapsed
if timeout_budget <= 0:
log.warning("Judge timeout after turn %d", turn)
log.info("judge.turn.timeout", turn=turn + 1, timeout=per_call_timeout)
# Safety net: if we have a partial result from a previous turn,
# try to parse a verdict from it before giving up.
if result and result.content:
return self._parse_verdict(
verdict = self._parse_verdict(
result.content,
func_name,
call_id,
int((time.monotonic() - start) * 1000),
func_args=func_args_json,
)
if verdict:
log.info("judge.verdict.from_partial", turn=turn + 1)
return verdict
raise _ExecutorPoisonedError from None
except Exception as e:
log.info("judge.turn.failed", turn=turn + 1, error=str(e))
return None
turn_elapsed = time.monotonic() - turn_start
log.info(
"judge.turn.response",
turn=turn + 1,
chars=len(result.content or ""),
tools=len(result.tool_calls or []),
elapsed=round(turn_elapsed, 1),
)
# Reset empty-response counter after any non-empty response
if result.content or result.tool_calls:
empty_retries = 0
# Check for tool calls
if result.tool_calls:
# Execute read-only tools and append results
@@ -1163,6 +1294,7 @@ class IntentJudge:
"content": tool_result,
}
)
turn += 1
continue
# No tool calls — parse the verdict from content
@@ -1175,6 +1307,11 @@ class IntentJudge:
func_args=func_args_json,
)
if verdict:
log.info(
"judge.verdict.success",
recommendation=verdict.recommendation,
confidence=verdict.confidence,
)
return verdict
# Model produced text but no parseable verdict — on last turn
# this means the model refused to comply with the forcing message.
@@ -1195,7 +1332,33 @@ class IntentJudge:
),
}
)
turn += 1
continue
# Empty response (0 chars, 0 tools). If the model hit the
# output token limit the finish_reason will be "length" — retrying
# with the same prompt and max_tokens is pointless.
if result.finish_reason == "length":
log.info("judge.empty_response.length_stop", turn=turn + 1)
return None
# Transient empty response — retry up to 3 times without
# consuming the turn budget.
empty_retries += 1
if empty_retries <= 3:
log.info("judge.empty_response.retry", retry=empty_retries, max_retries=3)
judge_messages.append(
{
"role": "user",
"content": (
"You returned an empty response. "
"Please analyze the tool call and respond with "
"the JSON verdict object."
),
}
)
continue
log.info("judge.empty_response.giving_up", retries=empty_retries)
return None
# Max turns reached without a final verdict
@@ -1256,27 +1419,45 @@ class IntentJudge:
total_chars += msg_chars
truncated.reverse()
# Filter to just role + content (strip internal keys)
clean_history: list[dict[str, Any]] = []
# Flatten history into a plaintext transcript inside a single user
# message. This avoids multi-turn role sequences (consecutive user/
# assistant messages, tool results without matching tool_calls) that
# strict providers like Google reject with schema validation errors.
transcript_lines: list[str] = []
for msg in truncated:
clean: dict[str, Any] = {"role": msg["role"]}
content = msg.get("content")
role = msg["role"]
content = msg.get("content", "")
if content is not None:
clean["content"] = content if isinstance(content, str) else str(content)
content_str = content if isinstance(content, str) else str(content)
else:
content_str = ""
if role == "tool":
transcript_lines.append(f"[Tool Result]:\n{content_str}")
continue
if msg.get("tool_calls"):
clean["tool_calls"] = msg["tool_calls"]
if msg.get("tool_call_id"):
clean["tool_call_id"] = msg["tool_call_id"]
if msg["role"] == "tool":
clean["content"] = msg.get("content", "")
clean_history.append(clean)
calls = []
for tc in msg["tool_calls"]:
fn = tc.get("function", {})
calls.append(f"[Tool Call -> {fn.get('name')}\nArgs: {fn.get('arguments')}]")
if content_str:
content_str += "\n\n" + "\n".join(calls)
else:
content_str = "\n".join(calls)
transcript_lines.append(f"{role.upper()}:\n{content_str}")
transcript = "\n\n".join(transcript_lines)
return [
{"role": "system", "content": _JUDGE_SYSTEM_PROMPT},
*clean_history,
{
"role": "user",
"content": (
f"Conversation context:\n\n{transcript}\n\n"
"---\n\n"
"Please evaluate the following tool call that is "
"pending human approval:\n\n"
f"{tool_detail}\n\n"
+17
View File
@@ -57,6 +57,14 @@ def save_message(
log.warning("Failed to save message for ws=%s role=%s", ws_id, role, exc_info=True)
def save_messages_bulk(rows: list[dict[str, Any]]) -> None:
"""Insert multiple conversation rows in a single transaction."""
try:
get_storage().save_messages_bulk(rows)
except Exception:
log.warning("Failed to bulk-save %d messages", len(rows), exc_info=True)
def load_messages(ws_id: str) -> list[dict[str, Any]]:
"""Load messages for a workstream and reconstruct OpenAI message format."""
try:
@@ -292,6 +300,15 @@ def get_workstream_display_name(ws_id: str) -> str | None:
return None
def get_workstream_metadata(ws_id: str) -> dict[str, Any] | None:
"""Return workstream metadata dict or None if not found."""
try:
return get_storage().get_workstream_metadata(ws_id)
except Exception:
log.warning("Failed to get workstream metadata ws=%s", ws_id, exc_info=True)
return None
def update_workstream_title(ws_id: str, title: str) -> None:
"""Set or update the auto-generated title for a workstream."""
try:
+1 -1
View File
@@ -321,7 +321,7 @@ def load_model_registry(
default_alias = "default"
else:
default_alias = next(iter(configs))
log.info(
log.debug(
"No '%s' model alias; using '%s' as default",
model_section.get("default", "default"),
default_alias,
+69
View File
@@ -0,0 +1,69 @@
"""Collect auto-populated node metadata using stdlib only."""
from __future__ import annotations
import logging
import os
import platform
import socket
from typing import Any
log = logging.getLogger(__name__)
def _is_loopback_or_link_local(addr: str) -> bool:
"""Return True for loopback and link-local addresses."""
return addr.startswith("127.") or addr == "::1" or addr.startswith("fe80:")
def _collect_interfaces() -> dict[str, list[str]]:
"""Best-effort host IP collection using stdlib.
Returns a mapping from hostname to non-loopback IP addresses.
Without psutil/netifaces, per-interface resolution is not available
from stdlib alone, so we report resolved host addresses honestly.
"""
result: dict[str, list[str]] = {}
try:
hostname = socket.gethostname()
addrs = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
ips = sorted({str(a[4][0]) for a in addrs if not _is_loopback_or_link_local(str(a[4][0]))})
if ips:
result[hostname] = ips
except OSError:
log.debug("node_info: interface collection failed", exc_info=True)
return result
def collect_node_info() -> dict[str, Any]:
"""Collect auto-populated node metadata.
Returns a dict of ``{key: value}`` where values are JSON-serializable.
Each field is collected independently one failure does not block others.
"""
info: dict[str, Any] = {}
for key, fn in (
("hostname", socket.gethostname),
("fqdn", socket.getfqdn),
("os", platform.system),
("os_release", platform.release),
("arch", platform.machine),
("python", platform.python_version),
("cpu_count", os.cpu_count),
):
try:
val = fn()
if val is not None:
info[key] = val
except Exception:
log.debug("node_info: failed to collect %s", key, exc_info=True)
try:
ifaces = _collect_interfaces()
if ifaces:
info["interfaces"] = ifaces
except Exception:
log.debug("node_info: failed to collect interfaces", exc_info=True)
return info
+19 -4
View File
@@ -38,11 +38,12 @@ _provider_lock = threading.Lock()
_openai_provider = OpenAIResponsesProvider()
_openai_compat_provider = OpenAIChatCompletionsProvider()
_anthropic_provider: LLMProvider | None = None
_google_provider: LLMProvider | None = None
def create_provider(provider_name: str) -> LLMProvider:
"""Return a provider adapter for the given provider name. Thread-safe."""
global _anthropic_provider # noqa: PLW0603
global _anthropic_provider, _google_provider # noqa: PLW0603
if provider_name == "openai":
return _openai_provider
if provider_name == "openai-compatible":
@@ -54,16 +55,28 @@ def create_provider(provider_name: str) -> LLMProvider:
_anthropic_provider = AnthropicProvider()
return _anthropic_provider
if provider_name == "google":
with _provider_lock:
if _google_provider is None:
from turnstone.core.providers._google import GoogleProvider
_google_provider = GoogleProvider()
return _google_provider
raise ValueError(
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
f"Unknown provider: {provider_name!r}. "
"Supported: openai, anthropic, google, openai-compatible"
)
def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
"""Create an SDK client for the given provider."""
if provider_name in ("openai", "openai-compatible"):
if provider_name in ("openai", "openai-compatible", "google"):
from openai import OpenAI
if not base_url and provider_name == "google":
from turnstone.core.providers._google import GOOGLE_DEFAULT_BASE_URL
base_url = GOOGLE_DEFAULT_BASE_URL
if base_url:
return OpenAI(base_url=base_url, api_key=api_key)
return OpenAI(api_key=api_key)
@@ -76,7 +89,8 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
kwargs["base_url"] = base_url
return anthropic.Anthropic(**kwargs)
raise ValueError(
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
f"Unknown provider: {provider_name!r}. "
"Supported: openai, anthropic, google, openai-compatible"
)
@@ -113,4 +127,5 @@ def list_known_models(provider: str) -> list[str]:
from turnstone.core.providers._anthropic import _ANTHROPIC_CAPABILITIES
return sorted(_ANTHROPIC_CAPABILITIES.keys())
# Google models change frequently — no static table.
return []
+49
View File
@@ -0,0 +1,49 @@
"""Google-specific provider adapter using OpenAI-compatible interface.
Shares the core mechanics of OpenAI Chat Completions but with Google-specific
defaults (large context window, vision support). Uses the Gemini
``/v1beta/openai/`` endpoint which is wire-compatible with the OpenAI SDK.
The caller must provide a ``base_url`` pointing at the Gemini endpoint
(e.g. ``https://generativelanguage.googleapis.com/v1beta/openai/``);
:func:`~turnstone.core.providers.create_client` fills in this default
automatically when ``provider_name="google"`` and no URL is given.
"""
from __future__ import annotations
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
from turnstone.core.providers._protocol import ModelCapabilities
# Default endpoint used when no base_url is configured.
GOOGLE_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
# Baseline capabilities for Google models. Since Google updates models
# frequently, we use a single generous default rather than maintaining a
# static per-model table. The values below are safe for Gemini 2.5 Pro
# (the most capable model at time of writing) and degrade gracefully for
# smaller models — the API simply ignores over-specified max_tokens.
_GOOGLE_DEFAULT = ModelCapabilities(
context_window=2_000_000,
max_output_tokens=65_536,
supports_temperature=True,
supports_vision=True,
# Gemini's OpenAI-compat endpoint accepts max_tokens (not
# max_completion_tokens which is OpenAI Responses-specific).
token_param="max_tokens",
)
class GoogleProvider(OpenAIChatCompletionsProvider):
"""Provider for Google models using the OpenAI-compatible endpoint."""
@property
def provider_name(self) -> str:
return "google"
def get_capabilities(self, model: str) -> ModelCapabilities:
# Returns a single default instance for all Google models.
# lookup_model_capabilities() relies on the identity check
# (caps is default) to correctly return None for Google,
# signalling "no static per-model entry".
return _GOOGLE_DEFAULT
+111 -11
View File
@@ -53,6 +53,7 @@ from turnstone.core.memory import (
normalize_key,
resolve_workstream,
save_message,
save_messages_bulk,
save_structured_memory,
save_workstream_config,
search_history,
@@ -941,9 +942,29 @@ class ChatSession:
+ output[-half:]
)
def _generate_title(self) -> None:
"""Generate a short title for this session via a background LLM call."""
def request_title_refresh(self, current_title: str = "") -> None:
"""Request a title regeneration (thread-safe public API).
Resets the title-generated flag and spawns a background thread
to produce a new title via LLM. Safe to call from server endpoints.
"""
self._title_generated = False
import threading
threading.Thread(
target=self._generate_title,
args=(current_title,),
daemon=True,
).start()
def _generate_title(self, current_title: str = "") -> None:
"""Generate a short title for this session via a background LLM call.
When *current_title* is provided (e.g. during a refresh), the prompt
asks the LLM to produce a **different** title.
"""
ws_id = self._ws_id # Capture before async work
log.info("ws.title.gen_start", ws_id=ws_id[:8])
try:
# Gather first user message and first assistant reply
user_msg = ""
@@ -960,11 +981,32 @@ class ChatSession:
if user_msg and asst_msg:
break
if not user_msg:
log.info("ws.title.gen_skip", ws_id=ws_id[:8], reason="no_user_message")
# Broadcast current name so UI resets any "refreshing" indicator
if current_title and self._ws_id == ws_id:
self.ui.on_rename(current_title)
return
log.info(
"ws.title.gen_messages",
ws_id=ws_id[:8],
user_msg=user_msg[:100],
asst_msg=asst_msg[:100],
)
snippet = f"Generate a title for this conversation:\n\nUser: {user_msg}"
if asst_msg:
snippet += f"\nAssistant: {asst_msg}"
if current_title:
snippet += (
f'\n\nThe current title is: "{current_title}"\n'
"The user wants a DIFFERENT title. Generate a new, distinct title "
"that is NOT the same as the current one."
)
snippet += "\n\nTitle:"
log.info("ws.title.llm_call_start", ws_id=ws_id[:8])
# Use slightly higher temperature for refreshes to encourage variety
temp = 0.7 if current_title else 0.3
result = self._utility_completion(
[
{
@@ -981,33 +1023,57 @@ class ChatSession:
{"role": "user", "content": snippet},
],
max_tokens=200,
temperature=temp,
)
raw = (result.content or "").strip()
log.info("ws.title.llm_response", ws_id=ws_id[:8], raw=raw[:200])
# Take first line, strip quotes
title = raw.split("\n")[0].strip().strip('"').strip("'")
if title and self._ws_id == ws_id:
log.info("ws.title.updating", ws_id=ws_id[:8], title=title)
update_workstream_title(ws_id, title[:80])
self.ui.on_rename(title[:80])
except Exception:
log.info("ws.title.success", ws_id=ws_id[:8], title=title)
else:
log.info(
"ws.title.skip",
ws_id=ws_id[:8],
reason="empty_title_or_ws_changed",
title=title,
)
# Broadcast current name so the UI resets the "refreshing" indicator
if current_title and self._ws_id == ws_id:
self.ui.on_rename(current_title)
except Exception as e:
# Only reset if ws_id hasn't changed (e.g., via /resume) to
# avoid re-enabling titling for a different workstream.
if self._ws_id == ws_id:
self._title_generated = False
log.debug("Title generation failed for ws=%s", ws_id, exc_info=True)
# Broadcast current name so the UI resets the "refreshing" indicator
if current_title:
self.ui.on_rename(current_title)
log.warning("ws.title.failed", ws_id=ws_id[:8], error=str(e), exc_info=True)
def resume(self, ws_id: str) -> bool:
def resume(self, ws_id: str, *, fork: bool = False) -> bool:
"""Load messages from a previous workstream and resume it.
Replaces the current conversation with the loaded messages,
adopting the old ws_id so new messages continue in the same
workstream. Restores persisted config (temperature, reasoning_effort,
etc.) so the resumed workstream behaves identically to the original.
Returns True on success.
When *fork* is ``False`` (default), replaces the current
conversation with the loaded messages **and adopts the old
ws_id** so new messages continue in the same workstream.
When *fork* is ``True``, the messages are copied but
``self._ws_id`` is **kept unchanged** the fork gets its own
identity while inheriting the conversation history.
Restores persisted config (temperature, reasoning_effort, etc.)
so the resumed/forked workstream behaves identically to the
original. Returns True on success.
"""
messages = load_messages(ws_id)
if not messages:
return False
self._ws_id = ws_id
if not fork:
self._ws_id = ws_id
self.messages = messages
self._read_files.clear()
self._recent_tool_sigs.clear()
@@ -1084,6 +1150,40 @@ class ChatSession:
self._skill_name = None
if "notify_on_complete" in config:
self._notify_on_complete = config["notify_on_complete"]
# When forking, persist the copied messages and restored config under
# the fork's own ws_id so they survive restarts.
if fork:
# Bulk-insert all messages in a single transaction for performance.
bulk_rows: list[dict[str, Any]] = []
for msg in self.messages:
tc = msg.get("tool_calls")
tc_json = json.dumps(tc) if tc else None
pd = msg.get("provider_data")
try:
pd_str = json.dumps(pd) if pd and not isinstance(pd, str) else pd
except (TypeError, ValueError):
pd_str = None
bulk_rows.append(
{
"ws_id": self._ws_id,
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
"tool_name": msg.get("name"),
"tool_call_id": msg.get("tool_call_id"),
"tool_calls": tc_json,
"provider_data": pd_str,
}
)
save_messages_bulk(bulk_rows)
self._save_config()
self._title_generated = False # allow auto-title for the fork
log.info(
"ws.fork.messages_copied",
source_ws_id=ws_id[:8],
fork_ws_id=self._ws_id[:8],
message_count=len(self.messages),
)
if self._mem_cfg.nudges and should_nudge(
"resume",
self._metacog_state,
+32
View File
@@ -460,6 +460,38 @@ def _build_registry() -> dict[str, SettingDef]:
"private keys, connection strings) are replaced with [REDACTED] markers "
"before tool output enters the conversation.",
),
SettingDef(
"judge.cancel_on_approval",
"bool",
False,
"Cancel remaining judge evaluations when user approves",
"judge",
help="When enabled, the judge stops evaluating remaining tool calls as soon as "
"you approve or deny. This saves inference resources but means you won't see "
"verdicts for later tool calls. When disabled (default), the judge evaluates "
"every tool call to completion so all verdicts are available for later review.",
),
# -- interface --------------------------------------------------------
SettingDef(
"interface.close_tab_action",
"str",
"last_used",
"Action when closing a workstream tab",
"interface",
choices=["last_used", "nearest_left", "nearest_right", "dashboard"],
help="Determines which workstream to switch to after closing a tab. "
"'last_used' goes to the most recently active tab, 'nearest_left/right' "
"goes to the adjacent tab, 'dashboard' returns to the saved workstreams view.",
),
SettingDef(
"interface.theme",
"str",
"dark",
"Current UI theme",
"interface",
choices=["dark", "light"],
help="Controls the visual theme of the user interface.",
),
# -- skills ---------------------------------------------------------
SettingDef(
"skills.discovery_url",
+172 -4
View File
@@ -189,6 +189,35 @@ class PostgreSQLBackend:
)
conn.commit()
def save_messages_bulk(self, rows: list[dict[str, Any]]) -> None:
if not rows:
return
# Single timestamp for all rows — ordering is preserved by auto-increment id.
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
insert_rows = []
ws_ids: set[str] = set()
for row in rows:
ws_ids.add(row["ws_id"])
insert_rows.append(
{
"ws_id": row["ws_id"],
"timestamp": now,
"role": row["role"],
"content": sanitize_text(row["content"]),
"tool_name": row.get("tool_name"),
"tool_call_id": row.get("tool_call_id"),
"provider_data": sanitize_text(row.get("provider_data")),
"tool_calls": row.get("tool_calls"),
}
)
with self._conn() as conn:
conn.execute(sa.insert(conversations), insert_rows)
for wid in ws_ids:
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == wid).values(updated=now)
)
conn.commit()
def load_messages(self, ws_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
@@ -235,7 +264,7 @@ class PostgreSQLBackend:
return list(
conn.execute(
sa.text(
"SELECT w.ws_id, w.alias, w.title, w.created, w.updated, "
"SELECT w.ws_id, w.alias, w.title, w.name, w.created, w.updated, "
"(SELECT COUNT(*) FROM conversations c "
" WHERE c.ws_id = w.ws_id), "
"w.node_id "
@@ -368,14 +397,39 @@ class PostgreSQLBackend:
def get_workstream_display_name(self, ws_id: str) -> str | None:
with self._conn() as conn:
row = conn.execute(
sa.select(workstreams.c.alias, workstreams.c.title).where(
sa.select(workstreams.c.alias, workstreams.c.title, workstreams.c.name).where(
workstreams.c.ws_id == ws_id
)
).fetchone()
if row:
value = row[0] or row[1]
value = row[0] or row[1] or row[2]
return str(value) if value is not None else None
return None
return None
def get_workstream_metadata(self, ws_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
workstreams.c.ws_id,
workstreams.c.alias,
workstreams.c.title,
workstreams.c.name,
workstreams.c.node_id,
workstreams.c.skill_id,
workstreams.c.skill_version,
).where(workstreams.c.ws_id == ws_id)
).fetchone()
if row:
return {
"ws_id": row[0],
"alias": row[1],
"title": row[2],
"name": row[3],
"node_id": row[4],
"skill_id": row[5],
"skill_version": row[6],
}
return None
def update_workstream_title(self, ws_id: str, title: str) -> None:
with self._conn() as conn:
@@ -1283,6 +1337,120 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
# -- Node metadata ---------------------------------------------------------
def get_node_metadata(self, node_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
rows = conn.execute(
sa.select(node_metadata)
.where(node_metadata.c.node_id == node_id)
.order_by(node_metadata.c.key)
).fetchall()
return [dict(r._mapping) for r in rows]
def get_all_node_metadata(self) -> dict[str, list[dict[str, Any]]]:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
rows = conn.execute(
sa.select(node_metadata).order_by(node_metadata.c.node_id, node_metadata.c.key)
).fetchall()
result: dict[str, list[dict[str, Any]]] = {}
for r in rows:
d = dict(r._mapping)
result.setdefault(d["node_id"], []).append(d)
return result
def set_node_metadata(self, node_id: str, key: str, value: str, source: str = "user") -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
from turnstone.core.storage._schema import node_metadata
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(node_metadata).values(
node_id=node_id,
key=key,
value=value,
source=source,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[node_metadata.c.node_id, node_metadata.c.key],
set_={"value": value, "source": source, "updated": now},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def set_node_metadata_bulk(self, node_id: str, entries: list[tuple[str, str, str]]) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
from turnstone.core.storage._schema import node_metadata
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
for key, value, source in entries:
stmt = pg_insert(node_metadata).values(
node_id=node_id,
key=key,
value=value,
source=source,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[node_metadata.c.node_id, node_metadata.c.key],
set_={"value": value, "source": source, "updated": now},
)
conn.execute(stmt)
conn.commit()
def delete_node_metadata(self, node_id: str, key: str) -> bool:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
result = conn.execute(
sa.delete(node_metadata).where(
(node_metadata.c.node_id == node_id) & (node_metadata.c.key == key)
)
)
conn.commit()
return result.rowcount > 0
def delete_node_metadata_by_source(self, node_id: str, source: str) -> int:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
result = conn.execute(
sa.delete(node_metadata).where(
(node_metadata.c.node_id == node_id) & (node_metadata.c.source == source)
)
)
conn.commit()
return result.rowcount
def filter_nodes_by_metadata(self, filters: dict[str, str]) -> set[str]:
from turnstone.core.storage._schema import node_metadata
if not filters:
return set()
conditions = [
sa.and_(node_metadata.c.key == k, node_metadata.c.value == v)
for k, v in filters.items()
]
stmt = (
sa.select(node_metadata.c.node_id)
.where(sa.or_(*conditions))
.group_by(node_metadata.c.node_id)
.having(sa.func.count() == len(filters))
)
with self._conn() as conn:
rows = conn.execute(stmt).fetchall()
return {r[0] for r in rows}
# -- Hash ring routing -----------------------------------------------------
def list_ring_buckets(self) -> list[dict[str, Any]]:
+45
View File
@@ -28,6 +28,17 @@ class StorageBackend(Protocol):
"""Log a message to the conversations table."""
...
def save_messages_bulk(self, rows: list[dict[str, Any]]) -> None:
"""Insert multiple conversation rows in a single transaction.
Each dict must include ``ws_id``, ``role``, and ``content``
(which may be ``None`` for assistant messages with only tool_calls).
Optional keys: ``tool_name``, ``tool_call_id``, ``provider_data``,
``tool_calls``. Timestamp and workstream
updated-at are handled internally.
"""
...
def load_messages(self, ws_id: str) -> list[dict[str, Any]]:
"""Load messages for a workstream and reconstruct OpenAI message format."""
...
@@ -75,6 +86,10 @@ class StorageBackend(Protocol):
"""Return the alias (or title) for a workstream, or None if unset."""
...
def get_workstream_metadata(self, ws_id: str) -> dict[str, Any] | None:
"""Return workstream metadata dict or None if not found."""
...
def update_workstream_title(self, ws_id: str, title: str) -> None:
"""Set or update the auto-generated title for a workstream."""
...
@@ -465,6 +480,36 @@ class StorageBackend(Protocol):
"""Remove a service registration. Returns True if existed."""
...
# -- Node metadata ---------------------------------------------------------
def get_node_metadata(self, node_id: str) -> list[dict[str, Any]]:
"""Return all metadata rows for a node."""
...
def get_all_node_metadata(self) -> dict[str, list[dict[str, Any]]]:
"""Return metadata grouped by node_id for all nodes."""
...
def set_node_metadata(self, node_id: str, key: str, value: str, source: str = "user") -> None:
"""Upsert a single metadata key for a node."""
...
def set_node_metadata_bulk(self, node_id: str, entries: list[tuple[str, str, str]]) -> None:
"""Upsert multiple (key, value, source) entries for a node. Atomic."""
...
def delete_node_metadata(self, node_id: str, key: str) -> bool:
"""Delete a single metadata key. Returns True if existed."""
...
def delete_node_metadata_by_source(self, node_id: str, source: str) -> int:
"""Delete all metadata for a node with the given source. Returns count."""
...
def filter_nodes_by_metadata(self, filters: dict[str, str]) -> set[str]:
"""Return node_ids where ALL key=value filters match (exact match)."""
...
# -- Hash ring routing ---
def list_ring_buckets(self) -> list[dict[str, Any]]:
+18
View File
@@ -233,6 +233,24 @@ services = sa.Table(
sa.Index("idx_services_type_heartbeat", services.c.service_type, services.c.last_heartbeat)
# ---------------------------------------------------------------------------
# Node metadata (per-node key/value with source tracking)
# ---------------------------------------------------------------------------
node_metadata = sa.Table(
"node_metadata",
metadata,
sa.Column("node_id", sa.Text, nullable=False),
sa.Column("key", sa.Text, nullable=False),
sa.Column("value", sa.Text, nullable=False),
sa.Column("source", sa.Text, nullable=False, server_default="user"),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("node_id", "key"),
)
sa.Index("idx_node_metadata_key", node_metadata.c.key)
# ---------------------------------------------------------------------------
# Hash ring routing tables
# ---------------------------------------------------------------------------
+182 -4
View File
@@ -243,6 +243,45 @@ class SQLiteBackend:
)
conn.commit()
def save_messages_bulk(self, rows: list[dict[str, Any]]) -> None:
if not rows:
return
# Single timestamp for all rows — ordering is preserved by auto-increment id.
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
insert_rows = []
ws_ids: set[str] = set()
for row in rows:
ws_ids.add(row["ws_id"])
insert_rows.append(
{
"ws_id": row["ws_id"],
"timestamp": now,
"role": row["role"],
"content": sanitize_text(row["content"]),
"tool_name": row.get("tool_name"),
"tool_call_id": row.get("tool_call_id"),
"provider_data": sanitize_text(row.get("provider_data")),
"tool_calls": row.get("tool_calls"),
}
)
with self._conn() as conn:
conn.execute(sa.insert(conversations), insert_rows)
for wid in ws_ids:
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == wid).values(updated=now)
)
# Rebuild FTS5 index so bulk-inserted messages are searchable.
if self._fts5_available:
try:
conn.execute(
sa.text(
"INSERT INTO conversations_fts(conversations_fts) VALUES ('rebuild')"
)
)
except Exception:
self._fts5_available = False
conn.commit()
def load_messages(self, ws_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
@@ -304,7 +343,7 @@ class SQLiteBackend:
return list(
conn.execute(
sa.text(
"SELECT w.ws_id, w.alias, w.title, w.created, w.updated, "
"SELECT w.ws_id, w.alias, w.title, w.name, w.created, w.updated, "
"(SELECT COUNT(*) FROM conversations c "
" WHERE c.ws_id = w.ws_id), "
"w.node_id "
@@ -452,14 +491,39 @@ class SQLiteBackend:
def get_workstream_display_name(self, ws_id: str) -> str | None:
with self._conn() as conn:
row = conn.execute(
sa.select(workstreams.c.alias, workstreams.c.title).where(
sa.select(workstreams.c.alias, workstreams.c.title, workstreams.c.name).where(
workstreams.c.ws_id == ws_id
)
).fetchone()
if row:
value = row[0] or row[1]
value = row[0] or row[1] or row[2]
return str(value) if value is not None else None
return None
return None
def get_workstream_metadata(self, ws_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
workstreams.c.ws_id,
workstreams.c.alias,
workstreams.c.title,
workstreams.c.name,
workstreams.c.node_id,
workstreams.c.skill_id,
workstreams.c.skill_version,
).where(workstreams.c.ws_id == ws_id)
).fetchone()
if row:
return {
"ws_id": row[0],
"alias": row[1],
"title": row[2],
"name": row[3],
"node_id": row[4],
"skill_id": row[5],
"skill_version": row[6],
}
return None
def update_workstream_title(self, ws_id: str, title: str) -> None:
with self._conn() as conn:
@@ -1350,6 +1414,120 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
# -- Node metadata ---------------------------------------------------------
def get_node_metadata(self, node_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
rows = conn.execute(
sa.select(node_metadata)
.where(node_metadata.c.node_id == node_id)
.order_by(node_metadata.c.key)
).fetchall()
return [dict(r._mapping) for r in rows]
def get_all_node_metadata(self) -> dict[str, list[dict[str, Any]]]:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
rows = conn.execute(
sa.select(node_metadata).order_by(node_metadata.c.node_id, node_metadata.c.key)
).fetchall()
result: dict[str, list[dict[str, Any]]] = {}
for r in rows:
d = dict(r._mapping)
result.setdefault(d["node_id"], []).append(d)
return result
def set_node_metadata(self, node_id: str, key: str, value: str, source: str = "user") -> None:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from turnstone.core.storage._schema import node_metadata
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = sqlite_insert(node_metadata).values(
node_id=node_id,
key=key,
value=value,
source=source,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=["node_id", "key"],
set_={"value": value, "source": source, "updated": now},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def set_node_metadata_bulk(self, node_id: str, entries: list[tuple[str, str, str]]) -> None:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from turnstone.core.storage._schema import node_metadata
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
for key, value, source in entries:
stmt = sqlite_insert(node_metadata).values(
node_id=node_id,
key=key,
value=value,
source=source,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=["node_id", "key"],
set_={"value": value, "source": source, "updated": now},
)
conn.execute(stmt)
conn.commit()
def delete_node_metadata(self, node_id: str, key: str) -> bool:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
result = conn.execute(
sa.delete(node_metadata).where(
(node_metadata.c.node_id == node_id) & (node_metadata.c.key == key)
)
)
conn.commit()
return result.rowcount > 0
def delete_node_metadata_by_source(self, node_id: str, source: str) -> int:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
result = conn.execute(
sa.delete(node_metadata).where(
(node_metadata.c.node_id == node_id) & (node_metadata.c.source == source)
)
)
conn.commit()
return result.rowcount
def filter_nodes_by_metadata(self, filters: dict[str, str]) -> set[str]:
from turnstone.core.storage._schema import node_metadata
if not filters:
return set()
conditions = [
sa.and_(node_metadata.c.key == k, node_metadata.c.value == v)
for k, v in filters.items()
]
stmt = (
sa.select(node_metadata.c.node_id)
.where(sa.or_(*conditions))
.group_by(node_metadata.c.node_id)
.having(sa.func.count() == len(filters))
)
with self._conn() as conn:
rows = conn.execute(stmt).fetchall()
return {r[0] for r in rows}
# -- Hash ring routing -----------------------------------------------------
def list_ring_buckets(self) -> list[dict[str, Any]]:
@@ -0,0 +1,50 @@
"""Add node_metadata table for per-node key/value metadata.
Revision ID: 035
Revises: 034
Create Date: 2026-04-05
"""
import sqlalchemy as sa
from alembic import op
revision = "035"
down_revision = "034"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"node_metadata",
sa.Column("node_id", sa.Text, nullable=False),
sa.Column("key", sa.Text, nullable=False),
sa.Column("value", sa.Text, nullable=False),
sa.Column("source", sa.Text, nullable=False, server_default="user"),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("node_id", "key"),
)
op.create_index("idx_node_metadata_key", "node_metadata", ["key"])
# Grant admin.nodes permission to the built-in admin role
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || ',admin.nodes' "
"WHERE role_id = 'builtin-admin' "
"AND permissions NOT LIKE '%admin.nodes%'"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = REPLACE(permissions, ',admin.nodes', '') "
"WHERE role_id = 'builtin-admin'"
)
)
op.drop_index("idx_node_metadata_key", table_name="node_metadata")
op.drop_table("node_metadata")
+5
View File
@@ -30,6 +30,11 @@ async def read_json_or_400(request: Request) -> dict[str, Any] | JSONResponse:
return body
except (ValueError, json.JSONDecodeError):
return _JSONResponse({"error": "Invalid JSON body"}, status_code=400)
except Exception:
import structlog
structlog.get_logger(__name__).warning("read_json_or_400.unexpected", exc_info=True)
return _JSONResponse({"error": "Failed to read request body"}, status_code=500)
def require_storage_or_503(
+3
View File
@@ -139,6 +139,7 @@ class WorkstreamManager:
skill_version: int = 0,
ws_id: str = "",
client_type: str = "",
judge_model: str | None = None,
) -> Workstream:
"""Create a new workstream. Returns the new ws.
@@ -183,6 +184,8 @@ class WorkstreamManager:
factory_kwargs: dict[str, Any] = {"skill": skill}
if client_type:
factory_kwargs["client_type"] = client_type
if judge_model:
factory_kwargs["judge_model"] = judge_model
ws.session = self._session_factory(ws.ui, model, ws.id, **factory_kwargs)
# Authoritative insert under lock with re-check (another thread may
+379 -10
View File
@@ -121,6 +121,9 @@ class WebUI:
# and piggybacked onto the ws_state:idle global SSE event, then reset.
self._ws_turn_content: list[str] = []
self._ws_turn_content_size: int = 0
# Cached LLM verdicts keyed by call_id — replayed on SSE reconnect
# so tab-switching doesn't lose the final judge result.
self._llm_verdicts: dict[str, dict[str, Any]] = {}
def _enqueue(self, data: dict[str, Any]) -> None:
# Stamp ws_id on every per-workstream event so the client can
@@ -221,6 +224,8 @@ class WebUI:
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
self._last_verdict_decision = "" # reset for new approval cycle
with self._ws_lock:
self._llm_verdicts.clear() # clear stale verdicts from prior cycle
pending = [it for it in items if it.get("needs_approval") and not it.get("error")]
# Always send tool info to the browser
@@ -519,6 +524,15 @@ class WebUI:
def on_intent_verdict(self, verdict: dict[str, Any]) -> None:
"""Deliver LLM judge verdict to frontend via SSE."""
# Cache for replay on SSE reconnect (tab switching)
call_id = verdict.get("call_id", "")
if call_id:
with self._ws_lock:
# Evict oldest entry if cache is full (defensive cap of 50)
if len(self._llm_verdicts) >= 50 and call_id not in self._llm_verdicts:
oldest_key = next(iter(self._llm_verdicts))
del self._llm_verdicts[oldest_key]
self._llm_verdicts[call_id] = verdict
self._enqueue({"type": "intent_verdict", **verdict})
# Persist the LLM verdict (fire-and-forget)
try:
@@ -915,6 +929,11 @@ async def events_sse(request: Request) -> Response:
# Re-inject pending approval or plan review
if ui._pending_approval is not None:
yield {"data": json.dumps(ui._pending_approval)}
# Replay any LLM verdicts received since the approval was sent
with ui._ws_lock:
cached_verdicts = list(ui._llm_verdicts.values())
for v in cached_verdicts:
yield {"data": json.dumps({"type": "intent_verdict", **v})}
if ui._pending_plan_review is not None:
yield {"data": json.dumps(ui._pending_plan_review)}
@@ -976,7 +995,7 @@ def _build_node_snapshot(app_state: Any) -> dict[str, Any]:
ws_list.append(
{
"id": ws.id,
"name": ws.name,
"name": title or ws.name,
"state": ws.state.value,
"title": title,
"tokens": tok,
@@ -1065,13 +1084,16 @@ async def global_events_sse(request: Request) -> Response:
async def list_workstreams(request: Request) -> JSONResponse:
"""GET /v1/api/workstreams — list all workstreams."""
from turnstone.core.memory import get_workstream_display_name
mgr: WorkstreamManager = request.app.state.workstreams
result = []
for ws in mgr.list_all():
title = get_workstream_display_name(ws.id) or ws.name
result.append(
{
"id": ws.id,
"name": ws.name,
"name": title,
"state": ws.state.value,
}
)
@@ -1106,7 +1128,7 @@ async def dashboard(request: Request) -> JSONResponse:
ws_list.append(
{
"id": ws.id,
"name": ws.name,
"name": title or ws.name,
"state": ws.state.value,
"title": title,
"tokens": tok,
@@ -1145,11 +1167,12 @@ async def list_saved_workstreams(request: Request) -> JSONResponse:
"ws_id": wid,
"alias": alias,
"title": title,
"name": name,
"created": created,
"updated": updated,
"message_count": count,
}
for wid, alias, title, created, updated, count, *_extra in rows
for wid, alias, title, name, created, updated, count, *_extra in rows
]
return JSONResponse({"workstreams": result})
@@ -1803,6 +1826,7 @@ def _deliver_notification(
async def create_workstream(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/new — create a new workstream."""
from turnstone.core.memory import get_workstream_display_name
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
@@ -1863,6 +1887,7 @@ async def create_workstream(request: Request) -> JSONResponse:
skill_version=applied_skill_version,
ws_id=requested_ws_id,
client_type=body.get("client_type", "") or "",
judge_model=body.get("judge_model", "") or None,
)
if not isinstance(ws.ui, WebUI):
raise TypeError(f"Expected WebUI, got {type(ws.ui).__name__}")
@@ -1875,13 +1900,14 @@ async def create_workstream(request: Request) -> JSONResponse:
runner, dispatch_fn=_make_watch_dispatch(ws, ws.session, ws.ui)
)
# Emit creation event on global queue for SSE consumers (console)
display_name = get_workstream_display_name(ws.id) or ws.name
gq: queue.Queue[dict[str, Any]] = request.app.state.global_queue
with contextlib.suppress(queue.Full):
gq.put_nowait(
{
"type": "ws_created",
"ws_id": ws.id,
"name": ws.name,
"name": display_name,
"model": ws.session.model if ws.session else "",
"model_alias": ws.session.model_alias if ws.session else "",
}
@@ -1905,16 +1931,29 @@ async def create_workstream(request: Request) -> JSONResponse:
from turnstone.core.memory import get_workstream_display_name, resolve_workstream
target_id = resolve_workstream(resume_ws_id)
if target_id and ws.session.resume(target_id):
if target_id and ws.session.resume(target_id, fork=True):
resumed = True
message_count = len(ws.session.messages)
ws.name = get_workstream_display_name(target_id) or ws.name
# If the user provided a custom name, set it as the fork's alias
# so it takes priority in display. Otherwise inherit the source name.
user_name = body.get("name", "").strip()
if user_name:
from turnstone.core.memory import set_workstream_alias
set_workstream_alias(ws.id, user_name)
ws.name = user_name
else:
ws.name = get_workstream_display_name(target_id) or ws.name
ui = ws.ui
if isinstance(ui, WebUI):
ui._enqueue({"type": "clear_ui"})
history = _build_history(ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
# Broadcast a rename so the tab picks up the correct fork name
# (the ws_created event fired before fork with the pre-fork name).
with contextlib.suppress(queue.Full):
gq.put_nowait({"type": "ws_rename", "ws_id": ws.id, "name": ws.name})
# Apply skill session config (only for new workstreams with a skill)
if skill_data and not resumed and ws.session:
@@ -2029,6 +2068,12 @@ async def close_workstream(request: Request) -> JSONResponse:
return body
ws_id = str(body.get("ws_id", ""))
mgr = request.app.state.workstreams
# Distinguish "last workstream" (400) from "not found" (404).
# Note: get() and close() acquire the manager lock independently, so a
# concurrent close between the two could produce a wrong error code.
# The failure mode is cosmetic (400 instead of 404), not data corruption.
if not mgr.get(ws_id):
return JSONResponse({"error": "Workstream not found"}, status_code=404)
if mgr.close(ws_id):
gq: queue.Queue[dict[str, Any]] = request.app.state.global_queue
with contextlib.suppress(queue.Full):
@@ -2037,6 +2082,165 @@ async def close_workstream(request: Request) -> JSONResponse:
return JSONResponse({"error": "Cannot close last workstream"}, status_code=400)
async def delete_workstream_endpoint(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/delete — permanently delete a saved workstream."""
from turnstone.core.log import get_logger
from turnstone.core.memory import delete_workstream
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
if not ws_id:
log.warning("ws.delete.failed", reason="empty_ws_id")
return JSONResponse({"error": "ws_id is required"}, status_code=400)
try:
if delete_workstream(ws_id):
log.info("ws.deleted", ws_id=ws_id[:8])
return JSONResponse({"deleted": ws_id})
log.warning("ws.delete.failed", reason="not_found", ws_id=ws_id[:8])
return JSONResponse({"error": "Workstream not found"}, status_code=404)
except Exception as e:
log.exception("ws.delete.error", ws_id=ws_id[:8], error=str(e))
return JSONResponse({"error": "Delete failed"}, status_code=500)
async def refresh_workstream_title(request: Request, ws_id: str = "") -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/refresh-title — regenerate workstream title via LLM."""
from turnstone.core.log import get_logger
from turnstone.core.memory import get_workstream_display_name
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
log.info("ws.title.refresh_requested", ws_id=ws_id[:8] if ws_id else "empty")
mgr = request.app.state.workstreams
ws = mgr.get(ws_id)
if not ws or not ws.session:
log.warning(
"ws.title.refresh_failed",
ws_id=ws_id[:8] if ws_id else "empty",
reason="workstream_not_found",
)
return JSONResponse({"error": "Workstream not found or not active"}, status_code=404)
# Fetch current title so the LLM can generate something different
current_title = get_workstream_display_name(ws_id) or ""
log.info("ws.title.refresh_triggered", ws_id=ws_id[:8], current_title=current_title[:50])
ws.session.request_title_refresh(current_title)
return JSONResponse({"status": "ok"})
async def set_workstream_title(request: Request, ws_id: str = "") -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/title — set workstream title manually.
Stores the user-chosen title as the workstream *alias* so it takes
priority over the LLM auto-generated title in the display name
fallback chain (alias -> title -> name).
"""
from turnstone.core.log import get_logger
from turnstone.core.memory import set_workstream_alias
from turnstone.core.web_helpers import read_json_or_400
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
log.info("ws.title.set_requested", ws_id=ws_id[:8] if ws_id else "empty")
if not ws_id:
return JSONResponse({"error": "ws_id is required"}, status_code=400)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
title = str(body.get("title", "")).strip()
if not title:
return JSONResponse({"error": "title is required"}, status_code=400)
title = title[:80]
if not set_workstream_alias(ws_id, title):
log.warning("ws.title.set_alias_conflict", ws_id=ws_id[:8], title=title[:50])
return JSONResponse(
{"error": "That name is already used by another workstream"},
status_code=409,
)
log.info("ws.title.set_alias_updated", ws_id=ws_id[:8])
mgr = request.app.state.workstreams
ws = mgr.get(ws_id)
if ws and ws.session and ws.session.ui:
ws.session.ui.on_rename(title)
log.info("ws.title.set_success", ws_id=ws_id[:8], title=title)
return JSONResponse({"status": "ok", "title": title})
async def open_workstream(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/open — load a saved workstream into memory.
Unlike resume (which creates a NEW workstream and forks), this endpoint
loads the existing workstream into memory with its original ws_id preserved.
"""
from turnstone.core.log import get_logger
from turnstone.core.memory import get_workstream_display_name, resolve_workstream
from turnstone.core.storage import get_storage as _get_storage
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
if not ws_id:
return JSONResponse({"error": "ws_id is required"}, status_code=400)
resolved_id = resolve_workstream(ws_id)
if not resolved_id:
return JSONResponse({"error": "Workstream not found"}, status_code=404)
mgr: WorkstreamManager = request.app.state.workstreams
if mgr.get(resolved_id):
return JSONResponse(
{
"ws_id": resolved_id,
"name": get_workstream_display_name(resolved_id) or resolved_id,
"already_loaded": True,
}
)
_st = _get_storage()
ws_row = _st.get_workstream_metadata(resolved_id)
if not ws_row:
return JSONResponse({"error": "Workstream not found in storage"}, status_code=404)
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = getattr(auth, "user_id", "") or ""
try:
ws = mgr.create(
name=ws_row.get("name", ""),
ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid),
ws_id=resolved_id,
)
except Exception as e:
log.warning("ws.open.create_failed", ws_id=resolved_id[:8], error=str(e))
return JSONResponse({"error": f"Failed to load workstream: {e}"}, status_code=500)
if not isinstance(ws.ui, WebUI):
msg = f"Expected WebUI, got {type(ws.ui).__name__}"
raise TypeError(msg)
if ws.session is not None and ws.session.resume(resolved_id):
ws.name = get_workstream_display_name(resolved_id) or ws.name
ui = ws.ui
ui._enqueue({"type": "clear_ui"})
history = _build_history(ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
gq: queue.Queue[dict[str, Any]] = request.app.state.global_queue
with contextlib.suppress(queue.Full):
gq.put_nowait(
{
"type": "ws_created",
"ws_id": ws.id,
"name": ws.name,
"model": ws.session.model if ws.session else "",
"model_alias": ws.session.model_alias if ws.session else "",
}
)
log.info("ws.opened", ws_id=resolved_id[:8])
return JSONResponse({"ws_id": ws.id, "name": ws.name})
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
@@ -2318,12 +2522,110 @@ async def oidc_callback(request: Request) -> Response:
return await handle_oidc_callback(request, JWT_AUD_SERVER)
def list_interface_settings(request: Request) -> JSONResponse:
"""GET /v1/api/admin/settings — return interface settings from ConfigStore.
This lightweight endpoint mirrors the console's admin settings endpoint
so that the main UI can load interface preferences (theme, close_tab_action)
when accessed directly or through the console proxy. Only returns the
``interface.*`` settings full admin management is on the console.
"""
from turnstone.core.settings_registry import SETTINGS
cs = getattr(request.app.state, "config_store", None)
settings: list[dict[str, Any]] = []
for key, defn in sorted(SETTINGS.items()):
if not key.startswith("interface."):
continue
value = cs.get(key) if cs else defn.default
settings.append(
{
"key": key,
"value": value,
"source": "storage" if cs and key in cs.stored_keys() else "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
}
)
return JSONResponse({"settings": settings})
async def update_interface_setting(request: Request, key: str = "") -> JSONResponse:
"""POST /v1/api/admin/settings/{key} — update an interface.* setting.
Lightweight endpoint so the main UI (served via the console proxy) can
persist interface preferences without needing a PUT route. Only
``interface.*`` keys are accepted; full admin management stays on the
console.
Writes with ``node_id=""`` (global scope) so the console admin page
and all nodes see the same value.
"""
from turnstone.core.log import get_logger
from turnstone.core.settings_registry import SETTINGS, serialize_value, validate_value
from turnstone.core.storage import get_storage as _get_storage
from turnstone.core.web_helpers import read_json_or_400
log = get_logger(__name__)
key = request.path_params.get("key", "")
if not key.startswith("interface."):
return JSONResponse({"error": "only interface.* settings accepted"}, status_code=400)
if key not in SETTINGS:
return JSONResponse({"error": f"unknown setting: {key}"}, status_code=400)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
if "value" not in body:
return JSONResponse({"error": "value is required"}, status_code=400)
try:
typed_value = validate_value(key, body["value"])
except (ValueError, KeyError) as e:
return JSONResponse({"error": str(e)}, status_code=400)
# Write to storage with global scope (node_id="") so the console
# admin page and all nodes read the same value.
storage = _get_storage()
if storage is None:
return JSONResponse({"error": "storage unavailable"}, status_code=503)
defn = SETTINGS[key]
storage.upsert_system_setting(
key=key,
value=serialize_value(typed_value),
node_id="",
is_secret=defn.is_secret,
)
# Update the local ConfigStore cache so this node sees the change
# immediately (without waiting for a config-reload).
cs = getattr(request.app.state, "config_store", None)
if cs is not None:
cs.reload()
log.info("interface_setting.updated", key=key, value=typed_value)
# Broadcast settings_changed so other connected clients pick it up
gq = getattr(request.app.state, "global_queue", None)
if gq is not None:
with contextlib.suppress(queue.Full):
gq.put_nowait({"type": "settings_changed"})
return JSONResponse({"status": "ok", "key": key, "value": typed_value})
def config_reload(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/config-reload — invalidate config cache."""
cs = getattr(request.app.state, "config_store", None)
gq = getattr(request.app.state, "global_queue", None)
if not cs:
return JSONResponse({"status": "noop"})
cs.reload()
# Broadcast settings_changed event to all connected clients
if gq is not None:
with contextlib.suppress(queue.Full):
gq.put_nowait({"type": "settings_changed"})
return JSONResponse({"status": "ok"})
@@ -2387,9 +2689,15 @@ def internal_model_reload(request: Request) -> JSONResponse:
effective_default = new_registry.default
cs = getattr(request.app.state, "config_store", None)
if cs:
cs.reload() # Ensure latest settings from DB
cs_alias = cs.get("model.default_alias")
if cs_alias and cs_alias in new_registry.models:
effective_default = cs_alias
log.info(
"ConfigStore override: using '%s' as default model (registry had '%s')",
effective_default,
new_registry.default,
)
try:
registry.reload(
@@ -2676,6 +2984,30 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
_svc_storage.register_service("server", _svc_node_id, _svc_url)
log.info("server.service_registered", node_id=_svc_node_id, url=_svc_url)
# Collect and store node metadata (auto + config)
try:
from turnstone.core.config import load_config as _load_meta_config
from turnstone.core.node_info import collect_node_info
_auto_info = collect_node_info()
_meta_entries: list[tuple[str, str, str]] = [
(k, json.dumps(v), "auto") for k, v in _auto_info.items()
]
_cfg_meta = _load_meta_config("metadata")
_meta_entries.extend((k, json.dumps(v), "config") for k, v in _cfg_meta.items())
if _meta_entries:
# Clear stale auto/config rows from a prior run before upserting
_svc_storage.delete_node_metadata_by_source(_svc_node_id, "auto")
_svc_storage.delete_node_metadata_by_source(_svc_node_id, "config")
_svc_storage.set_node_metadata_bulk(_svc_node_id, _meta_entries)
log.info(
"server.node_metadata_stored",
node_id=_svc_node_id,
count=len(_meta_entries),
)
except Exception:
log.warning("server.node_metadata_failed", node_id=_svc_node_id, exc_info=True)
async def _heartbeat_loop() -> None:
"""Periodically update service heartbeat."""
from turnstone.core.storage._registry import StorageUnavailableError
@@ -2699,7 +3031,14 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
from turnstone.core.storage import get_storage as _get_svc_dereg
try:
await asyncio.to_thread(_get_svc_dereg().deregister_service, "server", _svc_node_id)
_dereg_storage = _get_svc_dereg()
await asyncio.to_thread(_dereg_storage.deregister_service, "server", _svc_node_id)
await asyncio.to_thread(
_dereg_storage.delete_node_metadata_by_source, _svc_node_id, "auto"
)
await asyncio.to_thread(
_dereg_storage.delete_node_metadata_by_source, _svc_node_id, "config"
)
log.info("server.service_deregistered", node_id=_svc_node_id)
except Exception:
log.exception("server.deregister_failed")
@@ -2778,6 +3117,20 @@ def create_app(
Route("/api/workstreams", list_workstreams),
Route("/api/dashboard", dashboard),
Route("/api/workstreams/saved", list_saved_workstreams),
Route("/api/workstreams/new", create_workstream, methods=["POST"]),
Route("/api/workstreams/close", close_workstream, methods=["POST"]),
Route(
"/api/workstreams/{ws_id}/delete",
delete_workstream_endpoint,
methods=["POST"],
),
Route("/api/workstreams/{ws_id}/open", open_workstream, methods=["POST"]),
Route(
"/api/workstreams/{ws_id}/refresh-title",
refresh_workstream_title,
methods=["POST"],
),
Route("/api/workstreams/{ws_id}/title", set_workstream_title, methods=["POST"]),
Route("/api/skills", list_skills_summary),
Route("/api/models", list_available_models),
Route("/api/send", send_message, methods=["POST"]),
@@ -2785,8 +3138,6 @@ def create_app(
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/memories", list_memories),
@@ -2800,6 +3151,12 @@ def create_app(
Route("/api/auth/whoami", auth_whoami),
Route("/api/auth/oidc/authorize", oidc_authorize),
Route("/api/auth/oidc/callback", oidc_callback),
Route("/api/admin/settings", list_interface_settings),
Route(
"/api/admin/settings/{key:path}",
update_interface_setting,
methods=["POST", "PUT"],
),
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
Route("/api/_internal/mcp-status", internal_mcp_status),
@@ -3159,6 +3516,7 @@ def main() -> None:
*,
skill: str | None = None,
client_type: str = "",
judge_model: str | None = None,
) -> ChatSession:
assert ui is not None
# Resolve the effective alias once and use it consistently
@@ -3187,6 +3545,17 @@ def main() -> None:
# Re-resolve from ConfigStore so new workstreams pick up hot-reloaded settings.
live_memory_config = _build_memory_config()
live_judge_config = _build_judge_config()
if live_judge_config and judge_model:
import dataclasses
try:
_jm_client, j_model, _jm_cfg = registry.resolve(judge_model)
live_judge_config = dataclasses.replace(
live_judge_config,
model=j_model,
)
except Exception as e:
log.warning("Failed to resolve judge_model %r: %s", judge_model, e)
return ChatSession(
client=r_client,
+5 -1
View File
@@ -85,6 +85,9 @@
--row-alt: rgba(0, 0, 0, 0.015);
}
html, body {
transition: background-color 0.15s ease, color 0.15s ease;
}
html, body {
height: 100%;
background: var(--bg);
@@ -277,7 +280,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 10001;
}
#login-box {
background: var(--bg-surface);
@@ -539,6 +542,7 @@ body {
Reduced motion base rules
========================================================================== */
@media (prefers-reduced-motion: reduce) {
html, body { transition: none; }
.dash-state-dot[data-state="running"],
.dash-state-dot[data-state="thinking"],
.dash-state-dot[data-state="attention"] { animation: none; opacity: 1; }
+5 -2
View File
@@ -4,12 +4,15 @@
function toggleTheme() {
var next = document.documentElement.dataset.theme === "light" ? "" : "light";
document.documentElement.dataset.theme = next;
localStorage.setItem("turnstone-theme", next || "dark");
localStorage.setItem("turnstone_interface.theme", next || "dark");
if (typeof window.onThemeChange === "function") window.onThemeChange(next);
}
(function initTheme() {
var stored = localStorage.getItem("turnstone-theme");
// Check both keys for backwards compatibility (old key: "turnstone-theme")
var stored =
localStorage.getItem("turnstone_interface.theme") ||
localStorage.getItem("turnstone-theme");
if (stored === "light") {
document.documentElement.dataset.theme = "light";
} else if (
File diff suppressed because it is too large Load Diff
+64 -2
View File
@@ -16,11 +16,20 @@
<h1>turnstone</h1>
<span id="mcp-status" role="status" aria-live="polite"></span>
<span id="health-indicator" class="health-ok" role="status" aria-live="polite" aria-atomic="true"></span>
<span class="header-spacer"></span>
<button id="theme-toggle" class="header-btn" onclick="toggleTheme()" aria-label="Toggle light/dark theme" title="Switch to light theme">&#9790;</button>
</div>
<div id="tab-bar" role="toolbar" aria-label="Workstreams">
<div id="tab-list" role="tablist"></div>
<div id="ws-action-group" class="ws-action-group hidden">
<span class="tab-bar-sep"></span>
<button id="refresh-title-btn" class="tab-bar-btn ws-action-btn" onclick="refreshWorkstreamTitle()" aria-label="Regenerate title" title="Regenerate title">&#x21bb;</button>
<button id="edit-title-btn" class="tab-bar-btn ws-action-btn" onclick="editWorkstreamTitle()" aria-label="Edit title" title="Edit title">&#x270E;</button>
<button id="fork-ws-btn" class="tab-bar-btn ws-action-btn" onclick="forkWorkstream()" aria-label="Fork workstream" title="Fork workstream">&#x2442;</button>
<button id="delete-ws-btn" class="tab-bar-btn ws-action-btn ws-action-btn-danger" onclick="confirmDeleteWorkstream()" aria-label="Delete workstream" title="Delete workstream"><span aria-hidden="true">&#x1f5d1;</span></button>
<span class="tab-bar-sep"></span>
</div>
<button id="new-tab-btn" onclick="newWorkstream()" title="New workstream (Ctrl+T)" aria-label="New workstream" aria-keyshortcuts="Control+t">+</button>
<button id="split-btn" onclick="splitFocusedPane()" title="Split pane (Ctrl+\)" aria-label="Split pane" aria-keyshortcuts="Control+Backslash">&#x29C9;</button>
</div>
@@ -51,8 +60,17 @@
<span class="dash-footer-stats" id="dash-footer-stats"></span>
</div>
<section class="dashboard-section" id="dashboard-saved-ws" aria-label="Saved workstreams">
<h2 class="dashboard-section-title">Saved Workstreams</h2>
<div class="dashboard-section-header">
<h2 class="dashboard-section-title">Saved Workstreams</h2>
<button id="ws-delete-btn" class="ws-delete-btn" onclick="startWsDeleteMode()" title="Delete workstreams"><span aria-hidden="true">&#x1f5d1;</span> Delete</button>
</div>
<div class="dashboard-cards" id="dashboard-saved-cards"></div>
<div id="ws-delete-bar" class="ws-delete-bar">
<span class="ws-delete-count-label" id="ws-delete-bar-count" role="status" aria-live="polite" aria-atomic="true">0 selected</span>
<button class="ws-delete-cancel-btn" onclick="cancelWsDeleteMode()">Cancel</button>
<button class="ws-delete-cancel-btn" id="ws-delete-bar-select-all" onclick="toggleSelectAll()">Select All</button>
<button class="ws-delete-bar-btn" id="ws-delete-bar-delete" onclick="confirmWsDeleteSelection()" disabled>Delete Selected</button>
</div>
</section>
</div>
</div>
@@ -68,6 +86,8 @@
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
<label for="new-ws-model">Model <span class="nws-hint">optional</span></label>
<select id="new-ws-model"><option value="">Default model</option></select>
<label for="new-ws-judge-model">Judge Model <span class="nws-hint">optional</span></label>
<select id="new-ws-judge-model"><option value="">Default (agent model)</option></select>
<label for="new-ws-skill">Skill <span class="nws-hint">optional</span></label>
<select id="new-ws-skill"><option value="">Use defaults</option></select>
<div id="new-ws-buttons">
@@ -77,6 +97,44 @@
</div>
</div>
<!-- Edit title modal -->
<div id="edit-title-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-title-heading">
<div id="edit-title-box">
<h3 id="edit-title-heading">Edit Title</h3>
<input id="edit-title-input" type="text" maxlength="80" placeholder="Enter title..." onkeydown="if(event.key==='Enter')submitEditTitle();if(event.key==='Escape')cancelEditTitle();">
<div id="edit-title-buttons">
<button type="button" onclick="cancelEditTitle()">Cancel</button>
<button type="button" onclick="submitEditTitle()">Save</button>
</div>
</div>
</div>
<!-- Delete workstream confirmation modal -->
<div id="delete-ws-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="delete-ws-heading">
<div id="delete-ws-box">
<h3 id="delete-ws-heading">Delete Workstream</h3>
<p id="delete-ws-message"></p>
<div id="delete-ws-buttons">
<button type="button" onclick="cancelDeleteWs()">Cancel</button>
<button type="button" class="danger" onclick="executeDeleteWs()">Delete</button>
</div>
</div>
</div>
<!-- Delete workstreams confirmation modal (batch) -->
<div id="ws-delete-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="ws-delete-title">
<div id="ws-delete-box">
<h3 id="ws-delete-title">Delete Workstreams</h3>
<div id="ws-delete-error" role="alert" aria-live="assertive"></div>
<p id="ws-delete-count"></p>
<div id="ws-delete-list"></div>
<div id="ws-delete-buttons">
<button id="ws-delete-cancel-btn" type="button" onclick="cancelWsDelete()">Cancel</button>
<button id="ws-delete-confirm-btn" type="button" onclick="confirmWsDelete()">Delete</button>
</div>
</div>
</div>
<!-- Plan review dialog -->
<div id="plan-overlay">
<div id="plan-dialog" role="dialog" aria-modal="true" aria-labelledby="plan-dialog-title">
@@ -98,7 +156,11 @@ window.TURNSTONE_KB_SHORTCUTS = [
{ desc: "Toggle dashboard", badge: '<span class="kb-key">Ctrl+D</span>' },
{ desc: "New workstream", badge: '<span class="kb-key">Ctrl+T</span>' },
{ desc: "Close workstream", badge: '<span class="kb-key">Ctrl+W</span>' },
{ desc: "Switch to tab 1\u20139", badge: '<span class="kb-key">Ctrl+1</span>\u2026<span class="kb-key">9</span>' }
{ desc: "Switch to tab 1\u20139", badge: '<span class="kb-key">Ctrl+1</span>\u2026<span class="kb-key">9</span>' },
{ desc: "Refresh title", badge: '<span class="kb-key">Ctrl+Shift+R</span>' },
{ desc: "Edit title", badge: '<span class="kb-key">Ctrl+Shift+E</span>' },
{ desc: "Fork workstream", badge: '<span class="kb-key">Ctrl+Shift+F</span>' },
{ desc: "Delete workstream", badge: '<span class="kb-key">Ctrl+Shift+X</span>' }
]},
{ title: "Split panes", keys: [
{ desc: "Split right", badge: '<span class="kb-key">Ctrl+\\</span>' },
+227 -1
View File
@@ -53,6 +53,9 @@
@media (max-width: 600px) {
.ws-tab .tab-close { opacity: 1; padding: 4px 6px; font-size: 16px; }
#split-btn { display: none; }
#refresh-title-btn { display: none !important; }
.ws-action-btn { padding: 4px 6px; margin: 0 1px; font-size: 13px; }
.tab-wsid { display: none; }
}
/* ==========================================================================
@@ -122,6 +125,30 @@
.ws-tab:hover .tab-close, .ws-tab:focus-within .tab-close, .ws-tab .tab-close:focus-visible { opacity: 1; }
.ws-tab .tab-close:hover { color: var(--red); }
/* Subtle ws_id badge in tabs */
.tab-wsid {
font-size: 9px;
color: var(--fg-dim);
opacity: 0;
margin-left: 4px;
font-family: 'IBM Plex Mono', monospace;
letter-spacing: 0.02em;
transition: opacity 0.15s;
}
.ws-tab:hover .tab-wsid,
.ws-tab.active .tab-wsid { opacity: 0.45; }
/* Subtle ws_id badge in saved workstream cards */
.card-wsid {
font-size: 9px;
color: var(--fg-dim);
opacity: 0.45;
margin-left: 6px;
font-family: 'IBM Plex Mono', monospace;
letter-spacing: 0.02em;
vertical-align: middle;
}
#new-tab-btn {
background: none;
border: 1px dashed var(--border-strong);
@@ -132,6 +159,7 @@
font-family: inherit;
font-size: 14px;
line-height: 1;
flex-shrink: 0;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
#new-tab-btn:hover { background: var(--bg-highlight); color: var(--accent); border-color: var(--accent); }
@@ -146,12 +174,83 @@
font-family: inherit;
font-size: 13px;
line-height: 1;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s, background 0.15s;
margin-left: 2px;
}
#split-btn:hover { background: var(--bg-highlight); color: var(--accent); border-color: var(--accent); }
#split-btn.hidden { display: none; }
.ws-action-btn {
background: none;
border: 1px solid var(--border);
color: var(--fg-dim);
border-radius: var(--radius-sm);
padding: 4px 8px;
cursor: pointer;
font-size: 14px;
line-height: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
margin: 0 2px;
}
.ws-action-btn:hover { background: var(--bg-highlight); color: var(--fg-bright); border-color: var(--accent); }
.ws-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ws-action-btn-danger:hover { color: var(--red); border-color: var(--red); }
.ws-action-group { display: flex; align-items: center; flex-shrink: 0; }
.ws-action-group.hidden { display: none; }
.tab-bar-sep { width: 1px; height: 16px; background: var(--border-strong); margin: 0 4px; flex-shrink: 0; }
.header-spacer { flex: 1; }
/* Edit title & delete modals */
#edit-title-overlay, #delete-ws-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
#edit-title-box, #delete-ws-box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
max-width: 400px;
width: 90%;
}
#edit-title-box h3, #delete-ws-box h3 { margin: 0 0 12px; font-size: 16px; color: var(--fg-bright); }
#edit-title-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--fg-bright);
font-size: 14px;
font-family: inherit;
margin-bottom: 16px;
box-sizing: border-box;
}
#edit-title-input:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
#edit-title-buttons, #delete-ws-buttons { display: flex; gap: 8px; justify-content: flex-end; }
#edit-title-buttons button, #delete-ws-buttons button {
padding: 8px 20px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--fg-bright);
}
#delete-ws-buttons button.danger {
background: var(--red);
color: #fff;
border-color: var(--red);
}
#delete-ws-message { font-size: 14px; color: var(--fg-bright); margin: 0 0 16px; }
/* ==========================================================================
Split panes
========================================================================== */
@@ -1290,6 +1389,12 @@ audio.media-player {
.dashboard-new-btn:hover { filter: brightness(1.1); }
.dashboard-new-btn:disabled { opacity: 0.35; cursor: not-allowed; filter: none; }
.dashboard-section { margin-bottom: 24px; }
.dashboard-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.dashboard-section-title {
font-family: var(--font-display);
font-size: 11px;
@@ -1297,10 +1402,25 @@ audio.media-player {
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 12px;
margin: 0;
}
.ws-delete-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--fg-dim);
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.ws-delete-btn:hover {
color: var(--red);
border-color: var(--red);
}
.dashboard-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
.dashboard-card {
position: relative;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -1314,6 +1434,112 @@ audio.media-player {
.dashboard-card .card-title { font-size: 13px; color: var(--fg-bright); font-weight: 500; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-card .card-meta { font-size: 11px; color: var(--fg-dim); }
/* Delete mode */
.dashboard-card.ws-delete-mode { cursor: default; }
.dashboard-card.ws-delete-mode:hover { border-color: var(--border); background: var(--bg-surface); }
.ws-card-check {
position: absolute;
top: 8px;
right: 8px;
width: 18px;
height: 18px;
accent-color: var(--red);
cursor: pointer;
z-index: 1;
}
.dashboard-card.ws-selected {
border-color: var(--red);
background: rgba(248, 113, 113, 0.08);
}
[data-theme="light"] .dashboard-card.ws-selected {
background: rgba(220, 38, 38, 0.08);
}
.ws-delete-bar {
display: none;
align-items: center;
gap: 12px;
margin-top: 12px;
padding: 8px 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.ws-delete-bar.visible { display: flex; }
.ws-delete-bar .ws-delete-count-label { font-size: 12px; color: var(--fg-dim); }
.ws-delete-bar .ws-delete-bar-btn {
margin-left: auto;
background: var(--red);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.ws-delete-bar .ws-delete-bar-btn:hover:not(:disabled) { filter: brightness(1.1); }
.ws-delete-bar .ws-delete-bar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ws-delete-bar .ws-delete-cancel-btn {
background: transparent;
color: var(--fg-dim);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.ws-delete-bar .ws-delete-cancel-btn:hover {
color: var(--fg-bright);
border-color: var(--border-strong);
background: var(--bg-highlight);
}
/* Delete modal */
#ws-delete-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
#ws-delete-box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
max-width: 480px;
width: 90%;
}
#ws-delete-box h3 { margin: 0 0 12px; font-size: 16px; color: var(--fg-bright); }
#ws-delete-list { max-height: 200px; overflow-y: auto; margin: 12px 0; }
#ws-delete-list .ws-delete-item {
padding: 6px 0;
font-size: 13px;
color: var(--fg-bright);
border-bottom: 1px solid var(--border);
}
#ws-delete-list .ws-delete-item:last-child { border-bottom: none; }
#ws-delete-list .ws-delete-item.ws-delete-error { color: var(--red); }
#ws-delete-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
#ws-delete-buttons button {
padding: 8px 20px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--fg-bright);
}
#ws-delete-buttons button:last-child {
background: var(--red);
color: #fff;
border-color: var(--red);
}
/* Server dashboard row — clickable */
.dash-row { cursor: pointer; }
Generated
+7 -7
View File
@@ -2011,11 +2011,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.22"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
{ url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" },
]
[[package]]
@@ -2496,7 +2496,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.2.0a3"
version = "1.2.0a4"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -2629,15 +2629,15 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.43.0"
version = "0.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" },
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
]
[[package]]